#!/usr/bin/env python3 """Serve the packwiz pack, the mod jars and the player setup guides over HTTP. Sits behind a reverse proxy. Deliberately serves an allowlist rather than the whole share: the same directory holds server.properties (which can carry an rcon password), ops.json, whitelist.json, usercache.json, logs and the world, and none of that should be reachable. packwiz-http --root /minecraft --bind 0.0.0.0 --port 18080 """ import argparse import http.server import os import posixpath import sys import urllib.parse # Top-level prefixes clients legitimately need. ALLOWED_DIRS = ("mods/", "packs/") def is_allowed(path: str) -> bool: """True if the URL path is something we publish.""" p = urllib.parse.urlparse(path).path p = urllib.parse.unquote(p).lstrip("/") # Collapse any traversal before deciding — posixpath.normpath turns # "packs/../server.properties" into "server.properties", which then fails # the allowlist instead of sneaking through on its prefix. if p: p = posixpath.normpath(p) if p.startswith("..") or p == ".": return False if p in ("", "."): return True if any(p == d.rstrip("/") or p.startswith(d) for d in ALLOWED_DIRS): return True if p.startswith("setup-") and p.endswith(".html") and "/" not in p: return True return False class Handler(http.server.SimpleHTTPRequestHandler): server_version = "packwiz-http/1.0" def do_GET(self): if not is_allowed(self.path): # 404 rather than 403: no reason to confirm what else is here. self.send_error(404, "Not Found") return super().do_GET() def do_HEAD(self): if not is_allowed(self.path): self.send_error(404, "Not Found") return super().do_HEAD() def end_headers(self): p = urllib.parse.urlparse(self.path).path # The pack manifest and index must never be cached, or clients keep # resolving an old pack after an update. The jars are content-addressed # by hash in the index, so they cache freely. if p.endswith(("pack.toml", "index.toml")): self.send_header("Cache-Control", "no-cache, must-revalidate") elif p.endswith(".pw.toml"): self.send_header("Cache-Control", "no-cache") super().end_headers() def log_message(self, format, *args): # noqa: A002 - signature fixed by base class # journald adds its own timestamps. sys.stderr.write("%s %s\n" % (self.address_string(), format % args)) Handler.extensions_map = dict(Handler.extensions_map) Handler.extensions_map.update({ ".toml": "text/plain", ".jar": "application/java-archive", }) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--root", default="/minecraft", help="directory to serve") ap.add_argument("--bind", default="0.0.0.0", help="address to listen on") ap.add_argument("--port", type=int, default=18080, help="port to listen on") args = ap.parse_args() if not os.path.isdir(args.root): sys.exit("error: root does not exist: %s" % args.root) if not os.path.ismount(args.root): # Same guard as the shell scripts: an unmounted dataset means we would # be serving an empty directory on the root filesystem. print("warning: %s is not a mountpoint" % args.root, file=sys.stderr) handler = lambda *a, **kw: Handler(*a, directory=args.root, **kw) # Threading matters: a full pack install is one request per mod, and a # single-threaded server serialises an entire lobby behind one download. httpd = http.server.ThreadingHTTPServer((args.bind, args.port), handler) httpd.daemon_threads = True print("serving %s on %s:%d" % (args.root, args.bind, args.port), file=sys.stderr) try: httpd.serve_forever() except KeyboardInterrupt: pass finally: httpd.server_close() if __name__ == "__main__": main()