117 lines
4.0 KiB
Python
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()
|