257 lines
10 KiB
Python
257 lines
10 KiB
Python
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": "<hex>", "Test": "<hex>"}
|
|
#
|
|
# 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 "<rel_path><compressed_suffix>"
|
|
# (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. /<hex-key>/manifest.json or /<hex-key>/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 "<compressed_dir>/system/game.exe.gz". updater.py
|
|
# only ever asks for "<rel_path><compressed_suffix>" 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
|