feat(nixos): add SteamCMD server support
This commit is contained in:
@@ -66,6 +66,37 @@
|
|||||||
|
|
||||||
registry.address.pikachu.self = "10.23.10.102";
|
registry.address.pikachu.self = "10.23.10.102";
|
||||||
|
|
||||||
|
modules.services.steamcmd = {
|
||||||
|
enable = true;
|
||||||
|
steamDir = "/pikachu/data/steamcmd";
|
||||||
|
serviceName = "valheim";
|
||||||
|
appId = 896660;
|
||||||
|
installDir = "/pikachu/data/servers/valheim/main";
|
||||||
|
stateDir = "/pikachu/media/games/servers/valheim/main";
|
||||||
|
executable = "./valheim_server.x86_64";
|
||||||
|
arguments = [
|
||||||
|
"-nographics"
|
||||||
|
"-batchmode"
|
||||||
|
"-name"
|
||||||
|
"main"
|
||||||
|
"-port"
|
||||||
|
"2456"
|
||||||
|
"-world"
|
||||||
|
"main"
|
||||||
|
"-savedir"
|
||||||
|
config.modules.services.steamcmd.stateDir
|
||||||
|
"-password"
|
||||||
|
"foobarbaz"
|
||||||
|
"-public"
|
||||||
|
"0"
|
||||||
|
];
|
||||||
|
udpPorts = [
|
||||||
|
2456
|
||||||
|
2457
|
||||||
|
2458
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
guests.actualbudget = {
|
guests.actualbudget = {
|
||||||
enable = true;
|
enable = true;
|
||||||
vlan = 20;
|
vlan = 20;
|
||||||
|
|||||||
165
configs/nixos/modules/services/steamcmd.nix
Normal file
165
configs/nixos/modules/services/steamcmd.nix
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
options.modules.services.steamcmd = {
|
||||||
|
enable = lib.mkEnableOption "SteamCMD-managed game server";
|
||||||
|
|
||||||
|
steamDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "/data/steamcmd";
|
||||||
|
description = "Directory where SteamCMD stores metadata and cache files.";
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "valheim";
|
||||||
|
description = "Name of the systemd service that runs the game server.";
|
||||||
|
};
|
||||||
|
|
||||||
|
appId = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
example = 896660;
|
||||||
|
description = "Steam app ID for the dedicated server package.";
|
||||||
|
};
|
||||||
|
|
||||||
|
installDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "/data/servers/valheim/main";
|
||||||
|
description = "Directory where SteamCMD installs the server files and where the game service starts.";
|
||||||
|
};
|
||||||
|
|
||||||
|
stateDir = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "/data/state/valheim/main";
|
||||||
|
description = "Writable directory for durable game server state.";
|
||||||
|
};
|
||||||
|
|
||||||
|
executable = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "./valheim_server.x86_64";
|
||||||
|
description = "Server executable, relative to installDir or absolute.";
|
||||||
|
};
|
||||||
|
|
||||||
|
arguments = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [
|
||||||
|
"-name"
|
||||||
|
"main"
|
||||||
|
];
|
||||||
|
description = "Arguments passed to the server executable.";
|
||||||
|
};
|
||||||
|
|
||||||
|
udpPorts = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.port;
|
||||||
|
default = [ ];
|
||||||
|
description = "UDP ports opened for the game service.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkMerge [
|
||||||
|
{
|
||||||
|
registry."stable-uid".${config.networking.hostName}.steamcmd = 20003;
|
||||||
|
}
|
||||||
|
|
||||||
|
(lib.mkIf config.modules.services.steamcmd.enable (
|
||||||
|
let
|
||||||
|
service = config.modules.services.steamcmd;
|
||||||
|
user = "steamcmd";
|
||||||
|
group = "storage";
|
||||||
|
|
||||||
|
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
${pkgs.coreutils}/bin/install -d \
|
||||||
|
-o ${user} \
|
||||||
|
-g ${group} \
|
||||||
|
-m 0770 \
|
||||||
|
${lib.escapeShellArg service.steamDir} \
|
||||||
|
${lib.escapeShellArg service.installDir} \
|
||||||
|
${lib.escapeShellArg service.stateDir}
|
||||||
|
'';
|
||||||
|
|
||||||
|
updateScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-update" ''
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export HOME=${lib.escapeShellArg service.steamDir}
|
||||||
|
exec ${lib.getExe pkgs.steamcmd} \
|
||||||
|
+force_install_dir ${lib.escapeShellArg service.installDir} \
|
||||||
|
+login anonymous \
|
||||||
|
+app_update ${toString service.appId} validate \
|
||||||
|
+quit
|
||||||
|
'';
|
||||||
|
|
||||||
|
startScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-start" ''
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd ${lib.escapeShellArg service.installDir}
|
||||||
|
export LD_LIBRARY_PATH=linux64:''${LD_LIBRARY_PATH-}
|
||||||
|
exec ${lib.getExe pkgs.steam-run} ${lib.escapeShellArg service.executable} ${lib.escapeShellArgs service.arguments}
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
users.users.${user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
uid = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
|
||||||
|
inherit group;
|
||||||
|
home = service.steamDir;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${service.steamDir} 0770 ${user} ${group} - -"
|
||||||
|
"z ${service.steamDir} 0770 ${user} ${group} - -"
|
||||||
|
"d ${service.installDir} 0770 ${user} ${group} - -"
|
||||||
|
"z ${service.installDir} 0770 ${user} ${group} - -"
|
||||||
|
"d ${service.stateDir} 0770 ${user} ${group} - -"
|
||||||
|
"z ${service.stateDir} 0770 ${user} ${group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services = {
|
||||||
|
"steamcmd-${service.serviceName}-update" = {
|
||||||
|
description = "Update ${service.serviceName} with SteamCMD";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = user;
|
||||||
|
Group = group;
|
||||||
|
WorkingDirectory = "-${service.steamDir}";
|
||||||
|
ExecStartPre = "+${prepareScript}";
|
||||||
|
ExecStart = updateScript;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
${service.serviceName} = {
|
||||||
|
description = "${service.serviceName} game server";
|
||||||
|
after = [
|
||||||
|
"network-online.target"
|
||||||
|
"steamcmd-${service.serviceName}-update.service"
|
||||||
|
];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = user;
|
||||||
|
Group = group;
|
||||||
|
WorkingDirectory = "-${service.installDir}";
|
||||||
|
ExecStartPre = [
|
||||||
|
"+${prepareScript}"
|
||||||
|
updateScript
|
||||||
|
];
|
||||||
|
ExecStart = startScript;
|
||||||
|
Restart = "on-failure";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.firewall.allowedUDPPorts = service.udpPorts;
|
||||||
|
}
|
||||||
|
))
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# 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/<game>.nix
|
|
||||||
configs/nixos/guests/<game>.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.<game>`
|
|
||||||
|
|
||||||
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.<game>`
|
|
||||||
|
|
||||||
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/<game>/<instance-name>
|
|
||||||
/media/games/servers/<game>/<instance-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `/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/<game>/<instance-name>`
|
|
||||||
|
|
||||||
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/<game>/<instance-name>`
|
|
||||||
|
|
||||||
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 <guest>` 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.<game>`.
|
|
||||||
2. Map game-specific options to `modules.services.steamcmd.instances`.
|
|
||||||
3. Add or update `guests.<game>`.
|
|
||||||
4. Mount `/data/steamcmd` into the guest.
|
|
||||||
5. Mount `/data/servers/<game>/<instance-name>` into the guest.
|
|
||||||
6. Mount `/media/games/servers/<game>/<instance-name>` 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 <host>`.
|
|
||||||
11. Check `container@<game>.service` on the host.
|
|
||||||
12. Enter the guest with `nixos-container root-login <game>`.
|
|
||||||
13. Check the game service with `systemctl status`.
|
|
||||||
14. Follow logs with `journalctl -u <unit> -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.
|
|
||||||
Reference in New Issue
Block a user