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