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 = {
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 = {

View File

@@ -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;
})
];
}