# 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) || 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; }; }; }