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 0000000..d9abb45 Binary files /dev/null and b/__pycache__/packwiz-http.cpython-314.pyc differ 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