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
parent
7a84ef3117
commit
b32b47f020
19
README.md
19
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
|
||||
|
|
|
|||
Binary file not shown.
4
build.sh
4
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
|
||||
|
|
|
|||
13
deploy.sh
13
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
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue