62416bd3ab
This actually allows disabling a service for the first time, since the configuration structure for the service is created irrespective of the enable option. Change-Id: I08e5e67565d6fe210fb0f65600b8750433ce4712 Reviewed-on: https://cl.tvl.fyi/c/depot/+/10175 Reviewed-by: sterni <sternenseemann@systemli.org> Tested-by: BuildkiteCI
497 lines
16 KiB
Nix
497 lines
16 KiB
Nix
# 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}";
|
|
value = {
|
|
description = "Minecraft server ${name} with the fabric mod loader";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
inherit (instanceCfg) enable;
|
|
|
|
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;
|
|
};
|
|
};
|
|
}
|