{ config, lib, pkgs, ... }: let cfg = config.modules.services.steamcmd; appType = lib.types.submodule ( { config, name, ... }: { options = { enable = lib.mkEnableOption "this SteamCMD app service"; 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.nullOr lib.types.str; default = null; 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."; }; 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."; }; 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."; }; 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 else "systemctl restart ${lib.escapeShellArg name}.service"; in { options.modules.services.steamcmd = { enable = lib.mkEnableOption "SteamCMD service management"; package = lib.mkPackageOption pkgs "steamcmd" { }; user = lib.mkOption { type = lib.types.str; default = "steamcmd"; description = "User account that runs SteamCMD."; }; group = lib.mkOption { type = lib.types.str; default = "steamcmd"; description = "Group account that owns SteamCMD files."; }; dataDir = lib.mkOption { type = lib.types.str; default = "/data/steamcmd"; description = "Directory where SteamCMD stores metadata and cache files."; }; apps = lib.mkOption { type = lib.types.attrsOf appType; default = { }; description = "SteamCMD-managed game services keyed by service name."; }; }; config = lib.mkMerge [ { registry."stable-uid".${config.networking.hostName}.steamcmd = 20003; } (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; uid = config.registry."stable-uid".${config.networking.hostName}.steamcmd; group = cfg.group; 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.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; }; }; ${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"; }; }; "steamcmd-${name}-restart" = lib.mkIf app.restart.enable { description = "Restart ${name}"; serviceConfig = { Type = "oneshot"; ExecStart = pkgs.writeShellScript "steamcmd-${name}-restart" '' set -eu exec ${restartCommand name app} ''; }; }; }) 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 ); networking.firewall.allowedTCPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.tcpPorts) enabledApps); networking.firewall.allowedUDPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.udpPorts) enabledApps); }) ]; }