From 361d3ed82a044635ef175cc36aaa189f4d2b5f08 Mon Sep 17 00:00:00 2001 From: The_miro Date: Thu, 6 Aug 2026 01:10:13 +0200 Subject: [PATCH] Support client-only mods via a separate side=client directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering mods link against LWJGL, which a dedicated server does not have, so Sodium in mods/ killed the server at boot with NoClassDefFoundError on org/lwjgl/Version. The kit had nowhere else to put such a mod: it used the server's own mods/ as the mirror, so reaching clients and not being loaded by the server were mutually exclusive. $SHARE/client-mods is now mirrored alongside mods/ and enters the pack with side = "client", so packwiz-installer delivers it to players while the server never loads it. packwiz url add always writes side = "both", so the field is rewritten explicitly after each add — inserted when absent, replaced when present, and re-asserted on unchanged mods in case it drifted. Covers packwiz-setup.sh, mc-refresh-restart.sh (both directories synced through one function), packwiz-http.py (allowlist) and mc-service-setup.sh (creates the directory). Traversal out of client-mods/ is still refused. Co-Authored-By: Claude Opus 5 --- README.md | 20 ++++ __pycache__/packwiz-http.cpython-314.pyc | Bin 6943 -> 6957 bytes deploy.conf.example | 2 + mc-refresh-restart.sh | 118 ++++++++++++++--------- mc-service-setup.sh | 4 + packwiz-http.py | 2 +- packwiz-setup.sh | 107 +++++++++++++------- 7 files changed, 174 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 0e1f34b..1218c46 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,26 @@ Every script refuses to run if `/minecraft` isn't a mountpoint. An unmounted dataset leaves it as an empty directory on the root filesystem, and writing a world or a mod mirror there fills the root disk instead of the pool. +## Client-only mods + +Rendering and UI mods — Sodium, Iris, Embeddium, Oculus, shader mods — link +against LWJGL, which does not exist on a dedicated server. Put one in `mods/` +and the server dies at boot with `NoClassDefFoundError: org/lwjgl/Version`. + +Two directories, both mirrored over HTTP: + +| Directory | Loaded by the server | In the pack as | +|---|---|---| +| `/minecraft/mods` | yes | `side = "both"` | +| `/minecraft/client-mods` | no | `side = "client"` | + +Drop client-only jars in `client-mods/` and they reach players normally while +the server never sees them — `packwiz-installer -s client` fetches both sides, +and nothing server-side reads the second directory. + +The `side` field is set explicitly after each `packwiz url add`, since packwiz +writes `both` by default. + ## Day-to-day Add or remove a jar in `/minecraft/mods`, then: diff --git a/__pycache__/packwiz-http.cpython-314.pyc b/__pycache__/packwiz-http.cpython-314.pyc index d9abb456871286dc86f20d6c4ce91593e47ed53d..2e8caa4748f2e4316482ddbd6993a9fbf1b70151 100644 GIT binary patch delta 50 zcmbPlw$_YSn~#@^0SML~D$Y{d$h%rnkhzLAH$SCVzltY0Co?s#M0fIT$v2F~lUGQU F0|0DA5C#AM delta 36 qcmZ2$Hs6d_n~#@^0SMYw6=#WU/dev/null 2>&1 || die "systemctl not found." @@ -46,65 +48,93 @@ mountpoint -q "${SHARE}" || die "${SHARE} is not a mountpoint — the ZFS datase [ -f "${PACK_DIR}/pack.toml" ] || die "no pack.toml in ${PACK_DIR}" [ -d "${MODS_DIR}" ] || die "mods dir not found: ${MODS_DIR}" +[ -d "${CLIENT_MODS_DIR}" ] || mkdir -p "${CLIENT_MODS_DIR}" DATASET="$(findmnt -no SOURCE "${SHARE}" 2>/dev/null || true)" [ -n "${DATASET}" ] || die "could not determine the dataset backing ${SHARE}" -### 1. Sync pack metadata against the live mods folder #### +### 1. Sync pack metadata against the live mods folders ### # Mods are sourced by URL with hashes pinned in the index, so `packwiz refresh` # alone will NOT notice a new or replaced jar — refresh only reindexes existing # .pw.toml files. Each jar has to be (re-)registered with `packwiz url add`. -log "Syncing pack metadata against ${MODS_DIR}..." +# +# Two directories: MODS_DIR is what the server loads and clients also get +# (side=both); CLIENT_MODS_DIR reaches clients only (side=client). Rendering +# mods like Sodium link against LWJGL and crash a dedicated server on boot, so +# they must never sit in MODS_DIR. +log "Syncing pack metadata..." cd "${PACK_DIR}" -shopt -s nullglob -JARS=("${MODS_DIR}"/*.jar) -shopt -u nullglob - -# Zero jars is legitimate — an empty pack, or every mod deliberately removed. -# The orphan sweep below then clears the metadata to match. -if (( ${#JARS[@]} > 0 )); then - probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "${BASE_URL}/" || true)" - probe="${probe:-000}" - [ "${probe}" = "000" ] && die "cannot reach ${BASE_URL} — packwiz needs it to hash each mod" -else - log "WARNING: no .jar files in ${MODS_DIR} — pack will end up empty" -fi - added=0; updated=0; unchanged=0; FAILED=() declare -A SEEN=() -for jar in "${JARS[@]}"; do - file="$(basename "${jar}")" - modname="$(printf '%s' "${file%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')" - [ -n "${modname}" ] || modname="${file%.jar}" - SEEN["${modname}"]=1 +# packwiz url add always writes side = "both"; force the right one. +set_side() { + local meta="$1" side="$2" + [ -f "${meta}" ] || return 0 + if grep -q '^side *=' "${meta}"; then + sed -i "s|^side *=.*|side = \"${side}\"|" "${meta}" + else + sed -i "0,/^filename *=/s|^\(filename *=.*\)$|\1\nside = \"${side}\"|" "${meta}" + fi +} - meta="mods/${modname}.pw.toml" - enc="$(printf '%s' "${file}" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/+/%2B/g')" +sync_dir() { + local dir="$1" url="$2" side="$3" label="$4" + local jars=() jar file modname meta enc sha256 sha512 action probe - # Re-add only when the jar is new or its content changed. packwiz downloads - # the URL to hash it, so skipping unchanged mods is what keeps this quick. - if [ -f "${meta}" ]; then - sha256="$(sha256sum "${jar}" | cut -d' ' -f1)" - sha512="$(sha512sum "${jar}" | cut -d' ' -f1)" - if grep -qF -e "${sha256}" -e "${sha512}" "${meta}"; then - (( unchanged++ )) || true - continue + shopt -s nullglob + jars=("${dir}"/*.jar) + shopt -u nullglob + + if (( ${#jars[@]} == 0 )); then + log " no ${label} jars in ${dir}" + return 0 + fi + + probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "${url}/" || true)" + probe="${probe:-000}" + [ "${probe}" = "000" ] && die "cannot reach ${url} — packwiz needs it to hash each mod" + + log " ${#jars[@]} ${label} jar(s) in ${dir}" + for jar in "${jars[@]}"; do + file="$(basename "${jar}")" + modname="$(printf '%s' "${file%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')" + [ -n "${modname}" ] || modname="${file%.jar}" + SEEN["${modname}"]=1 + + meta="mods/${modname}.pw.toml" + enc="$(printf '%s' "${file}" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/+/%2B/g')" + + # Re-add only when the jar is new or its content changed. packwiz + # downloads the URL to hash it, so skipping unchanged mods keeps this + # quick. The side is re-asserted either way, in case it drifted. + if [ -f "${meta}" ]; then + sha256="$(sha256sum "${jar}" | cut -d' ' -f1)" + sha512="$(sha512sum "${jar}" | cut -d' ' -f1)" + if grep -qF -e "${sha256}" -e "${sha512}" "${meta}"; then + set_side "${meta}" "${side}" + (( unchanged++ )) || true + continue + fi + action="updated" + else + action="added" fi - action="updated" - else - action="added" - fi - if packwiz url add "${modname}" "${BASE_URL}/${enc}" >/dev/null 2>&1; then - log " ${action}: ${modname}" - [ "${action}" = "added" ] && (( added++ )) || (( updated++ )) - else - log " FAILED: ${modname}" - FAILED+=("${file}") - fi -done + if packwiz url add "${modname}" "${url}/${enc}" >/dev/null 2>&1; then + set_side "${meta}" "${side}" + log " ${action}: ${modname} (${side})" + [ "${action}" = "added" ] && (( added++ )) || (( updated++ )) + else + log " FAILED: ${modname}" + FAILED+=("${file}") + fi + done +} + +sync_dir "${MODS_DIR}" "${BASE_URL}" both "server+client" +sync_dir "${CLIENT_MODS_DIR}" "${CLIENT_URL}" client "client-only" ### 1b. Drop metadata for jars that are gone ############## if (( REMOVE_ORPHANS )); then diff --git a/mc-service-setup.sh b/mc-service-setup.sh index 225165c..428c345 100755 --- a/mc-service-setup.sh +++ b/mc-service-setup.sh @@ -161,6 +161,10 @@ fi # before that. Setgid so jars dropped in by either side stay group-readable. install -d -o "$MCUSER" -g "$MCUSER" -m 2775 "$SHARE/mods" +# Client-only mods live outside mods/ so the dedicated server never loads them: +# rendering mods link against LWJGL, which does not exist server-side. +install -d -o "$MCUSER" -g "$MCUSER" -m 2775 "$SHARE/client-mods" + # Without this the chown above locks the human out of the share they author the # pack on. Share root and packs/ become group-writable and setgid, so files # created by either side stay readable to both. diff --git a/packwiz-http.py b/packwiz-http.py index 4a3b340..f4aa5b7 100644 --- a/packwiz-http.py +++ b/packwiz-http.py @@ -17,7 +17,7 @@ import sys import urllib.parse # Top-level prefixes clients legitimately need. -ALLOWED_DIRS = ("mods/", "packs/") +ALLOWED_DIRS = ("mods/", "client-mods/", "packs/") def is_allowed(path: str) -> bool: diff --git a/packwiz-setup.sh b/packwiz-setup.sh index ffbff84..0011a88 100755 --- a/packwiz-setup.sh +++ b/packwiz-setup.sh @@ -19,6 +19,8 @@ AUTHOR="" MCVER="1.21.1" NFVER="" # empty = latest MODSDIR="" +CLIENTDIR="" # client-only mods; not loaded by the server +CLIENTURL="" # defaults to $SITE_URL/client-mods BASEURL="" DIR="" PACKVER="1.0.0" @@ -45,6 +47,8 @@ while [ $# -gt 0 ]; do -m|--mc) MCVER="${2:-}"; shift 2 ;; -f|--neoforge) NFVER="${2:-}"; shift 2 ;; -M|--mods-dir) MODSDIR="${2:-}"; shift 2 ;; + -c|--client-mods) CLIENTDIR="${2:-}"; shift 2 ;; + --client-url) CLIENTURL="${2:-}"; shift 2 ;; -u|--base-url) BASEURL="${2:-}"; shift 2 ;; -v|--version) PACKVER="${2:-}"; shift 2 ;; -d|--dir) DIR="${2:-}"; shift 2 ;; @@ -104,6 +108,15 @@ if [ -n "$MODSDIR$BASEURL" ]; then info "creating $MODSDIR" mkdir -p "$MODSDIR" || die "could not create $MODSDIR" fi + + # Client-only mods (Sodium, Iris, shader and GUI mods) must reach players + # but must never be loaded by the dedicated server — they link against + # LWJGL, which does not exist server-side. Separate directory, side=client. + [ -n "$CLIENTDIR" ] || CLIENTDIR="$SHARE/client-mods" + if [ ! -d "$CLIENTDIR" ]; then + info "creating $CLIENTDIR" + mkdir -p "$CLIENTDIR" || die "could not create $CLIENTDIR" + fi fi BASEURL="${BASEURL%/}" @@ -118,6 +131,7 @@ if [ -n "$SCHEME" ]; then [ -z "$BASEURL" ] || BASEURL="$SCHEME://${BASEURL#*://}" [ -z "$SITE_URL" ] || SITE_URL="$SCHEME://${SITE_URL#*://}" [ -z "$PACK_URL" ] || PACK_URL="$SCHEME://${PACK_URL#*://}" + [ -z "$CLIENTURL" ] || CLIENTURL="$SCHEME://${CLIENTURL#*://}" fi # ------------------------------------------------------------------ site ---- @@ -138,6 +152,8 @@ if [ -n "$SITE_URL" ]; then [ -n "$SERVER_ADDR" ] || SERVER_ADDR="$(sed -e 's#^[a-z]*://##' -e 's#/.*##' -e 's#:.*##' <<<"$SITE_URL")" fi PACK_URL="${PACK_URL%/}" +[ -n "$CLIENTURL" ] || [ -z "$SITE_URL" ] || CLIENTURL="$SITE_URL/client-mods" +CLIENTURL="${CLIENTURL%/}" # Minecraft assumes 25565, so only show a port when it is not that — a # needless ":25565" in the address is a classic source of player confusion. @@ -230,62 +246,85 @@ info "initialising NeoForge pack for MC $MCVER in $(pwd)" # ---------------------------------------------------------------- mirror ---- -if [ -n "$MODSDIR" ]; then - grep -q 'url' <<<"$("$PACKWIZ" --help 2>&1 || true)" \ - || die "this packwiz build has no 'url' subcommand — cannot add mods by URL" - - shopt -s nullglob - jars=("$MODSDIR"/*.jar) - shopt -u nullglob - - failed=() - if [ "${#jars[@]}" -eq 0 ]; then - # An empty pack is a valid starting point: drop jars in later and run - # mc-refresh-restart.sh to register them. Nothing needs fetching, so the - # mirror does not have to be reachable yet either. - warn "no .jar files in $MODSDIR yet — creating an empty pack" +# packwiz url add always writes side = "both". Client-only mods have to say +# "client", so `packwiz-installer -s server` skips them and the dedicated +# server never tries to load a rendering mod. +set_side() { + local meta="$1" side="$2" + [ -f "$meta" ] || return 0 + if grep -q '^side *=' "$meta"; then + sed -i "s|^side *=.*|side = \"$side\"|" "$meta" else - # packwiz downloads each URL to hash it, so a mirror that isn't up yet - # produces a pile of confusing per-mod failures. Check once, up front. - info "checking mirror at $BASEURL" - probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "$BASEURL/" || true)" + sed -i "0,/^filename *=/s|^\(filename *=.*\)$|\1\nside = \"$side\"|" "$meta" + fi +} + +# Filename without .jar and without a trailing version: packwiz uses this as +# the .pw.toml basename, and it should stay stable across version bumps. +mod_name_for() { + local n + n="$(printf '%s' "${1%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')" + [ -n "$n" ] || n="${1%.jar}" + printf '%s' "$n" +} + +probe_mirror() { + local url="$1" probe + info "checking mirror at $url" + probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "$url/" || true)" probe="${probe:-000}" case "$probe" in 2*|3*|40[34]) ;; # a listing may legitimately be forbidden - 000) die "cannot reach $BASEURL — the pack server or proxy is not up yet" ;; - *) warn "$BASEURL/ returned HTTP $probe; continuing, individual mods may fail" ;; + 000) die "cannot reach $url — the pack server or proxy is not up yet" ;; + *) warn "$url/ returned HTTP $probe; continuing, individual mods may fail" ;; esac +} - info "adding ${#jars[@]} mods from the mirror" +add_dir() { + local dir="$1" url="$2" side="$3" label="$4" + local jars=() jar file modname enc + + shopt -s nullglob; jars=("$dir"/*.jar); shopt -u nullglob + if [ "${#jars[@]}" -eq 0 ]; then + info "no jars in $dir — no $label mods" + return 0 + fi + + probe_mirror "$url" + info "adding ${#jars[@]} $label mods" for jar in "${jars[@]}"; do file="$(basename "$jar")" - - # Mod name: filename without .jar and without a trailing version, since - # packwiz uses this as the .pw.toml basename and it should stay stable - # across version bumps. - modname="$(printf '%s' "${file%.jar}" | sed -E 's/-[0-9][0-9A-Za-z.+_-]*$//')" - [ -n "$modname" ] || modname="${file%.jar}" - - # Percent-encode the filename for the URL; leave the base URL alone. + modname="$(mod_name_for "$file")" + # Percent-encode the filename; leave the base URL alone. enc="$(printf '%s' "$file" | sed -e 's/%/%25/g' -e 's/ /%20/g' -e 's/+/%2B/g')" - if "$PACKWIZ" url add "$modname" "$BASEURL/$enc" >/dev/null 2>&1; then - printf ' + %s\n' "$modname" + if "$PACKWIZ" url add "$modname" "$url/$enc" >/dev/null 2>&1; then + set_side "mods/$modname.pw.toml" "$side" + printf ' + %-40s %s\n' "$modname" "$side" + total=$(( total + 1 )) else printf ' ! %s (failed)\n' "$modname" failed+=("$file") fi done +} - fi +if [ -n "$MODSDIR" ]; then + grep -q 'url' <<<"$("$PACKWIZ" --help 2>&1 || true)" \ + || die "this packwiz build has no 'url' subcommand — cannot add mods by URL" + + failed=(); total=0 + add_dir "$MODSDIR" "$BASEURL" both "server+client" + add_dir "$CLIENTDIR" "$CLIENTURL" client "client-only" "$PACKWIZ" refresh + info "$total mods in the pack" if [ "${#failed[@]}" -gt 0 ]; then echo - warn "${#failed[@]} of ${#jars[@]} mods failed to add:" + warn "${#failed[@]} mod(s) failed to add:" printf ' %s\n' "${failed[@]}" >&2 - warn "re-run those by hand to see the error, e.g." + warn "re-run one by hand to see the error, e.g." warn " $PACKWIZ url add $BASEURL/${failed[0]}" fi fi