From 266b90e488866c621228f76ef02128744d689108 Mon Sep 17 00:00:00 2001 From: alexion Date: Wed, 9 Sep 2026 21:43:37 -0400 Subject: [PATCH] refactor(nixos): make SteamCMD service single-instance --- configs/nixos/hosts/pikachu/default.nix | 64 ++- configs/nixos/modules/services/steamcmd.nix | 466 +++++++++----------- docs/00 INBOX/ai/steamcmd-server.md | 4 +- 3 files changed, 242 insertions(+), 292 deletions(-) diff --git a/configs/nixos/hosts/pikachu/default.nix b/configs/nixos/hosts/pikachu/default.nix index ef3d6ba..b788730 100644 --- a/configs/nixos/hosts/pikachu/default.nix +++ b/configs/nixos/hosts/pikachu/default.nix @@ -69,40 +69,38 @@ modules.services.steamcmd = { enable = true; dataDir = "/pikachu/data/steamcmd"; - apps.valheim = { - enable = true; - appId = 896660; - installDir = "/pikachu/data/servers/valheim/main"; - workingDirectory = "/pikachu/data/servers/valheim/main"; - group = "storage"; - dataDirs = [ "/pikachu/media/games/servers/valheim/main" ]; - packages = [ pkgs.steam-run ]; - environment = { - SteamAppId = "892970"; - SERVER_PASSWORD = "foobarbaz"; - }; - command = [ - "${pkgs.bash}/bin/bash" - "-c" - '' - export LD_LIBRARY_PATH="$PWD/linux64:$LD_LIBRARY_PATH" - exec steam-run ./valheim_server.x86_64 \ - -nographics \ - -batchmode \ - -name main \ - -port 2456 \ - -world main \ - -savedir /pikachu/media/games/servers/valheim/main \ - -password "$SERVER_PASSWORD" \ - -public 0 - '' - ]; - udpPorts = [ - 2456 - 2457 - 2458 - ]; + serviceName = "valheim"; + appId = 896660; + installDir = "/pikachu/data/servers/valheim/main"; + workingDirectory = "/pikachu/data/servers/valheim/main"; + group = "storage"; + dataDirs = [ "/pikachu/media/games/servers/valheim/main" ]; + packages = [ pkgs.steam-run ]; + environment = { + SteamAppId = "892970"; + SERVER_PASSWORD = "foobarbaz"; }; + command = [ + "${pkgs.bash}/bin/bash" + "-c" + '' + export LD_LIBRARY_PATH="$PWD/linux64:$LD_LIBRARY_PATH" + exec steam-run ./valheim_server.x86_64 \ + -nographics \ + -batchmode \ + -name main \ + -port 2456 \ + -world main \ + -savedir /pikachu/media/games/servers/valheim/main \ + -password "$SERVER_PASSWORD" \ + -public 0 + '' + ]; + udpPorts = [ + 2456 + 2457 + 2458 + ]; }; guests.actualbudget = { diff --git a/configs/nixos/modules/services/steamcmd.nix b/configs/nixos/modules/services/steamcmd.nix index 355f554..21ce947 100644 --- a/configs/nixos/modules/services/steamcmd.nix +++ b/configs/nixos/modules/services/steamcmd.nix @@ -7,201 +7,70 @@ let cfg = config.modules.services.steamcmd; - appType = lib.types.submodule ( - { config, name, ... }: - { - options = { - enable = lib.mkEnableOption "this SteamCMD app service"; + prepareScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-prepare" '' + set -eu - appId = lib.mkOption { - type = lib.types.ints.positive; - example = 896660; - description = "Steam app ID for the dedicated server."; - }; + ${pkgs.coreutils}/bin/install -d \ + -o ${lib.escapeShellArg cfg.user} \ + -g ${lib.escapeShellArg cfg.group} \ + -m 0770 \ + ${lib.escapeShellArg cfg.dataDir} - installDir = lib.mkOption { - type = lib.types.str; - example = "/data/servers/valheim/main"; - description = "Directory where SteamCMD installs the server files."; - }; + ${pkgs.coreutils}/bin/install -d \ + -o ${lib.escapeShellArg cfg.user} \ + -g ${lib.escapeShellArg cfg.group} \ + -m 0770 \ + ${lib.escapeShellArg cfg.installDir} \ + ${lib.escapeShellArgs cfg.dataDirs} + ''; - workingDirectory = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "/data/servers/valheim/main"; - description = "Directory where the game service starts."; - }; + updateScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-update" '' + set -eu - command = lib.mkOption { - type = lib.types.listOf lib.types.str; - example = [ - "/data/servers/valheim/main/valheim_server.x86_64" - "-name" - "main" - ]; - description = "Command that starts the game server."; - }; + export HOME=${lib.escapeShellArg cfg.dataDir} + exec ${lib.getExe cfg.package} \ + +force_install_dir ${lib.escapeShellArg cfg.installDir} \ + +login anonymous \ + +app_update ${toString cfg.appId} ${lib.optionalString cfg.validateUpdate "validate"} \ + +quit + ''; - user = lib.mkOption { - type = lib.types.str; - default = cfg.user; - defaultText = lib.literalExpression "config.modules.services.steamcmd.user"; - description = "User account that runs the game service."; - }; + startScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-start" '' + set -eu - group = lib.mkOption { - type = lib.types.str; - default = cfg.group; - defaultText = lib.literalExpression "config.modules.services.steamcmd.group"; - description = "Group account that owns writable server files."; - }; + cd ${lib.escapeShellArg cfg.workingDirectory} + exec ${lib.escapeShellArgs cfg.command} + ''; - environment = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = { }; - description = "Environment variables for the game service."; - }; - - environmentFiles = lib.mkOption { - type = lib.types.listOf lib.types.path; - default = [ ]; - description = "Environment files loaded by the game service."; - }; - - dataDirs = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - example = [ "/media/games/servers/valheim/main" ]; - description = "Additional writable directories created before the game service starts."; - }; - - updateOnStart = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Runs SteamCMD before each game service start."; - }; - - validateUpdate = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Passes validate to SteamCMD app_update."; - }; - - packages = lib.mkOption { - type = lib.types.listOf lib.types.package; - default = [ ]; - description = "Packages added to the game service path."; - }; - - tcpPorts = lib.mkOption { - type = lib.types.listOf lib.types.port; - default = [ ]; - description = "TCP ports opened for the game service."; - }; - - udpPorts = lib.mkOption { - type = lib.types.listOf lib.types.port; - default = [ ]; - description = "UDP ports opened for the game service."; - }; - - restart = { - enable = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Enables the scheduled restart timer."; - }; - - onCalendar = lib.mkOption { - type = lib.types.str; - default = "daily"; - example = "*-*-* 05:00:00"; - description = "Calendar expression for the restart timer."; - }; - - randomizedDelaySec = lib.mkOption { - type = lib.types.str; - default = "15m"; - description = "Maximum randomized delay for the restart timer."; - }; - - command = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - example = "systemctl restart valheim.service"; - description = "Command run by the restart timer."; - }; - }; - }; - - config = { - workingDirectory = lib.mkDefault config.installDir; - }; - } - ); - - enabledApps = lib.filterAttrs (_: app: app.enable) cfg.apps; - - prepareScript = name: app: - pkgs.writeShellScript "steamcmd-${name}-prepare" '' - set -eu - - ${pkgs.coreutils}/bin/install -d \ - -o ${lib.escapeShellArg cfg.user} \ - -g ${lib.escapeShellArg cfg.group} \ - -m 0770 \ - ${lib.escapeShellArg cfg.dataDir} - - ${pkgs.coreutils}/bin/install -d \ - -o ${lib.escapeShellArg app.user} \ - -g ${lib.escapeShellArg app.group} \ - -m 0770 \ - ${lib.escapeShellArg app.installDir} \ - ${lib.escapeShellArgs app.dataDirs} - ''; - - updateScript = name: app: - pkgs.writeShellScript "steamcmd-${name}-update" '' - set -eu - - export HOME=${lib.escapeShellArg cfg.dataDir} - exec ${lib.getExe cfg.package} \ - +force_install_dir ${lib.escapeShellArg app.installDir} \ - +login anonymous \ - +app_update ${toString app.appId} ${lib.optionalString app.validateUpdate "validate"} \ - +quit - ''; - - startScript = name: app: - pkgs.writeShellScript "steamcmd-${name}-start" '' - set -eu - - cd ${lib.escapeShellArg app.workingDirectory} - exec ${lib.escapeShellArgs app.command} - ''; - - restartCommand = name: app: - if app.restart.command != null then - app.restart.command + restartCommand = + if cfg.restart.command != null then + cfg.restart.command else - "systemctl restart ${lib.escapeShellArg name}.service"; + "systemctl restart ${lib.escapeShellArg cfg.serviceName}.service"; in { options.modules.services.steamcmd = { - enable = lib.mkEnableOption "SteamCMD service management"; + enable = lib.mkEnableOption "SteamCMD-managed game server"; package = lib.mkPackageOption pkgs "steamcmd" { }; + serviceName = lib.mkOption { + type = lib.types.str; + default = "steamcmd"; + example = "valheim"; + description = "Name of the systemd service that runs the game server."; + }; + user = lib.mkOption { type = lib.types.str; default = "steamcmd"; - description = "User account that runs SteamCMD."; + description = "User account that runs SteamCMD and the game server."; }; group = lib.mkOption { type = lib.types.str; default = "steamcmd"; - description = "Group account that owns SteamCMD files."; + description = "Group account that owns writable server files."; }; dataDir = lib.mkOption { @@ -210,10 +79,111 @@ in description = "Directory where SteamCMD stores metadata and cache files."; }; - apps = lib.mkOption { - type = lib.types.attrsOf appType; + appId = lib.mkOption { + type = lib.types.ints.positive; + example = 896660; + description = "Steam app ID for the dedicated server."; + }; + + installDir = lib.mkOption { + type = lib.types.str; + example = "/data/servers/valheim/main"; + description = "Directory where SteamCMD installs the server files."; + }; + + workingDirectory = lib.mkOption { + type = lib.types.str; + default = cfg.installDir; + defaultText = lib.literalExpression "config.modules.services.steamcmd.installDir"; + example = "/data/servers/valheim/main"; + description = "Directory where the game service starts."; + }; + + command = lib.mkOption { + type = lib.types.listOf lib.types.str; + example = [ + "/data/servers/valheim/main/valheim_server.x86_64" + "-name" + "main" + ]; + description = "Command that starts the game server."; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; default = { }; - description = "SteamCMD-managed game services keyed by service name."; + description = "Environment variables for the game service."; + }; + + environmentFiles = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + description = "Environment files loaded by the game service."; + }; + + dataDirs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "/media/games/servers/valheim/main" ]; + description = "Additional writable directories created before the game service starts."; + }; + + updateOnStart = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Runs SteamCMD before each game service start."; + }; + + validateUpdate = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Passes validate to SteamCMD app_update."; + }; + + packages = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "Packages added to the game service path."; + }; + + tcpPorts = lib.mkOption { + type = lib.types.listOf lib.types.port; + default = [ ]; + description = "TCP ports opened for the game service."; + }; + + udpPorts = lib.mkOption { + type = lib.types.listOf lib.types.port; + default = [ ]; + description = "UDP ports opened for the game service."; + }; + + restart = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enables the scheduled restart timer."; + }; + + onCalendar = lib.mkOption { + type = lib.types.str; + default = "daily"; + example = "*-*-* 05:00:00"; + description = "Calendar expression for the restart timer."; + }; + + randomizedDelaySec = lib.mkOption { + type = lib.types.str; + default = "15m"; + description = "Maximum randomized delay for the restart timer."; + }; + + command = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "systemctl restart valheim.service"; + description = "Command run by the restart timer."; + }; }; }; @@ -223,13 +193,6 @@ in } (lib.mkIf cfg.enable { - assertions = [ - { - assertion = enabledApps != { }; - message = "modules.services.steamcmd.apps must contain at least one enabled app."; - } - ]; - users.groups.${cfg.group} = { }; users.users.${cfg.user} = { isSystemUser = true; @@ -238,89 +201,78 @@ in home = cfg.dataDir; }; - systemd.tmpfiles.rules = - [ - "d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -" - "z ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -" - ] - ++ lib.flatten ( - lib.mapAttrsToList (_: app: [ - "d ${app.installDir} 0770 ${app.user} ${app.group} - -" - "z ${app.installDir} 0770 ${app.user} ${app.group} - -" - ] ++ map (dataDir: "d ${dataDir} 0770 ${app.user} ${app.group} - -") app.dataDirs - ++ map (dataDir: "z ${dataDir} 0770 ${app.user} ${app.group} - -") app.dataDirs) enabledApps - ); + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -" + "z ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.installDir} 0770 ${cfg.user} ${cfg.group} - -" + "z ${cfg.installDir} 0770 ${cfg.user} ${cfg.group} - -" + ] ++ map (dataDir: "d ${dataDir} 0770 ${cfg.user} ${cfg.group} - -") cfg.dataDirs + ++ map (dataDir: "z ${dataDir} 0770 ${cfg.user} ${cfg.group} - -") cfg.dataDirs; - systemd.services = lib.mkMerge ( - lib.mapAttrsToList (name: app: { - "steamcmd-${name}-update" = { - description = "Update ${name} with SteamCMD"; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - serviceConfig = { - Type = "oneshot"; - User = app.user; - Group = app.group; - WorkingDirectory = "-${cfg.dataDir}"; - ExecStartPre = "+${prepareScript name app}"; - ExecStart = updateScript name app; - }; + systemd.services = { + "steamcmd-${cfg.serviceName}-update" = { + description = "Update ${cfg.serviceName} with SteamCMD"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = "-${cfg.dataDir}"; + ExecStartPre = "+${prepareScript}"; + ExecStart = updateScript; }; + }; - ${name} = { - description = "${name} game server"; - after = [ - "network-online.target" - "steamcmd-${name}-update.service" - ]; - wants = [ "network-online.target" ]; - wantedBy = [ "multi-user.target" ]; - path = app.packages; - environment = app.environment; - serviceConfig = { - Type = "simple"; - User = app.user; - Group = app.group; - WorkingDirectory = "-${app.workingDirectory}"; - EnvironmentFile = app.environmentFiles; - ExecStartPre = [ - "+${prepareScript name app}" - ] ++ lib.optional app.updateOnStart (updateScript name app); - ExecStart = startScript name app; - Restart = "on-failure"; - }; + ${cfg.serviceName} = { + description = "${cfg.serviceName} game server"; + after = [ + "network-online.target" + "steamcmd-${cfg.serviceName}-update.service" + ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + path = cfg.packages; + environment = cfg.environment; + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + WorkingDirectory = "-${cfg.workingDirectory}"; + EnvironmentFile = cfg.environmentFiles; + ExecStartPre = [ + "+${prepareScript}" + ] ++ lib.optional cfg.updateOnStart updateScript; + ExecStart = startScript; + Restart = "on-failure"; }; + }; - "steamcmd-${name}-restart" = lib.mkIf app.restart.enable { - description = "Restart ${name}"; - serviceConfig = { - Type = "oneshot"; - ExecStart = pkgs.writeShellScript "steamcmd-${name}-restart" '' - set -eu + "steamcmd-${cfg.serviceName}-restart" = lib.mkIf cfg.restart.enable { + description = "Restart ${cfg.serviceName}"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-restart" '' + set -eu - exec ${restartCommand name app} - ''; - }; + exec ${restartCommand} + ''; }; - }) enabledApps - ); + }; + }; - systemd.timers = lib.mkMerge ( - lib.mapAttrsToList (name: app: { - "steamcmd-${name}-restart" = lib.mkIf app.restart.enable { - description = "Restart ${name} on a schedule"; - wantedBy = [ "timers.target" ]; - timerConfig = { - OnCalendar = app.restart.onCalendar; - RandomizedDelaySec = app.restart.randomizedDelaySec; - Persistent = true; - }; - }; - }) enabledApps - ); + systemd.timers."steamcmd-${cfg.serviceName}-restart" = lib.mkIf cfg.restart.enable { + description = "Restart ${cfg.serviceName} on a schedule"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.restart.onCalendar; + RandomizedDelaySec = cfg.restart.randomizedDelaySec; + Persistent = true; + }; + }; - networking.firewall.allowedTCPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.tcpPorts) enabledApps); - networking.firewall.allowedUDPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.udpPorts) enabledApps); + networking.firewall.allowedTCPPorts = cfg.tcpPorts; + networking.firewall.allowedUDPPorts = cfg.udpPorts; }) ]; } diff --git a/docs/00 INBOX/ai/steamcmd-server.md b/docs/00 INBOX/ai/steamcmd-server.md index 80b4cd7..6a4cc67 100644 --- a/docs/00 INBOX/ai/steamcmd-server.md +++ b/docs/00 INBOX/ai/steamcmd-server.md @@ -6,8 +6,8 @@ 2. Define `modules.services.steamcmd.enable`. 3. Create the `steamcmd` service user. 4. Configure the shared SteamCMD data directory. -5. Add one option for each Steam app service. -6. Run SteamCMD before each app service starts. +5. Add options for one Steam app service. +6. Run SteamCMD before the game service starts. 7. Update the Steam app on each service restart. 8. Add a restart timer with default settings. 9. Add an option that overrides the restart command.