From 6ef6e9c97f7fbcb368940fb56530075e2023498e Mon Sep 17 00:00:00 2001 From: sterni Date: Mon, 6 Jun 2022 12:33:13 +0200 Subject: feat(sterni/modules): module for fabric minecraft servers This adds the module I've been using for running my minecraft servers. It is inspired by the declarative minecraft server module in nixpkgs, but * does not support a non-declarative mode. * supports more than one server on the same machine. * patches the fabric mod loader into the server.jar on startup. * its stopping mechanism is more robust: It issues a `save-all` and `stop` command over RCON and uses flock(1) for waiting on the server's shutdown instead of relying on checking for the PID via kill(1) in a loop. It has some gaps in terms of features that I personally don't need, but can be filled in over time. Change-Id: I31b9139cab41a6398e5a08ecc72be33cd021ed2e Reviewed-on: https://cl.tvl.fyi/c/depot/+/7291 Reviewed-by: sterni Tested-by: BuildkiteCI --- users/sterni/modules/default.nix | 2 + users/sterni/modules/minecraft-fabric.nix | 426 ++++++++++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 users/sterni/modules/default.nix create mode 100644 users/sterni/modules/minecraft-fabric.nix diff --git a/users/sterni/modules/default.nix b/users/sterni/modules/default.nix new file mode 100644 index 0000000000..5cc8be3cc6 --- /dev/null +++ b/users/sterni/modules/default.nix @@ -0,0 +1,2 @@ +# Stop readTree from looking at this directory +_: { } diff --git a/users/sterni/modules/minecraft-fabric.nix b/users/sterni/modules/minecraft-fabric.nix new file mode 100644 index 0000000000..1afe7c7490 --- /dev/null +++ b/users/sterni/modules/minecraft-fabric.nix @@ -0,0 +1,426 @@ +# Declarative, but low Nix module for a modded minecraft server using the +# fabric mod loader. That is to say, the build of the final server JAR +# is not encapsulated in a derivation. +# +# The module has the following interesting properties: +# +# * The fabric installer is executed on each server startup to assemble the +# patched server.jar. This is unfortunately necessary, as it seems to be +# difficult to do so in a derivation (fabric-installer accesses the network, +# the build doesn't seem to be reproducible). At least this avoids the +# question of the patched jar's redistributability. +# * RCON is used for starting and stopping which should prevent data loss, +# since we can issue a manual save command. +# * The entire runtime directory of the server is assembled from scratch on +# each start, so only blessed state (like the world) and declarative +# configuration (whitelist.json, server.properties, ...) survive. +# * It supports more than one server running on the same machine. +# +# Missing features: +# +# * Support for bans +# * Support for mutable whitelist, ops, … +# * Op levels +# +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2022 sterni + +{ lib, pkgs, config, depot, ... }: + +let + # + # Dependencies + # + inherit (depot.nix.utils) storePathName; + inherit (depot.nix) getBins; + + bins = getBins pkgs.mcrcon [ "mcrcon" ] + // getBins pkgs.jre [ "java" ] + // getBins pkgs.diffutils [ "diff" ] + // getBins pkgs.moreutils [ "sponge" ] + // getBins pkgs.extrace [ "pwait" ] + // getBins pkgs.util-linux [ "flock" ]; + + # + # Needed JARs + # + fetchJar = { pname, version, url, sha256, passthru ? { } }: + pkgs.fetchurl { + name = "${pname}-${version}.jar"; + inherit url sha256; + passthru = passthru // { inherit version; }; + }; + + fabricInstallerJar = + fetchJar rec { + pname = "fabric-installer"; + version = "0.11.0"; + url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/${version}/fabric-installer-${version}.jar"; + sha256 = "02ni9whjvd9lfadr2x7fahl4302b2z2xc6njgl86vfl29zm45fk8"; + }; + + # log4j workaround for Minecraft Server >= 1.12 && < 1.17 + log4jFix_112_116 = pkgs.fetchurl { + url = "https://launcher.mojang.com/v1/objects/02937d122c86ce73319ef9975b58896fc1b491d1/log4j2_112-116.xml"; + sha256 = "1paha357xbaffl38ckzgdh4l5iib2ydqbv7jsg67nj31nlalclr9"; + }; + + serverJars = { + # Manually updated list of known minecraft `server.jar`s for now. + # Making this comprehensive isn't that interesting for now, since the module + # is annoying to use outside of depot anyways as it uses //nix. + "1.16.5" = fetchJar { + pname = "server"; + version = "1.16.5"; + url = "https://launcher.mojang.com/v1/objects/1b557e7b033b583cd9f66746b7a9ab1ec1673ced/server.jar"; + sha256 = "19ix6x5ij4jcwqam1dscnqwm0m251gysc2j793wjcrb9sb3jkwsq"; + passthru = { + baseJvmOpts = [ + "-Dlog4j.configurationFile=${log4jFix_112_116}" + ]; + }; + }; + }; + + # + # mods directory for fabric + # + makeModFolder = name: mods: + pkgs.runCommand "${name}-fabric-mod-folder" { } ( + '' + mkdir -p "$out" + '' + lib.concatMapStrings + (mod: '' + test -f "${mod}" || { + printf 'Not a regular file: %s\n' "${mod}" >&2 + exit 1 + } + ln -s "${mod}" "$out/${storePathName mod}" + '') + mods + ); + + # + # Create a server.properties file + # + propertyValue = v: + if builtins.isBool v + then lib.boolToString v + else toString v; + + serverPropertiesFile = name: instanceCfg: + let + serverProperties' = + builtins.removeAttrs instanceCfg.serverProperties [ + "rcon.password" + ] // { + enable-rcon = true; + }; + in + pkgs.writeText "${name}-server.properties" ('' + # created by minecraft-fabric.nix + '' + lib.concatStrings (lib.mapAttrsToList + (key: value: '' + ${key}=${propertyValue value} + '') + serverProperties')); + + # + # Create JSON “state” files + # + writeJson = name: data: pkgs.writeText "${name}.json" (builtins.toJSON data); + + toWhitelist = name: uuid: { inherit name uuid; }; + + whitelistFile = name: instanceCfg: + writeJson "${name}-whitelist" ( + lib.mapAttrsToList toWhitelist instanceCfg.whitelist + ); + + opsFile = name: instanceCfg: + writeJson "${name}-ops" ( + lib.mapAttrsToList + (name: value: + toWhitelist name value // { + level = 4; + bypassesPlayerLimit = true; + } + ) + instanceCfg.ops + ); + + # + # Service start and stop scripts + # + stopScript = name: instanceCfg: + pkgs.writeShellScript "minecraft-fabric-${name}-stop" '' + set -eu + + # Before shutting down, display the diff between prescribed and used + # server.properties file for debugging purposes; filter out credential + actualProperties="''${RUNTIME_DIRECTORY}/server.properties" + sort "$actualProperties" | ${bins.sponge} "$actualProperties" + ( ${bins.diff} -u "${serverPropertiesFile name instanceCfg}" \ + "$actualProperties" \ + || true ) | grep -v rcon.password + + export MCRCON_HOST=localhost + export MCRCON_PORT=${lib.escapeShellArg instanceCfg.serverProperties."rcon.port"} + # Unfortunately, mcrcon can't read the password from a file + export MCRCON_PASS="$(cat "''${CREDENTIALS_DIRECTORY}/rcon-password")" + + # Send stop request + "${bins.mcrcon}" 'say Server is stopping' save-all stop + + # Wait for service to come down (systemd SIGTERMs right after ExecStop) + "${bins.flock}" "''${RUNTIME_DIRECTORY}" true + ''; + + startScript = name: instanceCfg: + let + serverJar = serverJars.${instanceCfg.version} or + (throw "Don't have server.jar for Minecraft Server ${instanceCfg.version}"); + + in + + pkgs.writeShellScript "minecraft-fabric-${name}-start" '' + set -eu + + cd "''${RUNTIME_DIRECTORY}" + + copyFromStore() { + install -m600 "$1" "$2" + } + + # Check if world is available + if test ! -d "${instanceCfg.world}"; then + echo "Could not find world, generating new one" >&2 + mkdir -p "${instanceCfg.world}" + fi + + # Put required files into place + echo eula=true > eula.txt + ln -s "${instanceCfg.world}" "${instanceCfg.level-name or "world"}" + copyFromStore "${serverJar}" server.jar + copyFromStore "${whitelistFile name instanceCfg}" whitelist.json + copyFromStore "${opsFile name instanceCfg}" ops.json + ln -s "${makeModFolder name instanceCfg.mods}" mods + + # Create config and set password from credentials (echo hopefully doesn't leak) + copyFromStore "${serverPropertiesFile name instanceCfg}" server.properties + echo "rcon.password=$(cat "$CREDENTIALS_DIRECTORY/rcon-password")" >> server.properties + + # Build patched jar + "${bins.java}" -jar "${fabricInstallerJar}" \ + server -mcversion "${instanceCfg.version}" + + # Lock is held as long as the server is running, so that we can wait for + # the actual shutdown in the stop script without relying on $MAINPID. + exec "${bins.flock}" "''${RUNTIME_DIRECTORY}" \ + "${bins.java}" \ + ${lib.escapeShellArgs (serverJar.baseJvmOpts ++ instanceCfg.jvmOpts)} \ + -jar fabric-server-launch.jar nogui + ''; + + # + # Option types + # + impurePath = lib.types.path // { + name = "impurePath"; + check = x: + lib.types.path.check x + && !(builtins.isPath x) + && !(lib.hasPrefix builtins.storeDir (toString x)); + }; + + + instanceType = lib.types.submodule { + options = { + enable = lib.mkEnableOption "Minecraft server instance with the fabric mod loader"; + + version = lib.mkOption { + type = lib.types.str; + description = "Minecraft Server version to use."; + example = "1.16.5"; + }; + + mods = lib.mkOption { + type = with lib.types; listOf package; + description = "List of fabric mod JARs to load."; + default = [ ]; + }; + + world = lib.mkOption { + type = impurePath; + description = "Path to the Minecraft world folder to use."; + example = "/var/minecraft/world"; + }; + + jvmOpts = lib.mkOption { + type = with lib.types; listOf str; + default = [ ]; + example = [ + "-Xmx2048M" + "-Xms2048M" + ]; + description = '' + Options to pass to + + java + 1 + + in order to tweak the runtime of the JVM. + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "minecraft"; + description = '' + Name of an existing user to run the server as. Needs to have write + access to the specified world. + ''; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "users"; + description = '' + Name of an existing group to run the server under. + ''; + }; + + rconPasswordFile = lib.mkOption { + type = impurePath; + description = '' + File (outised the store) that stores the password to use for Minecraft's + RCON interface. + ''; + example = "/var/secrets/minecraft-rcon"; + }; + + whitelist = lib.mkOption { + type = with lib.types; attrsOf str; + description = '' + Attribute set mapping whitelisted user names to their user ids. + ''; + example = { + sternenseemann = "d8e48069-1905-4886-a5da-a4ee917ee254"; + }; + }; + + ops = lib.mkOption { + type = with lib.types; attrsOf str; + description = '' + Attribute set mapping op-ed user names to their user ids. + Setting permission levels is not possible at the moment, + set to 4 by default. + ''; + example = { + sternenseemann = "d8e48069-1905-4886-a5da-a4ee917ee254"; + }; + }; + + serverProperties = lib.mkOption { + type = lib.types.submodule { + freeformType = lib.types.attrs; + + # Only options the module needs to access are declared explicitly + options = { + server-port = lib.mkOption { + type = lib.types.port; + default = 25565; + description = '' + Port to listen on. + ''; + }; + + "rcon.port" = lib.mkOption { + type = lib.types.port; + default = 25575; + description = '' + Port to use for the RCON control mechanism. + ''; + }; + }; + }; + }; + }; + }; + + cfg = config.services.minecraft-fabric-server; + + serverPorts = lib.mapAttrsToList + (_: instanceCfg: + instanceCfg.serverProperties.server-port + ) + cfg; + + rconPorts = lib.mapAttrsToList + (_: instanceCfg: + instanceCfg.serverProperties."rcon.port" + ) + cfg; +in + +{ + options = { + services.minecraft-fabric-server = lib.mkOption { + type = with lib.types; attrsOf instanceType; + default = { }; + description = "Minecraft server instances with the fabric mod loader"; + }; + }; + + config = { + assertions = [ + { + assertion = builtins.all (instance: !instance.enable) (builtins.attrValues cfg) + || config.nixpkgs.config.allowUnfreeRedistributable or false + || config.nixpkgs.config.allowUnfree or false; + message = lib.concatStringsSep " " [ + "You need to allow unfree software for minecraft," + "as you'll implicitly agree to Mojang's EULA." + ]; + } + { + assertion = + let + allPorts = serverPorts ++ rconPorts; + in + lib.unique allPorts == allPorts; + message = "All assigned ports need to be unique."; + } + ]; + + systemd.services = lib.mapAttrs' + (name: instanceCfg: + { + name = "minecraft-fabric-${name}"; + inherit (instanceCfg) enable; + value = { + description = "Minecraft server ${name} with the fabric mod loader"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + User = instanceCfg.user; + Group = instanceCfg.group; + ExecStart = startScript name instanceCfg; + ExecStop = stopScript name instanceCfg; + RuntimeDirectory = "minecraft-fabric-${name}"; + LoadCredential = "rcon-password:${instanceCfg.rconPasswordFile}"; + RestartSec = "40s"; + }; + }; + } + ) + cfg; + + networking.firewall = { + allowedTCPPorts = serverPorts; + allowedUDPPorts = serverPorts; + }; + }; +} -- cgit 1.4.1