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

272 lines
9.5 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 + 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="13G"
XMX="13G"
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() { 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 ;;
-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"
# ----------------------------------------------------------------- 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 ----
# 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
# 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
# -------------------------------------------------------------- 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"
# ----------------------------------------------------------------- units ----
info "writing $UNIT_DIR/minecraft.socket"
cat > "$UNIT_DIR/minecraft.socket" <<EOF
[Unit]
Description=Minecraft server console FIFO
PartOf=minecraft.service
[Socket]
ListenFIFO=$FIFO
Service=minecraft.service
SocketUser=$MCUSER
SocketGroup=$MCUSER
SocketMode=0660
RemoveOnStop=yes
EOF
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
Requires=minecraft.socket
RequiresMountsFor=$SHARE
[Service]
Type=simple
User=$MCUSER
Group=$MCUSER
WorkingDirectory=$SHARE
# The console FIFO becomes the server's stdin, so ExecStop can hand it a real
# 'stop' command. Without this systemd would SIGTERM the JVM and risk a world
# save being cut in half.
Sockets=minecraft.socket
StandardInput=socket
StandardOutput=journal
StandardError=journal
ExecStart=/usr/bin/java @$SHARE/user_jvm_args.txt @$ARGS_FILE nogui
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.socket minecraft.service >/dev/null
echo
info "installed. Start it with:"
cat <<EOF
sudo systemctl start minecraft
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