diff options
Diffstat (limited to 'users/sterni/modules/minecraft-fabric.nix')
-rw-r--r-- | users/sterni/modules/minecraft-fabric.nix | 497 |
1 files changed, 497 insertions, 0 deletions
diff --git a/users/sterni/modules/minecraft-fabric.nix b/users/sterni/modules/minecraft-fabric.nix new file mode 100644 index 000000000000..daca54b6d113 --- /dev/null +++ b/users/sterni/modules/minecraft-fabric.nix @@ -0,0 +1,497 @@ +# 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-2023 sterni <sternenseemann@systemli.org> + +{ 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.2"; + url = "https://maven.fabricmc.net/net/fabricmc/fabric-installer/${version}/fabric-installer-${version}.jar"; + sha256 = "09sw013b385cw1w4n4f89bgsy1n9q90fagmy4xr5laxi3gpmpbf6"; + }; + + # 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}" + ]; + }; + }; + "1.17" = fetchJar { + pname = "server"; + version = "1.17"; + url = "https://launcher.mojang.com/v1/objects/0a269b5f2c5b93b1712d0f5dc43b6182b9ab254e/server.jar"; + sha256 = "0jqz7hpx7zvjj2n5rfrh8jmdj6ziqyp8c9nq4sr4jmkbky6hsfbv"; + passthru.baseJvmOpts = [ + "-Dlog4j2.formatMsgNoLookups=true" + ]; + }; + "1.17.1" = fetchJar { + pname = "server"; + version = "1.17.1"; + url = "https://launcher.mojang.com/v1/objects/a16d67e5807f57fc4e550299cf20226194497dc2/server.jar"; + sha256 = "0pzmzagvrrapjsnd8xg4lqwynwnb5rcqk2n9h2kzba8p2fs13hp8"; + passthru.baseJvmOpts = [ + "-Dlog4j2.formatMsgNoLookups=true" + ]; + }; + "1.18" = fetchJar { + pname = "server"; + version = "1.18"; + url = "https://launcher.mojang.com/v1/objects/3cf24a8694aca6267883b17d934efacc5e44440d/server.jar"; + sha256 = "0vvycjcfq96z7cl5dsrq98k9b7j7l4x0y9nflrcqmcvink7fs5w4"; + passthru.baseJvmOpts = [ + "-Dlog4j2.formatMsgNoLookups=true" + ]; + }; + "1.18.1" = fetchJar { + pname = "server"; + version = "1.18.1"; + url = "https://launcher.mojang.com/v1/objects/125e5adf40c659fd3bce3e66e67a16bb49ecc1b9/server.jar"; + sha256 = "1pyvym6xzjb1siizzj4ma7lpb05qhgxnzps8lmlbk00lv0515kgb"; + }; + "1.18.2" = fetchJar { + pname = "server"; + version = "1.18.2"; + url = "https://launcher.mojang.com/v1/objects/c8f83c5655308435b3dcf03c06d9fe8740a77469/server.jar"; + sha256 = "0hx330bnadixph44sip0h5h986m11qxbdba6lbgwz4da6lg9vgjp"; + }; + "1.19" = fetchJar { + pname = "server"; + version = "1.19"; + url = "https://launcher.mojang.com/v1/objects/e00c4052dac1d59a1188b2aa9d5a87113aaf1122/server.jar"; + sha256 = "1cnjrqr2vn8gppd1y1lcdrc46fd7m1b3zl28zpbw72fgy1bd1vyy"; + }; + "1.19.1" = fetchJar { + pname = "server"; + version = "1.19.1"; + url = "https://piston-data.mojang.com/v1/objects/8399e1211e95faa421c1507b322dbeae86d604df/server.jar"; + sha256 = "0jnlb5z8a7qi6p6bbwnmdl77b8kq83ryfdp58dhx8kg2hf6lbfx8"; + }; + "1.19.2" = fetchJar { + pname = "server"; + version = "1.19.2"; + url = "https://piston-data.mojang.com/v1/objects/f69c284232d7c7580bd89a5a4931c3581eae1378/server.jar"; + sha256 = "15jdxh5zvsgvvk9hnv47swgjfg8fr653g6nx99q1rxpmkq32frxj"; + }; + "1.19.3" = fetchJar { + pname = "server"; + version = "1.19.3"; + url = "https://piston-data.mojang.com/v1/objects/c9df48efed58511cdd0213c56b9013a7b5c9ac1f/server.jar"; + sha256 = "06qykz3nq7qmfw4phs3wvq3nk28clg8s3qrs37856aai8b8kmgaf"; + }; + # Starting with 1.19.4 we could use --pidFile for systemd's PIDFile=, but as + # the service doesn't fork, there seems to be no point. + "1.19.4" = fetchJar { + pname = "server"; + version = "1.19.4"; + url = "https://piston-data.mojang.com/v1/objects/8f3112a1049751cc472ec13e397eade5336ca7ae/server.jar"; + sha256 = "0lrzpqd6zjvqh9g2byicgh66n43z0hwzp863r22ifx2hll6s2955"; + }; + }; + + # + # 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 or [ ] ++ 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 + <citerefentry> + <refentrytitle>java</refentrytitle> + <manvolnum>1</manvolnum> + </citerefentry> + 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) + || pkgs.config.allowUnfreeRedistributable or false + || pkgs.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; + }; + }; +} |