import os import json import socket import posixpath import urllib.parse from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer CONFIG_FILENAME = "config.json" def load_config(): config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILENAME) with open(config_path, "r", encoding="utf-8") as f: return json.load(f) CONFIG = load_config() HOST = CONFIG.get("server", {}).get("host", "0.0.0.0") PORT = CONFIG.get("server", {}).get("port", 3232) # config.json keeps client folders and access keys under two separate # top-level maps, both keyed by the same friendly client name # ("Main", "Test", ...): # # "clients": {"Main": "game_client", "Test": "game_client_test"} # "access_keys": {"Main": "", "Test": ""} # # This mirrors how updater.py's CLIENT_KEYS is keyed (by display name), # so quickstart.py can populate both from the exact same source of truth # without the server and the updater disagreeing about what "Main" means. # The server only cares about the resulting access_key -> folder mapping, # built once here at startup. _CLIENT_FOLDERS = CONFIG.get("clients", {}) _ACCESS_KEYS = CONFIG.get("access_keys", {}) CLIENTS = {} for _name, _folder in _CLIENT_FOLDERS.items(): _key = _ACCESS_KEYS.get(_name) if not _key: print(f"WARNING: client '{_name}' has no access_key configured in config.json - skipping it.") continue CLIENTS[_key] = _folder # Absolute, resolved base directories for each client key, computed once at # startup. Relative folder paths in config.json are resolved relative to # this script's own location, not the process's current working directory, # so `python server.py` works the same regardless of where it's launched # from. Every request is checked against these to make sure the final path # can never land outside the intended client folder. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) CLIENT_BASE_DIRS = { key: os.path.realpath(os.path.join(SCRIPT_DIR, folder)) for key, folder in CLIENTS.items() } # A path that is guaranteed not to exist, used to force a 404 through the # normal http.server flow instead of ever falling back to serving files # from the script's own working directory. NONEXISTENT_PATH = os.path.join(os.path.abspath(os.sep), "__no_such_path__") # Simple per-IP concurrent connection cap. This is not a substitute for a # real reverse proxy (nginx/Caddy) doing proper rate limiting, but it stops # a single naive client (or a basic flood) from tying up every worker # thread the process has. Put a real reverse proxy in front for anything # internet-facing - see README. MAX_CONNECTIONS_PER_IP = CONFIG.get("server", {}).get("max_connections_per_ip", 20) _active_connections = {} # Where generate_manifest.py puts precompressed .gz copies, relative to # each client folder - e.g. "_gz" means game_client/_gz/system/game.exe.gz # instead of game_client/system/game.exe.gz sitting right next to the # original. updater.py always requests "" # (e.g. "system/game.exe.gz") with no knowledge of this subfolder at all - # _resolve_client_path below is what quietly redirects that request to # the real location on disk, so updater.py never needs to change. # Empty string ("") restores the old behavior of .gz files living right # next to their originals. COMPRESSED_SUFFIX = CONFIG.get("manifest", {}).get("compressed_suffix", ".gz") COMPRESSED_DIR = CONFIG.get("manifest", {}).get("compressed_dir", "") or "" # ------------------------------------------------------------ # Client-build info endpoint # # build_client.py runs on a separate Windows machine and needs a handful # of config.json values to bake into updater.exe (access keys, the # PUBLIC signing key, server address, UI settings, etc.) without ever # having the server's own config.json - which lives only here, and which # never contains the private signing key in the first place (see # generate_key.py / SIGNING_PRIVATE_KEY.txt). This endpoint hands out # exactly that subset as JSON, deliberately excluding the "server" section # (host/port/connection-limit are this machine's own topology, not # something a client build needs to know) and never touching the private # key file on disk. # # Not access-key-gated like game files: everything in this response ends # up baked into updater.exe in plaintext anyway (that's the whole point - # see build_client.py), so gating it behind a key would just be security # theater while adding a step. If you don't want this exposed publicly, # put the reverse proxy in front of it the same way you would for # anything else internet-facing (see README). # ------------------------------------------------------------ CLIENT_BUILD_INFO_PATH = "/client_build_info.json" _CLIENT_BUILD_INFO_BYTES = json.dumps( {k: v for k, v in CONFIG.items() if k != "server"}, indent=4, sort_keys=True, ).encode("utf-8") class MultiClientHandler(SimpleHTTPRequestHandler): """ Serves files only under a URL path that starts with a known access key, e.g. //manifest.json or //some/file. Everything else gets a 404: - a bare "/" (the directory listing that exposes server.py, config.json, the raw client folders, etc.) - an unknown or missing key - any path that tries to escape the client folder (e.g. via "..") - directory listings even *inside* a valid client folder This is enforced by never letting execution fall through to the default SimpleHTTPRequestHandler behavior, which serves the process's current working directory. """ # Keep noisy default logging off stdout in production; flip to True # (or add real logging) if you need request-level debugging. def log_message(self, format, *args): pass def _resolve_client_path(self, path): path = urllib.parse.urlparse(path).path path = urllib.parse.unquote(path) parts = [p for p in path.split('/') if p] if not parts: return None key = parts[0] if key not in CLIENT_BASE_DIRS: return None base_dir = CLIENT_BASE_DIRS[key] rel_parts = parts[1:] rel_path = posixpath.join(*rel_parts) if rel_parts else "" direct_path = self._safe_join(base_dir, rel_path) if direct_path is None: return None if os.path.isfile(direct_path): return direct_path # Not found at the direct location. If this looks like a # precompressed file (".gz" suffix) and a compressed_dir is # configured, try the same relative path underneath that # subfolder instead - e.g. a request for "system/game.exe.gz" # falls back to "/system/game.exe.gz". updater.py # only ever asks for "" at the # direct-looking URL, so this fallback is the only thing that # needs to know .gz files might actually live elsewhere on disk. if COMPRESSED_DIR and COMPRESSED_SUFFIX and rel_path.endswith(COMPRESSED_SUFFIX): gz_path = self._safe_join(base_dir, posixpath.join(COMPRESSED_DIR, rel_path)) if gz_path is not None and os.path.isfile(gz_path): return gz_path # Genuinely missing either way - return the direct path so the # normal "file not found" -> 404 flow in http.server still # applies (translate_path callers expect a path, not a bool). return direct_path @staticmethod def _safe_join(base_dir, rel_path): """ Joins rel_path onto base_dir and confirms the result is still inside base_dir (blocks "..", symlink tricks, etc. - the same check previously done inline in _resolve_client_path, now shared with the compressed_dir fallback above). Returns None if the result would escape base_dir. """ full_path = os.path.realpath(os.path.join(base_dir, rel_path)) try: if os.path.commonpath([full_path, base_dir]) != base_dir: return None except ValueError: return None return full_path def do_GET(self): if self.path.split("?", 1)[0] == CLIENT_BUILD_INFO_PATH: self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(_CLIENT_BUILD_INFO_BYTES))) self.end_headers() self.wfile.write(_CLIENT_BUILD_INFO_BYTES) return super().do_GET() def translate_path(self, path): resolved = self._resolve_client_path(path) if resolved is None: return NONEXISTENT_PATH return resolved def list_directory(self, path): # Never render a directory listing, even for a path inside a valid # client folder - only exact file paths are servable. self.send_error(404, "Not Found") return None def finish(self): try: super().finish() finally: ip = self.client_address[0] remaining = _active_connections.get(ip, 1) - 1 if remaining <= 0: _active_connections.pop(ip, None) else: _active_connections[ip] = remaining def handle_one_request(self): ip = self.client_address[0] current = _active_connections.get(ip, 0) if current >= MAX_CONNECTIONS_PER_IP: self.close_connection = True try: self.send_error(429, "Too Many Requests") except (BrokenPipeError, ConnectionResetError): pass return _active_connections[ip] = current + 1 super().handle_one_request() class Server(ThreadingHTTPServer): # Handle each connection in its own thread so one slow/large download # doesn't stall every other player's update. daemon_threads=True keeps # Ctrl+C responsive instead of hanging on stray worker threads. daemon_threads = True allow_reuse_address = True if __name__ == '__main__': server_address = (HOST, PORT) httpd = Server(server_address, MultiClientHandler) print(f"Server running on {HOST}:{PORT}...") if CLIENTS: active_names = [name for name in _CLIENT_FOLDERS if _ACCESS_KEYS.get(name)] print(f"Configured clients: {', '.join(active_names)}") else: print("WARNING: no clients configured (check config.json 'clients' and 'access_keys').") try: httpd.serve_forever() except KeyboardInterrupt: pass