diff --git a/nixos/system/neuro/minecraft.nix b/nixos/system/neuro/minecraft.nix deleted file mode 100644 index 1ddfa126..00000000 --- a/nixos/system/neuro/minecraft.nix +++ /dev/null @@ -1,211 +0,0 @@ -{ - config, - pkgs, - ... -}: let - dataDir = config.services.minecraft-servers.dataDir; - createMods = pkgs.linkFarmFromDrvs "create-aeronautics-mods" ( - builtins.attrValues { - Sable = pkgs.fetchurl { - url = "https://cdn.modrinth.com/data/T9PomCSv/versions/g8CObHcP/sable-neoforge-1.21.1-1.1.3.jar"; - sha512 = "8180e214681c171c9e3b7fa307f7a92bd7de0b8125d671291425f04a4ba26b408758d8ea80a6386d8e73bb1e6b02caf3f20afb9b91ecedd48c37ed44363ac961"; - }; - Create = pkgs.fetchurl { - url = "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar"; - sha512 = "11cc8fc049d2f67f6548c7abfada6b82a3adb5c7ca410a742de04bbca76e03862c518721b88d806f6e6d768a4d68531fdb903a85859b25d1484d550cc7bafd4b"; - }; - CreateAeronautics = pkgs.fetchurl { - url = "https://cdn.modrinth.com/data/oWaK0Q19/versions/1sv6OtSz/create-aeronautics-bundled-1.21.1-1.1.3.jar"; - sha512 = "94831bc4702b3864524258fa0a73a50ab3cd37e9c157b5c6688a6845b866ec5838452804050b55e490549d91dad909fc37f0d619f354c5676e2e2651b9c15ec6"; - }; - } - ); -in { - sops.secrets."minecraft/storage-box-key" = { - sopsFile = ../../../sus/neuro.yaml; - owner = "minecraft-map-import"; - group = "minecraft-map-import"; - mode = "0400"; - }; - - users.groups.minecraft-map-import = { }; - users.users.minecraft-map-import = { - description = "Minecraft map importer"; - isSystemUser = true; - group = "minecraft-map-import"; - extraGroups = [ "minecraft" ]; - }; - - programs.ssh.knownHosts."u664722.your-storagebox.de".publicKey = - "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw=="; - - systemd.services.minecraft-world-import-wowMineMap = { - description = "Import WoW Mine custom Minecraft map"; - before = [ "minecraft-server-wowMineMap.service" ]; - wants = [ "network-online.target" ]; - after = [ "network-online.target" ]; - path = [ pkgs.coreutils pkgs.findutils pkgs.openssh pkgs.unar ]; - serviceConfig = { - Type = "oneshot"; - User = "minecraft-map-import"; - Group = "minecraft-map-import"; - RemainAfterExit = true; - StateDirectory = "minecraft-maps"; - TimeoutStartSec = "30min"; - ProtectSystem = "strict"; - ProtectHome = true; - PrivateDevices = true; - PrivateTmp = true; - NoNewPrivileges = true; - CapabilityBoundingSet = [ "" ]; - ReadWritePaths = [ "/var/lib/minecraft-maps" dataDir ]; - UMask = "0077"; - }; - script = '' - set -eu - umask 077 - - cache_dir=/var/lib/minecraft-maps - server_dir=${dataDir}/wowMineMap - archive="$cache_dir/wow mine map.rar" - temporary_archive="$cache_dir/.wow-mine-map.rar.$$" - extraction_dir="$cache_dir/.wow-mine-map.$$" - key=${config.sops.secrets."minecraft/storage-box-key".path} - - cleanup() { - rm -f "$temporary_archive" - rm -rf "$extraction_dir" - } - trap cleanup EXIT - - mkdir -p "$cache_dir" "$server_dir" - chmod 0700 "$cache_dir" - chgrp minecraft "$server_dir" - chmod 0770 "$server_dir" - - if [ -d "$server_dir/world" ]; then - if [ -f "$server_dir/world/level.dat" ]; then - exit 0 - fi - echo "Minecraft world directory exists but has no level.dat" >&2 - exit 1 - fi - - if [ ! -f "$archive" ]; then - downloaded=false - attempt=1 - while [ "$attempt" -le 3 ]; do - if sftp \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts \ - -i "$key" \ - -b - \ - u664722@u664722.your-storagebox.de <&2 - exit 1 - fi - mv "$temporary_archive" "$archive" - fi - - if ! printf '%s %s\n' \ - bc80084de10a06b0fc2cb1651c61936b9e2fd2288f3f0fe44c964d83a393aa30 \ - "$archive" | sha256sum -c -; then - rm -f "$archive" - echo "Cached WoW Mine map checksum mismatch" >&2 - exit 1 - fi - - mkdir -p "$extraction_dir" - unar -quiet -output-directory "$extraction_dir" "$archive" - - find "$extraction_dir" \ - \( -type l -o -type b -o -type c -o -type p -o -type s \) \ - -delete - - world_level_dat=$(find "$extraction_dir" -type f -name level.dat -print -quit) - if [ -z "$world_level_dat" ]; then - echo "RAR archive contains no Minecraft level.dat" >&2 - exit 1 - fi - - mv "$(dirname "$world_level_dat")" "$server_dir/world" - chgrp -R minecraft "$server_dir/world" - chmod -R u+rwX,g+rwX,o-rwx "$server_dir/world" - ''; - }; - - systemd.services.minecraft-server-wowMineMap = { - requires = [ "minecraft-world-import-wowMineMap.service" ]; - after = [ "minecraft-world-import-wowMineMap.service" ]; - }; - - services.minecraft-servers = { - enable = true; - eula = true; - openFirewall = true; - - servers = { - vanilla = { - enable = true; - jvmOpts = "-Xmx6G -Xms2G"; - package = pkgs.minecraftServers.vanilla-1_21_11; - - serverProperties = { - server-port = 25565; - difficulty = "hard"; - online-mode = true; - view-distance = 32; - level-seed = "8306359138650378643"; - pause-when-empty-seconds = 0; - }; - }; - - createAeronautics = { - enable = true; - jvmOpts = "-Xmx8G -Xms2G"; - package = pkgs.minecraftServers.neoforge-1_21_1; - - symlinks = { - mods = createMods; - }; - - serverProperties = { - server-port = 25566; - difficulty = "hard"; - online-mode = true; - view-distance = 20; - pause-when-empty-seconds = 0; - }; - }; - - wowMineMap = { - enable = true; - jvmOpts = "-Xmx8G -Xms2G"; - package = pkgs.minecraftServers.neoforge-1_21_1; - - symlinks.mods = createMods; - - serverProperties = { - server-port = 25567; - difficulty = "hard"; - online-mode = true; - view-distance = 20; - level-name = "world"; - pause-when-empty-seconds = 0; - }; - }; - }; - }; -} diff --git a/nixos/system/neuro/minecraft/create-aeronautics.nix b/nixos/system/neuro/minecraft/create-aeronautics.nix new file mode 100644 index 00000000..a83e7e97 --- /dev/null +++ b/nixos/system/neuro/minecraft/create-aeronautics.nix @@ -0,0 +1,21 @@ +{ pkgs, ... }: + +{ + services.minecraft-servers.servers.createAeronautics = { + enable = true; + jvmOpts = "-Xmx8G -Xms2G"; + package = pkgs.minecraftServers.neoforge-1_21_1; + + symlinks = { + mods = import ./mods.nix { inherit pkgs; }; + }; + + serverProperties = { + server-port = 25566; + difficulty = "hard"; + online-mode = true; + view-distance = 20; + pause-when-empty-seconds = 0; + }; + }; +} diff --git a/nixos/system/neuro/minecraft/default.nix b/nixos/system/neuro/minecraft/default.nix new file mode 100644 index 00000000..56760386 --- /dev/null +++ b/nixos/system/neuro/minecraft/default.nix @@ -0,0 +1,14 @@ +{ + imports = [ + ./vanilla.nix + ./create-aeronautics.nix + ./wow-mine-map.nix + ./world-import.nix + ]; + + services.minecraft-servers = { + enable = true; + eula = true; + openFirewall = true; + }; +} diff --git a/nixos/system/neuro/minecraft/mods.nix b/nixos/system/neuro/minecraft/mods.nix new file mode 100644 index 00000000..31a5dcfe --- /dev/null +++ b/nixos/system/neuro/minecraft/mods.nix @@ -0,0 +1,18 @@ +{ pkgs }: + +pkgs.linkFarmFromDrvs "create-aeronautics-mods" ( + builtins.attrValues { + Sable = pkgs.fetchurl { + url = "https://cdn.modrinth.com/data/T9PomCSv/versions/g8CObHcP/sable-neoforge-1.21.1-1.1.3.jar"; + sha512 = "8180e214681c171c9e3b7fa307f7a92bd7de0b8125d671291425f04a4ba26b408758d8ea80a6386d8e73bb1e6b02caf3f20afb9b91ecedd48c37ed44363ac961"; + }; + Create = pkgs.fetchurl { + url = "https://cdn.modrinth.com/data/LNytGWDc/versions/UjX6dr61/create-1.21.1-6.0.10.jar"; + sha512 = "11cc8fc049d2f67f6548c7abfada6b82a3adb5c7ca410a742de04bbca76e03862c518721b88d806f6e6d768a4d68531fdb903a85859b25d1484d550cc7bafd4b"; + }; + CreateAeronautics = pkgs.fetchurl { + url = "https://cdn.modrinth.com/data/oWaK0Q19/versions/1sv6OtSz/create-aeronautics-bundled-1.21.1-1.1.3.jar"; + sha512 = "94831bc4702b3864524258fa0a73a50ab3cd37e9c157b5c6688a6845b866ec5838452804050b55e490549d91dad909fc37f0d619f354c5676e2e2651b9c15ec6"; + }; + } +) diff --git a/nixos/system/neuro/minecraft/vanilla.nix b/nixos/system/neuro/minecraft/vanilla.nix new file mode 100644 index 00000000..d4e773d6 --- /dev/null +++ b/nixos/system/neuro/minecraft/vanilla.nix @@ -0,0 +1,18 @@ +{ pkgs, ... }: + +{ + services.minecraft-servers.servers.vanilla = { + enable = true; + jvmOpts = "-Xmx6G -Xms2G"; + package = pkgs.minecraftServers.vanilla-1_21_11; + + serverProperties = { + server-port = 25565; + difficulty = "hard"; + online-mode = true; + view-distance = 32; + level-seed = "8306359138650378643"; + pause-when-empty-seconds = 0; + }; + }; +} diff --git a/nixos/system/neuro/minecraft/world-import.nix b/nixos/system/neuro/minecraft/world-import.nix new file mode 100644 index 00000000..e5cd3385 --- /dev/null +++ b/nixos/system/neuro/minecraft/world-import.nix @@ -0,0 +1,312 @@ +{ + config, + lib, + pkgs, + ... +}: let + inherit (lib) mkOption types; + + cfg = config.services.minecraft-world-imports; + enabledImports = lib.filterAttrs (_: import: import.enable) cfg; + dataDir = config.services.minecraft-servers.dataDir; + minecraftServers = config.services.minecraft-servers.servers; + targetPairs = lib.mapAttrsToList (_: import: + "${import.serverName}:${import.worldName}") enabledImports; + + importerUser = name: let + descriptiveName = "minecraft-map-import-${name}"; + in + if builtins.stringLength descriptiveName <= 31 + then descriptiveName + else "mc-import-${builtins.substring 0 12 (builtins.hashString "sha256" name)}"; + + stateDirectory = cacheDir: + if lib.hasPrefix "/var/lib/" cacheDir + then lib.removePrefix "/var/lib/" cacheDir + else null; + + stateDirectoryCompatible = cacheDir: let + relative = stateDirectory cacheDir; + components = lib.splitString "/" (if relative == null then "" else relative); + in + relative != null + && relative != "" + && lib.all (component: component != "" && component != "." && component != "..") components; + + escapeSftp = value: + "\"${lib.replaceStrings ["\\" "\""] ["\\\\" "\\\""] value}\""; + + escapeTmpfiles = value: + lib.replaceStrings ["%" " " "\t"] ["%%" "\\x20" "\\x09"] value; + + importerServices = lib.mkMerge (lib.mapAttrsToList (name: import: let + user = importerUser name; + unitName = "minecraft-world-import-${name}"; + serverUnit = "minecraft-server-${import.serverName}.service"; + cacheStateDirectory = stateDirectory import.cacheDir; + serverDir = "${dataDir}/${import.serverName}"; + worldDir = "${serverDir}/${import.worldName}"; + in { + ${unitName} = { + description = "Import Minecraft world ${name}"; + before = [ serverUnit ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + path = [ pkgs.coreutils pkgs.findutils pkgs.openssh pkgs.unar ]; + serviceConfig = { + Type = "oneshot"; + User = user; + Group = user; + RemainAfterExit = true; + TimeoutStartSec = import.timeoutStartSec; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateDevices = true; + PrivateTmp = true; + NoNewPrivileges = true; + CapabilityBoundingSet = [ "" ]; + ReadWritePaths = [ import.cacheDir dataDir ]; + UMask = "0077"; + } // lib.optionalAttrs (stateDirectoryCompatible import.cacheDir) { + StateDirectory = cacheStateDirectory; + }; + script = '' + set -eu + umask 077 + + cache_dir=${lib.escapeShellArg import.cacheDir} + server_dir=${lib.escapeShellArg serverDir} + world_dir=${lib.escapeShellArg worldDir} + archive_name=${lib.escapeShellArg import.archiveName} + world_name=${lib.escapeShellArg import.worldName} + archive="$cache_dir/$archive_name" + temporary_archive="$cache_dir/.$archive_name.$$" + extraction_dir="$cache_dir/.minecraft-world-import-${name}.$$" + staged_world="$server_dir/.$world_name.import.$$" + key=${lib.escapeShellArg import.sshKeyFile} + + cleanup() { + rm -f "$temporary_archive" + rm -rf "$extraction_dir" "$staged_world" + } + trap cleanup EXIT + + mkdir -p "$cache_dir" "$server_dir" + chmod 0700 "$cache_dir" + chgrp minecraft "$server_dir" + chmod 0770 "$server_dir" + + if [ -d "$world_dir" ]; then + if [ -f "$world_dir/level.dat" ]; then + exit 0 + fi + echo "Minecraft world directory exists but has no level.dat" >&2 + exit 1 + fi + + if [ ! -f "$archive" ]; then + downloaded=false + attempt=1 + while [ "$attempt" -le ${toString import.downloadRetries} ]; do + if sftp \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts \ + -i "$key" \ + -b - \ + ${lib.escapeShellArg "${import.remoteUser}@${import.remoteHost}"} <&2 + exit 1 + fi + mv "$temporary_archive" "$archive" + fi + + if ! printf '%s %s\n' \ + ${lib.escapeShellArg import.archiveSha256} \ + "$archive" | sha256sum -c -; then + rm -f "$archive" + echo "Cached Minecraft world ${name} checksum mismatch" >&2 + exit 1 + fi + + mkdir -p "$extraction_dir" + unar -quiet -output-directory "$extraction_dir" "$archive" + + find "$extraction_dir" \ + \( -type l -o -type b -o -type c -o -type p -o -type s \) \ + -delete + + world_level_dat=$(find "$extraction_dir" -type f -name level.dat -print -quit) + if [ -z "$world_level_dat" ]; then + echo "Minecraft world archive contains no level.dat" >&2 + exit 1 + fi + + mv "$(dirname "$world_level_dat")" "$staged_world" + chgrp -R minecraft "$staged_world" + chmod -R u+rwX,g+rwX,o-rwx "$staged_world" + mv "$staged_world" "$world_dir" + ''; + }; + + "minecraft-server-${import.serverName}" = { + requires = [ "${unitName}.service" ]; + after = [ "${unitName}.service" ]; + }; + }) enabledImports); +in { + options.services.minecraft-world-imports = mkOption { + default = { }; + type = types.attrsOf (types.submodule ({ name, ... }: { + options = { + enable = lib.mkEnableOption "Minecraft world import ${name}"; + + serverName = mkOption { + type = types.str; + description = "minecraft-servers server receiving imported world"; + }; + + remoteHost = mkOption { + type = types.str; + description = "SSH host serving world archive"; + }; + + remoteUser = mkOption { + type = types.str; + description = "SSH user used to download world archive"; + }; + + remotePath = mkOption { + type = types.str; + description = "Remote path to world archive"; + }; + + archiveName = mkOption { + type = types.str; + description = "Archive file name inside cache directory"; + }; + + cacheDir = mkOption { + type = types.str; + default = "/var/lib/minecraft-world-imports/${name}"; + description = "Persistent archive cache directory"; + }; + + archiveSha256 = mkOption { + type = types.strMatching "[0-9a-fA-F]{64}"; + description = "Expected SHA-256 digest of world archive"; + }; + + sshKeyFile = mkOption { + type = types.str; + description = "Runtime path to private SSH key"; + }; + + worldName = mkOption { + type = types.str; + default = "world"; + description = "World directory name beneath server directory"; + }; + + hostPublicKey = mkOption { + type = types.str; + description = "Pinned SSH host public key"; + }; + + downloadRetries = mkOption { + type = types.ints.positive; + default = 3; + description = "Maximum SFTP download attempts"; + }; + + retryDelaySeconds = mkOption { + type = types.ints.unsigned; + default = 10; + description = "Delay between SFTP download attempts"; + }; + + timeoutStartSec = mkOption { + type = types.str; + default = "30min"; + description = "Importer service start timeout"; + }; + }; + })); + description = "Minecraft worlds imported before selected servers start"; + }; + + config = lib.mkIf (enabledImports != { }) { + assertions = lib.flatten (lib.mapAttrsToList (name: import: [ + { + assertion = builtins.match "[A-Za-z0-9_-]+" name != null; + message = "services.minecraft-world-imports.${name}: name must contain only letters, digits, underscores, or hyphens"; + } + { + assertion = builtins.stringLength name <= 24; + message = "services.minecraft-world-imports.${name}: name must be at most 24 characters"; + } + { + assertion = builtins.match "/.*" import.cacheDir != null; + message = "services.minecraft-world-imports.${name}.cacheDir must be absolute"; + } + { + assertion = builtins.match "[A-Za-z0-9_-]+" import.serverName != null; + message = "services.minecraft-world-imports.${name}.serverName must contain only letters, digits, underscores, or hyphens"; + } + { + assertion = !lib.hasInfix "\n" import.remotePath && !lib.hasInfix "\r" import.remotePath; + message = "services.minecraft-world-imports.${name}.remotePath must not contain newlines"; + } + { + assertion = builtins.hasAttr import.serverName minecraftServers + && (builtins.getAttr import.serverName minecraftServers).enable; + message = "services.minecraft-world-imports.${name}.serverName must name an enabled Minecraft server"; + } + { + assertion = lib.length (lib.unique targetPairs) == lib.length targetPairs; + message = "services.minecraft-world-imports: each server/world target must be unique"; + } + { + assertion = builtins.match "[^/]+" import.archiveName != null; + message = "services.minecraft-world-imports.${name}.archiveName must be a file name"; + } + { + assertion = builtins.match "[^/]+" import.worldName != null; + message = "services.minecraft-world-imports.${name}.worldName must be a directory name"; + } + ]) enabledImports); + + users.groups = lib.mapAttrs' (name: _: lib.nameValuePair (importerUser name) { }) enabledImports; + users.users = lib.mapAttrs' (name: _: let + user = importerUser name; + in lib.nameValuePair user { + description = "Minecraft world importer ${name}"; + isSystemUser = true; + group = user; + extraGroups = [ "minecraft" ]; + }) enabledImports; + + programs.ssh.knownHosts = lib.mapAttrs' (name: import: + lib.nameValuePair "minecraft-world-import-${name}" { + hostNames = [ import.remoteHost ]; + publicKey = import.hostPublicKey; + }) enabledImports; + + systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: import: + lib.optional (!stateDirectoryCompatible import.cacheDir) + "d ${escapeTmpfiles import.cacheDir} 0700 ${importerUser name} ${importerUser name} -") enabledImports); + + systemd.services = importerServices; + }; +} diff --git a/nixos/system/neuro/minecraft/wow-mine-map.nix b/nixos/system/neuro/minecraft/wow-mine-map.nix new file mode 100644 index 00000000..a01949b8 --- /dev/null +++ b/nixos/system/neuro/minecraft/wow-mine-map.nix @@ -0,0 +1,41 @@ +{ config, pkgs, ... }: + +{ + sops.secrets."minecraft/storage-box-key" = { + sopsFile = ../../../../sus/neuro.yaml; + owner = "minecraft-map-import-wowMineMap"; + group = "minecraft-map-import-wowMineMap"; + mode = "0400"; + }; + + services.minecraft-world-imports.wowMineMap = { + enable = true; + serverName = "wowMineMap"; + remoteHost = "u664722.your-storagebox.de"; + remoteUser = "u664722"; + remotePath = "minecraft/map/wow mine map.rar"; + archiveName = "wow mine map.rar"; + cacheDir = "/var/lib/minecraft-maps"; + archiveSha256 = "bc80084de10a06b0fc2cb1651c61936b9e2fd2288f3f0fe44c964d83a393aa30"; + sshKeyFile = config.sops.secrets."minecraft/storage-box-key".path; + worldName = "world"; + hostPublicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5EB5p/5Hp3hGW1oHok+PIOH9Pbn7cnUiGmUEBrCVjnAw+HrKyN8bYVV0dIGllswYXwkG/+bgiBlE6IVIBAq+JwVWu1Sss3KarHY3OvFJUXZoZyRRg/Gc/+LRCE7lyKpwWQ70dbelGRyyJFH36eNv6ySXoUYtGkwlU5IVaHPApOxe4LHPZa/qhSRbPo2hwoh0orCtgejRebNtW5nlx00DNFgsvn8Svz2cIYLxsPVzKgUxs8Zxsxgn+Q/UvR7uq4AbAhyBMLxv7DjJ1pc7PJocuTno2Rw9uMZi1gkjbnmiOh6TTXIEWbnroyIhwc8555uto9melEUmWNQ+C+PwAK+MPw=="; + }; + + services.minecraft-servers.servers.wowMineMap = { + enable = true; + jvmOpts = "-Xmx8G -Xms2G"; + package = pkgs.minecraftServers.neoforge-1_21_1; + + symlinks.mods = import ./mods.nix { inherit pkgs; }; + + serverProperties = { + server-port = 25567; + difficulty = "hard"; + online-mode = true; + view-distance = 20; + level-name = "world"; + pause-when-empty-seconds = 0; + }; + }; +} diff --git a/nixos/system/neuro/neuro.nix b/nixos/system/neuro/neuro.nix index fa39f215..86ade3bf 100644 --- a/nixos/system/neuro/neuro.nix +++ b/nixos/system/neuro/neuro.nix @@ -70,7 +70,7 @@ in { imports = [ self.nixosModules.hectic inputs.sops-nix.nixosModules.sops - ./minecraft.nix + ./minecraft ./hardware.nix ];