From 32c496825cdbd581a4667dabb3aa572cfb1a2e21 Mon Sep 17 00:00:00 2001 From: Doridian Date: Fri, 29 Mar 2024 13:01:44 -0700 Subject: [PATCH] Make everything linux line endings, add bootstrap procedure --- Dockerfile | 22 +++ config/spaceage_base.yml | 1 - dockerboot.sh | 20 +++ luabin.py | 314 ++++++++++++++++++++------------------- server.py | 63 +++++--- updateable.py | 42 +++--- 6 files changed, 267 insertions(+), 195 deletions(-) create mode 100644 Dockerfile create mode 100755 dockerboot.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3bdbad0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM steamcmd/steamcmd:ubuntu + +RUN apt update && \ + apt -y dist-upgrade && \ + apt --no-install-recommends -y install python3 python3-requests python3-yaml python3-pip git openssh-client lsof libssl3 libboost-system1.74.0 && \ + rm -rf /var/cache/apt +RUN pip3 install python_a2s + +RUN groupadd server -g 1000 && \ + useradd server -u 1000 -g 1000 -s /bin/false && \ + mkdir -p /home/server && \ + chown -R server:server /home/server +USER server + +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/game +ENV HOME=/home/server +ENV STARLORD_CONFIG=spaceage_gooniverse +ENV SPACEAGE_SERVER_TOKEN=dummy + +COPY . /opt/StarLord + +ENTRYPOINT ["/opt/StarLord/dockerboot.sh"] diff --git a/config/spaceage_base.yml b/config/spaceage_base.yml index a6df6e6..3abf716 100644 --- a/config/spaceage_base.yml +++ b/config/spaceage_base.yml @@ -10,7 +10,6 @@ server: restart_every: "24h" addons: - name: SpaceAge - private: yes trusted: yes gamemodes: - spaceage diff --git a/dockerboot.sh b/dockerboot.sh new file mode 100755 index 0000000..7513f0c --- /dev/null +++ b/dockerboot.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -ex + +SERVER_DIR="${HOME}" +STARLORD_DIR="${SERVER_DIR}/StarLord" +STARLORD_MAIN="${STARLORD_DIR}/__main__.py" + +if [ -f "${STARLORD_MAIN}" ]; then + exec /usr/bin/python3 "${STARLORD_MAIN}" + exit 1 +fi + +if [ -d "${STARLORD_DIR}" ]; then + rm -rf "${STARLORD_DIR}" +fi + +cp -r /opt/StarLord "${STARLORD_DIR}" + +exec /usr/bin/python3 "${STARLORD_MAIN}" +exit 1 diff --git a/luabin.py b/luabin.py index 275d040..9a1bf20 100644 --- a/luabin.py +++ b/luabin.py @@ -1,156 +1,158 @@ -from platform import architecture, system -from json import loads as json_loads, load as json_load, dump as json_dump -from os.path import join -from traceback import print_exception -from requests import get as http_get -from config import LuaBinConfig -from updateable import UpdateableResource -from typing import Any, Mapping - -usedDLLs: set[str] = set() - -class LuaBin(UpdateableResource): - storage: Any - - def __init__(self, folder: str, name: str) -> None: - super().__init__(folder, name) - self.load() - - def load(self): - usedDLLs.add(self.makeBinaryName()) - usedDLLs.add(self.makeMetaName()) - - file = self.formatPath(self.makeMetaName()) - try: - with open(file, "r") as f: - self.storage = json_load(f) - except FileNotFoundError as e: - self.storage = {} - except Exception as e: - print_exception(e) - self.storage = {} - - def save(self): - file = self.formatPath(self.makeMetaName()) - with open(file, "w") as f: - json_dump(self.storage, f) - - def formatPath(self, name: str): - return join(self.folder, name) - - def makeBinaryName(self): - arch_suffix = "" - if architecture()[0] == "64bit": - arch_suffix = "64" - - system_name = system() - - platform_suffix = "" - if system_name == "Windows": - # Windows is the only platform with a 32-bit suffix - # for some reason, thanks GMod - if arch_suffix == "": - arch_suffix = "32" - platform_suffix = "win" - elif system_name == "Linux": - platform_suffix = "linux" - elif system_name == "Darwin": - platform_suffix = "osx" - - return f"gmsv_{self.name}_{platform_suffix}{arch_suffix}.dll" - - def makeMetaName(self): - return f"{self.makeBinaryName()}.meta" - -class GithubReleaseLuaBin(LuaBin): - repo_org: str - repo_name: str - - def __init__(self, folder: str, name: str, config: Mapping[str, Any]): - super().__init__(folder, name) - self.repo_org = config["org"] - self.repo_name = config["name"] - - if "tag" in config: - self.fixed_tag = config["tag"] - self.release = f"tags/{self.fixed_tag}" - else: - self.fixed_tag = None - self.release = "latest" - - def queryReleaseInfo(self, use_release: str | None = None): - if use_release is None: - use_release = self.fixed_tag - - if use_release is not None: - release = self.storage.get("release", None) - if release is not None and release["tag_name"] == use_release: - return release - - res = http_get(url=f"https://github.com/repos/{self.repo_org}/{self.repo_name}/releases/{self.release}") - res.raise_for_status() - - release = json_loads(res.text) - - self.storage["release"] = release - self.save() - - return release - - def isReleaseInstalled(self, release: dict[str, Any]): - return release["tag_name"] == self.storage.get("tag_name", "") - - def storeRelease(self, release: dict[str, Any]): - self.storage["tag_name"] = release["tag_name"] - self.save() - - def getBinaryURL(self, release: dict[str, Any]): - binary_name = self.makeBinaryName() - for asset in release["assets"]: - if asset["name"] == binary_name: - return asset["browser_download_url"] - return None - - def checkUpdate(self, offline: bool = False): - if offline: - release = self.storage.get("release", None) - if release is None: - return True - else: - release = self.queryReleaseInfo() - - if self.isReleaseInstalled(release): - return False - if self.getBinaryURL(release) is None: - print("Found update, but no binary, pointless to update") - return False - return True - - def update(self): - release = self.storage.get("release", None) - if release is None: - release = self.queryReleaseInfo() - - if self.isReleaseInstalled(release): - return - - url = self.getBinaryURL(release) - if url is None: - print("LuaBin manifest missing binaries, ignoring") - return - - resp = http_get(url=url, stream=True) - resp.raise_for_status() - with open(self.formatPath(self.makeBinaryName()), "wb") as f: - _ = f.write(resp.content) - - self.storeRelease(release) - -def makeLuaBin(folder: str, config: LuaBinConfig): - if config.type == "github_release": - return GithubReleaseLuaBin(folder, config.name, config.config) - else: - raise ValueError(f"{config.type} is an invalid LuaBin type") - -def isDLLUsed(dll: str): - return dll in usedDLLs +from platform import architecture, system +from json import loads as json_loads, load as json_load, dump as json_dump +from os.path import join +from os import makedirs +from traceback import print_exception +from requests import get as http_get +from config import LuaBinConfig +from updateable import UpdateableResource +from typing import Any, Mapping + +usedDLLs: set[str] = set() + +class LuaBin(UpdateableResource): + storage: Any + + def __init__(self, folder: str, name: str) -> None: + super().__init__(folder, name) + self.load() + + def load(self): + usedDLLs.add(self.makeBinaryName()) + usedDLLs.add(self.makeMetaName()) + + file = self.formatPath(self.makeMetaName()) + try: + with open(file, "r") as f: + self.storage = json_load(f) + except FileNotFoundError as e: + self.storage = {} + except Exception as e: + print_exception(e) + self.storage = {} + + def save(self): + makedirs(self.folder, exist_ok=True) + file = self.formatPath(self.makeMetaName()) + with open(file, "w") as f: + json_dump(self.storage, f) + + def formatPath(self, name: str): + return join(self.folder, name) + + def makeBinaryName(self): + arch_suffix = "" + if architecture()[0] == "64bit": + arch_suffix = "64" + + system_name = system() + + platform_suffix = "" + if system_name == "Windows": + # Windows is the only platform with a 32-bit suffix + # for some reason, thanks GMod + if arch_suffix == "": + arch_suffix = "32" + platform_suffix = "win" + elif system_name == "Linux": + platform_suffix = "linux" + elif system_name == "Darwin": + platform_suffix = "osx" + + return f"gmsv_{self.name}_{platform_suffix}{arch_suffix}.dll" + + def makeMetaName(self): + return f"{self.makeBinaryName()}.meta" + +class GithubReleaseLuaBin(LuaBin): + repo_org: str + repo_name: str + + def __init__(self, folder: str, name: str, config: Mapping[str, Any]): + super().__init__(folder, name) + self.repo_org = config["org"] + self.repo_name = config["name"] + + if "tag" in config: + self.fixed_tag = config["tag"] + self.release = f"tags/{self.fixed_tag}" + else: + self.fixed_tag = None + self.release = "latest" + + def queryReleaseInfo(self, use_release: str | None = None): + if use_release is None: + use_release = self.fixed_tag + + if use_release is not None: + release = self.storage.get("release", None) + if release is not None and release["tag_name"] == use_release: + return release + + res = http_get(url=f"https://github.com/repos/{self.repo_org}/{self.repo_name}/releases/{self.release}") + res.raise_for_status() + + release = json_loads(res.text) + + self.storage["release"] = release + self.save() + + return release + + def isReleaseInstalled(self, release: dict[str, Any]): + return release["tag_name"] == self.storage.get("tag_name", "") + + def storeRelease(self, release: dict[str, Any]): + self.storage["tag_name"] = release["tag_name"] + self.save() + + def getBinaryURL(self, release: dict[str, Any]): + binary_name = self.makeBinaryName() + for asset in release["assets"]: + if asset["name"] == binary_name: + return asset["browser_download_url"] + return None + + def checkUpdate(self, offline: bool = False): + if offline: + release = self.storage.get("release", None) + if release is None: + return True + else: + release = self.queryReleaseInfo() + + if self.isReleaseInstalled(release): + return False + if self.getBinaryURL(release) is None: + print("Found update, but no binary, pointless to update") + return False + return True + + def update(self): + release = self.storage.get("release", None) + if release is None: + release = self.queryReleaseInfo() + + if self.isReleaseInstalled(release): + return + + url = self.getBinaryURL(release) + if url is None: + print("LuaBin manifest missing binaries, ignoring") + return + + resp = http_get(url=url, stream=True) + resp.raise_for_status() + with open(self.formatPath(self.makeBinaryName()), "wb") as f: + _ = f.write(resp.content) + + self.storeRelease(release) + +def makeLuaBin(folder: str, config: LuaBinConfig): + if config.type == "github_release": + return GithubReleaseLuaBin(folder, config.name, config.config) + else: + raise ValueError(f"{config.type} is an invalid LuaBin type") + +def isDLLUsed(dll: str): + return dll in usedDLLs diff --git a/server.py b/server.py index e250bab..7833d4f 100644 --- a/server.py +++ b/server.py @@ -1,10 +1,10 @@ -from os import chdir, path, O_NONBLOCK, read, write, close, getenv +from os import chdir, path, O_NONBLOCK, read, write, close, getenv, makedirs from subprocess import Popen, check_call, check_output from tempfile import NamedTemporaryFile from traceback import print_exc from workshop import getWorkshopItems from time import sleep -from json import loads as json_loads +from json import loads as json_loads, dumps as json_dumps from requests import get as http_get from utils import Timeout, get_default_ip, get_default_port from a2s import info as a2s_info # type: ignore @@ -14,17 +14,19 @@ from pty import openpty from fcntl import fcntl, F_GETFL, F_SETFL from config import ServerConfig -from typing import cast, Callable +from typing import cast, Callable, Any STREAM_STDOUT = 0 STREAM_STDERR = 1 STATE_STOPPED = 0 -STATE_STARTING = 1 -STATE_RUNNING = 2 -STATE_STOPPING = 3 -STATE_LISTENING = 4 -STATE_FAILING = 5 +STATE_STARTING_PRE_WORKSHOP = 1 +STATE_STARTING_IN_WORKSHOP = 2 +STATE_STARTING_POST_WORKSHOP = 3 +STATE_RUNNING = 4 +STATE_STOPPING = 5 +STATE_LISTENING = 6 +STATE_FAILING = 7 LOADADDONS_FILE_GMOD = "autorun/server/loadaddons.lua" LOADADDONS_FILE_SERVER = "garrysmod/lua/%s" % LOADADDONS_FILE_GMOD @@ -39,6 +41,7 @@ class ServerProcess: def __init__(self, folder: str, config: ServerConfig): super().__init__() self.folder = path.abspath(folder) + makedirs(self.folder, exist_ok=True) self.pidfile = path.join(self.folder, "pid") self.proc = None @@ -61,18 +64,26 @@ def __init__(self, folder: str, config: ServerConfig): if self.port == 0: self.port = get_default_port() - fh = open(path.join(self.folder, "garrysmod/data_static/sa_config/api.json")) - data = fh.read() - fh.close() - - dataDict = json_loads(data) - self.serverToken = dataDict["serverToken"] + apiJsonFile = path.join(self.folder, "garrysmod/data_static/sa_config/api.json") + self.serverToken = getenv("SPACEAGE_SERVER_TOKEN") self.apiAuth = "Server %s" % self.serverToken + apiJsonData = {} + if path.exists(apiJsonFile): + with open(apiJsonFile, "r") as fh: + apiJsonData = cast(dict[str, Any], json_loads(fh.read())) + + if apiJsonData.get("serverToken", "") != self.serverToken: + apiJsonData["serverToken"] = self.serverToken + makedirs(path.dirname(apiJsonFile), exist_ok=True) + with open(apiJsonFile, "w") as fh: + _ = fh.write(json_dumps(apiJsonData)) + def getAPIData(self): res = http_get("https://api.spaceage.mp/v2/servers/self/config", headers={ "Authorization": self.apiAuth, }) + res.raise_for_status() return json_loads(res.text) def writeLocalConfig(self): @@ -86,6 +97,7 @@ def writeLocalConfig(self): hostname "SpaceAge [%s]" """ % (data["steam_account_token"], data["rcon_password"], data["name"]) + makedirs(path.join(self.folder, "garrysmod/cfg"), exist_ok=True) fh = open(path.join(self.folder, "garrysmod/cfg/localgame.cfg"), "w") _ = fh.write(localCfg) fh.close() @@ -95,6 +107,7 @@ def writeLocalConfig(self): sentry.Setup("%s", {server_name = "%s"}) """ % (data["sentry_dsn"], data["name"]) + makedirs(path.join(self.folder, "garrysmod/lua/autorun/server"), exist_ok=True) fh = open(path.join(self.folder, "garrysmod/lua/autorun/server/localcfg.lua"), "w") _ = fh.write(localCfg) fh.close() @@ -160,7 +173,7 @@ def run(self): self.kill() self.running = True - self.setStateWithKillTimeout(STATE_STARTING, 60) + self.setStateWithKillTimeout(STATE_STARTING_PRE_WORKSHOP, 60) self.ptyMaster, self.ptySlave = openpty() fl = fcntl(self.ptyMaster, F_GETFL) @@ -185,9 +198,25 @@ def onOutput(self, data: str): _ = stdout.write(data) stdout.flush() + if self.state == STATE_STARTING_PRE_WORKSHOP: + if "WS: Processing " in data: + self.setStateWithKillTimeout(STATE_STARTING_IN_WORKSHOP, 60) + elif self.state == STATE_STARTING_IN_WORKSHOP: + if "Addon needs downloading..." in data: + self.reconfigureStateKillTimeout(600) + print("[StarLord] Kill timeout set to 600 seconds due to workshop download start", flush=True) + elif "Mounted!" in data: + self.reconfigureStateKillTimeout(60) + print("[StarLord] Kill timeout set to 60 seconds due to workshop download end", flush=True) + elif "WS: Finished!" in data: + self.setStateWithKillTimeout(STATE_STARTING_POST_WORKSHOP, 60) + def setStateWithKillTimeout(self, state: int, timeout: float): if self.setState(state): - self.setStateTimeout(timeout, self.kill) + self.reconfigureStateKillTimeout(timeout) + + def reconfigureStateKillTimeout(self, timeout: float): + self.setStateTimeout(timeout, self.kill) def setState(self, state: int): if self.state == state: @@ -271,7 +300,7 @@ def poll(self, waitTime: float): except: return True - if self.state == STATE_STARTING: + if self.state == STATE_STARTING_PRE_WORKSHOP or self.state == STATE_STARTING_IN_WORKSHOP or self.state == STATE_STARTING_POST_WORKSHOP: lsof = "" try: lsof = check_output(["lsof", "-Pani", "-p", "%d" % self.proc.pid, "-FPn"]).decode("utf8").strip().split("\n") diff --git a/updateable.py b/updateable.py index 879418b..55e0723 100644 --- a/updateable.py +++ b/updateable.py @@ -1,21 +1,21 @@ -from abc import ABC, abstractmethod - -class UpdateableResource(ABC): - folder: str - name: str - - def __init__(self, folder: str, name: str) -> None: - super().__init__() - self.folder = folder - self.name = name - - @abstractmethod - def checkUpdate(self, offline: bool=False) -> bool: - pass - - @abstractmethod - def update(self): - pass - - def __repr__(self) -> str: - return f"class={self.__class__.__name__} name={self.name} folder={self.folder}" +from abc import ABC, abstractmethod + +class UpdateableResource(ABC): + folder: str + name: str + + def __init__(self, folder: str, name: str) -> None: + super().__init__() + self.folder = folder + self.name = name + + @abstractmethod + def checkUpdate(self, offline: bool=False) -> bool: + pass + + @abstractmethod + def update(self): + pass + + def __repr__(self) -> str: + return f"class={self.__class__.__name__} name={self.name} folder={self.folder}"