diff --git a/lib/fudo/minecraft-clj.nix b/lib/fudo/minecraft-clj.nix index 309cbec..fb81b5c 100644 --- a/lib/fudo/minecraft-clj.nix +++ b/lib/fudo/minecraft-clj.nix @@ -40,21 +40,117 @@ let "-Daikars.new.flags=true" ]; + worldOpts = { name, ... }: + let world-name = name; + in { + options = with types; { + enable = mkEnableOption "Enable this world."; + + world-name = mkOption { + type = str; + description = "Name of this world."; + default = world-name; + }; + + port = mkOption { + type = port; + description = "Port on which to run this Minecraft world."; + default = 25565; + }; + + difficulty = mkOption { + type = enum [ "peaceful" "easy" "medium" "hard" ]; + description = "Difficulty setting of this server."; + default = "medium"; + }; + + game-mode = mkOption { + type = enum [ "survival" "creative" "adventure" "spectator" ]; + description = "Game mode of the server."; + default = "survival"; + }; + + hardcore = mkOption { + type = bool; + description = "Give players only one life to live."; + default = false; + }; + + world-seed = mkOption { + type = nullOr int; + description = "Seed to use while generating the world."; + default = null; + }; + + motd = mkOption { + type = str; + description = "Message with which to greet users."; + default = "Welcome to ${world-name}"; + }; + + allow-cheats = mkOption { + type = bool; + description = "Allow cheats on this server."; + default = true; + }; + + allocated-memory = mkOption { + type = int; + description = "Memory to allocate to Minecraft, in GB."; + default = 4; + }; + + pvp = mkOption { + type = bool; + description = "Allow player-vs-player combat."; + default = true; + }; + }; + }; + + validChar = c: !isNull (builtins.match "^[a-zA-Z0-9_-]$" c); + + swapSpace = replaceStrings [ " " ] [ "_" ]; + + sanitizeName = name: + concatStringsSep "" + (filter validChar (stringToCharacters (swapSpace name))); + + worldStateDir = worldOpts: + "${cfg.state-directory}/${sanitizeName worldOpts.world-name}"; + + genProps = worldOpts: { + level-name = worldOpts.world-name; + level-seed = worldOpts.world-seed; + motd = worldOpts.motd; + difficulty = worldOpts.difficulty; + gamemode = cfg.game-mode; + allow-cheats = worldOpts.allowCheats; + server-port = worldOpts.port; + hardcore = worldOpts.hardcore; + pvp = worldOpts.pvp; + }; + + toProps = attrs: + let + boolToString = v: if v then "true" else "false"; + toVal = v: if isBool v then boolToString v else toString v; + toProp = k: v: "${k}=${toVal v}"; + in concatStringsSep "\n" (mapAttrsToList toProp attrs); + + genPropsFile = worldOpts: + pkgs.writeText "mc-${sanitizeName worldOpts.world-name}.properties" + (toProps (genProps worldOpts)); + in { options.fudo.minecraft-clj = with types; { enable = mkEnableOption "Enable Minecraft server with Clojure repl."; - data-dir = mkOption { + state-directory = mkOption { type = str; description = "Path at which to store Minecraft data."; }; - allocated-memory = mkOption { - type = int; - description = "Memory to allocate to Minecraft, in GB."; - default = 4; - }; - user = mkOption { type = str; description = "User as which to run the minecraft server."; @@ -66,6 +162,18 @@ in { description = "Group as which to run the minecraft server."; default = "minecraft-clj"; }; + + admins = mkOption { + type = listOf str; + description = "List of users to treat as administrators."; + default = [ ]; + }; + + worlds = mkOption { + type = attrsOf (submodule worldOpts); + description = "List of worlds to run on this server."; + default = { }; + }; }; config = mkIf cfg.enable { @@ -79,35 +187,59 @@ in { groups."${cfg.group}" = { members = [ cfg.user ]; }; }; - systemd.services.minecraft-clj = { - description = "Minecraft server with Clojure REPL."; - wantedBy = [ "multi-user.target" ]; - after = [ "network-online.target" ]; - serviceConfig = { - User = cfg.user; - Group = cfg.group; - ExecStart = let - mem = "${toString cfg.allocated-memory}G"; - memFlags = [ "-Xms${mem}" "-Xmx${mem}" ]; - flags = commonFlags ++ memFlags - ++ (optionals (cfg.allocated-memory >= 12) highMemFlags); - flagStr = concatStringsSep " " flags; - in "${pkgs.papermc}/bin/minecraft-server ${flagStr}"; + systemd = { + tmpfiles.rules = map (worldOpts: + "d ${worldStateDir worldOpts} 0700 ${cfg.user} ${cfg.group} - -") + (attrValues cfg.worlds); - Restart = "always"; - NoNewPrivileges = true; - PrivateTmp = true; - PrivateDevices = true; - ProtectSystem = "strict"; - ProtectHome = true; - ProtectControlGroups = true; - ProtectKernelModules = true; - ProtectKernalTunables = true; - RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; - RestrictRealtime = true; - RestrictNamespaces = true; - MemoryDenyWriteExecute = true; - }; + services = mapAttrs' (_: worldOpts: + let + sanitizedName = sanitizeName worldOpts.world-name; + serverName = "minecraft-clj-${sanitizedName}"; + stateDir = worldStateDir worldOpts; + startScript = let + admins-file = pkgs.writeText "${sanitizedName}-ops.txt" + (concatStringsSep "\n" cfg.admins); + props-file = genPropsFile worldOpts; + + in pkgs.writeShellScript "mc-initialize-${sanitizedName}.sh" '' + cp -f ${admins-file} ${stateDir}/ops.txt + cp -f ${props-file} ${stateDir}/server.properties + chmod u+w ${stateDir}/server.properties + ''; + + in nameValuePair serverName { + enable = worldOpts.enable; + description = + "${worldOpts.world-name} Minecraft Server with Clojure REPL"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + WorkingDirectory = stateDir; + ExecStart = let + mem = "${toString worldOpts.allocated-memory}G"; + memFlags = [ "-Xms${mem}" "-Xmx${mem}" ]; + flags = commonFlags ++ memFlags + ++ (optionals (cfg.allocated-memory >= 12) highMemFlags); + flagStr = concatStringsSep " " flags; + in "${pkgs.papermc}/bin/minecraft-server ${flagStr}"; + + Restart = "always"; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernalTunables = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictRealtime = true; + RestrictNamespaces = true; + }; + }) cfg.worlds; }; }; }