From b32b47f02017ca82f419c9d19e62bc6278b4f2ec Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 5 Aug 2026 23:27:40 +0200 Subject: [PATCH] Add packwiz-http service and --scheme flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverse proxy runs on a different host than the game server, so it cannot alias the share directly — serving the pack needs an HTTP server on the game server itself. packwiz-http.py is a threaded static server over an allowlist: mods/, packs/, and setup-*.html. Everything else 404s, since the same directory holds server.properties, ops.json, whitelist.json, logs and the world. Threading matters because installing a pack is one request per mod and a single-threaded server serialises a whole lobby behind one download. pack.toml and index.toml are sent no-cache so a proxy cannot serve a stale pack. Installed as packwiz-http.service on port 18080 by default, configurable with -H/-B, skippable with --no-http, and read-only via ReadOnlyPaths. packwiz-setup.sh gains -S/--scheme, for when the pack is built against a mirror reachable only over plain HTTP but clients must be handed the public HTTPS URL. Co-Authored-By: Claude Opus 5 --- README.md | 19 ++++ __pycache__/packwiz-http.cpython-314.pyc | Bin 0 -> 6943 bytes build.sh | 4 +- deploy.sh | 13 ++- mc-service-setup.sh | 58 ++++++++++++ packwiz-http.py | 116 +++++++++++++++++++++++ packwiz-setup.sh | 15 +++ 7 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 __pycache__/packwiz-http.cpython-314.pyc create mode 100644 packwiz-http.py diff --git a/README.md b/README.md index e7d18a9..f2551d6 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,25 @@ pack directory survive. | `mc-refresh-restart.sh` | you | Re-sync the pack after changing mods, then restart | | `build.sh` | you | Builds the release zip | +## Pack HTTP server + +`packwiz-http.service` serves the pack, the mod jars and the setup guides on +port 18080 (`-H` to change, `-B` to change the bind address, `--no-http` to skip +it). Put a reverse proxy in front of it. + +It serves an **allowlist**, not the whole share: `mods/`, `packs/`, and +`setup-*.html`. Everything else 404s. That is deliberate — the same directory +holds `server.properties` (which can carry an rcon password), `ops.json`, +`whitelist.json`, `usercache.json`, logs and the world. + +`pack.toml` and `index.toml` are sent with `Cache-Control: no-cache`, so the +proxy can't hand clients a stale pack. Jars are pinned by hash in the index and +cache freely. + +If the mirror is only reachable over plain HTTP from the machine building the +pack, but clients need the public HTTPS URL, use `-S https` — it rewrites the +scheme on every generated URL. + ## Memory The heap defaults to 10 GB (`-X`/`-x` to change it). `-XX:+AlwaysPreTouch` means diff --git a/__pycache__/packwiz-http.cpython-314.pyc b/__pycache__/packwiz-http.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d9abb456871286dc86f20d6c4ce91593e47ed53d GIT binary patch literal 6943 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 DytIXc literal 0 HcmV?d00001 diff --git a/build.sh b/build.sh index 87d2a88..5b1132b 100755 --- a/build.sh +++ b/build.sh @@ -25,6 +25,7 @@ FILES=( mc-service-setup.sh packwiz-setup.sh mc-refresh-restart.sh + packwiz-http.py ) die() { echo "error: $*" >&2; exit 1; } @@ -69,6 +70,7 @@ fail=0 for f in "${FILES[@]}"; do case "$f" in *.sh) bash -n "$f" || { echo " FAILED: $f" >&2; fail=1; } ;; + *.py) python3 -m py_compile "$f" || { echo " FAILED: $f" >&2; fail=1; } ;; esac done (( fail )) && die "refusing to build a zip containing scripts that don't parse" @@ -84,7 +86,7 @@ mkdir -p "$DEST" for f in "${FILES[@]}"; do case "$f" in - *.sh) install -m 755 "$f" "$DEST/$f" ;; + *.sh|*.py) install -m 755 "$f" "$DEST/$f" ;; *) install -m 644 "$f" "$DEST/$f" ;; esac done diff --git a/deploy.sh b/deploy.sh index 38a0f1c..8fcc0fd 100755 --- a/deploy.sh +++ b/deploy.sh @@ -22,7 +22,7 @@ set -euo pipefail NAME=""; AUTHOR=""; BASEURL=""; NFVER="latest"; MCVER="1.21.1" -SHARE="/minecraft"; XMX="10G"; XMS="10G"; ACCEPT_EULA=""; PORT="25565"; KEEP_PACK=0; SNAPSHOT=1 +SHARE="/minecraft"; XMX="10G"; XMS="10G"; ACCEPT_EULA=""; PORT="25565"; KEEP_PACK=0; SNAPSHOT=1; HTTP_PORT="18080"; HTTP_BIND="0.0.0.0"; SCHEME="" HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" die() { echo "error: $*" >&2; exit 1; } @@ -38,6 +38,9 @@ while [ $# -gt 0 ]; do -m|--mc) MCVER="${2:-}"; shift 2 ;; -s|--share) SHARE="${2:-}"; shift 2 ;; -p|--port) PORT="${2:-}"; shift 2 ;; + -H|--http-port) HTTP_PORT="${2:-}"; shift 2 ;; + -B|--http-bind) HTTP_BIND="${2:-}"; shift 2 ;; + -S|--scheme) SCHEME="${2:-}"; shift 2 ;; -X|--xmx) XMX="${2:-}"; shift 2 ;; -x|--xms) XMS="${2:-}"; shift 2 ;; --accept-eula) ACCEPT_EULA="--accept-eula"; shift ;; @@ -91,21 +94,23 @@ fi step "1/2 Minecraft systemd service" sudo "$HERE/mc-service-setup.sh" \ - -s "$SHARE" -A "$USER" -X "$XMX" -x "$XMS" -m "$MCVER" -p "$PORT" \ + -s "$SHARE" -A "$USER" -X "$XMX" -x "$XMS" -m "$MCVER" -p "$PORT" -H "$HTTP_PORT" -B "$HTTP_BIND" \ ${NFVER:+-N "$NFVER"} ${ACCEPT_EULA} PACK_FORCE="" (( KEEP_PACK )) || PACK_FORCE="--force" +PACK_SCHEME="" +[ -z "$SCHEME" ] || PACK_SCHEME="-S $SCHEME" step "2/2 packwiz pack and player guides" # The admin group membership from step 1 isn't active in this shell yet, so run # the pack setup under the new group explicitly rather than telling you to log # out and back in. if id -nG "$USER" | tr ' ' '\n' | grep -qx minecraft; then - "$HERE/packwiz-setup.sh" -n "$NAME" -a "$AUTHOR" -m "$MCVER" -u "$BASEURL" -s "$SHARE" -p "$PORT" ${PACK_FORCE} + "$HERE/packwiz-setup.sh" -n "$NAME" -a "$AUTHOR" -m "$MCVER" -u "$BASEURL" -s "$SHARE" -p "$PORT" ${PACK_FORCE} ${PACK_SCHEME} else sg minecraft -c "$(printf '%q ' "$HERE/packwiz-setup.sh" -n "$NAME" -a "$AUTHOR" \ - -m "$MCVER" -u "$BASEURL" -s "$SHARE" -p "$PORT" ${PACK_FORCE})" + -m "$MCVER" -u "$BASEURL" -s "$SHARE" -p "$PORT" ${PACK_FORCE} ${PACK_SCHEME})" fi if (( WAS_ACTIVE )); then diff --git a/mc-service-setup.sh b/mc-service-setup.sh index dbe6acd..3c6e237 100755 --- a/mc-service-setup.sh +++ b/mc-service-setup.sh @@ -19,6 +19,9 @@ set -euo pipefail SHARE="/minecraft" MCUSER="minecraft" PORT="25565" # Minecraft server port +HTTP_PORT="18080" # packwiz/pack HTTP server port +HTTP_BIND="0.0.0.0" # address the pack server listens on +HTTP=1 # install the pack HTTP service XMS="10G" XMX="10G" NFVER="" # version, or "latest" to resolve one; empty = don't install @@ -38,6 +41,9 @@ while [ $# -gt 0 ]; do -s|--share) SHARE="${2:-}"; shift 2 ;; -U|--user) MCUSER="${2:-}"; shift 2 ;; -p|--port) PORT="${2:-}"; shift 2 ;; + -H|--http-port) HTTP_PORT="${2:-}"; shift 2 ;; + -B|--http-bind) HTTP_BIND="${2:-}"; shift 2 ;; + --no-http) HTTP=0; shift ;; -x|--xms) XMS="${2:-}"; shift 2 ;; -X|--xmx) XMX="${2:-}"; shift 2 ;; -N|--neoforge) NFVER="${2:-}"; shift 2 ;; @@ -52,6 +58,9 @@ done [ "$(id -u)" -eq 0 ] || die "run this with sudo — it creates a user and writes unit files" [[ "$PORT" =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] \ || die "-p wants a port between 1 and 65535, got: $PORT" +[[ "$HTTP_PORT" =~ ^[0-9]+$ ]] && [ "$HTTP_PORT" -ge 1 ] && [ "$HTTP_PORT" -le 65535 ] \ + || die "-H wants a port between 1 and 65535, got: $HTTP_PORT" +[ "$HTTP_PORT" != "$PORT" ] || die "the HTTP port and the Minecraft port cannot both be $PORT" # ----------------------------------------------------------------- share ---- @@ -217,6 +226,50 @@ cat > "$SHARE/user_jvm_args.txt" < "$UNIT_DIR/packwiz-http.service" </dev/null +if (( HTTP )); then + systemctl enable packwiz-http.service >/dev/null + systemctl restart packwiz-http.service + info "packwiz-http listening on ${HTTP_BIND}:${HTTP_PORT}" +fi echo info "installed. Start it with:" diff --git a/packwiz-http.py b/packwiz-http.py new file mode 100644 index 0000000..4a3b340 --- /dev/null +++ b/packwiz-http.py @@ -0,0 +1,116 @@ +#!/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() diff --git a/packwiz-setup.sh b/packwiz-setup.sh index 5428532..8998e58 100755 --- a/packwiz-setup.sh +++ b/packwiz-setup.sh @@ -29,6 +29,7 @@ SITE_URL="" # defaults to the origin of $BASEURL PACK_URL="" # defaults to $SITE_URL/packs/ SERVER_ADDR="" # defaults to the hostname of $SITE_URL MCPORT="25565" # Minecraft port, shown in the guides +SCHEME="" # force http or https on every generated URL GUIDES=1 # write the player setup guides FORCE=0 # move an existing pack aside and rebuild it @@ -54,6 +55,7 @@ while [ $# -gt 0 ]; do -P|--pack-url) PACK_URL="${2:-}"; shift 2 ;; -A|--server) SERVER_ADDR="${2:-}"; shift 2 ;; -p|--port) MCPORT="${2:-}"; shift 2 ;; + -S|--scheme) SCHEME="${2:-}"; shift 2 ;; --no-guides) GUIDES=0; shift ;; --force) FORCE=1; shift ;; -h|--help) usage 0 ;; @@ -100,6 +102,19 @@ if [ -n "$MODSDIR$BASEURL" ]; then fi BASEURL="${BASEURL%/}" +# The pack is usually built on the server, where the mirror may only be +# reachable over plain HTTP, while clients must be handed the public HTTPS URL. +# --scheme rewrites the scheme on every URL this run generates. +if [ -n "$SCHEME" ]; then + case "$SCHEME" in + http|https) ;; + *) die "--scheme wants http or https, got: $SCHEME" ;; + esac + [ -z "$BASEURL" ] || BASEURL="$SCHEME://${BASEURL#*://}" + [ -z "$SITE_URL" ] || SITE_URL="$SCHEME://${SITE_URL#*://}" + [ -z "$PACK_URL" ] || PACK_URL="$SCHEME://${PACK_URL#*://}" +fi + # ------------------------------------------------------------------ site ---- # Everything the guides need is derivable from the mirror URL, so one -u is