Add packwiz-http service and --scheme flag

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 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-08-05 23:27:40 +02:00
parent 7a84ef3117
commit b32b47f020
7 changed files with 220 additions and 5 deletions

View File

@ -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

Binary file not shown.

View File

@ -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

View File

@ -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

View File

@ -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" <<EOF
EOF
chown "$MCUSER":"$MCUSER" "$SHARE/user_jvm_args.txt"
# ------------------------------------------------------------ pack http ----
if (( HTTP )); then
SRV_SRC="$(dirname -- "$(readlink -f -- "$0")")/packwiz-http.py"
[ -f "$SRV_SRC" ] || die "packwiz-http.py not found next to this script"
info "installing /usr/local/bin/packwiz-http"
install -m 755 "$SRV_SRC" /usr/local/bin/packwiz-http
info "writing $UNIT_DIR/packwiz-http.service (${HTTP_BIND}:${HTTP_PORT})"
cat > "$UNIT_DIR/packwiz-http.service" <<EOF
[Unit]
Description=packwiz pack and mod mirror (HTTP)
After=network-online.target
Wants=network-online.target
RequiresMountsFor=$SHARE
[Service]
Type=simple
User=$MCUSER
Group=$MCUSER
WorkingDirectory=$SHARE
ExecStart=/usr/local/bin/packwiz-http --root $SHARE --bind $HTTP_BIND --port $HTTP_PORT
Restart=on-failure
RestartSec=5
# Serves an allowlist only, and never writes anything.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=$SHARE
[Install]
WantedBy=multi-user.target
EOF
if systemctl is-active --quiet firewalld; then
info "opening $HTTP_PORT/tcp"
firewall-cmd --quiet --permanent --add-port="$HTTP_PORT/tcp"
firewall-cmd --quiet --reload
fi
fi
# ----------------------------------------------------------------- units ----
info "writing $UNIT_DIR/minecraft.socket"
@ -282,6 +335,11 @@ EOF
info "reloading systemd"
systemctl daemon-reload
systemctl enable minecraft.socket minecraft.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:"

116
packwiz-http.py Normal file
View File

@ -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()

View File

@ -29,6 +29,7 @@ SITE_URL="" # defaults to the origin of $BASEURL
PACK_URL="" # defaults to $SITE_URL/packs/<slug>
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