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 <sternenseemann@systemli.org>
Tested-by: BuildkiteCI
This commit is contained in:
sterni 2022-06-06 12:33:13 +02:00
parent 3b6bdc8c72
commit 6ef6e9c97f
2 changed files with 428 additions and 0 deletions

View file

@ -0,0 +1,2 @@
# Stop readTree from looking at this directory
_: { }

View file

@ -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 <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.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
<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;
};
};
}