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