From 8d63e56204e615cc44dc71c57da3274a355da945 Mon Sep 17 00:00:00 2001 From: xenia Date: Mon, 1 Sep 2025 19:29:28 -0400 Subject: [PATCH] WIP: update satisfactory dedicated server --- module.nix | 3 +- .../satisfactory-dedicated-server/default.nix | 421 ++++++++++++++++++ overlay.nix | 3 + .../satisfactory-dedicated-server/default.nix | 19 +- pkgs/temp/depotdownloader/default.nix | 39 ++ pkgs/temp/depotdownloader/deps.json | 72 +++ 6 files changed, 547 insertions(+), 10 deletions(-) create mode 100644 modules/satisfactory-dedicated-server/default.nix create mode 100644 pkgs/temp/depotdownloader/default.nix create mode 100644 pkgs/temp/depotdownloader/deps.json diff --git a/module.nix b/module.nix index 6a6a585..e35f3d9 100644 --- a/module.nix +++ b/module.nix @@ -1,8 +1,9 @@ { ... }: { imports = [ ./modules/ghidra-server - ./modules/regdom ./modules/machine-info + ./modules/satisfactory-dedicated-server + ./modules/regdom ]; # set some nix settings defaults diff --git a/modules/satisfactory-dedicated-server/default.nix b/modules/satisfactory-dedicated-server/default.nix new file mode 100644 index 0000000..9583ab5 --- /dev/null +++ b/modules/satisfactory-dedicated-server/default.nix @@ -0,0 +1,421 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.services.satisfactory; +in { + options.services.satisfactory = with lib; { + enable = mkEnableOption "satisfactory"; + + package = mkPackageOption pkgs "satisfactory-dedicated-server" {}; + + directory = mkOption { + description = '' + Directory where Satisfactory Dedicated Server data will be stored + ''; + default = "/var/lib/satisfactory"; + type = types.str; + example = literalExpression "\"/data/games/satisfactory\""; + }; + + user = mkOption { + description = "User account under which Satisfactory Dedicated Server runs."; + default = "satisfactory"; + type = types.str; + example = literalExpression "\"satisfactory2\""; + }; + + group = mkOption { + description = "Group under which Satisfactory Dedicated Server runs."; + default = "satisfactory"; + type = types.str; + example = literalExpression "\"satisfactory2\""; + }; + + useACMEHost = mkOption { + description = '' + If set, the server will use the ACME-provided TLS certificate for the given host. + + Note that this module does not actually provision the specified certificate; you must + use additional config (e.g., `services.nginx.virtualHosts..enableACME = true`) to + provision the certificate using a supported ACME method. + ''; + default = null; + type = types.nullOr types.str; + example = literalExpression "\"myserver.example\""; + }; + + port = mkOption { + description = '' + Server port number (TCP/UDP) + + This corresponds to the `-Port` command line option. + ''; + default = 7777; + type = types.port; + example = literalExpression "7778"; + }; + + reliablePort = mkOption { + description = '' + Server reliable port number + + This corresponds to the `-ReliablePort` command line option. + ''; + default = 8888; + type = types.port; + example = literalExpression "8889"; + }; + + externalReliablePort = mkOption { + description = '' + Server reliable port number as seen outside NAT. + + This corresponds to the `-ExternalReliablePort` command line option. + ''; + default = null; + type = types.nullOr types.port; + example = literalExpression "12345"; + }; + + disableSeasonalEvents = mkOption { + description = '' + Whether to run the server with seasonal events disabled. + + This corresponds to the `-DisableSeasonalEvents` command line option. + ''; + default = false; + type = types.bool; + example = literalExpression "true"; + }; + + extraIniOptions = mkOption { + description = '' + Run the server with additional ini configuration values. + + This is a nested attribute set of values. + + - The top level attribute specifies the ini file containing the value to set (i.e., the + first component of the `-ini` command line option), for example `Game` or `Engine`. + - The secondary level attribute specifies the ini file category, without brackets, + for example `/Script/Engine.GameSession`. + - The final level attribute specifies the option name to set, for example + `MaxPlayers`. The value of the attribute is the value to set on the command line. + + This corresponds to the `-ini` command line option. + ''; + default = {}; + type = with types; attrsOf (attrsOf (attrsOf str)); + example = literalExpression '' + { + Game."/Script/Engine.GameSession".MaxPlayers = "8"; + } + ''; + }; + + initialSettings = mkOption { + description = '' + Settings to apply to the server via the server API on the first run. + ''; + type = types.submodule { + options = { + serverName = mkOption { + description = '' + The name of the server. + + If this is provided, `adminPasswordFile` must also be set. + ''; + type = with types; nullOr str; + default = null; + example = literalExpression "\"My Dedicated Server\""; + }; + + adminPasswordFile = mkOption { + description = '' + Path to a file containing the initial admin password. + + If this is provided, `serverName` must also be set. + ''; + type = with types; nullOr path; + default = null; + example = literalExpression "\"/var/lib/secrets/admin-password.txt\""; + }; + + clientPasswordFile = mkOption { + description = '' + Path to a file containing the initial client password. If not set, the server will + not be configured with a client password and will be accessible to any client. + ''; + type = with types; nullOr path; + default = null; + example = literalExpression "\"/var/lib/secrets/client-password.txt\""; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = with cfg.initialSettings; (serverName == null) == (adminPasswordFile == null); + message = '' + When either of services.satisfactory.initialSettings.serverName or + services.satisfactory.initialSettings.adminPasswordFile are set, the other must also be + set. The dedicated server API requires configuring both options simultaneously. + ''; + } + { + assertion = with cfg.initialSettings; (clientPasswordFile == null) || (serverName != null); + message = '' + Option services.satisfactory.initialSettings.clientPasswordFile is set, but there are + no options set for the initial server claim data (i.e., serverName and adminPasswordFile). + Setting a client password is not possible without executing a server claim. + ''; + } + ]; + + users.users."${cfg.user}" = { + isSystemUser = true; + home = cfg.directory; + group = cfg.group; + createHome = false; + }; + + users.groups."${cfg.group}" = {}; + + systemd.tmpfiles.settings."satisfactory" = let + default = { + inherit (cfg) user group; + mode = "0755"; + }; + in { + "${cfg.directory}".d = default; + "${cfg.directory}/saves".d = default; + "${cfg.directory}/settings".d = default; + }; + + systemd.services = let + base_url = "https://127.0.0.1:${builtins.toString cfg.port}/api/v1/"; + binary = "${cfg.directory}/server/Engine/Binaries/Linux/FactoryServer-Linux-Shipping"; + ini_list = lib.flatten ( + lib.mapAttrsToList (filename: fileOpts: + lib.mapAttrsToList (section: sectionOpts: + lib.mapAttrsToList (key: value: + " -ini:${filename}:[${section}]:${key}=${value}" + ) sectionOpts + ) fileOpts + ) cfg.extraIniOptions + ); + ini_args = lib.concatStringsSep " " ini_list; + + port = with builtins; + "-Port=${toString cfg.port} -ReliablePort=${toString cfg.reliablePort}"; + extport = + if cfg.externalReliablePort == null then + "" + else + " -ExternalReliablePort=${builtins.toString cfg.externalReliablePort}"; + + seasonalEvts = + if cfg.disableSeasonalEvents then + " -DisableSeasonalEvents" + else + ""; + + args = "${port}${extport}${seasonalEvts}${ini_args}"; + server_command = "${binary} FactoryGame ${args}"; + + doSetup = cfg.initialSettings.serverName != null; + + commonConfig = { + after = ["network.target"] ++ lib.optionals (cfg.useACMEHost != null) [ + "acme-finished-${cfg.useACMEHost}.target" + ]; + + environment = { + "LD_LIBRARY_PATH" = "${cfg.directory}/server/linux64"; + }; + + unitConfig = { + RequiresMountsFor = cfg.directory; + }; + + serviceConfig = { + Nice = "-5"; + User = cfg.user; + Group = cfg.user; + WorkingDirectory = cfg.directory; + StandardOutput = "journal"; + + LoadCredential = lib.mkIf (cfg.useACMEHost != null) (let + certDir = config.security.acme.certs.${cfg.useACMEHost}.directory; + in [ + "cert_chain.pem:${certDir}/fullchain.pem" + "private_key.pem:${certDir}/key.pem" + ]); + + ProtectSystem = true; + ProtectHome = true; + NoNewPrivileges = true; + + # virtualize the file system to synthesize what is read only with what is dedicated server + # game state + + PrivateTmp = true; + + TemporaryFileSystem = [ + "${cfg.directory}:ro" + ]; + BindReadOnlyPaths = [ + "${cfg.package}:${cfg.directory}/server" + ]; + BindPaths = [ + "${cfg.directory}/saves:${cfg.directory}/.config/Epic" + "/var/tmp:${cfg.directory}/server/FactoryGame/Intermediate" + "${cfg.directory}/settings:${cfg.directory}/server/FactoryGame/Saved" + ] ++ lib.optionals (cfg.useACMEHost != null) [ + "%d:${cfg.directory}/server/FactoryGame/Certificates" + ]; + + Restart = "on-failure"; + RestartSec = 60; + SuccessExitStatus = 143; + }; + }; + in { + "satisfactory" = lib.mkMerge [ + commonConfig + { + description = "Satisfactory Dedicated Server"; + + wantedBy = [ "multi-user.target" ]; + + requires = lib.optionals doSetup ["satisfactory-first-time-setup.service"]; + after = lib.optionals doSetup ["satisfactory-first-time-setup.service"]; + + serviceConfig = { + ExecStart = server_command; + }; + } + ]; + + "satisfactory-first-time-setup" = lib.mkIf doSetup (lib.mkMerge [ + commonConfig + { + description = "Satisfactory Dedicated Server first-time setup"; + + path = with pkgs; [ + curl + jq + ]; + + unitConfig = { + ConditionPathExists = + "!${cfg.directory}/saves/FactoryGame/Saved/SaveGames/ServerSettings.${builtins.toString cfg.port}.sav"; + }; + + serviceConfig = { + Type = "oneshot"; + + # isolate satisfactory during configuration + PrivateNetwork = true; + + LoadCredential = + (lib.optionals (cfg.initialSettings.adminPasswordFile != null) [ + "admin_password.txt:${cfg.initialSettings.adminPasswordFile}" + ]) ++ (lib.optionals (cfg.initialSettings.clientPasswordFile != null) [ + "client_password.txt:${cfg.initialSettings.clientPasswordFile}" + ]); + }; + + script = '' + set -euo pipefail + set -m + + echo Starting server... + ${server_command} & + server_pid=$! + + server_status="" + + for i in {1..5}; do + server_status="$(curl -SsLk -XPOST -H "Content-Type: application/json" \ + --data '{"function":"HealthCheck","data":{"clientCustomData":""}}' \ + "${base_url}" | jq -r '.data.health' || true)" + + if [ "$server_status" == "healthy" ]; then + break + fi + + sleep 5 + done + + if [ "$server_status" != "healthy"; then + echo Server did not report healthy status in time + exit 1 + fi + + token="$(curl -SsLk -XPOST -H "Content-Type: application/json" \ + --data '{"function":"PasswordlessLogin","data":{"MinimumPrivilegeLevel":"InitialAdmin"}}' \ + "${base_url}" | jq -r '.data.authenticationToken')" + + if [ "$token" == "null" ]; then + echo Server authentication failed + exit 2 + fi + + echo Executing server claim... + data="$(jq -n \ + --arg "serverName" "${cfg.initialSettings.serverName}" \ + --rawfile "password" "$CREDENTIALS_DIRECTORY/admin_password.txt" \ + '{} |.function="ClaimServer" | .data.ServerName=$serverName | .data.AdminPassword=($password|rtrimstr("\n"))')" + new_token="$(curl -SsLk -XPOST -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + --data "$data" \ + "${base_url}" | jq -r '.data.authenticationToken')" + + if [ "$new_token" == "null" ]; then + echo Server claim failed + exit 2 + fi + + token="$new_token" + + if [ -f "$CREDENTIALS_DIRECTORY/client_password.txt" ]; then + echo Setting client password... + data="$(jq -n \ + --rawfile "password" "$CREDENTIALS_DIRECTORY/client_password.txt" \ + '{} |.function="SetClientPassword" | .data.Password=($password|rtrimstr("\n"))')" + result="$(curl -SsLk -XPOST -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + --data "$data" \ + "${base_url}" | jq -r '.data')" + + if [ "$result" != "" ]; then + echo "Password set failed: $result" + exit 4 + fi + fi + + echo Setup complete + + echo Stopping server... + kill -SIGTERM $server_pid + wait $server_pid + ''; + } + ]); + + "satisfactory-restart-certs" = lib.mkIf (cfg.useACMEHost != null) { + description = "Restart Satisfactory Dedicated Server after cert provisioning"; + wantedBy = ["acme-finished-${cfg.useACMEHost}.target"]; + path = [config.systemd.package]; + script = '' + systemctl try-restart satisfactory.service + ''; + serviceConfig = { + Type = "simple"; + }; + }; + }; + }; +} diff --git a/overlay.nix b/overlay.nix index 5b897db..80b07f7 100644 --- a/overlay.nix +++ b/overlay.nix @@ -36,7 +36,10 @@ final: prev: { feedvalidator = final.python312Packages.feedvalidator; megacom = final.python312Packages.megacom; + # temporary upgrade so we can actually download satisfactory + depotdownloader = prev.callPackage ./pkgs/temp/depotdownloader {}; outer-wilds-text-adventure = prev.callPackage ./pkgs/games/outer-wilds-text-adventure {}; + satisfactory-dedicated-server = prev.callPackage ./pkgs/games/satisfactory-dedicated-server {}; mkNginxServer = prev.callPackage ./lib/dev-nginx {}; diff --git a/pkgs/games/satisfactory-dedicated-server/default.nix b/pkgs/games/satisfactory-dedicated-server/default.nix index 60115b2..8504a09 100644 --- a/pkgs/games/satisfactory-dedicated-server/default.nix +++ b/pkgs/games/satisfactory-dedicated-server/default.nix @@ -7,32 +7,30 @@ }: let appId = "1690800"; - buildId = "15636842"; + buildId = "19234106"; steamworks_sdk = fetchFromSteam { name = "steamworks-sdk"; inherit appId; depot = { depotId = "1006"; - manifestId = "7138471031118904166"; + manifestId = "5587033981095108078"; }; - hash = "sha256-OtPI1kAx6+9G09IEr2kYchyvxlPl3rzx/ai/xEVG4oM="; + hash = "sha256-CjrVpq5ztL6wTWIa63a/4xHM35DzgDR/O6qVf1YV5xw="; }; server_dist = fetchFromSteam { name = "satisfactory-dedicated-server"; inherit appId; depot = { depotId = "1690802"; - manifestId = "1910179703516567959"; + manifestId = "5693629351763493998"; }; - hash = "sha256-TxPegZFAwiAzuHgw9xLGr5sAP7KAVMMfPFYL7TRX1O0="; + hash = "sha256-0svLwO4JYKIPwoNCRfT9+pocZ0n1QpSEqP41DdUhEac="; }; in stdenv.mkDerivation { pname = "satisfactory-dedicated-server"; version = "build-${buildId}"; src = server_dist; - buildInputs = [ steamworks_sdk ]; - propagatedBuildInputs = [ SDL2 ]; dontConfigure = true; dontBuild = true; @@ -44,6 +42,7 @@ in stdenv.mkDerivation { mkdir -p $out/FactoryGame/Intermediate mkdir -p $out/FactoryGame/Saved + mkdir -p $out/FactoryGame/Certificates rm $out/FactoryServer.sh ''; @@ -58,10 +57,12 @@ in stdenv.mkDerivation { chmod +x $out/Engine/Binaries/Linux/FactoryServer-Linux-Shipping - patchelf --add-needed ${SDL2}/lib/libSDL2-2.0.so.0 \ + patchelf \ + --add-needed ${SDL2}/lib/libSDL2-2.0.so.0 \ $out/linux64/steamclient.so - patchelf --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" \ + patchelf \ + --set-interpreter "$(cat $NIX_CC/nix-support/dynamic-linker)" \ --add-needed $out/linux64/steamclient.so \ $out/Engine/Binaries/Linux/FactoryServer-Linux-Shipping ''; diff --git a/pkgs/temp/depotdownloader/default.nix b/pkgs/temp/depotdownloader/default.nix new file mode 100644 index 0000000..bd5e701 --- /dev/null +++ b/pkgs/temp/depotdownloader/default.nix @@ -0,0 +1,39 @@ +{ + lib, + buildDotnetModule, + fetchFromGitHub, + dotnetCorePackages, +}: + +buildDotnetModule rec { + pname = "depotdownloader"; + version = "3.4.0"; + + src = fetchFromGitHub { + owner = "SteamRE"; + repo = "DepotDownloader"; + rev = "DepotDownloader_${version}"; + hash = "sha256-zduNWIQi+ItNSh9RfRfY0giIw/tMQIMRh9woUzQ5pJw="; + }; + + projectFile = "DepotDownloader.sln"; + nugetDeps = ./deps.json; + dotnet-sdk = dotnetCorePackages.sdk_9_0; + dotnet-runtime = dotnetCorePackages.runtime_9_0; + + passthru.updateScript = ./update.sh; + + meta = { + description = "Steam depot downloader utilizing the SteamKit2 library"; + changelog = "https://github.com/SteamRE/DepotDownloader/releases/tag/DepotDownloader_${version}"; + license = lib.licenses.gpl2Only; + maintainers = [ lib.maintainers.babbaj ]; + platforms = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + mainProgram = "DepotDownloader"; + }; +} diff --git a/pkgs/temp/depotdownloader/deps.json b/pkgs/temp/depotdownloader/deps.json new file mode 100644 index 0000000..1eaf833 --- /dev/null +++ b/pkgs/temp/depotdownloader/deps.json @@ -0,0 +1,72 @@ +[ + { + "pname": "Microsoft.NETCore.Platforms", + "version": "5.0.0", + "hash": "sha256-LIcg1StDcQLPOABp4JRXIs837d7z0ia6+++3SF3jl1c=" + }, + { + "pname": "Microsoft.Win32.Registry", + "version": "5.0.0", + "hash": "sha256-9kylPGfKZc58yFqNKa77stomcoNnMeERXozWJzDcUIA=" + }, + { + "pname": "Microsoft.Windows.CsWin32", + "version": "0.3.183", + "hash": "sha256-bn0rHYoVLRTqiZqkkp6u3PMKtg0NNxA2F++1e/+3Jhw=" + }, + { + "pname": "Microsoft.Windows.SDK.Win32Docs", + "version": "0.1.42-alpha", + "hash": "sha256-6DvzmNzrGVfWmNJNqooj+Ya+7bAQlyeg7pmyKaUlIws=" + }, + { + "pname": "Microsoft.Windows.SDK.Win32Metadata", + "version": "61.0.15-preview", + "hash": "sha256-OB60ThIv8e7AMGaRRzJ8dWme5HjN+Q0HoUDquP2ejTg=" + }, + { + "pname": "Microsoft.Windows.WDK.Win32Metadata", + "version": "0.12.8-experimental", + "hash": "sha256-YaN6JlgnpIooLYu3NdFVHwoqFwZYTeePtekXCfTiLTo=" + }, + { + "pname": "protobuf-net", + "version": "3.2.52", + "hash": "sha256-phXeroBt5KbHYkApkkMa0mRCVkDY+dtOOXXNY+i50Ek=" + }, + { + "pname": "protobuf-net.Core", + "version": "3.2.52", + "hash": "sha256-/9Jj26tuSKeYJb9udwew5i5EVvaoeNu/vBCKS0VhSQQ=" + }, + { + "pname": "QRCoder", + "version": "1.6.0", + "hash": "sha256-2Ev/6d7PH6K4dVYQQHlZ+ZggkCnDtrlaGygs65mDo28=" + }, + { + "pname": "SteamKit2", + "version": "3.2.0", + "hash": "sha256-hB/36fP9kf+1mIx+hTELUMHe8ZkmSKxOK41ZzOaBa3E=" + }, + { + "pname": "System.IO.Hashing", + "version": "9.0.4", + "hash": "sha256-rbcQzEncB3VuUZIcsE1tq30suf5rvRE4HkE+0lR/skU=" + }, + { + "pname": "System.Security.AccessControl", + "version": "5.0.0", + "hash": "sha256-ueSG+Yn82evxyGBnE49N4D+ngODDXgornlBtQ3Omw54=" + }, + { + "pname": "System.Security.Principal.Windows", + "version": "5.0.0", + "hash": "sha256-CBOQwl9veFkrKK2oU8JFFEiKIh/p+aJO+q9Tc2Q/89Y=" + }, + { + "pname": "ZstdSharp.Port", + "version": "0.8.5", + "hash": "sha256-+UQFeU64md0LlSf9nMXif6hHnfYEKm+WRyYd0Vo2QvI=" + } +]