diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b6411989..88a3ca33 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -50,6 +50,21 @@ ## 2026-09-29 +- **Added Valheim dedicated server package, service, and preset**: + - Added `steam-fetcher` (`github:nix-community/steam-fetcher`) flake input with `inputs.nixpkgs.follows = "nixpkgs"`. + - Added `inputs.steam-fetcher.overlay` to system overlays in `lib/default.nix`. + - Added `pkgs/valheim-server/package.nix` wrapping the Valheim dedicated server with modern `buildFHSEnv`, updated to latest release 1.0.16 (Steam depot `896661`, manifest `1285123405092214913`), with graceful fallback for non-x86_64-linux platforms. + - Added `overlays/valheim.nix` exposing `valheim-server`, `valheim-server-unwrapped`, and `fetchValheimThunderstoreMod` for compatibility. + - Added NixOS service module `platforms/nixos/modules/services/valheim.nix` defining `services.valheim`: + - Manages dedicated user/group `valheim` and systemd service `valheim.service`. + - Supports runtime decryption/loading of server password via `passwordFile` (avoiding plaintext secrets in the Nix store) as well as fallback `password` string option. + - Configures firewall rules for Valheim UDP ports (`port`, `port + 1`, `port + 2`). + - Configures admin, permitted, and banned Steam ID list files. + - Added preset module `platforms/nixos/modules/nmasur/presets/services/valheim/valheim.nix` with options for `nmasur.presets.services.valheim.enable`: + - Wires up `secrets.valheim-password` with encrypted secret file `valheim-password.age` and sets `systemd.services.valheim-password-secret` dependency before `valheim.service`. + - Adds `valheim = "valheim.masu.rs"` hostname to `flake.nix` and configures `nmasur.presets.services.cloudflare.noProxyDomains = [ hostnames.valheim ]` for DNS-only UDP routing. + - Adds `valheim.enable = lib.mkDefault false;` under `services` in `platforms/nixos/modules/nmasur/profiles/communications.nix`. + - **Separated Restic backup repositories per host**: - Updated `services.restic.backups.default.repository` in `platforms/nixos/modules/nmasur/presets/services/restic/restic.nix` to append `/${config.networking.hostName}`, isolating backups by host in S3 (e.g., `.../restic/flame` and `.../restic/swan`). - Enabled `services.restic.backups.default.initialize = true` to automatically initialize the repository in S3 if it does not already exist. diff --git a/flake.lock b/flake.lock index f20fa2b6..f74f8285 100644 --- a/flake.lock +++ b/flake.lock @@ -547,6 +547,7 @@ "nixpkgs-stable": "nixpkgs-stable", "nur": "nur", "rust-overlay": "rust-overlay", + "steam-fetcher": "steam-fetcher", "wsl": "wsl", "zellij-switch": "zellij-switch", "zenyd-mpv-scripts": "zenyd-mpv-scripts" @@ -570,6 +571,26 @@ "type": "github" } }, + "steam-fetcher": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1758622040, + "narHash": "sha256-BRUZZoQDCD1qByJjHm4YKipQlB2PburWLG9Iaggr8XQ=", + "owner": "nix-community", + "repo": "steam-fetcher", + "rev": "ab5e3a0828b4b179feb548c57be584ce812956a8", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "steam-fetcher", + "type": "github" + } + }, "systems": { "locked": { "lastModified": 1681028828, diff --git a/flake.nix b/flake.nix index 5144b16b..84698435 100644 --- a/flake.nix +++ b/flake.nix @@ -92,6 +92,12 @@ inputs.nixpkgs.follows = "nixpkgs"; }; + # Steam depot fetcher + steam-fetcher = { + url = "github:nix-community/steam-fetcher"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + # # Text editor # helix = { # url = "github:helix-editor/helix"; @@ -166,6 +172,7 @@ status = "status.${baseName}"; stream = "stream.${baseName}"; transmission = "transmission.${baseName}"; + valheim = "valheim.${baseName}"; }; lib = import ./lib inputs; diff --git a/lib/default.nix b/lib/default.nix index 00674e20..aed3ba5f 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -67,6 +67,7 @@ lib inputs.nur.overlays.default inputs.nix2vim.overlay inputs.api.overlays.default + inputs.steam-fetcher.overlay (final: prev: { zellij-switch = inputs.zellij-switch.packages.${prev.stdenv.hostPlatform.system}.default; }) diff --git a/overlays/valheim.nix b/overlays/valheim.nix new file mode 100644 index 00000000..6ca0fc51 --- /dev/null +++ b/overlays/valheim.nix @@ -0,0 +1,25 @@ +_inputs: final: prev: + +{ + fetchValheimThunderstoreMod = + { + owner, + name, + version, + hash, + }: + let + depString = "${owner}-${name}"; + in + prev.fetchzip { + inherit version hash; + name = "valheim-thunderstore-${depString}"; + url = "https://thunderstore.io/package/download/${owner}/${name}/${version}/"; + extension = "zip"; + stripRoot = false; + }; +} +// prev.lib.optionalAttrs (prev.stdenv.hostPlatform.isLinux && prev.stdenv.hostPlatform.isx86_64) { + valheim-server = final.nmasur.valheim-server; + valheim-server-unwrapped = final.nmasur.valheim-server.unwrapped; +} diff --git a/pkgs/valheim-server/package.nix b/pkgs/valheim-server/package.nix new file mode 100644 index 00000000..8596d996 --- /dev/null +++ b/pkgs/valheim-server/package.nix @@ -0,0 +1,82 @@ +{ + lib, + stdenv, + fetchSteam, + buildFHSEnv, + writeScript, + steamworks-sdk-redist, + zlib, + pulseaudio, +}: + +if !stdenv.hostPlatform.isLinux || !stdenv.hostPlatform.isx86_64 then + null +else + let + valheim-server-unwrapped = stdenv.mkDerivation (finalAttrs: { + pname = "valheim-server-unwrapped"; + version = "1.0.16"; + + src = fetchSteam { + name = "valheim-server"; + appId = "896660"; + depotId = "896661"; + manifestId = "1285123405092214913"; + hash = "sha256-AH9yU0YW0RpgkD9VSGFLj+qIXk3UlvrAgIu9fy/D+N8="; + }; + + dontBuild = true; + dontConfigure = true; + dontFixup = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out + shopt -s nullglob + cp -r *.so *.debug valheim_server.x86_64 valheim_server_Data $out + chmod +x $out/valheim_server.x86_64 + + runHook postInstall + ''; + + meta = with lib; { + description = "Valheim dedicated server (unwrapped)"; + homepage = "https://steamdb.info/app/896660/"; + changelog = "https://store.steampowered.com/news/app/892970?updates=true"; + sourceProvenance = with sourceTypes; [ + binaryBytecode + binaryNativeCode + ]; + license = licenses.unfree; + platforms = [ "x86_64-linux" ]; + }; + }); + in + buildFHSEnv { + name = "valheim-server"; + pname = "valheim-server"; + version = valheim-server-unwrapped.version; + + runScript = writeScript "valheim-server-wrapper" '' + export LD_LIBRARY_PATH=${steamworks-sdk-redist}/lib:$LD_LIBRARY_PATH + export SteamAppId=892970 + exec ${valheim-server-unwrapped}/valheim_server.x86_64 "$@" + ''; + + targetPkgs = pkgs: [ + valheim-server-unwrapped + steamworks-sdk-redist + zlib + pulseaudio + ]; + + passthru = { + unwrapped = valheim-server-unwrapped; + }; + + meta = valheim-server-unwrapped.meta // { + description = "Valheim dedicated server"; + mainProgram = "valheim-server"; + }; + } diff --git a/platforms/nixos/modules/nmasur/presets/services/valheim/valheim-password.age b/platforms/nixos/modules/nmasur/presets/services/valheim/valheim-password.age new file mode 100644 index 00000000..b84a4fae --- /dev/null +++ b/platforms/nixos/modules/nmasur/presets/services/valheim/valheim-password.age @@ -0,0 +1,16 @@ +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHNzaC1lZDI1NTE5IE1nSGFPdyA5V29j +dWc3Rmd2T3hHdkNlSE1nejVKdGY0UkUxM2U4eStQWEk5Z2pLOXlVCkExWWhzMzZZ +Yk5uWVY2anc3TTJTNkdVd0pRcmx6bGhkRWtHZlh6Yk93bGMKLT4gc3NoLWVkMjU1 +MTkgWXlTVU1RIHhzallQU211VzduSkVHWVMzblRDcnY0L1VLMENoWi9RSkhaL0dS +a1Q2MjAKM1ZHcENURld0Ry9iUjVGRUd3RHA3Q29oQW8vYlN1SW1pNTdVOE5LSDRu +MAotPiBzc2gtZWQyNTUxOSBuanZYNUEgMi9IVU5rWXp3UHVZYWxFWE5mN0lHRlJY +V0dNSWh2YVRya1o5US9nSURSZwpXSHFhdTBOVExxSlhsYlRnWTR4Y1ZURWlCK2pG +OTdQdFNXUHoraWF5bW1VCi0+IHNzaC1lZDI1NTE5IENxSU9VQSBESmZuM25IYjNx +L3BDWmh6ZDFLdDVvMlY2Vzlra0t5WHZMbkUxOTlJMWlnCjZNcmdoZ2o1c1ZkSW02 +QTR0WTRDcWFIYkZpRzdJMHJ4aWhJYjVFRU9aUDQKLT4gc3NoLWVkMjU1MTkgejFP +Y1p3IGVZUnZUM2p3R1ROZk92MVNscHRTMytKd3EvZE5JT0orYmE3U1h0SERyV28K +SXNIa0FMTHJ6dE5lVW85Q0N2N2F5QU1EU3crc1diTmM0SXVkQ0xlY3lpYwotLS0g +Y1ZxeE8wSkVpRllTT29QZTQ3L25ReGZ6WEMzSnZvSGJQaUNNV2J4ZzNzYwp3Ll3z +WxwIP3+BEVkWO52W77uZVfS9it6ecqvcFmEQK0PCzrKsTkm5Xlw3mAp42kXeIg== +-----END AGE ENCRYPTED FILE----- diff --git a/platforms/nixos/modules/nmasur/presets/services/valheim/valheim.nix b/platforms/nixos/modules/nmasur/presets/services/valheim/valheim.nix new file mode 100644 index 00000000..fcbb18a1 --- /dev/null +++ b/platforms/nixos/modules/nmasur/presets/services/valheim/valheim.nix @@ -0,0 +1,90 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + inherit (config.nmasur.settings) hostnames; + cfg = config.nmasur.presets.services.valheim; +in + +{ + + options.nmasur.presets.services.valheim = { + enable = lib.mkEnableOption "Valheim dedicated server"; + + serverName = lib.mkOption { + type = lib.types.str; + default = "Midgard"; + description = "Server name in server browser."; + }; + + worldName = lib.mkOption { + type = lib.types.str; + default = "Midgard"; + description = "World save name."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 2456; + description = "Server port."; + }; + + crossplay = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable cross-platform play."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Open UDP ports in firewall."; + }; + + passwordFile = lib.mkOption { + type = lib.types.path; + default = ./valheim-password.age; + description = "Age-encrypted secret file containing the server password."; + }; + }; + + config = lib.mkIf cfg.enable { + + allowUnfreePackages = [ + "valheim-server" + "steamworks-sdk-redist" + ]; + + secrets.valheim-password = { + source = cfg.passwordFile; + dest = "${config.secretsDirectory}/valheim-password"; + owner = "valheim"; + group = "valheim"; + permissions = "0400"; + }; + + systemd.services.valheim-password-secret = { + requiredBy = [ "valheim.service" ]; + before = [ "valheim.service" ]; + }; + + services.valheim = { + enable = true; + serverName = cfg.serverName; + worldName = cfg.worldName; + port = cfg.port; + crossplay = cfg.crossplay; + openFirewall = cfg.openFirewall; + passwordFile = config.secrets.valheim-password.dest; + }; + + # Configure Cloudflare DNS (no proxy for UDP game traffic) + nmasur.presets.services.cloudflare.noProxyDomains = [ hostnames.valheim ]; + + }; + +} diff --git a/platforms/nixos/modules/nmasur/profiles/communications.nix b/platforms/nixos/modules/nmasur/profiles/communications.nix index 63a812c2..aa5f4c7d 100644 --- a/platforms/nixos/modules/nmasur/profiles/communications.nix +++ b/platforms/nixos/modules/nmasur/profiles/communications.nix @@ -61,6 +61,7 @@ in stalwart.enable = lib.mkDefault true; thelounge.enable = lib.mkDefault true; uptime-kuma.enable = lib.mkDefault true; + valheim.enable = lib.mkDefault false; vaultwarden.enable = lib.mkDefault true; victoriametrics.enable = lib.mkDefault true; }; diff --git a/platforms/nixos/modules/services/valheim.nix b/platforms/nixos/modules/services/valheim.nix new file mode 100644 index 00000000..62726c52 --- /dev/null +++ b/platforms/nixos/modules/services/valheim.nix @@ -0,0 +1,249 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + cfg = config.services.valheim; + + startScript = pkgs.writeShellScriptBin "valheim-server-start" '' + set -euo pipefail + + CONFIG_DIR="${cfg.dataDir}/.config/unity3d/IronGate/Valheim" + mkdir -p "$CONFIG_DIR" + + ${lib.optionalString (cfg.adminList != [ ]) '' + cat << 'EOF' > "$CONFIG_DIR/adminlist.txt" + // List of Steam IDs for adminlist ONE per line + ${lib.concatStringsSep "\n" cfg.adminList} + EOF + ''} + + ${lib.optionalString (cfg.permittedList != [ ]) '' + cat << 'EOF' > "$CONFIG_DIR/permittedlist.txt" + // List of Steam IDs for permittedlist ONE per line + ${lib.concatStringsSep "\n" cfg.permittedList} + EOF + ''} + + ${lib.optionalString (cfg.bannedList != [ ]) '' + cat << 'EOF' > "$CONFIG_DIR/bannedlist.txt" + // List of Steam IDs for bannedlist ONE per line + ${lib.concatStringsSep "\n" cfg.bannedList} + EOF + ''} + + ${ + if cfg.passwordFile != null then + '' + if [ ! -f "${cfg.passwordFile}" ]; then + echo "Valheim password file '${cfg.passwordFile}' not found!" >&2 + exit 1 + fi + PASS="$(tr -d '\r\n' < '${cfg.passwordFile}')" + '' + else + '' + PASS="${cfg.password}" + '' + } + + if [ ''${#PASS} -lt 5 ]; then + echo "Valheim server password must be at least 5 characters long!" >&2 + exit 1 + fi + + ARGS=( + "-name" "${cfg.serverName}" + "-port" "${toString cfg.port}" + "-world" "${cfg.worldName}" + "-password" "$PASS" + "-public" "${if cfg.public then "1" else "0"}" + ) + + ${lib.optionalString cfg.crossplay ''ARGS+=("-crossplay")''} + ${lib.optionalString (cfg.saveDir != null) ''ARGS+=("-savedir" "${cfg.saveDir}")''} + ${lib.concatMapStringsSep "\n" (arg: ''ARGS+=("${arg}")'') cfg.extraArgs} + + exec ${cfg.package}/bin/valheim-server "''${ARGS[@]}" + ''; + +in +{ + + options.services.valheim = { + enable = lib.mkEnableOption "Valheim dedicated server"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.valheim-server; + defaultText = lib.literalExpression "pkgs.valheim-server"; + description = "The valheim-server package to use."; + }; + + serverName = lib.mkOption { + type = lib.types.str; + default = "Valheim Server"; + example = "Some cozy server"; + description = "The name listed in the server browser."; + }; + + worldName = lib.mkOption { + type = lib.types.str; + default = "Dedicated"; + example = "Midgard"; + description = '' + The name of the world file to use, without the extension. + If the world does not exist, the server will generate a world from a random seed. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 2456; + description = '' + The port on which to listen for incoming connections. + Ports (port + 1) and (port + 2) are used for Steam queries and routing. + ''; + }; + + crossplay = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable cross-platform play via PlayFab backend."; + }; + + public = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to list the server in the public server browser."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open UDP ports in the firewall."; + }; + + password = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + The server password in plain text. + Prefer `passwordFile` to avoid storing passwords in the world-readable Nix store. + ''; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr (lib.types.either lib.types.str lib.types.path); + default = null; + description = "Path to file containing the server password."; + }; + + dataDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/valheim"; + description = "Directory for valheim server data and state."; + }; + + saveDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Directory where world saves are placed (-savedir). Defaults to Unity config dir in dataDir."; + }; + + adminList = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "72057602627862526" ]; + description = "List of Steam IDs to add to adminlist.txt."; + }; + + permittedList = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "72057602627862526" ]; + description = "List of Steam IDs to add to permittedlist.txt."; + }; + + bannedList = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "72057602627862526" ]; + description = "List of Steam IDs to add to bannedlist.txt."; + }; + + bepinexMods = lib.mkOption { + type = lib.types.listOf lib.types.package; + default = [ ]; + description = "BepInEx mods to install."; + }; + + bepinexConfigs = lib.mkOption { + type = lib.types.listOf lib.types.path; + default = [ ]; + description = "Config files for BepInEx mods."; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Extra command line arguments to pass to valheim-server."; + }; + }; + + config = lib.mkIf cfg.enable { + + users.users.valheim = { + isSystemUser = true; + group = "valheim"; + home = cfg.dataDir; + createHome = true; + }; + users.groups.valheim = { }; + + systemd.services.valheim = { + description = "Valheim dedicated server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + path = with pkgs; [ coreutils ]; + serviceConfig = { + Type = "exec"; + User = "valheim"; + Group = "valheim"; + StateDirectory = "valheim"; + WorkingDirectory = cfg.dataDir; + Restart = "on-failure"; + RestartSec = "10s"; + ExecStart = "${startScript}/bin/valheim-server-start"; + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedUDPPorts = [ + cfg.port + (cfg.port + 1) + (cfg.port + 2) + ]; + }; + + assertions = [ + { + assertion = cfg.serverName != ""; + message = "services.valheim.serverName must not be empty."; + } + { + assertion = cfg.worldName != ""; + message = "services.valheim.worldName must not be empty."; + } + { + assertion = (cfg.password != null && cfg.password != "") || cfg.passwordFile != null; + message = "services.valheim requires either services.valheim.password or services.valheim.passwordFile to be set."; + } + ]; + + }; + +}