mynixos-config/hosts/sire/guests/minecraft.nix

601 lines
19 KiB
Nix

# FIXME: todo: host the proxy on sentinel so the IPs are not lost in natting
{
config,
globals,
lib,
pkgs,
...
}:
let
inherit (lib) getExe;
minecraftDomain = "mc.${globals.domains.me}";
dataDir = "/var/lib/minecraft";
minecraft-attach = pkgs.writeShellApplication {
name = "minecraft-attach";
runtimeInputs = [ pkgs.tmux ];
text = ''
shopt -s nullglob
[[ $EUID == 0 ]] || { echo "You have to be root (or use sudo) to attach to the console." >&2; exit 1; }
SERVER_NAME="''${1-none}"
TMUX_SOCKET="/run/minecraft-$1/tmux"
if [[ ! -e "$TMUX_SOCKET" ]]; then
echo "error: Unknown server name '$SERVER_NAME', or service not started." >&2
AVAILABLE=("/run/minecraft-"*"/tmux")
if [[ "''${#AVAILABLE[@]}" == 0 ]]; then
echo "There are currently no servers available. Check your system services." >&2
else
avail=("''${AVAILABLE[@]#"/run/minecraft-"}")
avail=("''${avail[@]%"/tmux"}")
echo "Available servers: ''${avail[*]}" >&2
fi
exit 1
fi
exec runuser -u minecraft -- tmux -S "$TMUX_SOCKET" attach-session
'';
};
helper-functions =
# bash
''
################################################################
# General helper functions
function print_error() { echo "error: $*" >&2; }
function die() { print_error "$@"; exit 1; }
function substatus() { echo "$*"; }
function datetime() { date "+%Y-%m-%d %H:%M:%S"; }
function status_time() { echo "[$(datetime)] $*"; }
function flush_stdin() {
local empty_stdin
# Unused variable is intentional.
# shellcheck disable=SC2034
while read -r -t 0.01 empty_stdin; do true; done
}
function ask() {
local response
while true; do
flush_stdin
read -r -p "$* (Y/n) " response || die "Error in read"
case "''${response,,}" in
"") return 0 ;;
y|yes) return 0 ;;
n|no) return 1 ;;
*) continue ;;
esac
done
}
################################################################
# Download helper functions
# $@: command to run as minecraft if user was changed.
# You want to pass path/to/curent/script.sh "$@".
function become_minecaft() {
if [[ $(id -un) != "minecraft" ]]; then
if [[ $EUID == 0 ]] && ask "This script must be executed as the minecraft user. Change user and continue?"; then
# shellcheck disable=SC2093
exec runuser -u minecraft "$@"
die "Could not change user!"
else
die "This script must be executed as the minecraft user!"
fi
fi
}
# $1: output file name
function download_paper() {
local paper_version
local paper_build
local paper_download
paper_version="$(curl -s -o - "https://papermc.io/api/v2/projects/paper" | jq -r ".versions[-1]")" \
|| die "Error while retrieving paper version"
paper_build="$(curl -s -o - "https://papermc.io/api/v2/projects/paper/versions/$paper_version" | jq -r ".builds[-1]")" \
|| die "Error while retrieving paper builds"
paper_download="$(curl -s -o - "https://papermc.io/api/v2/projects/paper/versions/$paper_version/builds/$paper_build" | jq -r ".downloads.application.name")" \
|| die "Error while retrieving paper download name"
substatus "Downloading paper version $paper_version build $paper_build ($paper_download)"
wget -q --show-progress "https://papermc.io/api/v2/projects/paper/versions/$paper_version/builds/$paper_build/downloads/$paper_download" \
-O "$1" \
|| die "Could not download paper"
}
# $1: output file name
function download_velocity() {
local velocity_version
local velocity_build
local velocity_download
velocity_version="$(curl -s -o - "https://papermc.io/api/v2/projects/velocity" | jq -r ".versions[-1]")" \
|| die "Error while retrieving velocity version"
velocity_build="$(curl -s -o - "https://papermc.io/api/v2/projects/velocity/versions/$velocity_version" | jq -r ".builds[-1]")" \
|| die "Error while retrieving velocity builds"
velocity_download="$(curl -s -o - "https://papermc.io/api/v2/projects/velocity/versions/$velocity_version/builds/$velocity_build" | jq -r ".downloads.application.name")" \
|| die "Error while retrieving velocity download name"
substatus "Downloading velocity version $velocity_version build $velocity_build ($velocity_download)"
wget -q --show-progress "https://papermc.io/api/v2/projects/velocity/versions/$velocity_version/builds/$velocity_build/downloads/$velocity_download" \
-O "$1" \
|| die "Could not download velocity"
}
# $1: repo, e.g. "oddlama/vane"
declare -A LATEST_GITHUB_RELEASE_TAG_CACHE
function latest_github_release_tag() {
local repo=$1
if [[ ! -v "LATEST_GITHUB_RELEASE_TAG_CACHE[$repo]" ]]; then
local tmp
tmp=$(curl -s "https://api.github.com/repos/$repo/releases/latest" | jq -r .tag_name) \
|| die "Error while retrieving latest github release tag of $repo"
LATEST_GITHUB_RELEASE_TAG_CACHE[$repo]="$tmp"
fi
echo "''${LATEST_GITHUB_RELEASE_TAG_CACHE[$repo]}"
}
# $1: repo, e.g. "oddlama/vane"
# $2: remote file name.
# {TAG} will be replaced with the release tag
# {VERSION} will be replaced with release tag excluding a leading v, if present
# $3: output file name
function download_latest_github_release() {
local repo=$1
local remote_file=$2
local output=$3
local tag
tag=$(latest_github_release_tag "$repo")
local version="''${tag#v}" # Always strip leading v in version.
remote_file="''${remote_file//"{TAG}"/"$tag"}"
remote_file="''${remote_file//"{VERSION}"/"$version"}"
wget -q --show-progress "https://github.com/$repo/releases/download/$tag/$remote_file" -O "$output" \
|| die "Could not download $remote_file from github repo $repo"
}
# $1: url
# $2: output file name
function download_file() {
wget -q --show-progress "$1" -O "$2" || die "Could not download $1"
}
'';
server-backup-script = pkgs.writeShellApplication {
name = "minecraft-backup";
runtimeInputs = [ pkgs.rdiff-backup ];
text = ''
BACKUP_LOG_FILE="logs/backup.log"
BACKUP_TO="backups"
BACKUP_DIRS=(
'plugins'
'world'
'world_nether'
'world_the_end'
)
cd ${dataDir}/server || exit 1
${helper-functions}
status_time "Starting backup"
mkdir -p "$BACKUP_TO" &>/dev/null
for i in "''${!BACKUP_DIRS[@]}"; do
status_time "Backing up ''${BACKUP_DIRS[$i]}" | tee -a "$BACKUP_LOG_FILE"
rdiff-backup "''${BACKUP_DIRS[$i]}" "$BACKUP_TO/''${BACKUP_DIRS[$i]}" &>> "$BACKUP_LOG_FILE"
done
status_time "Backup finished" | tee -a "$BACKUP_LOG_FILE"
'';
};
server-start-script = pkgs.writeShellApplication {
name = "minecraft-server-start";
runtimeInputs = [
pkgs.procps
pkgs.gnugrep
];
text = ''
cd ${dataDir}/server
# Update velocity secret
VELOCITY_SECRET="$(cat ../proxy/forwarding.secret)" \
${getExe pkgs.yq-go} -i '.proxies.velocity.secret = strenv(VELOCITY_SECRET)' \
config/paper-global.yml
# Use 80% of RAM, but not more than 12GiB and not less than 1GiB
total_ram_gibi=$(free -g | grep -oP '\d+' | head -n1)
ram="$((total_ram_gibi * 8 / 10))"
[[ "$ram" -le 8 ]] || ram=8
[[ "$ram" -ge 1 ]] || ram=1
echo "Executing server using ''${ram}GiB of RAM"
exec ${getExe pkgs.temurin-jre-bin} -Xms''${ram}G -Xmx''${ram}G \
-XX:+UseG1GC \
-XX:+ParallelRefProcEnabled \
-XX:MaxGCPauseMillis=200 \
-XX:+UnlockExperimentalVMOptions \
-XX:+DisableExplicitGC \
-XX:+AlwaysPreTouch \
-XX:G1NewSizePercent=30 \
-XX:G1MaxNewSizePercent=40 \
-XX:G1HeapRegionSize=8M \
-XX:G1ReservePercent=20 \
-XX:G1HeapWastePercent=5 \
-XX:G1MixedGCCountTarget=4 \
-XX:InitiatingHeapOccupancyPercent=15 \
-XX:G1MixedGCLiveThresholdPercent=90 \
-XX:G1RSetUpdatingPauseTimePercent=5 \
-XX:SurvivorRatio=32 \
-XX:+PerfDisableSharedMem \
-XX:MaxTenuringThreshold=1 \
-Dusing.aikars.flags=https://mcflags.emc.gs \
-Daikars.new.flags=true \
-jar paper.jar nogui
'';
};
proxy-start-script = pkgs.writeShellApplication {
name = "minecraft-proxy-start";
text = ''
cd ${dataDir}/proxy
echo "Executing proxy server"
exec ${getExe pkgs.temurin-jre-bin} -Xms1G -Xmx1G -XX:+UseG1GC -XX:G1HeapRegionSize=4M -XX:+UnlockExperimentalVMOptions -XX:+ParallelRefProcEnabled -XX:+AlwaysPreTouch -XX:MaxInlineLevel=15 -jar velocity.jar
'';
};
server-update-script = pkgs.writeShellApplication {
name = "minecraft-server-update";
runtimeInputs = [
pkgs.wget
pkgs.curl
pkgs.jq
];
text = ''
cd ${dataDir}/server || exit 1
${helper-functions}
become_minecaft "./update.sh"
################################################################
# Download paper and prepare plugins
download_paper paper.jar
# Create plugins directory
mkdir -p plugins \
|| die "Could not create directory 'plugins'"
# Create optional plugins directory
mkdir -p plugins/optional \
|| die "Could not create directory 'plugins/optional'"
################################################################
# Download plugins
substatus "Downloading plugins"
for module in admin bedtime core enchantments permissions portals regions trifles; do
download_latest_github_release "oddlama/vane" "vane-$module-{VERSION}.jar" "plugins/vane-$module.jar"
done
download_file "https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/artifact/build/libs/ProtocolLib.jar" plugins/ProtocolLib.jar
download_latest_github_release "BlueMap-Minecraft/BlueMap" "BlueMap-{VERSION}-spigot.jar" plugins/bluemap.jar
'';
};
proxy-update-script = pkgs.writeShellApplication {
name = "minecraft-proxy-update";
runtimeInputs = [
pkgs.wget
pkgs.curl
pkgs.jq
];
text = ''
cd ${dataDir}/proxy || exit 1
${helper-functions}
become_minecaft "./update.sh"
################################################################
# Download velocity and prepare plugins
download_velocity velocity.jar
# Create plugins directory
mkdir -p plugins \
|| die "Could not create directory 'plugins'"
################################################################
# Download plugins
substatus "Downloading plugins"
download_latest_github_release "oddlama/vane" "vane-velocity-{VERSION}.jar" "plugins/vane-velocity.jar"
'';
};
commonServiceConfig = {
Restart = "on-failure";
User = "minecraft";
# Hardening
AmbientCapabilities = [ "CAP_KILL" ];
CapabilityBoundingSet = [ "CAP_KILL" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
UMask = "0027";
};
in
{
microvm.mem = 1024 * 24;
microvm.vcpu = 16;
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.firewallRuleForNode.sentinel.allowedTCPPorts =
[
80
25565
25566
];
users.groups.minecraft.members = [ "nginx" ];
users.users.minecraft = {
description = "Minecraft server service user";
home = dataDir;
isSystemUser = true;
group = "minecraft";
};
environment.persistence."/persist".directories = [
{
directory = dataDir;
user = "minecraft";
group = "minecraft";
mode = "0750";
}
];
globals.services.minecraft.domain = minecraftDomain;
globals.monitoring.tcp.minecraft = {
host = minecraftDomain;
port = 25565;
network = "internet";
};
globals.monitoring.http.minecraft-map = {
url = "https://${minecraftDomain}";
expectedBodyRegex = "Minecraft Dynamic Map";
network = "internet";
};
nodes.sentinel = {
# Rewrite destination addr with dnat on incoming connections
# and masquerade responses to make them look like they originate from this host.
# - 25565,25566 (wan) -> 25565,25566 (proxy-sentinel)
networking.nftables.chains = {
postrouting.to-minecraft = {
after = [ "hook" ];
rules = [
"iifname wan ip daddr ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv4
} tcp dport 25565 masquerade random"
"iifname wan ip6 daddr ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv6
} tcp dport 25565 masquerade random"
"iifname wan ip daddr ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv4
} tcp dport 25566 masquerade random"
"iifname wan ip6 daddr ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv6
} tcp dport 25566 masquerade random"
];
};
prerouting.to-minecraft = {
after = [ "hook" ];
rules = [
"iifname wan tcp dport 25565 dnat ip to ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv4
}"
"iifname wan tcp dport 25565 dnat ip6 to ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv6
}"
"iifname wan tcp dport 25566 dnat ip to ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv4
}"
"iifname wan tcp dport 25566 dnat ip6 to ${
globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv6
}"
];
};
};
services.nginx = {
upstreams.minecraft = {
servers."${globals.wireguard.proxy-sentinel.hosts.${config.node.name}.ipv4}:80" = { };
extraConfig = ''
zone minecraft 64k;
keepalive 2;
'';
monitoring = {
enable = true;
expectedBodyRegex = "Minecraft Dynamic Map";
};
};
virtualHosts.${minecraftDomain} = {
forceSSL = true;
useACMEWildcardHost = true;
locations."/" = {
proxyPass = "http://minecraft";
};
};
};
};
systemd.services.minecraft-server = {
description = "Minecraft Server Service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
path = [
# for infocmp
pkgs.ncurses
# for dynmap
pkgs.libwebp
];
serviceConfig = commonServiceConfig // {
Type = "forking";
ExecStart = ''${getExe pkgs.tmux} -S /run/minecraft-server/tmux set -g default-shell ${getExe pkgs.bashInteractive} ";" new-session -d "${getExe pkgs.python3} ${./minecraft/server-loop.py} --block control/start.block ./start.sh :POST: ./backup.sh"'';
ExecStop = "${getExe pkgs.tmux} -S /run/minecraft-server/tmux kill-server";
WorkingDirectory = "${dataDir}/server";
RuntimeDirectory = "minecraft-server";
ReadWritePaths = [
"${dataDir}/server"
"${dataDir}/web"
];
ReadOnlyPaths = "${dataDir}/proxy";
};
preStart = ''
ln -sfT ${getExe server-start-script} start.sh
ln -sfT ${getExe server-backup-script} backup.sh
ln -sfT ${getExe server-update-script} update.sh
function copyFile() {
cp "$1" "$2"
chmod 600 "$2"
}
copyFile ${./minecraft/server/eula.txt} eula.txt
copyFile ${./minecraft/server/server.properties} server.properties
copyFile ${./minecraft/server/spigot.yml} spigot.yml
copyFile ${./minecraft/server/commands.yml} commands.yml
mkdir -p config
copyFile ${./minecraft/server/config/paper-global.yml} config/paper-global.yml
copyFile ${./minecraft/server/config/paper-world-defaults.yml} config/paper-world-defaults.yml
if [[ ! -e paper.jar ]]; then
./update.sh
fi
'';
};
systemd.services.minecraft-proxy = {
description = "Minecraft Proxy Service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
path = [ pkgs.ncurses ]; # for infocmp
serviceConfig = commonServiceConfig // {
Type = "forking";
ExecStart = ''${getExe pkgs.tmux} -S /run/minecraft-proxy/tmux set -g default-shell ${getExe pkgs.bashInteractive} ";" new-session -d "${getExe pkgs.python3} ${./minecraft/server-loop.py} ./start.sh"'';
ExecStop = "${getExe pkgs.tmux} -S /run/minecraft-proxy/tmux kill-server";
WorkingDirectory = "${dataDir}/proxy";
RuntimeDirectory = "minecraft-proxy";
ReadWritePaths = [
"${dataDir}/proxy"
"${dataDir}/server/control"
];
};
preStart = ''
ln -sfT ${getExe proxy-start-script} start.sh
ln -sfT ${getExe proxy-update-script} update.sh
function copyFile() {
cp "$1" "$2"
chmod 600 "$2"
}
copyFile ${./minecraft/proxy/velocity.toml} velocity.toml
mkdir -p plugins/vane-velocity
copyFile ${./minecraft/proxy/plugins/vane-velocity/config.toml} plugins/vane-velocity/config.toml
if [[ ! -e velocity.jar ]]; then
./update.sh
fi
'';
};
systemd.tmpfiles.settings."50-minecraft" = {
"${dataDir}".d = {
user = "minecraft";
mode = "0750";
};
"${dataDir}/server".d = {
user = "minecraft";
mode = "0700";
};
"${dataDir}/server/control".d = {
user = "minecraft";
mode = "0700";
};
"${dataDir}/proxy".d = {
user = "minecraft";
mode = "0700";
};
"${dataDir}/web".d = {
user = "minecraft";
mode = "0750";
};
};
environment.systemPackages = [
minecraft-attach
];
services.phpfpm.pools.dynmap = {
user = "nginx";
group = "nginx";
phpPackage = pkgs.php82;
phpOptions = ''
error_log = 'stderr'
log_errors = on
'';
settings = {
"listen.owner" = "nginx";
"listen.group" = "nginx";
"listen.mode" = "0660";
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"catch_workers_output" = true;
};
};
services.nginx = {
enable = true;
recommendedSetup = false;
virtualHosts.${minecraftDomain} = {
root = "${dataDir}/web/dynmap";
locations."~ \\.php$".extraConfig = ''
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:${config.services.phpfpm.pools.dynmap.socket};
include ${config.services.nginx.package}/conf/fastcgi.conf;
include ${config.services.nginx.package}/conf/fastcgi_params;
'';
};
};
}