Packwiz-Abdelpak-hosting-kit/mc-service-setup.sh

431 lines
16 KiB
Bash
Executable File

#!/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 <mc-minor>.<mc-patch>.<build>, 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 '<version>[^<]+</version>' \
| 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 <version>."
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 <neoforge-version>
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" <<EOF
-Xms${XMS}
-Xmx${XMX}
-XX:+UseG1GC
-XX:MaxGCPauseMillis=130
-XX:+ParallelRefProcEnabled
-XX:+UnlockExperimentalVMOptions
-XX:+DisableExplicitGC
-XX:+AlwaysPreTouch
-XX:G1HeapRegionSize=8M
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.service"
cat > "$UNIT_DIR/minecraft.service" <<EOF
[Unit]
Description=Minecraft Server (NeoForge)
Documentation=https://docs.neoforged.net/
After=network-online.target zfs-mount.service
Wants=network-online.target
RequiresMountsFor=$SHARE
[Service]
Type=simple
User=$MCUSER
Group=$MCUSER
WorkingDirectory=$SHARE
StandardOutput=journal
StandardError=journal
# Console FIFO. The service creates and opens it itself rather than having a
# socket unit do it: SELinux denies init_t read/write on a fifo_file in /run,
# so anything routed through PID 1 fails with EACCES.
#
# fd 3 is opened read-write so the open does not block and the reader never
# sees EOF when a writer disconnects; java then inherits it as stdin. The
# second exec replaces the shell, so MAINPID is the JVM.
RuntimeDirectory=minecraft
RuntimeDirectoryMode=0755
ExecStartPre=/bin/sh -c 'rm -f $FIFO && mkfifo -m 0660 $FIFO'
ExecStart=/bin/bash -c 'exec 3<>$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 <<EOF
sudo systemctl start minecraft # listening on port $PORT
journalctl -u minecraft -f # watch the console
echo "say hello" | sudo tee $FIFO # send a console command
echo "op yourname" | sudo tee $FIFO
sudo systemctl stop minecraft # graceful, saves the world
Not started automatically — check server.properties in $SHARE first
(difficulty, MOTD, and especially online-mode).
EOF