Packwiz-Abdelpak-hosting-kit/packwiz-http.py

117 lines
4.0 KiB
Python

#!/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/", "client-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()