Files
dev/configs/nixos/modules/services/games/palworld.nix
alexion 66efae302f fix(nixos): address Nix review findings
Render Palworld secrets through its documented settings file and remove stale RCON configuration.

Harden generated commands, centralize shared modules, and codify the reviewed Nix conventions.
2026-10-13 21:43:50 -04:00

198 lines
6.8 KiB
Nix

{
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.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the Palworld server after the guest reaches network readiness.";
};
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";
defaultSettingsFile = "${service.installDir}/DefaultPalWorldSettings.ini";
settingsFile = "${savedDir}/Config/LinuxServer/PalWorldSettings.ini";
renderSettingsScript = pkgs.writeText "render-palworld-settings.py" ''
import json
import os
from pathlib import Path
import re
import sys
import tempfile
default_path, settings_path, server_name, password_path, admin_password_path = sys.argv[1:]
default_path = Path(default_path)
settings_path = Path(settings_path)
source_path = settings_path if settings_path.exists() else default_path
text = source_path.read_text(encoding="utf-8")
def read_secret(path):
return Path(path).read_text(encoding="utf-8").rstrip("\r\n")
def set_string_setting(text, name, value):
assignment = f"{name}={json.dumps(value, ensure_ascii=False)}"
pattern = re.compile(rf'(?<![A-Za-z0-9_]){re.escape(name)}="(?:[^"\\]|\\.)*"')
text, replacements = pattern.subn(lambda _match: assignment, text, count=1)
if replacements:
return text
marker = "OptionSettings=("
if marker not in text:
raise ValueError(f"{source_path} has no OptionSettings declaration")
return text.replace(marker, f"{marker}{assignment},", 1)
settings = {
"ServerName": server_name,
"ServerPassword": read_secret(password_path),
"AdminPassword": read_secret(admin_password_path),
}
for name, value in settings.items():
text = set_string_setting(text, name, value)
settings_path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{settings_path.name}.",
dir=settings_path.parent,
)
temporary_path = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as settings_file:
settings_file.write(text)
os.replace(temporary_path, settings_path)
finally:
temporary_path.unlink(missing_ok=True)
'';
startScript = pkgs.writeShellScript "palworld-start" ''
set -eu
${lib.getExe' pkgs.coreutils "install"} -d \
-m 0770 \
-- \
${lib.escapeShellArg service.stateDir} \
${lib.escapeShellArg (builtins.dirOf savedDir)}
if [ -L ${lib.escapeShellArg savedDir} ]; then
current_target="$(${lib.getExe' pkgs.coreutils "readlink"} -- ${lib.escapeShellArg savedDir})"
if [ "$current_target" != ${lib.escapeShellArg service.stateDir} ]; then
${lib.getExe' pkgs.coreutils "rm"} -- ${lib.escapeShellArg savedDir}
fi
fi
if [ -d ${lib.escapeShellArg savedDir} ] && [ ! -L ${lib.escapeShellArg savedDir} ]; then
if ${lib.getExe' pkgs.findutils "find"} -- ${lib.escapeShellArg service.stateDir} -mindepth 1 -maxdepth 1 | ${lib.getExe pkgs.gnugrep} -q .; then
${lib.getExe' pkgs.coreutils "rm"} -rf -- ${lib.escapeShellArg savedDir}
else
${lib.getExe' pkgs.coreutils "cp"} -a -- ${lib.escapeShellArg savedDir}/. ${lib.escapeShellArg service.stateDir}/
${lib.getExe' pkgs.coreutils "rm"} -rf -- ${lib.escapeShellArg savedDir}
fi
fi
if [ ! -e ${lib.escapeShellArg savedDir} ]; then
${lib.getExe' pkgs.coreutils "ln"} -s -- ${lib.escapeShellArg service.stateDir} ${lib.escapeShellArg savedDir}
fi
${lib.getExe pkgs.python3} \
${renderSettingsScript} \
${lib.escapeShellArg defaultSettingsFile} \
${lib.escapeShellArg settingsFile} \
${lib.escapeShellArg service.serverName} \
${lib.escapeShellArg service.passwordFile} \
${lib.escapeShellArg service.adminPasswordFile}
exec ./PalServer.sh "$@"
'';
in
{
modules.services.steamcmd = {
enable = true;
inherit (service) steamDir installDir stateDir;
serviceName = "palworld";
appId = 2394010;
inherit (service) autoStart;
executable = "${startScript}";
arguments = [
"-port=${toString service.port}"
"-players=${toString service.maxPlayers}"
]
++ lib.optionals service.publicLobby (
[
"-publiclobby"
"-publicport=${toString service.port}"
]
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
);
udpPorts = [ service.port ];
};
}
);
}