#!/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" 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" 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" # ----------------------------------------------------------------- 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 ---- 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" 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.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 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 <