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:
parent
3b6bdc8c72
commit
6ef6e9c97f
2 changed files with 428 additions and 0 deletions
2
users/sterni/modules/default.nix
Normal file
2
users/sterni/modules/default.nix
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# Stop readTree from looking at this directory
|
||||||
|
_: { }
|
426
users/sterni/modules/minecraft-fabric.nix
Normal file
426
users/sterni/modules/minecraft-fabric.nix
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in a new issue