refactor(nixos): make SteamCMD service single-instance

This commit is contained in:
2026-09-09 21:43:37 -04:00
parent 88f082f1c0
commit 266b90e488
3 changed files with 242 additions and 292 deletions

View File

@@ -69,40 +69,38 @@
modules.services.steamcmd = { modules.services.steamcmd = {
enable = true; enable = true;
dataDir = "/pikachu/data/steamcmd"; dataDir = "/pikachu/data/steamcmd";
apps.valheim = { serviceName = "valheim";
enable = true; appId = 896660;
appId = 896660; installDir = "/pikachu/data/servers/valheim/main";
installDir = "/pikachu/data/servers/valheim/main"; workingDirectory = "/pikachu/data/servers/valheim/main";
workingDirectory = "/pikachu/data/servers/valheim/main"; group = "storage";
group = "storage"; dataDirs = [ "/pikachu/media/games/servers/valheim/main" ];
dataDirs = [ "/pikachu/media/games/servers/valheim/main" ]; packages = [ pkgs.steam-run ];
packages = [ pkgs.steam-run ]; environment = {
environment = { SteamAppId = "892970";
SteamAppId = "892970"; SERVER_PASSWORD = "foobarbaz";
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
];
}; };
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 = { guests.actualbudget = {

View File

@@ -7,201 +7,70 @@
let let
cfg = config.modules.services.steamcmd; cfg = config.modules.services.steamcmd;
appType = lib.types.submodule ( prepareScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-prepare" ''
{ config, name, ... }: set -eu
{
options = {
enable = lib.mkEnableOption "this SteamCMD app service";
appId = lib.mkOption { ${pkgs.coreutils}/bin/install -d \
type = lib.types.ints.positive; -o ${lib.escapeShellArg cfg.user} \
example = 896660; -g ${lib.escapeShellArg cfg.group} \
description = "Steam app ID for the dedicated server."; -m 0770 \
}; ${lib.escapeShellArg cfg.dataDir}
installDir = lib.mkOption { ${pkgs.coreutils}/bin/install -d \
type = lib.types.str; -o ${lib.escapeShellArg cfg.user} \
example = "/data/servers/valheim/main"; -g ${lib.escapeShellArg cfg.group} \
description = "Directory where SteamCMD installs the server files."; -m 0770 \
}; ${lib.escapeShellArg cfg.installDir} \
${lib.escapeShellArgs cfg.dataDirs}
'';
workingDirectory = lib.mkOption { updateScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-update" ''
type = lib.types.nullOr lib.types.str; set -eu
default = null;
example = "/data/servers/valheim/main";
description = "Directory where the game service starts.";
};
command = lib.mkOption { export HOME=${lib.escapeShellArg cfg.dataDir}
type = lib.types.listOf lib.types.str; exec ${lib.getExe cfg.package} \
example = [ +force_install_dir ${lib.escapeShellArg cfg.installDir} \
"/data/servers/valheim/main/valheim_server.x86_64" +login anonymous \
"-name" +app_update ${toString cfg.appId} ${lib.optionalString cfg.validateUpdate "validate"} \
"main" +quit
]; '';
description = "Command that starts the game server.";
};
user = lib.mkOption { startScript = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-start" ''
type = lib.types.str; set -eu
default = cfg.user;
defaultText = lib.literalExpression "config.modules.services.steamcmd.user";
description = "User account that runs the game service.";
};
group = lib.mkOption { cd ${lib.escapeShellArg cfg.workingDirectory}
type = lib.types.str; exec ${lib.escapeShellArgs cfg.command}
default = cfg.group; '';
defaultText = lib.literalExpression "config.modules.services.steamcmd.group";
description = "Group account that owns writable server files.";
};
environment = lib.mkOption { restartCommand =
type = lib.types.attrsOf lib.types.str; if cfg.restart.command != null then
default = { }; cfg.restart.command
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 else
"systemctl restart ${lib.escapeShellArg name}.service"; "systemctl restart ${lib.escapeShellArg cfg.serviceName}.service";
in in
{ {
options.modules.services.steamcmd = { options.modules.services.steamcmd = {
enable = lib.mkEnableOption "SteamCMD service management"; enable = lib.mkEnableOption "SteamCMD-managed game server";
package = lib.mkPackageOption pkgs "steamcmd" { }; 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 { user = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "steamcmd"; default = "steamcmd";
description = "User account that runs SteamCMD."; description = "User account that runs SteamCMD and the game server.";
}; };
group = lib.mkOption { group = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "steamcmd"; default = "steamcmd";
description = "Group account that owns SteamCMD files."; description = "Group account that owns writable server files.";
}; };
dataDir = lib.mkOption { dataDir = lib.mkOption {
@@ -210,10 +79,111 @@ in
description = "Directory where SteamCMD stores metadata and cache files."; description = "Directory where SteamCMD stores metadata and cache files.";
}; };
apps = lib.mkOption { appId = lib.mkOption {
type = lib.types.attrsOf appType; 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 = { }; 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 { (lib.mkIf cfg.enable {
assertions = [
{
assertion = enabledApps != { };
message = "modules.services.steamcmd.apps must contain at least one enabled app.";
}
];
users.groups.${cfg.group} = { }; users.groups.${cfg.group} = { };
users.users.${cfg.user} = { users.users.${cfg.user} = {
isSystemUser = true; isSystemUser = true;
@@ -238,89 +201,78 @@ in
home = cfg.dataDir; home = cfg.dataDir;
}; };
systemd.tmpfiles.rules = systemd.tmpfiles.rules = [
[ "d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -"
"d ${cfg.dataDir} 0770 ${cfg.user} ${cfg.group} - -" "z ${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} - -"
++ lib.flatten ( ] ++ map (dataDir: "d ${dataDir} 0770 ${cfg.user} ${cfg.group} - -") cfg.dataDirs
lib.mapAttrsToList (_: app: [ ++ map (dataDir: "z ${dataDir} 0770 ${cfg.user} ${cfg.group} - -") cfg.dataDirs;
"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 ( systemd.services = {
lib.mapAttrsToList (name: app: { "steamcmd-${cfg.serviceName}-update" = {
"steamcmd-${name}-update" = { description = "Update ${cfg.serviceName} with SteamCMD";
description = "Update ${name} with SteamCMD"; after = [ "network-online.target" ];
after = [ "network-online.target" ]; wants = [ "network-online.target" ];
wants = [ "network-online.target" ]; serviceConfig = {
serviceConfig = { Type = "oneshot";
Type = "oneshot"; User = cfg.user;
User = app.user; Group = cfg.group;
Group = app.group; WorkingDirectory = "-${cfg.dataDir}";
WorkingDirectory = "-${cfg.dataDir}"; ExecStartPre = "+${prepareScript}";
ExecStartPre = "+${prepareScript name app}"; ExecStart = updateScript;
ExecStart = updateScript name app;
};
}; };
};
${name} = { ${cfg.serviceName} = {
description = "${name} game server"; description = "${cfg.serviceName} game server";
after = [ after = [
"network-online.target" "network-online.target"
"steamcmd-${name}-update.service" "steamcmd-${cfg.serviceName}-update.service"
]; ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
path = app.packages; path = cfg.packages;
environment = app.environment; environment = cfg.environment;
serviceConfig = { serviceConfig = {
Type = "simple"; Type = "simple";
User = app.user; User = cfg.user;
Group = app.group; Group = cfg.group;
WorkingDirectory = "-${app.workingDirectory}"; WorkingDirectory = "-${cfg.workingDirectory}";
EnvironmentFile = app.environmentFiles; EnvironmentFile = cfg.environmentFiles;
ExecStartPre = [ ExecStartPre = [
"+${prepareScript name app}" "+${prepareScript}"
] ++ lib.optional app.updateOnStart (updateScript name app); ] ++ lib.optional cfg.updateOnStart updateScript;
ExecStart = startScript name app; ExecStart = startScript;
Restart = "on-failure"; Restart = "on-failure";
};
}; };
};
"steamcmd-${name}-restart" = lib.mkIf app.restart.enable { "steamcmd-${cfg.serviceName}-restart" = lib.mkIf cfg.restart.enable {
description = "Restart ${name}"; description = "Restart ${cfg.serviceName}";
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
ExecStart = pkgs.writeShellScript "steamcmd-${name}-restart" '' ExecStart = pkgs.writeShellScript "steamcmd-${cfg.serviceName}-restart" ''
set -eu set -eu
exec ${restartCommand name app} exec ${restartCommand}
''; '';
};
}; };
}) enabledApps };
); };
systemd.timers = lib.mkMerge ( systemd.timers."steamcmd-${cfg.serviceName}-restart" = lib.mkIf cfg.restart.enable {
lib.mapAttrsToList (name: app: { description = "Restart ${cfg.serviceName} on a schedule";
"steamcmd-${name}-restart" = lib.mkIf app.restart.enable { wantedBy = [ "timers.target" ];
description = "Restart ${name} on a schedule"; timerConfig = {
wantedBy = [ "timers.target" ]; OnCalendar = cfg.restart.onCalendar;
timerConfig = { RandomizedDelaySec = cfg.restart.randomizedDelaySec;
OnCalendar = app.restart.onCalendar; Persistent = true;
RandomizedDelaySec = app.restart.randomizedDelaySec; };
Persistent = true; };
};
};
}) enabledApps
);
networking.firewall.allowedTCPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.tcpPorts) enabledApps); networking.firewall.allowedTCPPorts = cfg.tcpPorts;
networking.firewall.allowedUDPPorts = lib.flatten (lib.mapAttrsToList (_: app: app.udpPorts) enabledApps); networking.firewall.allowedUDPPorts = cfg.udpPorts;
}) })
]; ];
} }

View File

@@ -6,8 +6,8 @@
2. Define `modules.services.steamcmd.enable`. 2. Define `modules.services.steamcmd.enable`.
3. Create the `steamcmd` service user. 3. Create the `steamcmd` service user.
4. Configure the shared SteamCMD data directory. 4. Configure the shared SteamCMD data directory.
5. Add one option for each Steam app service. 5. Add options for one Steam app service.
6. Run SteamCMD before each app service starts. 6. Run SteamCMD before the game service starts.
7. Update the Steam app on each service restart. 7. Update the Steam app on each service restart.
8. Add a restart timer with default settings. 8. Add a restart timer with default settings.
9. Add an option that overrides the restart command. 9. Add an option that overrides the restart command.