Files
simple-updater/server.py
T
2026-09-09 12:30:40 +03:00

179 lines
6.5 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 = {}
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 ""
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