diff --git a/apps/updater/main.ts b/apps/updater/main.ts index 9ad4291..5a8bc1e 100644 --- a/apps/updater/main.ts +++ b/apps/updater/main.ts @@ -1,6 +1,8 @@ import { parseArgs } from 'https://deno.land/std@0.208.0/cli/parse_args.ts'; import { LockFile, SteamObject, parseObject } from './parser.ts' +const utf8Decoder = new TextDecoder(); + /** * Recursively find all files matching `fileName` under `baseDir` * @@ -31,7 +33,7 @@ const getAppInfo = async (appId: number): Promise => { ], }); const app_info_bytes = (await steamcmd.output()).stdout; - const app_info_str = new TextDecoder().decode(app_info_bytes); + const app_info_str = utf8Decoder.decode(app_info_bytes); const info_start = `"${appId}"`; const info_start_idx = app_info_str.indexOf(info_start); const app_info_obj = app_info_str.slice(info_start_idx + info_start.length); @@ -140,11 +142,13 @@ async function prefetch( const output = await command.output(); if (!output.success) { - console.error(new TextDecoder().decode(output.stdout)); + console.error(utf8Decoder.decode(output.stdout)); throw new Error(`Prefetching failed with code ${output.code}`); } - return new TextDecoder().decode(output.stdout); + return utf8Decoder + .decode(output.stdout) + .trim(); } function usage(): never { diff --git a/apps/updater/prefetch.sh b/apps/updater/prefetch.sh index 54a3283..727995d 100755 --- a/apps/updater/prefetch.sh +++ b/apps/updater/prefetch.sh @@ -4,6 +4,9 @@ set -e +# Redirect all stdout to stderr, but save reference to original stdout +exec 3>&1 >&2 + # Enable new nix features export NIX_CONFIG="experimental-features = nix-command" @@ -23,15 +26,16 @@ if [ -n "$debug" ]; then args+=(-debug) fi -echo "DepotDownloader ${args[*]} -dir ${downloadDir}" >&2 +echo "DepotDownloader ${args[*]} -dir ${downloadDir}" DepotDownloader \ "${args[@]}" \ - -dir "${downloadDir}" >&2 + -dir "${downloadDir}" if [ -n "$addToStore" ]; then echo "Adding depot to store" - nix store add-path --name "${name:?}" "${downloadDir}" >&2 + nix store add-path --name "${name:?}" "${downloadDir}" fi -nix hash path "${downloadDir}" + +nix hash path "${downloadDir}" >&3 rm -rf "${downloadDir}" \ No newline at end of file diff --git a/modules/default.nix b/modules/default.nix index 3b52ef4..ab00276 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -22,11 +22,13 @@ in { flake.nixosModules.default = { config, + pkgs, lib, ... }: with lib; let cfg = config.services.steam-servers; + userHome = config.users.users.${cfg.user}.home; anyServersEnabled = any (conf: conf.enable) @@ -44,33 +46,20 @@ in { inputs.steam-fetcher.overlays.default ]; - # Can't use tmpfiles because tmpfiles won't create directories with different owner than parent - systemd.services."make-steam-servers-dir" = let - services = - map - (name: "${name}.service") - (builtins.attrNames cfg.servers); - in { - wantedBy = services; - before = services; - - script = '' - mkdir -p ${cfg.datadir} - chown ${cfg.user}:${cfg.group} ${cfg.datadir} - ''; - - serviceConfig = { - Type = "oneshot"; - }; - }; - - users.users.${cfg.user} = { + users.users."${cfg.user}" = { isSystemUser = true; home = "${cfg.datadir}"; - group = "${cfg.group}"; + createHome = true; + homeMode = "750"; + inherit (cfg) group; }; - users.groups.${cfg.group} = {}; + users.groups."${cfg.group}" = {}; + + systemd.tmpfiles.rules = [ + "d ${userHome}/.steam 0755 ${cfg.user} ${cfg.user} - -" + "L+ ${userHome}/.steam/sdk64 - - - - ${pkgs.steamworks-sdk-redist}/lib" + ]; }; }; } diff --git a/modules/lib.nix b/modules/lib.nix index 1c6a0d9..cd6113f 100644 --- a/modules/lib.nix +++ b/modules/lib.nix @@ -107,7 +107,8 @@ in { echo "${n} already exists and isn't a directory, moving" mv "${n}" "${n}.bak" fi - ${pkgs.rsync}/bin/rsync -avu "${v}" "${n}" + ${pkgs.rsync}/bin/rsync -avu "${v}/" "${n}" + chmod -R u+w "${n}" '') dirs)); } diff --git a/modules/palworld/default.nix b/modules/palworld/default.nix new file mode 100644 index 0000000..8660b57 --- /dev/null +++ b/modules/palworld/default.nix @@ -0,0 +1,101 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + baseCfg = config.services.steam-servers; + cfg = baseCfg.palworld; + enabledServers = filterAttrs (_: conf: conf.enable) cfg; + # settingsFormat = pkgs.formats.ini {}; + settingsFormat = { + generate = name: value: let + optionSettings = + mapAttrsToList + (optName: optVal: let + optType = builtins.typeOf optVal; + encodedVal = + if optType == "string" + then "\"${optVal}\"" + else if optType == "bool" + then + if optVal + then "True" + else "False" + else optVal; + in "${optName}=${encodedVal}") + value; + in + builtins.toFile name '' + [/Script/Pal.PalGameWorldSettings] + OptionSettings=(${concatStringsSep "," optionSettings}) + ''; + }; +in { + imports = [./options.nix]; + + config = mkIf (enabledServers != {}) { + networking.firewall = + mkMerge + (map + (conf: + mkIf conf.openFirewall { + # allowedUDPPorts = [conf.config.UpdatePort conf.config.GamePort]; + allowedUDPPorts = [8211]; + }) + (builtins.attrValues enabledServers)); + + services.steam-servers.servers = + mapAttrs' + (name: conf: let + settingsFile = settingsFormat.generate "PalWorldSettings.ini" conf.worldSettings; + in + nameValuePair "palworld-${name}" { + # inherit args; + inherit (conf) enable datadir; + + dirs = { + Pal = "${conf.package}/Pal"; + Engine = "${conf.package}/Engine"; + }; + + files = { + # Copy start script since it derefernces symlinks to find the server root dir + "PalServer.sh" = "${conf.package}/PalServer.sh"; + + "Pal/Saved/Config/LinuxServer/PalWorldSettings.ini" = settingsFile; + }; + + executable = "chmod +x ${conf.datadir}/PalServer.sh; ${pkgs.steam-run}/bin/steam-run ${conf.datadir}/PalServer.sh"; + + args = + [ + "-port=${toString conf.port}" + "-useperfthreads" + "-NoAsyncLoadingThread" + "-UseMultithreadForDS" + ] + ++ conf.extraArgs; + }) + cfg; + + systemd.services = + mapAttrs' + ( + name: _conf: + nameValuePair "palworld-${name}" { + path = with pkgs; [ + xdg-user-dirs + ]; + + serviceConfig = { + # Palworld needs namespaces and system calls + RestrictNamespaces = false; + SystemCallFilter = []; + }; + } + ) + enabledServers; + }; +} diff --git a/modules/palworld/default.test.nix b/modules/palworld/default.test.nix new file mode 100644 index 0000000..2776e97 --- /dev/null +++ b/modules/palworld/default.test.nix @@ -0,0 +1,23 @@ +{lib, ...}: +with lib; { + name = "palworld"; + + nodes = { + server = { + virtualisation = { + cores = 8; + memorySize = 16 * 1024; + diskSize = 8 * 1024; + }; + + services.steam-servers.palworld.test = { + enable = true; + openFirewall = true; + }; + }; + }; + + testScript = '' + server.wait_for_unit("palworld-test.service") + ''; +} diff --git a/modules/palworld/options.nix b/modules/palworld/options.nix new file mode 100644 index 0000000..4e8b6cd --- /dev/null +++ b/modules/palworld/options.nix @@ -0,0 +1,62 @@ +{ + config, + lib, + pkgs, + ... +}: +with lib; let + baseCfg = config.services.steam-servers; + moduleLib = import ../lib.nix lib; + inherit (moduleLib) mkOpt; + + serverModule = {name, ...}: { + options = { + enable = mkEnableOption (mdDoc "Palworld Dedicated Server"); + + package = mkOption { + type = types.package; + default = pkgs.palworld; + defaultText = literalExpression "pkgs.palworld"; + description = mdDoc "Package to use for Palworld binary"; + }; + + datadir = mkOption { + type = types.path; + default = "${baseCfg.datadir}/palworld/${name}"; + defaultText = literalExpression "\${services.steam-servers.datadir}/palworld/\${name}"; + description = mdDoc '' + Directory to store save state of the game server. (eg world, saves, etc) + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = mdDoc "Whether to open ports in the firewall."; + }; + + port = mkOption { + type = types.port; + default = 8211; + description = mdDoc "UDP port to listen on"; + }; + + worldSettings = mkOption { + # inherit (settingsFormat) type; + type = types.attrs; + default = {}; + description = mdDoc "World settings used to generate PalWorldSettings.ini"; + }; + + extraArgs = mkOpt (with types; listOf str) [] "Extra command line arguments to pass to the server"; + }; + }; +in { + options.services.steam-servers.palworld = mkOption { + type = types.attrsOf (types.submodule serverModule); + default = {}; + description = mdDoc '' + Options to configure one or more Stationers servers. + ''; + }; +} diff --git a/modules/servers/default.nix b/modules/servers/default.nix index 2004e5c..7654be5 100644 --- a/modules/servers/default.nix +++ b/modules/servers/default.nix @@ -67,6 +67,16 @@ in { User = mkDefault "${baseCfg.user}"; Group = mkDefault "${baseCfg.group}"; + RuntimeDirectory = mkDefault "steam-servers"; + RuntimeDirectoryPreserve = mkDefault true; + + # These don't use mkDefault as they are inherent to how this module works + # Type = "forking"; + # GuessMainPID = true; + + PrivateDevices = mkDefault true; + PrivateTmp = mkDefault true; + PrivateUsers = mkDefault true; ProtectClock = mkDefault true; ProtectProc = mkDefault "noaccess"; ProtectKernelLogs = mkDefault true; @@ -74,7 +84,6 @@ in { ProtectKernelTunables = mkDefault true; ProtectControlGroups = mkDefault true; ProtectHostname = mkDefault true; - PrivateDevices = mkDefault true; RestrictRealtime = mkDefault true; RestrictNamespaces = mkDefault true; LockPersonality = mkDefault true; diff --git a/pkgs/default.nix b/pkgs/default.nix index a879c0d..20d599d 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -5,6 +5,7 @@ }: let pkgsToImport = { "7-days-to-die" = ./7-days-to-die; + palworld = ./palworld; stationeers = ./stationeers; }; diff --git a/pkgs/palworld/default.nix b/pkgs/palworld/default.nix new file mode 100644 index 0000000..415be4c --- /dev/null +++ b/pkgs/palworld/default.nix @@ -0,0 +1,23 @@ +{ + lib, + mkSteamPackage, + gcc-unwrapped, +}: +mkSteamPackage { + lockFile = ./lock.json; + + buildInputs = [ + gcc-unwrapped + ]; + + meta = with lib; { + description = "Palworld Dedicated Server"; + homepage = "https://steamdb.info/app/2394010/"; + changelog = "https://store.steampowered.com/news/app/1623730?updates=true"; + sourceProvenance = with sourceTypes; [ + binaryNativeCode # Steam games are always going to contain some native binary component. + ]; + license = licenses.unfree; + platforms = ["x86_64-linux"]; + }; +} diff --git a/pkgs/palworld/lock.json b/pkgs/palworld/lock.json new file mode 100644 index 0000000..5adf4d5 --- /dev/null +++ b/pkgs/palworld/lock.json @@ -0,0 +1,20 @@ +{ + "appId": 2394010, + "depotId": 2394012, + "name": "palworld-server", + "branches": { + "public": "13312441" + }, + "builds": { + "13225464": { + "hash": "sha256-NEnskCOl031yb0+jmsWkFHMZVVrRzM4BLLVZGko1Jk8=", + "manifestId": "4603741190199642564", + "version": "13225464" + }, + "13312441": { + "hash": "sha256-IoI5Up+Vj6Rti9BhPH2e1o+TbpjIGmcWCc5bCV8M2Ps=", + "manifestId": "4190579964382773830", + "version": "13312441" + } + } +} \ No newline at end of file