diff --git a/configs/nixos/hosts/pikachu/default.nix b/configs/nixos/hosts/pikachu/default.nix index 9172899..2b43df4 100644 --- a/configs/nixos/hosts/pikachu/default.nix +++ b/configs/nixos/hosts/pikachu/default.nix @@ -66,6 +66,45 @@ registry.address.pikachu.self = "10.23.10.102"; + 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"; + dataDirs = [ "/pikachu/media/games/servers/valheim/main" ]; + packages = [ pkgs.steam-run ]; + environment = { + SteamAppId = "892970"; + SERVER_PASSWORD = "foobarbaz"; + }; + command = [ + "${pkgs.bash}/bin/bash" + "-c" + '' + ${pkgs.coreutils}/bin/install -d -m 0770 /pikachu/media/games/servers/valheim/main + 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 = { enable = true; vlan = 20; diff --git a/configs/nixos/modules/services/steamcmd.nix b/configs/nixos/modules/services/steamcmd.nix new file mode 100644 index 0000000..3f17288 --- /dev/null +++ b/configs/nixos/modules/services/steamcmd.nix @@ -0,0 +1,325 @@ +{ + 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 ${cfg.user} ${cfg.group} - -" + "z ${app.installDir} 0770 ${cfg.user} ${cfg.group} - -" + ]) 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); + }) + ]; +} diff --git a/docs/00 INBOX/ai/steamcmd-server.md b/docs/00 INBOX/ai/steamcmd-server.md index 6893d43..80b4cd7 100644 --- a/docs/00 INBOX/ai/steamcmd-server.md +++ b/docs/00 INBOX/ai/steamcmd-server.md @@ -1,226 +1,93 @@ -# SteamCMD server notes - -These notes describe the emerging pattern for SteamCMD-based game servers in this monorepo. -They come from the Valheim prototype on Pikachu. - -## Goal - -Use NixOS to manage server operations, not game content. - -Nix owns service wiring, launch flags, ports, timers, users, and mounts. -SteamCMD owns downloaded server files. -The game owns world data, generated config, saves, and modded files. - -## Layering - -Use three layers for SteamCMD-based servers. - -```text -configs/nixos/modules/services/steamcmd.nix -configs/nixos/modules/services/.nix -configs/nixos/guests/.nix -``` - -### `modules.services.steamcmd` - -This module provides the generic SteamCMD machinery. - -It runs on any NixOS system. -That system can be a physical host or a guest. - -It owns: - -- the `steamcmd` service user, -- the shared SteamCMD data directory, -- per-instance Steam app updates, -- per-instance game service units, -- optional hard restart timers, -- firewall openings for declared ports. - -It does not know Valheim, PalWorld, or Project Zomboid semantics. -Game modules translate game-specific settings into SteamCMD instances. - -### `modules.services.` - -A game service module wraps `modules.services.steamcmd`. - -For Valheim, this layer owns: - -- Steam app id `896660`, -- the Valheim runtime app id, -- launch flags such as `-name`, `-world`, and `-port`, -- the expected UDP port range, -- the mapping from instance options to a SteamCMD instance. - -This layer exposes operational config only. -It avoids owning mutable gameplay files unless the game exposes stable launch flags. - -### `guests.` - -A guest module turns a game service into a deployable guest. - -It owns host-facing concerns: - -- VLAN placement, -- guest address registry entry, -- host paths and bind mounts, -- resource limits, -- secret file mounts, -- game-family instance declarations. - -Do not add a reusable `guests.steamcmd` base guest. -The guest auto-loader turns `configs/nixos/guests/steamcmd.nix` into a concrete guest. -A specialized guest cannot use it as a base without creating another container. - -## Instance model - -Use one guest per game family. -Use named instances inside that guest. - -For example, `guests.palworld` can contain `instances.current` and `instances.old`. -Each instance has its own ports and paths. -The host config must avoid clashes. - -For Valheim, the prototype uses `instances.foo`. -The instance name is the operational identity. -The world name defaults to the instance name. - -Keep these names separate in the module interface: - -```text -instance name -> unit names and paths -worldName -> game launch flag -``` - -This permits a test instance to load a copied world later. - -## Storage layout - -Use three storage roots. - -```text -/data/steamcmd -/data/servers// -/media/games/servers// -``` - -### `/data/steamcmd` - -Store SteamCMD data and cache here. - -This directory contains mutable Steam metadata and download state. -Do not treat it as durable game state. - -### `/data/servers//` - -Store the installed dedicated server files here. - -SteamCMD writes these files. -Mods can also mutate this tree. -For that reason, use per-instance app installs. -Do not share one app install between modded or historical instances. - -### `/media/games/servers//` - -Store world data, saves, generated config, and durable game state here. - -Backups are out of scope for the first prototype. -This path gives a stable backup boundary later. - -## Updates - -Do not pin game server versions in Nix. -Steam controls the server build. - -Nix controls when updates run. -The prototype updates on service start. -A daily hard restart timer can pull updates during a maintenance window. - -The generic SteamCMD layer supports a restart timer. -The default timer command restarts the instance service. -Game modules or host config can override that command later. - -Avoid generic player-warning or player-count logic in the SteamCMD layer. -Those flows are game-specific. - -## Secrets - -Use sops-backed environment files for durable configuration. -Mount them read-only into the guest. - -The Valheim prototype temporarily supports a hardcoded password. -That password leaks into the Nix store and process arguments. -Use it only for throwaway tests. - -The target model is: - -```text -host sops secret -> read-only guest mount -> systemd EnvironmentFile -``` - -For Valheim, the environment file contains: - -```sh -SERVER_PASSWORD=example-password -``` - -## Networking - -Open ports in the guest firewall from the game module. - -Valheim uses the base UDP port through `basePort + 2`. -The default base port is `2456`, so open UDP `2456-2458`. - -For public access, forward those UDP ports to the guest address. -DNS is optional for the first deployment. - -Use `machinectl status ` to confirm the current DHCP address. -Do not trust a guessed address. -Add a DHCP reservation before depending on a committed address. - -## Valheim prototype - -The prototype proved these facts: - -- Valheim runs inside a NixOS guest on Pikachu. -- SteamCMD installs the dedicated server files at service start. -- `steam-run` runs the mutable Valheim server binary. -- LAN clients can connect to the guest address and UDP port. -- The storage split works for SteamCMD data, server files, and world data. - -The prototype used: - -```text -guest: valheim -instance: foo -password: foobarbaz -actual DHCP address during test: 10.23.20.197 -``` - -The hardcoded password and test instance name need cleanup before a final PR. - -## Bring-up checklist - -1. Add or update `modules.services.`. -2. Map game-specific options to `modules.services.steamcmd.instances`. -3. Add or update `guests.`. -4. Mount `/data/steamcmd` into the guest. -5. Mount `/data/servers//` into the guest. -6. Mount `/media/games/servers//` into the guest. -7. Add a sops environment file for secrets. -8. Open the required game ports in the guest firewall. -9. Enable the guest on the target host. -10. Deploy with `deploy-to `. -11. Check `container@.service` on the host. -12. Enter the guest with `nixos-container root-login `. -13. Check the game service with `systemctl status`. -14. Follow logs with `journalctl -u -f`. -15. Connect from LAN before adding public forwarding. - -## Open cleanup items - -- Replace hardcoded prototype passwords with sops environment files. -- Choose durable instance names before long-term use. -- Add DHCP reservations for game guests. -- Add backup policy for world and modded server data. -- Decide whether server install trees need backup for modded servers. +# Add a SteamCMD server + +## 1. Configure the generic SteamCMD module + +1. Create or update `configs/nixos/modules/services/steamcmd.nix`. +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. +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. +10. Open declared in-machine firewall ports. + +## 2. Configure the game service module + +1. Create or update `configs/nixos/modules/services/.nix`. +2. Define `modules.services..enable`. +3. Add the Steam app ID for the dedicated server. +4. Add runtime packages that the server binary needs. +5. Add `serverName` for the service identity. +6. Use `serverName` as the default world or save name. +7. Add a game-specific world or save option when the game supports it. +8. Add guest-internal paths for server files and durable state. +9. Add options for required ports. +10. Add options for required secret files or values. +11. Map the game options to `modules.services.steamcmd`. +12. Configure the game service command. +13. Pass secrets to the service without embedding secret values in Nix. +14. Open the required in-machine firewall ports. + +## 3. Configure the guest module + +1. Create or update `configs/nixos/guests/.nix`. +2. Define `guests.` options for host-level wiring. +3. Add `serverName`. +4. Pass `serverName` to `modules.services.` inside `interior`. +5. Add host path options for SteamCMD data, server files, and durable state. +6. Use names such as `steamcmdPath`, `serverPath`, and `statePath`. +7. Mount each host path into the guest. +8. Mount sops-backed secret files read-only into the guest. +9. Pass guest paths for secret files to `modules.services.`. +10. Set guest resource limits. +11. Keep VLAN placement and address wiring in the guest or host configuration. + +## 4. Add host storage + +1. Choose a host path for SteamCMD data. +2. Choose a host path for downloaded server files. +3. Choose a host path for world data, saves, and generated config. +4. Configure those paths through `guests.`. + +## 5. Add secrets + +1. Add a sops secret for each durable server secret. +2. Configure the guest option that names each sops secret. +3. Do not put passwords or tokens in Nix strings. +4. Do not pass passwords or tokens as process arguments. + +## 6. Configure networking + +1. Keep game port knowledge in `modules.services.`. +2. Open game ports from `modules.services.`. +3. Configure guest VLAN placement from the guest or host configuration. +4. Add a DHCP reservation before you commit a stable guest address. +5. Forward public ports to the guest address when the server needs public access. + +## 7. Enable the guest on the target host + +1. Set `guests..enable = true`. +2. Set `guests..serverName` to the server identity. +3. Set the host paths for SteamCMD data, server files, and durable state. +4. Set the secret names for the server. +5. Set the guest network options required by the host. + +## 8. Deploy the host + +1. Run `deploy-to ` from a clean checkout. +2. Check `container@.service` on the host. +3. Enter the guest with `nixos-container root-login `. +4. Check the SteamCMD update service. +5. Check the game service with `systemctl status `. +6. Follow logs with `journalctl -u -f`. + +## 9. Validate the server + +1. Confirm the guest address with `machinectl status `. +2. Connect from the LAN before you add public forwarding. +3. Restart the game service. +4. Confirm that the restart updates the Steam app. +5. Confirm that the restart timer uses the expected command. +6. Confirm that durable state lands in the mounted state path.