#!/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. The service holds a console FIFO at # /run/minecraft/console, which 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" 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 MCVER="1.21.1" # only used to pick the matching NeoForge series ADMIN="" # human user who authors the pack on the share ACCEPT_EULA=0 UNIT_DIR="/etc/systemd/system" FIFO="/run/minecraft/console" # console FIFO, inside the unit's RuntimeDirectory die() { echo "error: $*" >&2; exit 1; } info() { echo ">>> $*"; } warn() { echo " warning: $*" >&2; } usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; exit "${1:-0}"; } while [ $# -gt 0 ]; do case "$1" in -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 ;; -m|--mc) MCVER="${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" [[ "$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" # --------------------------------------------------------------- cleanup ---- # Re-running has to repair whatever an earlier, partly-applied run left behind, # not trip over it. Everything here is safe when there is nothing to clean. info "clearing previous service state" systemctl stop minecraft.service packwiz-http.service >/dev/null 2>&1 || true # The old socket unit could never work: SELinux denies init_t read/write on a # var_run_t fifo_file, so systemd could not open the FIFO. if [ -e "$UNIT_DIR/minecraft.socket" ]; then info "removing obsolete minecraft.socket" systemctl disable --now minecraft.socket >/dev/null 2>&1 || true rm -f "$UNIT_DIR/minecraft.socket" fi # Drop-ins override the unit we are about to write and win silently — including # any hand-made `systemctl edit` workaround. Move them aside rather than delete, # in case one was deliberate. for d in "$UNIT_DIR/minecraft.service.d" "$UNIT_DIR/packwiz-http.service.d"; do if [ -d "$d" ]; then bak="$d.bak.$(date +%Y%m%d%H%M%S)" warn "moving override drop-in aside: $d -> $bak" mv "$d" "$bak" fi done # FIFO from the old socket-unit layout, and any left by a killed run. rm -f /run/minecraft-console /run/minecraft/console systemctl reset-failed minecraft.service minecraft.socket packwiz-http.service >/dev/null 2>&1 || true systemctl daemon-reload # ----------------------------------------------------------------- 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." # NeoForge 21.1.x targets Java 21 exactly. A newer JDK on PATH — Fedora ships # early-access builds — passes a ">= 21" test and then fails inside ModLauncher, # so find a real 21 and pin the unit to its absolute path. java_version_of() { "$1" -version 2>&1 | head -1 | grep -oE '"[0-9]+' | tr -d '"'; } find_java21() { local c for c in /usr/lib/jvm/java-21-openjdk*/bin/java \ /usr/lib/jvm/java-21*/bin/java \ /usr/lib/jvm/*-21-*/bin/java \ /usr/lib/jvm/*21*/bin/java; do [ -x "$c" ] || continue [ "$(java_version_of "$c")" = "21" ] && { printf '%s' "$c"; return 0; } done if command -v java >/dev/null && [ "$(java_version_of "$(command -v java)")" = "21" ]; then command -v java return 0 fi return 1 } JAVA_BIN="$(find_java21 || true)" if [ -z "$JAVA_BIN" ]; then have="none" command -v java >/dev/null && have="$(java_version_of "$(command -v java)")" die "no Java 21 found (default java is version $have). NeoForge for Minecraft 1.21.x needs Java 21 specifically — a newer JDK fails inside ModLauncher rather than refusing to start. sudo dnf install java-21-openjdk-headless then re-run." fi info "java 21: $JAVA_BIN" # ------------------------------------------------------------------ user ---- CREATED_USER=0 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" CREATED_USER=1 else info "user $MCUSER already exists" fi # A blanket chown -R belongs to first-time setup only. On a re-run it would # take the pack and the generated guides away from whoever authors them, and # the next run of packwiz-setup.sh could no longer overwrite its own files. if (( CREATED_USER )); then info "setting ownership of $SHARE to $MCUSER" chown -R "$MCUSER":"$MCUSER" "$SHARE" else info "user pre-existed — leaving ownership under $SHARE alone" fi # The server creates mods/ on first run, but the pack setup needs it to exist # before that. Setgid so jars dropped in by either side stay group-readable. install -d -o "$MCUSER" -g "$MCUSER" -m 2775 "$SHARE/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. 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" # An earlier version chowned the whole share on every run, which took the # pack and the generated guides away from the author. Give them back. chown -R "$ADMIN":"$MCUSER" "$SHARE/packs" 2>/dev/null || true for f in "$SHARE"/setup-*-packwiz.html; do [ -e "$f" ] && chown "$ADMIN":"$MCUSER" "$f" 2>/dev/null || true done 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 ---- # NeoForge versions are .., so 1.21.1 -> 21.1.x and # 1.21 -> 21.0.x. Resolve the newest stable build in that series from the # NeoForged maven; the [0-9]+$ anchor keeps betas out. resolve_neoforge() { local mc="$1" prefix rest meta rest="${mc#1.}" case "$rest" in *.*) prefix="$rest" ;; *) prefix="${rest}.0" ;; esac meta="$(curl -fsSL --max-time 25 \ https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml 2>/dev/null)" \ || return 1 printf '%s' "$meta" \ | grep -oE '[^<]+' \ | sed -e 's/<[^>]*>//g' \ | grep -E "^${prefix//./\\.}\.[0-9]+$" \ | sort -V | tail -1 } if [ "$NFVER" = "latest" ]; then info "resolving latest NeoForge for Minecraft $MCVER" NFVER="$(resolve_neoforge "$MCVER" || true)" [ -n "$NFVER" ] || die "could not resolve a NeoForge version for Minecraft $MCVER Pick one manually from https://projects.neoforged.net/neoforged/neoforge and pass it with -N ." info "resolved NeoForge $NFVER" fi 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" # mktemp -d gives root a 0700 directory, so the service user cannot read the # jar out of it. Open up the path — the installer is a public download. chmod 0755 "$tmp" chmod 0644 "$tmp/installer.jar" # The installer writes into the working directory, so run it as the service # user to avoid leaving root-owned files scattered through the share. HOME is # set explicitly because runuser otherwise leaves root's, and the installer # writes a cache next to it. ( cd "$SHARE" && runuser -u "$MCUSER" -- \ env HOME="$SHARE" java -jar "$tmp/installer.jar" --installServer ) \ || die "NeoForge installer failed" rm -rf "$tmp"; trap - EXIT fi [ -d "$SHARE/libraries" ] && chown -R "$MCUSER":"$MCUSER" "$SHARE/libraries" # 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 # ------------------------------------------------------------ properties ---- # Update in place so a server.properties the server already generated keeps all # its other settings; create the key if it is not there yet. set_prop() { local key="$1" val="$2" f="$SHARE/server.properties" if [ -f "$f" ] && grep -q "^${key}=" "$f"; then sed -i "s|^${key}=.*|${key}=${val}|" "$f" else printf '%s=%s\n' "$key" "$val" >> "$f" fi chown "$MCUSER":"$MCUSER" "$f" } info "setting server-port=$PORT" set_prop server-port "$PORT" if systemctl is-active --quiet firewalld; then info "opening $PORT/tcp" firewall-cmd --quiet --permanent --add-port="$PORT/tcp" firewall-cmd --quiet --reload else warn "firewalld is not running — make sure $PORT/tcp is reachable" 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/packwiz-http.service" < "$UNIT_DIR/minecraft.service" <$FIFO; exec $JAVA_BIN @$SHARE/user_jvm_args.txt @$ARGS_FILE nogui <&3' ExecStop=/bin/sh -c '/bin/echo stop > $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.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:" cat <