From e738422dbe221cc7d1a8d577ca98d39116f63786 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 5 Aug 2026 22:23:34 +0200 Subject: [PATCH] Initial commit: packwiz + NeoForge hosting kit Deploys a self-hosted NeoForge server whose modpack clients pull automatically on launch, with mods served from an HTTP mirror of the server's own mods dir rather than Modrinth or CurseForge. - mc-service-setup.sh: minecraft user, NeoForge install, systemd service paired with a console FIFO socket so stops are graceful and the world saves - packwiz-setup.sh: packwiz install, pack init, mod index built from the mirror, and per-OS player setup guides generated with real URLs baked in - mc-refresh-restart.sh: re-sync after mod changes, ZFS snapshot, restart - deploy.sh: runs the above in order - build.sh: release zip, refusing to package scripts that don't parse Every entry point refuses to run when /minecraft is not a mountpoint, so an unmounted dataset cannot silently fill the root filesystem. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + README.md | 97 ++++++++ build.sh | 104 ++++++++ deploy.sh | 81 ++++++ mc-refresh-restart.sh | 204 +++++++++++++++ mc-service-setup.sh | 230 +++++++++++++++++ packwiz-setup.sh | 564 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1282 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 build.sh create mode 100755 deploy.sh create mode 100755 mc-refresh-restart.sh create mode 100755 mc-service-setup.sh create mode 100755 packwiz-setup.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22652d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# build output +/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..13f0fe9 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# mc-packwiz-deploy + +Self-hosted NeoForge Minecraft server with a packwiz modpack that clients pull +automatically on every launch. Mods are served from an HTTP mirror of the +server's own mods directory — no Modrinth or CurseForge dependency. + +Everything lives on a ZFS dataset mounted at `/minecraft`. + +## Quick start + +```bash +./deploy.sh -n "Opengames" -a themiro \ + -u https://games.abdelbaki.eu/mods \ + -N 21.1.72 --accept-eula +``` + +Then: + +```bash +sudo systemctl start minecraft +journalctl -u minecraft -f +``` + +Send the players `https:///setup-windows-packwiz.html` or +`setup-linux-packwiz.html`. Those are generated with your real pack URL and +server address already filled in. + +## What's in here + +| Script | Runs as | Does | +|---|---|---| +| `deploy.sh` | you | Runs the two setup scripts in order | +| `mc-service-setup.sh` | root | `minecraft` user, NeoForge server, systemd units | +| `packwiz-setup.sh` | you | packwiz install, pack init, mod index, player guides | +| `mc-refresh-restart.sh` | you | Re-sync the pack after changing mods, then restart | +| `build.sh` | you | Builds the release zip | + +## Requirements + +- Fedora (or any systemd distro; `dnf` calls need swapping otherwise) +- A ZFS dataset mounted at `/minecraft` +- Java 21 +- A web server already publishing `/minecraft` — `/mods` and `/packs` must be + reachable over HTTP + +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. + +## Day-to-day + +Add or remove a jar in `/minecraft/mods`, then: + +```bash +./mc-refresh-restart.sh +``` + +That registers new mods, re-pins any whose file changed, drops metadata for +deleted ones, takes a ZFS snapshot, restarts the server, and prunes to the +8 most recent snapshots. Players get the change on their next launch. + +It refuses to restart if any mod failed to register, so the server never comes +up on a half-synced pack. Edit the config block at the top first — `PACK_DIR`, +`BASE_URL`, and `PACK_URL` need to match your deployment. + +## The server unit + +`minecraft.service` pairs with `minecraft.socket`, a console FIFO at +`/run/minecraft-console`. That's what makes a clean shutdown possible: + +```bash +sudo systemctl stop minecraft # sends "stop", world saves properly +echo "say hello" | sudo tee /run/minecraft-console +journalctl -u minecraft -f # console output +``` + +Without the FIFO, systemd would SIGTERM the JVM and risk cutting a world save +in half. + +## Web server + +`/minecraft` needs to be served over HTTP. Serve `pack.toml` and `index.toml` +with `Cache-Control: no-cache` — a caching proxy will otherwise hand clients a +stale pack while the server runs the new one. `mc-refresh-restart.sh` checks for +exactly that and stops before restarting if it finds it. + +## Caveats + +- `packwiz-setup.sh` probes `packwiz --help` at runtime rather than assuming + flag names, since the NeoForge options have moved between builds. If it bails + with an interface complaint, the installed packwiz differs from what's + expected. +- Mod names come from filenames with the trailing version stripped + (`create-1.21.1-6.0.4.jar` → `create`). It's a heuristic; rename the + generated `.pw.toml` files if you don't like the result. +- Hashes are pinned. Replacing a jar in place under the same filename requires + a re-sync, or clients fail the hash check. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..31c778b --- /dev/null +++ b/build.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Build the release zip. +# +# ./build.sh -> dist/mc-packwiz-deploy-.zip +# ./build.sh -o /tmp -> writes there instead +# ./build.sh -v 1.2.0 -> override the version +# +# Version comes from the latest git tag when there is one, otherwise VERSION +# below. A dirty working tree gets a -dirty suffix so a zip built mid-edit is +# never mistaken for a tagged release. + +set -euo pipefail + +VERSION="0.1.0" +NAME="mc-packwiz-deploy" +OUTDIR="" +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +# Everything the zip ships. Anything not listed here stays out, so stray notes +# or test output in the working tree can't leak into a release. +FILES=( + README.md + deploy.sh + mc-service-setup.sh + packwiz-setup.sh + mc-refresh-restart.sh +) + +die() { echo "error: $*" >&2; exit 1; } +info() { echo ">>> $*"; } +usage() { sed -n '2,11p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; } + +while [ $# -gt 0 ]; do + case "$1" in + -o|--outdir) OUTDIR="${2:-}"; shift 2 ;; + -v|--version) VERSION="${2:-}"; VERSION_FORCED=1; shift 2 ;; + -h|--help) usage 0 ;; + *) echo "unknown option: $1" >&2; usage 1 ;; + esac +done + +cd "$HERE" +command -v zip >/dev/null || die "zip not found — sudo dnf install zip" + +# ---------------------------------------------------------------- version --- + +if [ -z "${VERSION_FORCED:-}" ] && git rev-parse --git-dir >/dev/null 2>&1; then + tag="$(git describe --tags --abbrev=0 2>/dev/null || true)" + [ -n "$tag" ] && VERSION="${tag#v}" + if ! git diff --quiet HEAD -- "${FILES[@]}" 2>/dev/null; then + VERSION="${VERSION}-dirty" + info "working tree has uncommitted changes to shipped files" + fi +fi + +[ -n "$OUTDIR" ] || OUTDIR="$HERE/dist" +mkdir -p "$OUTDIR" +ZIP="$OUTDIR/${NAME}-${VERSION}.zip" + +# ------------------------------------------------------------------ check --- + +for f in "${FILES[@]}"; do + [ -f "$f" ] || die "missing file: $f" +done + +info "syntax-checking scripts" +fail=0 +for f in "${FILES[@]}"; do + case "$f" in + *.sh) bash -n "$f" || { echo " FAILED: $f" >&2; fail=1; } ;; + esac +done +(( fail )) && die "refusing to build a zip containing scripts that don't parse" + +# ------------------------------------------------------------------ stage --- + +# Staged in a temp tree so the zip has a single top-level directory and the +# scripts land executable regardless of how git checked them out. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +DEST="$STAGE/$NAME" +mkdir -p "$DEST" + +for f in "${FILES[@]}"; do + case "$f" in + *.sh) install -m 755 "$f" "$DEST/$f" ;; + *) install -m 644 "$f" "$DEST/$f" ;; + esac +done + +printf '%s\n' "$VERSION" > "$DEST/VERSION" + +# ------------------------------------------------------------------ build --- + +rm -f "$ZIP" +( cd "$STAGE" && zip -qr "$ZIP" "$NAME" ) + +info "built $ZIP" +unzip -l "$ZIP" | sed 's/^/ /' + +if command -v sha256sum >/dev/null; then + sha256sum "$ZIP" | tee "$ZIP.sha256" | sed 's/^/ /' +fi diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..f338569 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# One-shot deploy: systemd service + packwiz pack + player setup guides. +# +# ./deploy.sh -n "Opengames" -a themiro -u https://games.abdelbaki.eu/mods \ +# -N 21.1.72 --accept-eula +# +# Runs, in order: +# 1. mc-service-setup.sh (root) — minecraft user, NeoForge, systemd units +# 2. packwiz-setup.sh (you) — pack, mod index from the mirror, guides +# +# Run as your normal user; it calls sudo for the parts that need it. + +set -euo pipefail + +NAME=""; AUTHOR=""; BASEURL=""; NFVER=""; MCVER="1.21.1" +SHARE="/minecraft"; XMX="8G"; XMS="4G"; ACCEPT_EULA="" +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +die() { echo "error: $*" >&2; exit 1; } +step() { printf '\n\033[1m=== %s ===\033[0m\n\n' "$*"; } +usage() { sed -n '2,13p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; } + +while [ $# -gt 0 ]; do + case "$1" in + -n|--name) NAME="${2:-}"; shift 2 ;; + -a|--author) AUTHOR="${2:-}"; shift 2 ;; + -u|--base-url) BASEURL="${2:-}"; shift 2 ;; + -N|--neoforge) NFVER="${2:-}"; shift 2 ;; + -m|--mc) MCVER="${2:-}"; shift 2 ;; + -s|--share) SHARE="${2:-}"; shift 2 ;; + -X|--xmx) XMX="${2:-}"; shift 2 ;; + -x|--xms) XMS="${2:-}"; shift 2 ;; + --accept-eula) ACCEPT_EULA="--accept-eula"; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown option: $1" >&2; usage 1 ;; + esac +done + +[ "$(id -u)" -ne 0 ] || die "run this as your normal user, not root — it sudos where needed" +[ -n "$NAME" ] || die "missing -n " +[ -n "$AUTHOR" ] || die "missing -a " +[ -n "$BASEURL" ] || die "missing -u , e.g. https://games.abdelbaki.eu/mods" + +for s in mc-service-setup.sh packwiz-setup.sh mc-refresh-restart.sh; do + [ -x "$HERE/$s" ] || die "missing or not executable: $HERE/$s" +done + +# Fail early rather than halfway through, since step 1 makes system changes. +sudo -v || die "sudo is required" + +step "1/2 Minecraft systemd service" +sudo "$HERE/mc-service-setup.sh" \ + -s "$SHARE" -A "$USER" -X "$XMX" -x "$XMS" \ + ${NFVER:+-N "$NFVER"} ${ACCEPT_EULA} + +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" +else + sg minecraft -c "$(printf '%q ' "$HERE/packwiz-setup.sh" -n "$NAME" -a "$AUTHOR" \ + -m "$MCVER" -u "$BASEURL" -s "$SHARE")" +fi + +step "Done" +cat </dev/null 2>&1 || die "systemctl not found." +command -v packwiz >/dev/null 2>&1 || die "packwiz not found in PATH" + +# An unmounted dataset leaves /minecraft as an empty dir on the root +# filesystem. Refuse rather than snapshot nothing and archive nothing. +mountpoint -q "${SHARE}" || die "${SHARE} is not a mountpoint — the ZFS dataset is not mounted. + Check 'zpool status' and 'zfs mount -a'." + +[ -f "${PACK_DIR}/pack.toml" ] || die "no pack.toml in ${PACK_DIR}" +[ -d "${MODS_DIR}" ] || die "mods dir not found: ${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 #### +# 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}..." +cd "${PACK_DIR}" + +probe="$(curl -so /dev/null -w '%{http_code}' --max-time 10 "${BASE_URL}/" || echo 000)" +[ "${probe}" = "000" ] && die "cannot reach ${BASE_URL} — packwiz needs it to hash each mod" + +shopt -s nullglob +JARS=("${MODS_DIR}"/*.jar) +shopt -u nullglob +(( ${#JARS[@]} > 0 )) || die "no .jar files in ${MODS_DIR}" + +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 + + 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 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 + 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 + +### 1b. Drop metadata for jars that are gone ############## +if (( REMOVE_ORPHANS )); then + shopt -s nullglob + for meta in mods/*.pw.toml; do + modname="$(basename "${meta}" .pw.toml)" + [ -n "${SEEN[${modname}]:-}" ] && continue + log " orphaned, removing: ${modname}" + packwiz remove "${modname}" >/dev/null 2>&1 || rm -f -- "${meta}" + done + shopt -u nullglob +fi + +log "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged." + +if (( ${#FAILED[@]} > 0 )); then + log "WARNING: ${#FAILED[@]} mod(s) failed to register:" + printf ' %s\n' "${FAILED[@]}" + die "refusing to restart with an incomplete pack — fix these first" +fi + +### 2. Refresh packwiz index ############################# +log "Refreshing packwiz index..." +packwiz refresh +log "index.toml refreshed." + +### 2b. Verify clients can actually see the new pack ###### +# Clients pull pack.toml -> index.toml -> mods/*.pw.toml over HTTP. If PACK_DIR +# isn't served, or a proxy is caching it, the server restarts onto a pack no +# client can fetch — so compare what the mirror hands back against what's on +# disk before touching the service. +PACK_URL="${PACK_URL%/}" +if [ -n "${PACK_URL}" ]; then + log "Verifying ${PACK_URL}/pack.toml ..." + remote="$(curl -fsS --max-time 10 -H 'Cache-Control: no-cache' "${PACK_URL}/pack.toml" 2>/dev/null || true)" + + if [ -z "${remote}" ]; then + die "cannot fetch ${PACK_URL}/pack.toml — ${PACK_DIR} is not being served. + Point the web root at it, or set PACK_URL=\"\" to skip this check." + fi + + if [ "$(printf '%s' "${remote}" | sha256sum | cut -d' ' -f1)" \ + = "$(sha256sum pack.toml | cut -d' ' -f1)" ]; then + log "Mirror is serving the current pack.toml." + else + die "${PACK_URL}/pack.toml differs from ${PACK_DIR}/pack.toml. + Usually a caching proxy. Serve pack.toml and index.toml with + 'Cache-Control: no-cache', or purge the cache, then re-run." + fi + + # index.toml is fetched as a separate request and cached separately. + idx="$(curl -fsS --max-time 10 -o /dev/null -w '%{http_code}' "${PACK_URL}/index.toml" || echo 000)" + [ "${idx}" = "200" ] || die "${PACK_URL}/index.toml returned HTTP ${idx} — clients cannot resolve the pack" +fi + +### 3. Snapshot the share ################################ +# Taken BEFORE the restart so a server that fails to come up can be rolled +# back to the exact state that last worked. +TIMESTAMP="$(date '+%Y%m%d%H%M')" +SNAPSHOT="${DATASET}@${SNAP_PREFIX}-${TIMESTAMP}" +log "Snapshotting ${SNAPSHOT}..." +if priv zfs snapshot "${SNAPSHOT}"; then + log "Snapshot complete: ${SNAPSHOT}" +else + die "zfs snapshot failed" +fi + +### 4. Restart the server via systemd ##################### +log "Restarting ${SERVICE_NAME}..." +priv systemctl restart "${SERVICE_NAME}" + +waited=0 +until systemctl is-active --quiet "${SERVICE_NAME}" || (( waited >= START_TIMEOUT )); do + sleep 1 + (( waited++ )) || true +done + +if systemctl is-active --quiet "${SERVICE_NAME}"; then + log "${SERVICE_NAME} is active." +else + log "WARNING: ${SERVICE_NAME} did not report active within ${START_TIMEOUT}s." + log " Check: journalctl -u ${SERVICE_NAME} -n 50" + log " Roll back: sudo systemctl stop ${SERVICE_NAME} && sudo zfs rollback ${SNAPSHOT}" +fi + +### 5. Prune old snapshots, keep newest N ################# +log "Pruning snapshots, keeping newest ${KEEP_SNAPSHOTS}..." +mapfile -t SNAPSHOTS < <( + zfs list -H -o name -t snapshot -s creation "${DATASET}" 2>/dev/null \ + | grep -- "@${SNAP_PREFIX}-" || true +) + +if (( ${#SNAPSHOTS[@]} > KEEP_SNAPSHOTS )); then + # Sorted oldest-first by creation, so the excess is at the front. + for old in "${SNAPSHOTS[@]:0:$(( ${#SNAPSHOTS[@]} - KEEP_SNAPSHOTS ))}"; do + log "Destroying old snapshot: ${old}" + priv zfs destroy -- "${old}" + done +else + log "Nothing to prune (${#SNAPSHOTS[@]}/${KEEP_SNAPSHOTS})." +fi + +log "Done." diff --git a/mc-service-setup.sh b/mc-service-setup.sh new file mode 100755 index 0000000..b0cfd39 --- /dev/null +++ b/mc-service-setup.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# +# Install the Minecraft NeoForge server as a systemd service on the ZFS share. +# +# sudo ./mc-service-setup.sh --accept-eula +# sudo ./mc-service-setup.sh --accept-eula -N 21.1.72 -X 12G +# +# Creates a `minecraft` system user, optionally installs the NeoForge server, +# and writes minecraft.service + minecraft.socket. The socket is a console FIFO +# at /run/minecraft-console, which is what lets systemd stop the server with a +# real `stop` command so the world saves instead of being killed mid-write. +# +# systemctl start|stop|restart minecraft +# echo "say hello" | sudo tee /run/minecraft-console # console commands +# journalctl -u minecraft -f # console output + +set -euo pipefail + +SHARE="/minecraft" +MCUSER="minecraft" +XMS="4G" +XMX="8G" +NFVER="" # set to install the NeoForge server +ADMIN="" # human user who authors the pack on the share +ACCEPT_EULA=0 +UNIT_DIR="/etc/systemd/system" +FIFO="/run/minecraft-console" + +die() { echo "error: $*" >&2; exit 1; } +info() { echo ">>> $*"; } +warn() { echo " warning: $*" >&2; } +usage() { sed -n '2,17p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; } + +while [ $# -gt 0 ]; do + case "$1" in + -s|--share) SHARE="${2:-}"; shift 2 ;; + -U|--user) MCUSER="${2:-}"; shift 2 ;; + -x|--xms) XMS="${2:-}"; shift 2 ;; + -X|--xmx) XMX="${2:-}"; shift 2 ;; + -N|--neoforge) NFVER="${2:-}"; shift 2 ;; + -A|--admin) ADMIN="${2:-}"; shift 2 ;; + --accept-eula) ACCEPT_EULA=1; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown option: $1" >&2; usage 1 ;; + esac +done + +[ "$(id -u)" -eq 0 ] || die "run this with sudo — it creates a user and writes unit files" + +# ----------------------------------------------------------------- share ---- + +[ -d "$SHARE" ] || die "$SHARE does not exist" +mountpoint -q "$SHARE" || die "$SHARE is not a mountpoint — the ZFS dataset is not mounted. + Check 'zpool status' and 'zfs mount -a' first; installing a server onto the + unmounted path would fill the root filesystem instead of the pool." + +command -v java >/dev/null || die "java not found — install it first: dnf install java-21-openjdk-headless" + +jver="$(java -version 2>&1 | head -1 | grep -oE '"[0-9]+' | tr -d '"' || echo 0)" +[ "${jver:-0}" -ge 21 ] || warn "java looks like version $jver; NeoForge for 1.21.x needs 21" + +# ------------------------------------------------------------------ user ---- + +if ! getent passwd "$MCUSER" >/dev/null; then + info "creating system user $MCUSER" + useradd --system --home-dir "$SHARE" --shell /usr/sbin/nologin \ + --comment "Minecraft server" "$MCUSER" +else + info "user $MCUSER already exists" +fi + +info "setting ownership of $SHARE to $MCUSER" +chown -R "$MCUSER":"$MCUSER" "$SHARE" + +# 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. +if [ -n "$ADMIN" ]; then + getent passwd "$ADMIN" >/dev/null || die "admin user does not exist: $ADMIN" + info "granting $ADMIN write access to $SHARE (group $MCUSER)" + usermod -aG "$MCUSER" "$ADMIN" + chown "$ADMIN":"$MCUSER" "$SHARE" + chmod 2775 "$SHARE" + install -d -o "$ADMIN" -g "$MCUSER" -m 2775 "$SHARE/packs" + warn "$ADMIN's new group membership needs a fresh login to take effect in + existing shells — 'newgrp $MCUSER' works for the current one." +fi + +# -------------------------------------------------------------- neoforge ---- + +if [ -n "$NFVER" ]; then + info "installing NeoForge $NFVER server into $SHARE" + url="https://maven.neoforged.net/releases/net/neoforged/neoforge/${NFVER}/neoforge-${NFVER}-installer.jar" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + + curl -fsSL -o "$tmp/installer.jar" "$url" \ + || die "could not download $url — check the version number exists" + + # The installer writes into the working directory, so run it as the service + # user to avoid leaving root-owned files scattered through the share. + ( cd "$SHARE" && runuser -u "$MCUSER" -- java -jar "$tmp/installer.jar" --installServer ) \ + || die "NeoForge installer failed" + rm -rf "$tmp"; trap - EXIT +fi + +# Find the generated arg file — its path carries the NeoForge version, so +# detect it rather than hardcoding one that goes stale on the next upgrade. +ARGS_FILE="$(find "$SHARE/libraries/net/neoforged/neoforge" -name unix_args.txt 2>/dev/null | sort -V | tail -1 || true)" + +if [ -z "$ARGS_FILE" ]; then + die "no NeoForge server found under $SHARE + Install one with: sudo $0 --accept-eula -N + Versions are listed at https://projects.neoforged.net/neoforged/neoforge" +fi +info "server args: $ARGS_FILE" + +# ------------------------------------------------------------------ eula ---- + +if [ ! -f "$SHARE/eula.txt" ] || ! grep -q '^eula=true' "$SHARE/eula.txt"; then + if (( ACCEPT_EULA )); then + info "writing eula.txt (accepted via --accept-eula)" + echo "eula=true" > "$SHARE/eula.txt" + chown "$MCUSER":"$MCUSER" "$SHARE/eula.txt" + else + die "the Minecraft EULA has not been accepted. + Read https://aka.ms/MinecraftEULA then re-run with --accept-eula, + or write 'eula=true' into $SHARE/eula.txt yourself." + fi +fi + +# -------------------------------------------------------------- jvm args ---- + +# Written fresh so -X/-x actually take effect on re-runs. G1 tuning is the +# widely-used server config; adjust here if you want different flags. +info "writing user_jvm_args.txt (Xms=$XMS Xmx=$XMX)" +cat > "$SHARE/user_jvm_args.txt" < "$UNIT_DIR/minecraft.socket" < "$UNIT_DIR/minecraft.service" < $FIFO' + +# Generous: a big world with many chunks loaded can take a while to save. +TimeoutStopSec=180 +Restart=on-failure +RestartSec=15 + +# Hardening. The server only ever needs to write inside the share. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=$SHARE + +[Install] +WantedBy=multi-user.target +EOF + +# ---------------------------------------------------------------- enable ---- + +info "reloading systemd" +systemctl daemon-reload +systemctl enable minecraft.socket minecraft.service >/dev/null + +echo +info "installed. Start it with:" +cat < +SERVER_ADDR="" # defaults to the hostname of $SITE_URL +GUIDES=1 # write the player setup guides + +die() { echo "error: $*" >&2; exit 1; } +info() { echo ">>> $*"; } +warn() { echo " warning: $*" >&2; } +usage() { sed -n '2,15p' "$0" | sed 's/^# \?//'; exit "${1:-0}"; } + +while [ $# -gt 0 ]; do + case "$1" in + -n|--name) NAME="${2:-}"; shift 2 ;; + -a|--author) AUTHOR="${2:-}"; shift 2 ;; + -m|--mc) MCVER="${2:-}"; shift 2 ;; + -f|--neoforge) NFVER="${2:-}"; shift 2 ;; + -M|--mods-dir) MODSDIR="${2:-}"; shift 2 ;; + -u|--base-url) BASEURL="${2:-}"; shift 2 ;; + -v|--version) PACKVER="${2:-}"; shift 2 ;; + -d|--dir) DIR="${2:-}"; shift 2 ;; + -s|--share) SHARE="${2:-}"; shift 2 ;; + -r|--root) PACKROOT="${2:-}"; shift 2 ;; + -W|--webroot) WEBROOT="${2:-}"; shift 2 ;; + -U|--site-url) SITE_URL="${2:-}"; shift 2 ;; + -P|--pack-url) PACK_URL="${2:-}"; shift 2 ;; + -A|--server) SERVER_ADDR="${2:-}"; shift 2 ;; + --no-guides) GUIDES=0; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown option: $1" >&2; usage 1 ;; + esac +done + +[ "$(id -u)" -ne 0 ] || die "run this as your normal user, not root" +[ -n "$NAME" ] || die "missing -n " +[ -n "$AUTHOR" ] || die "missing -a " + +# ----------------------------------------------------------------- share ---- + +# If the ZFS dataset isn't mounted, /minecraft is just an empty directory on +# root — writing a pack there would silently land on the 15G root filesystem +# instead of the pool. Refuse rather than fill up the wrong disk. +[ -d "$SHARE" ] || die "$SHARE does not exist" +if ! mountpoint -q "$SHARE"; then + die "$SHARE exists but nothing is mounted there — the ZFS dataset is not mounted. + Check 'zpool status' and 'zfs mount -a' before running this." +fi + +fstype="$(findmnt -no FSTYPE "$SHARE" 2>/dev/null || true)" +[ "$fstype" = "zfs" ] || warn "$SHARE is $fstype, not zfs — continuing anyway" + +[ -n "$PACKROOT" ] || PACKROOT="$SHARE/packs" + +slug="$(printf '%s' "$NAME" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-')" +[ -n "$slug" ] || die "pack name '$NAME' has no usable characters for a directory name" +[ -n "$DIR" ] || DIR="$PACKROOT/$slug" + +# Fail on permissions now, with the fix, rather than midway through init. +mkdir -p "$PACKROOT" 2>/dev/null || true +[ -w "$PACKROOT" ] || die "cannot write to $PACKROOT + fix with: sudo install -d -o $USER -g $USER $PACKROOT" + +# ----------------------------------------------------------------- mirror --- + +# The mods dir defaults to the share; -u is what turns mirroring on. +[ -n "$BASEURL" ] && [ -z "$MODSDIR" ] && MODSDIR="$SHARE/mods" +if [ -n "$MODSDIR$BASEURL" ]; then + [ -n "$BASEURL" ] || die "-M given without -u " + [ -d "$MODSDIR" ] || die "mods dir does not exist: $MODSDIR" +fi +BASEURL="${BASEURL%/}" + +# ------------------------------------------------------------------ site ---- + +# Everything the guides need is derivable from the mirror URL, so one -u is +# usually enough; each piece can still be overridden individually. +[ -n "$SITE_URL" ] || [ -z "$BASEURL" ] || SITE_URL="$(awk -F/ '{print $1"//"$3}' <<<"$BASEURL")" +SITE_URL="${SITE_URL%/}" + +if [ -n "$SITE_URL" ]; then + [ -n "$PACK_URL" ] || PACK_URL="$SITE_URL/packs/$slug" + [ -n "$SERVER_ADDR" ] || SERVER_ADDR="$(sed -e 's#^[a-z]*://##' -e 's#:.*##' <<<"$SITE_URL")" +fi +PACK_URL="${PACK_URL%/}" + +[ -n "$WEBROOT" ] || WEBROOT="$SHARE" + +if (( GUIDES )); then + if [ -z "$SITE_URL" ]; then + warn "no -u or -U given, so the guide URLs can't be derived — skipping guides" + GUIDES=0 + elif [ ! -d "$WEBROOT" ]; then + die "webroot does not exist: $WEBROOT (set it with -W, or pass --no-guides)" + elif [ ! -w "$WEBROOT" ]; then + die "cannot write guides to $WEBROOT + fix with: sudo install -d -o $USER -g $USER $WEBROOT + or pass --no-guides" + fi +fi + +# ------------------------------------------------------------------ deps ---- + +if ! command -v go >/dev/null; then + info "installing Go" + sudo dnf install -y golang >/dev/null || die "could not install golang" +fi + +GOBIN="$(go env GOPATH)/bin" + +if ! command -v packwiz >/dev/null && [ ! -x "$GOBIN/packwiz" ]; then + info "building packwiz (pulls from the Go module proxy, takes a minute)" + go install github.com/packwiz/packwiz@latest \ + || die "go install failed — check network access to proxy.golang.org" +fi + +PACKWIZ="$(command -v packwiz || echo "$GOBIN/packwiz")" +[ -x "$PACKWIZ" ] || die "packwiz not found after install; expected $GOBIN/packwiz" +info "packwiz: $PACKWIZ" + +if ! command -v packwiz >/dev/null; then + for rc in ~/.zshrc ~/.bashrc; do + [ -f "$rc" ] || continue + grep -q '/go/bin\|GOPATH/bin' "$rc" && continue + printf '\nexport PATH="$PATH:%s"\n' "$GOBIN" >> "$rc" + info "added $GOBIN to PATH in $rc" + done + export PATH="$PATH:$GOBIN" +fi + +# ------------------------------------------------------------------ init ---- + +[ -e "$DIR/pack.toml" ] && die "$DIR/pack.toml already exists — refusing to overwrite + (run 'packwiz init --reinit' there if that's what you want)" + +mkdir -p "$DIR" +cd "$DIR" + +# The NeoForge flag names have moved between packwiz builds, so ask this +# binary what it supports instead of assuming. +help="$("$PACKWIZ" init --help 2>&1 || true)" +grep -q -- '--modloader' <<<"$help" || die "unexpected packwiz init interface; run '$PACKWIZ init' interactively" + +args=(--name "$NAME" --author "$AUTHOR" --version "$PACKVER" --mc-version "$MCVER" --modloader neoforge) + +if [ -n "$NFVER" ]; then + grep -q -- '--neoforge-version' <<<"$help" \ + || die "this packwiz build has no --neoforge-version flag; run '$PACKWIZ init' interactively" + args+=(--neoforge-version "$NFVER") +elif grep -q -- '--neoforge-latest' <<<"$help"; then + args+=(--neoforge-latest) +fi + +info "initialising NeoForge pack for MC $MCVER in $(pwd)" +"$PACKWIZ" init "${args[@]}" +[ -f pack.toml ] || die "packwiz init did not produce a pack.toml" + +# ---------------------------------------------------------------- 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" + + # 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/" || echo 000)" + case "$probe" in + 2*|3*|40[34]) ;; # a listing may legitimately be forbidden + 000) die "cannot reach $BASEURL — start the mirror before running this" ;; + *) warn "$BASEURL/ returned HTTP $probe; continuing, individual mods may fail" ;; + esac + + shopt -s nullglob + jars=("$MODSDIR"/*.jar) + shopt -u nullglob + [ "${#jars[@]}" -gt 0 ] || die "no .jar files found in $MODSDIR" + + info "adding ${#jars[@]} mods from the mirror" + failed=() + 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. + 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" + else + printf ' ! %s (failed)\n' "$modname" + failed+=("$file") + fi + done + + "$PACKWIZ" refresh + + if [ "${#failed[@]}" -gt 0 ]; then + echo + warn "${#failed[@]} of ${#jars[@]} mods failed to add:" + printf ' %s\n' "${failed[@]}" >&2 + warn "re-run those by hand to see the error, e.g." + warn " $PACKWIZ url add $BASEURL/${failed[0]}" + fi +fi + +# ---------------------------------------------------------------- guides ---- + +# One page per OS at $WEBROOT/setup--packwiz.html, generated with the real +# pack URL and server address baked in so players never edit anything. +write_guide() { + local os="$1" out="$2" + local os_label install_intro install_cmd install_note java_note dl_intro dl_cmd dl_note + + case "$os" in + windows) + os_label="Windows" + install_intro="From PowerShell:" + install_cmd="winget install --id PrismLauncher.PrismLauncher" + install_note="Or grab the installer from prismlauncher.org if you'd rather not use winget." + java_note="Prism can download and manage Java 21 for you — on first run it offers to, and you should let it." + dl_intro="Open PowerShell in that folder and run:" + dl_cmd="curl.exe -L -o packwiz-installer-bootstrap.jar https://github.com/packwiz/packwiz-installer-bootstrap/releases/latest/download/packwiz-installer-bootstrap.jar" + dl_note="Or open that URL in a browser and move the downloaded file in." + ;; + linux) + os_label="Linux" + install_intro="Flatpak works on every distro and is the easiest to keep current:" + install_cmd="flatpak install flathub org.prismlauncher.PrismLauncher" + install_note="Fedora also has sudo dnf install prismlauncher, and Arch ships it in extra." + java_note="The Flatpak bundles its own Java and ignores the system one — nothing to install. On a native package, Fedora: sudo dnf install java-21-openjdk, Debian/Ubuntu: sudo apt install openjdk-21-jre." + dl_intro="From a terminal in that folder:" + dl_cmd="curl -LO https://github.com/packwiz/packwiz-installer-bootstrap/releases/latest/download/packwiz-installer-bootstrap.jar" + dl_note="Flatpak instances live under ~/.var/app/org.prismlauncher.PrismLauncher/data/PrismLauncher/instances/." + ;; + *) die "unknown OS for guide: $os" ;; + esac + + # Expanding heredoc: \$INST_JAVA and \$INST_MC_DIR are escaped so they reach + # the page literally — Prism substitutes those at launch, not this script. + cat > "$out" <$NAME — Setup on $os_label + + +
+
+

Prism Launcher · $os_label

+

Join $NAME, and stay on it

+

A one-time setup that makes Prism re-sync your mods from the + server every time you hit Launch. A mod gets added, you get it next session — + no reinstalls, no zip files in chat.

+
    +
  • MC $MCVER
  • +
  • Loader NeoForge
  • +
  • Java 21
  • +
  • Server $SERVER_ADDR
  • +
+
+ +
01
+

Install Prism Launcher

+
+

$install_intro

+
$install_cmd
+

$install_note

+
+
+ +
02
+

Get Java 21

+
+

Minecraft $MCVER on NeoForge needs Java 21. Check what you have:

+
java -version
+

$java_note

+
+
+ +
03
+

Create the instance

+
+

Click Add Instance and set it up as:

+
    +
  • Version — $MCVER
  • +
  • Mod loader — NeoForge, latest for $MCVER
  • +
+

Create it, but don't launch it yet. Leave the mods folder empty — + the next two steps fill it, and anything you add by hand is removed on the + first sync.

+
+
+ +
04
+

Drop in the installer

+
+

Select the instance, click Folder in the right-hand panel, then open + the .minecraft folder inside it.

+

$dl_intro

+
$dl_cmd
+

$dl_note

+
+
+ +
05
+

Wire up the automatic pull

+
+

This is the step that does the work. Right-click the instance → + EditSettingsCustom commands. Tick + Custom commands, then paste this into Pre-launch command:

+
"\$INST_JAVA" -jar packwiz-installer-bootstrap.jar -g -s client $PACK_URL/pack.toml
+

Every launch from now on, Prism runs that first: it reads the pack from the + server, downloads anything new, removes anything dropped, and only then starts + the game.

+
What the flags do +

-g skips the progress window — drop it the first + time so you can watch it work and see any errors. + -s client installs client-side mods only.

+
+
+
+ +
06
+

Launch and add the server

+
+

Hit Launch. The first run downloads the full mod set, so give it a + minute. At the title screen go to MultiplayerAdd Server:

+
$SERVER_ADDR
+

That's it. From here on just launch normally — updates arrive on their own.

+
+
+ +
+

What happens on every launch

+
    +
  1. pack.tomlFetched first. Names the index and its hash, so a changed pack is spotted immediately.
  2. +
  3. index.tomlLists every mod entry with its hash.
  4. +
  5. mods/*.pw.tomlOne per mod: where to download it, and the hash it must match.
  6. +
  7. your mods folderMissing files downloaded, changed files replaced, removed files deleted.
  8. +
+
+ +
+

When it doesn't work

+ +
"Unable to access jarfile packwiz-installer-bootstrap.jar" +
+

The pre-launch command ran somewhere other than the folder holding the jar. Use the full path instead:

+
"\$INST_JAVA" -jar "\$INST_MC_DIR/packwiz-installer-bootstrap.jar" -g -s client $PACK_URL/pack.toml
+
+
+ +
Game launches with no mods at all +
+

The pre-launch command didn't run. Check that Custom commands is + actually ticked in the instance settings — pasting the command without + enabling the checkbox is the usual cause.

+

Remove the -g flag and launch again; you'll see + the installer window and whatever it's complaining about.

+
+
+ +
A hash doesn't match and the install stops +
+

A file on the server changed but the pack still lists the old hash. That's + the pack being out of date, not your machine — nothing to fix client-side.

+

Tell $AUTHOR to re-run the refresh script, then launch again.

+
+
+ +
Crash mentioning class versions or UnsupportedClassVersionError +
+

Wrong Java. NeoForge for $MCVER needs Java 21, and an older one loads far + enough to crash confusingly.

+

In Prism: EditSettingsJavaAuto-detect, and pick a 21.

+
+
+ +
Mods I added by hand keep disappearing +
+

Working as intended — the sync makes your mods folder match the server's + exactly, so anything not in the pack is removed each launch.

+

Want a personal client-side mod? Ask $AUTHOR to add it to the pack.

+
+
+
+ +
Pack served from $SITE_URL · $NAME $PACKVER · If the pre-launch step +fails, the game still launches, with whatever mods it had last time.
+
+ + +EOF +} + +if (( GUIDES )); then + info "writing player setup guides to $WEBROOT" + for os in windows linux; do + out="$WEBROOT/setup-$os-packwiz.html" + write_guide "$os" "$out" + chmod 644 "$out" + printf ' %s -> %s/setup-%s-packwiz.html\n' "$out" "$SITE_URL" "$os" + done +fi + +# ---------------------------------------------------------------- finish ---- + +cat > .gitignore <<'EOF' +# packwiz build output +/build/ +EOF + +if command -v git >/dev/null && [ ! -d .git ]; then + git init -q && git add -A && git commit -qm "Initial packwiz pack" \ + && info "git repo initialised with an initial commit" +fi + +echo +info "done — pack is in $(pwd)" +cat < # add one more mod from the mirror + packwiz remove + +Clients need $(pwd) served at $PACK_URL — that is where +pack.toml, index.toml and mods/*.pw.toml are fetched from. Verify with: + + curl -fsS $PACK_URL/pack.toml | head -3 +EOF