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 = {} 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 "" full_path = os.path.realpath(os.path.join(base_dir, rel_path)) # Confirm the resolved path is still inside base_dir (blocks "..", # symlink tricks, etc.). commonpath needs both paths to exist on the # same drive/root, which realpath + os.path.join guarantees here. try: if os.path.commonpath([full_path, base_dir]) != base_dir: return None except ValueError: return None return full_path 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