feat(nixos): add Palworld Tracion guest

This commit was merged in pull request #39.
This commit is contained in:
2026-09-10 14:37:50 -04:00
parent 8bd9ff166e
commit 9e7469796d
9 changed files with 525 additions and 17 deletions

View File

@@ -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";
};
};
};
}

View File

@@ -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;

View File

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

View File

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

View File

@@ -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

View File

@@ -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