From 9e7469796d80ba913609a3052eadb88be07a45fd Mon Sep 17 00:00:00 2001 From: alexion Date: Thu, 10 Sep 2026 14:37:50 -0400 Subject: [PATCH] feat(nixos): add Palworld Tracion guest --- configs/nixos/guests/palworld.nix | 146 +++++++++++++++++ configs/nixos/hosts/pikachu/default.nix | 19 +++ .../nixos/modules/services/games/palworld.nix | 148 ++++++++++++++++++ configs/nixos/modules/services/steamcmd.nix | 7 + .../shell/fish/functions/deploy-to.fish | 7 - configs/nixos/secrets/shared.yaml | 6 +- docs/.obsidian/workspace.json | 29 +++- .../how to setup a steamcmd server.md | 125 +++++++++++++++ docs/TODO.md | 55 ++++++- 9 files changed, 525 insertions(+), 17 deletions(-) create mode 100644 configs/nixos/guests/palworld.nix create mode 100644 configs/nixos/modules/services/games/palworld.nix create mode 100644 docs/00 INBOX/how to setup a steamcmd server.md diff --git a/configs/nixos/guests/palworld.nix b/configs/nixos/guests/palworld.nix new file mode 100644 index 0000000..212a20d --- /dev/null +++ b/configs/nixos/guests/palworld.nix @@ -0,0 +1,146 @@ +{ + config, + lib, + ... +}: +let + cfg = config.guests.palworld; + steamDir = "/data/steamcmd"; + installDir = "/data/server"; + stateDir = "/data/state"; +in +{ + options.guests.palworld = { + steamPath = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/srv/steamcmd"; + description = "Host path bind-mounted as SteamCMD's metadata directory."; + }; + + installPath = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/srv/palworld/server"; + description = "Host path bind-mounted as Palworld's server install directory."; + }; + + statePath = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/srv/palworld/state"; + description = "Host path bind-mounted as Palworld's durable state directory."; + }; + + serverName = lib.mkOption { + type = lib.types.str; + default = "palworld"; + description = "Name shown for the Palworld server."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8211; + description = "UDP port for the Palworld server."; + }; + + publicIP = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Public IP address advertised by the Palworld server."; + }; + + passwordSecret = lib.mkOption { + type = lib.types.str; + default = "services/game-servers/password"; + description = "SOPS secret name containing the Palworld server password."; + }; + + adminPasswordSecret = lib.mkOption { + type = lib.types.str; + default = "services/game-servers/password"; + description = "SOPS secret name containing the Palworld admin password."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.steamPath != null; + message = "guests.palworld.steamPath must point at the host directory that stores SteamCMD metadata."; + } + { + assertion = cfg.installPath != null; + message = "guests.palworld.installPath must point at the host directory that stores Palworld server files."; + } + { + assertion = cfg.statePath != null; + message = "guests.palworld.statePath must point at the host directory that stores Palworld durable state."; + } + ]; + + guests.palworld = { + interior.modules.services.games.palworld = { + enable = true; + inherit (cfg) serverName port publicIP; + inherit steamDir installDir stateDir; + passwordFile = "/run/secrets/${cfg.passwordSecret}"; + adminPasswordFile = "/run/secrets/${cfg.adminPasswordSecret}"; + }; + + mounts = + { + ${steamDir} = { + directory = cfg.steamPath; + owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd; + group = "storage"; + mode = "0770"; + }; + ${installDir} = { + directory = cfg.installPath; + owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd; + group = "storage"; + mode = "0770"; + }; + ${stateDir} = { + directory = cfg.statePath; + owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd; + group = "storage"; + mode = "0770"; + }; + ${"/run/secrets/${cfg.passwordSecret}"} = { + file = config.sops.secrets.${cfg.passwordSecret}.path; + readOnly = true; + }; + } + // lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) { + ${"/run/secrets/${cfg.adminPasswordSecret}"} = { + file = config.sops.secrets.${cfg.adminPasswordSecret}.path; + readOnly = true; + }; + }; + + limits = { + memory = lib.mkDefault "8G"; + cpu = lib.mkDefault "400%"; + tasksMax = lib.mkDefault 2048; + }; + }; + + sops.secrets = + { + ${cfg.passwordSecret} = { + owner = "root"; + group = "storage"; + mode = "0440"; + }; + } + // lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) { + ${cfg.adminPasswordSecret} = { + owner = "root"; + group = "storage"; + mode = "0440"; + }; + }; + }; +} diff --git a/configs/nixos/hosts/pikachu/default.nix b/configs/nixos/hosts/pikachu/default.nix index d5854e3..87da021 100644 --- a/configs/nixos/hosts/pikachu/default.nix +++ b/configs/nixos/hosts/pikachu/default.nix @@ -86,6 +86,25 @@ }; }; + guests.palworld = { + enable = true; + vlan = 20; + address = "10.23.20.250"; + steamPath = "/pikachu/data/steamcmd"; + installPath = "/pikachu/data/servers/palworld/tracion"; + statePath = "/pikachu/media/games/servers/palworld/tracion"; + serverName = "Tracion"; + adminPasswordSecret = "services/game-servers/admin/password"; + publicIP = config.guests.palworld.address; + reverseProxy = { + enable = true; + route = { + domain = "palworld.alexion.dev"; + udp.ports = [ config.guests.palworld.port ]; + }; + }; + }; + guests.actualbudget = { enable = true; vlan = 20; diff --git a/configs/nixos/modules/services/games/palworld.nix b/configs/nixos/modules/services/games/palworld.nix new file mode 100644 index 0000000..7c321da --- /dev/null +++ b/configs/nixos/modules/services/games/palworld.nix @@ -0,0 +1,148 @@ +{ + config, + lib, + pkgs, + ... +}: +{ + options.modules.services.games.palworld = { + enable = lib.mkEnableOption "Palworld dedicated server"; + + steamDir = lib.mkOption { + type = lib.types.str; + example = "/data/steamcmd"; + description = "Directory where SteamCMD stores metadata and cache files."; + }; + + installDir = lib.mkOption { + type = lib.types.str; + example = "/data/servers/palworld/tracion"; + description = "Directory where SteamCMD installs the Palworld server files."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + example = "/data/state/palworld/tracion"; + description = "Directory where Palworld stores durable server state."; + }; + + serverName = lib.mkOption { + type = lib.types.str; + default = "palworld"; + description = "Name shown for the Palworld server."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8211; + description = "UDP port for the Palworld server."; + }; + + publicIP = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Public IP address advertised by the Palworld server."; + }; + + maxPlayers = lib.mkOption { + type = lib.types.ints.between 1 32; + default = 32; + description = "Maximum player count for the Palworld server."; + }; + + publicLobby = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Advertise the server in Palworld's public lobby."; + }; + + rcon = { + enable = lib.mkEnableOption "Palworld RCON" // { + default = true; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 25575; + description = "TCP port for Palworld RCON."; + }; + }; + + passwordFile = lib.mkOption { + type = lib.types.path; + description = "Runtime file containing the Palworld server password."; + }; + + adminPasswordFile = lib.mkOption { + type = lib.types.path; + description = "Runtime file containing the Palworld admin password."; + }; + }; + + config = lib.mkIf config.modules.services.games.palworld.enable ( + let + service = config.modules.services.games.palworld; + savedDir = "${service.installDir}/Pal/Saved"; + startScript = pkgs.writeShellScript "palworld-start" '' + set -eu + + ${pkgs.coreutils}/bin/install -d \ + -m 0770 \ + ${lib.escapeShellArg service.stateDir} \ + ${lib.escapeShellArg (builtins.dirOf savedDir)} + + if [ -L ${lib.escapeShellArg savedDir} ]; then + current_target="$(${pkgs.coreutils}/bin/readlink ${lib.escapeShellArg savedDir})" + if [ "$current_target" != ${lib.escapeShellArg service.stateDir} ]; then + ${pkgs.coreutils}/bin/rm ${lib.escapeShellArg savedDir} + fi + fi + + if [ -d ${lib.escapeShellArg savedDir} ] && [ ! -L ${lib.escapeShellArg savedDir} ]; then + if ${pkgs.findutils}/bin/find ${lib.escapeShellArg service.stateDir} -mindepth 1 -maxdepth 1 | ${pkgs.gnugrep}/bin/grep -q .; then + ${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir} + else + ${pkgs.coreutils}/bin/cp -a ${lib.escapeShellArg savedDir}/. ${lib.escapeShellArg service.stateDir}/ + ${pkgs.coreutils}/bin/rm -rf ${lib.escapeShellArg savedDir} + fi + fi + + if [ ! -e ${lib.escapeShellArg savedDir} ]; then + ${pkgs.coreutils}/bin/ln -s ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir} + fi + + password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.passwordFile})" + admin_password="$(${pkgs.coreutils}/bin/tr -d '\n' < ${lib.escapeShellArg service.adminPasswordFile})" + + exec ./PalServer.sh \ + "$@" \ + "-serverpassword=$password" \ + "-adminpassword=$admin_password" + ''; + in + { + modules.services.steamcmd = { + enable = true; + inherit (service) steamDir installDir stateDir; + serviceName = "palworld-${lib.toLower service.serverName}"; + appId = 2394010; + executable = "${startScript}"; + arguments = + [ + "-useperfthreads" + "-NoAsyncLoadingThread" + "-UseMultithreadForDS" + "-port=${toString service.port}" + "-publicport=${toString service.port}" + "-servername=${service.serverName}" + "-players=${toString service.maxPlayers}" + ] + ++ lib.optional service.publicLobby "-publiclobby" + ++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ] + ++ lib.optional service.rcon.enable "-rcon"; + udpPorts = [ service.port ]; + tcpPorts = [ service.port ]; + }; + } + ); +} diff --git a/configs/nixos/modules/services/steamcmd.nix b/configs/nixos/modules/services/steamcmd.nix index 22a67ab..d1203d0 100644 --- a/configs/nixos/modules/services/steamcmd.nix +++ b/configs/nixos/modules/services/steamcmd.nix @@ -59,6 +59,12 @@ default = [ ]; description = "UDP ports opened for the game service."; }; + + tcpPorts = lib.mkOption { + type = lib.types.listOf lib.types.port; + default = [ ]; + description = "TCP ports opened for the game service."; + }; }; config = lib.mkMerge [ @@ -159,6 +165,7 @@ }; networking.firewall.allowedUDPPorts = service.udpPorts; + networking.firewall.allowedTCPPorts = service.tcpPorts; } )) ]; diff --git a/configs/nixos/modules/shell/fish/functions/deploy-to.fish b/configs/nixos/modules/shell/fish/functions/deploy-to.fish index 19c34f6..6f98a04 100644 --- a/configs/nixos/modules/shell/fish/functions/deploy-to.fish +++ b/configs/nixos/modules/shell/fish/functions/deploy-to.fish @@ -18,13 +18,6 @@ or begin return 2 end -set untracked_lines (git status --porcelain --untracked-files=normal | string match -r '^\?\? ') -if test (count $untracked_lines) -gt 0 - echo "deploy-to: refusing to deploy untracked files" >&2 - echo "deploy-to: git add new files that Nix must see, or remove them" >&2 - return 1 -end - git fetch origin or return $status diff --git a/configs/nixos/secrets/shared.yaml b/configs/nixos/secrets/shared.yaml index b58e483..4e8dc80 100644 --- a/configs/nixos/secrets/shared.yaml +++ b/configs/nixos/secrets/shared.yaml @@ -6,6 +6,8 @@ services: password: ENC[AES256_GCM,data:SSyo2l6tCjgjxIuCEREIxU+inw2WHtJJR0/IEKUqefFfuAdA5Nf+lH/jfPw=,iv:9225w1Yur3KL3dBr1ct1aPvDdVJ94O8q9qAUiRrj8Nc=,tag:qZgx4XieVq4BcxkTZc0KGQ==,type:str] game-servers: password: ENC[AES256_GCM,data:Wtq03MLRnc6/,iv:HhbiP8ZcltVGbCNDdP/gH5mCZXhVCTFv1DGzfrrgQG8=,tag:srsqcBQSD/HU8zFsuFimJQ==,type:str] + admin: + password: ENC[AES256_GCM,data:52jwSCfV,iv:MQf1VOHnGBf2yF+9SXk04FniAjghSaZe2Gn/xvAXU/0=,tag:SnrJFlHwOaM6vsNLBc84nw==,type:str] sops: age: - enc: | @@ -44,7 +46,7 @@ sops: 28uWUpKzzgErq1aEbKNgh4cU5TH2RlAHjtzRC3ARUu9hlW6lnB4pSw== -----END AGE ENCRYPTED FILE----- recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh - lastmodified: "2026-09-10T13:40:57Z" - mac: ENC[AES256_GCM,data:3nu+ho3TNDnGE55LIaHqJxlMs1hWVK2wwiJiBIDH0jop9Wo/AgaUXzwXY1MwMXJHhGrkcjhwWWJdEgxX88NNFTc59b4kLtRqE3bfNXoQ3y3giyDMFyVE9YCNd4jyl+pLhpVSZA/+TKEIBpdZRwaXHrYSaLtysg/OdzXing0l3SM=,iv:C/vZ6X1HikuBn+pWTlPIfCfg5mtW2i4P6XLwN1VwF6k=,tag:UQl10fueTwCtXuirP5+C/g==,type:str] + lastmodified: "2026-09-10T18:32:33Z" + mac: ENC[AES256_GCM,data:g3xIylSvyn5Z0JBA8siFmMrZxoM3a3U5c+f9qFmb3zXDLF03/feCUlvG2jjB7P2N9phIGSIEOPk1dzZDBMwv6D2hK6MppupV+MIe9diIt0gGARIOJNphrxLDxMHtwcWg6Z20D6tbPl0ECtLI0K9GOtwvI0OXeyYzu5VgFaFlZHw=,iv:TAUVJRXFTKNVA0ORMD7x56WVGjG+zimj3iqEn7urh64=,tag:SeOOV9ZUY4x5Ns0A16F1uw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.3 diff --git a/docs/.obsidian/workspace.json b/docs/.obsidian/workspace.json index cee2fe8..3bd14bd 100644 --- a/docs/.obsidian/workspace.json +++ b/docs/.obsidian/workspace.json @@ -22,6 +22,26 @@ } } ] + }, + { + "id": "72e6501450a46859", + "type": "tabs", + "children": [ + { + "id": "06bc3d52246b600b", + "type": "leaf", + "state": { + "type": "markdown", + "state": { + "file": "00 INBOX/ai/steamcmd servers.md", + "mode": "source", + "source": true + }, + "icon": "lucide-file", + "title": "steamcmd servers" + } + } + ] } ], "direction": "vertical" @@ -233,13 +253,16 @@ }, "active": "0c587e5452c16787", "lastOpenFiles": [ + "00 INBOX/how to setup a steamcmd server.md", + "00 INBOX/ai/steamcmd servers.md", + "03 wrk/ariadne.md", + "00 INBOX/ai/ariadne-cli-skeleton.md", "00 INBOX/ai/ariadne-project-goals.md", + "TODO.md", "00 INBOX/ai/dictionary-cli.md", "00 INBOX/ai/dictionary-skill.md", "03 wrk/falinks.md", - "03 wrk/ariadne.md", "03 wrk/machoke.md", - "TODO.md", "00 INBOX/home assistant agent integration.md", "02 notes/falinks public surfaces.md", "03 wrk/machoke.md~", @@ -261,8 +284,6 @@ "00 INBOX/ai/hoopa design review findings.md", "00 INBOX/ai/hoopa follow up decisions.md", "01 sources/obsidian and ai were made for each other.md", - "99 meta/technical writing.md", - "99 meta/dictionary.md", "99 meta/Untitled", "03 wrk", "02 notes", diff --git a/docs/00 INBOX/how to setup a steamcmd server.md b/docs/00 INBOX/how to setup a steamcmd server.md new file mode 100644 index 0000000..df0243e --- /dev/null +++ b/docs/00 INBOX/how to setup a steamcmd server.md @@ -0,0 +1,125 @@ +--- +date-created: 2026-09-10 12:01 +--- + +# How to set up a `steamcmd` server + +This note explains how to add a SteamCMD-managed game server to the NixOS configuration. + +Use these existing files as examples: + +- `configs/nixos/modules/services/steamcmd.nix` +- `configs/nixos/modules/services/games/valheim.nix` +- `configs/nixos/guests/valheim.nix` +- `configs/nixos/hosts/pikachu/default.nix` + +## Understand the layers + +The shared `steamcmd` module installs and runs a dedicated server. +It owns the low-level systemd service, update service, user, directories, and firewall ports. + +A game module maps game-specific options onto `modules.services.steamcmd`. +It hides Steam app IDs, executable names, launch arguments, and port calculations. + +A guest module maps host-level storage, secrets, and resource limits onto the game module. +It keeps host paths outside the guest's interior configuration. + +A host enables the guest and supplies network placement, storage paths, and routing. + +## Create the game module + +1. Create `configs/nixos/modules/services/games/.nix`. +2. Add an option namespace at `modules.services.games.`. +3. Add an `enable` option with `lib.mkEnableOption`. +4. Add options for game-specific settings. +5. Add options for `steamDir`, `installDir`, and `stateDir`. +6. Use `lib.mkIf config.modules.services.games..enable` for the module configuration. +7. Set `modules.services.steamcmd.enable = true`. +8. Pass `steamDir`, `installDir`, and `stateDir` to `modules.services.steamcmd`. +9. Set `serviceName` to the game service name. +10. Set `appId` to the Steam dedicated-server app ID. +11. Set `executable` to the server executable or a generated wrapper script. +12. Set `arguments` to the game launch arguments. +13. Set `udpPorts` to every UDP port the server needs. +14. Put secrets in runtime files instead of option values. +15. Read secret files from a wrapper script when the server needs secret values. + +Use `configs/nixos/modules/services/games/valheim.nix` as the main pattern. +It wraps the executable because Valheim receives its password as a command-line argument. + +## Create the guest module + +1. Create `configs/nixos/guests/.nix`. +2. Add an option namespace at `guests.`. +3. Add host-path options for the SteamCMD metadata, install, and state directories. +4. Give host-path options a `null` default. +5. Add assertions that require each host path when the guest is enabled. +6. Define guest-internal paths in a `let` block. +7. Enable `guests..interior.modules.services.games.`. +8. Pass the guest-internal paths to the game module. +9. Add mounts that bind host paths to the guest-internal paths. +10. Set directory mount `owner`, `group`, and `mode` together. +11. Mount secret files under `/run/secrets/...` when the game needs secrets. +12. Declare matching `sops.secrets` entries for those secret files. +13. Set conservative default resource limits under `guests..limits`. + +Use `configs/nixos/guests/valheim.nix` as the main pattern. +It mounts the password secret into the guest and passes only the runtime path inward. + +## Enable the guest on a host + +1. Open the target host file under `configs/nixos/hosts//default.nix`. +2. Set `guests..enable = true`. +3. Set `vlan` and `address` for the guest. +4. Set host storage paths for SteamCMD metadata, server files, and state. +5. Set game options such as server name and port. +6. Enable reverse-proxy routing when the server needs external access. +7. Route every game UDP port exposed by the guest. +8. Add required secrets to the host's SOPS files. + +Use the Valheim section in `configs/nixos/hosts/pikachu/default.nix` as the host-level pattern. + +## Test the change + +1. Stage new Nix files before evaluation. + Flake evaluation ignores untracked files. + + ```console + $ git add configs/nixos/modules/services/games/.nix configs/nixos/guests/.nix + ``` + +2. Build the target host configuration. + + ```console + $ nixos-rebuild build --flake .# + ``` + +3. Deploy the host when the build succeeds. + + ```console + $ sudo nixos-rebuild switch --flake .# + ``` + +4. Enter the guest from the host. + + ```console + $ sudo machinectl shell + ``` + +5. Check the update service inside the guest. + + ```console + $ systemctl status steamcmd--update.service + ``` + +6. Check the game service inside the guest. + + ```console + $ systemctl status .service + ``` + +7. Inspect the logs when either service fails. + + ```console + $ journalctl -u steamcmd--update.service -u .service + ``` diff --git a/docs/TODO.md b/docs/TODO.md index 273336a..3f0b45c 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -45,7 +45,7 @@ - subagents - test-driven-development -## pikachu proxmox -> nixos +## pikachu + pichu proxmox -> nixos - lidarr (requires raichu) - immich @@ -57,16 +57,60 @@ - adguard - homepage +## raichu proxmox -> nixos +- dwn torrent (vpn) +- dwn usenet (vpn) +- dwn-torrent-novpn +- prowlarr +- flaresolverr +- radarr +- sonarr +- radarr-anime +- sonarr-anime +- configarr +- lidarr +- audiobookshelf +- portainer +- seerr +- plex +- pterodactyl + - palworld old + - project zomboid + - minecraft + - cobblemon + # learning -- https://www.youtube.com/@EeroAlvar/videos +- YouTube + - EeroAlvar + - https://www.youtube.com/watch?v=DzhLFiblPUk + - https://www.youtube.com/watch?v=hQGAwD9vkLo + - https://www.youtube.com/watch?v=hhtExS5UQBI + - https://www.youtube.com/watch?v=Y1xO9OsCBC4 + - https://www.youtube.com/watch?v=DWWrLlM3gwQ + - https://www.youtube.com/watch?v=yHL-gmkzC-4 + - https://www.youtube.com/watch?v=KRVYUkM16hE + - https://www.youtube.com/watch?v=RxdvzenLcl4 + - https://www.youtube.com/watch?v=mdbpIpPSPwQ + - https://www.youtube.com/watch?v=GHsq0klC_4g + - https://www.youtube.com/watch?v=ciC6ffUqI8k + - https://www.youtube.com/watch?v=gYxZt9Qe0fk + - https://www.youtube.com/watch?v=0tqty8ltKDA + - https://www.youtube.com/watch?v=kzcI5F4tGiU + - https://www.youtube.com/watch?v=iKwPaB5TUdI + - https://www.youtube.com/watch?v=A-CtdQFRX64 # tech debt + +- better remote deployment + ## nix refactor ```text +nixos ├── guests │ ├── actualbudget.nix │ ├── copyparty.nix -│ └── reverse-proxy.nix +│ ├── reverse-proxy.nix +│ └── valheim.nix ├── hosts │ ├── neogaia │ │ ├── default.nix @@ -120,9 +164,12 @@ │ │ ├── vlans.nix │ │ └── vpn-client.nix │ ├── services +│ │ ├── games +│ │ │ └── valheim.nix │ │ ├── actualbudget.nix │ │ ├── copyparty.nix -│ │ └── reverse-proxy.nix +│ │ ├── reverse-proxy.nix +│ │ └── steamcmd.nix │ ├── shell │ │ ├── direnv │ │ │ ├── default.nix -- 2.47.3