diff --git a/doc/release-notes/rl-2505.section.md b/doc/release-notes/rl-2505.section.md index 544c54218b0cb6..a9f8d3beade5a4 100644 --- a/doc/release-notes/rl-2505.section.md +++ b/doc/release-notes/rl-2505.section.md @@ -36,6 +36,10 @@ - This should not be necessary going forward, because loading app state from 0.7.0 or newer is now supported. This is documented in the [0.7.1 changelog](https://github.com/Nexus-Mods/NexusMods.App/releases/tag/v0.7.1). +## Other Notable Changes {#sec-nixpkgs-release-25.05-notable-changes} + +- `services.syncthing.settings.folders..devices` previously only accepted strings, but not also accepts `attrset`s, which allows you to set `encryptionPasswordFile` for a device. + ## Nixpkgs Library {#sec-nixpkgs-release-25.05-lib} ### Breaking changes {#sec-nixpkgs-release-25.05-lib-breaking} diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index 27f9b2cd6bf473..339c4bb38c36e8 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -928,8 +928,6 @@ - `buildNimSbom` was added as an alternative to `buildNimPackage`. `buildNimSbom` uses [SBOMs](https://cyclonedx.org/) to generate packages whereas `buildNimPackage` uses a custom JSON lockfile format. -- `services.syncthing.folders..devices` now accepts an `attrset`, allowing to set `encryptionPassword` file for a device. - ## Detailed Migration Information {#sec-release-24.11-migration} ### `sound` options removal {#sec-release-24.11-migration-sound} diff --git a/nixos/modules/services/networking/syncthing.nix b/nixos/modules/services/networking/syncthing.nix index 5fd672fe3d6499..81479163af6ba0 100644 --- a/nixos/modules/services/networking/syncthing.nix +++ b/nixos/modules/services/networking/syncthing.nix @@ -37,19 +37,14 @@ let devices = let folderDevices = folder.devices; in - if builtins.isList folderDevices then - map (device: - if builtins.isString device then - { deviceId = cfg.settings.devices.${device}.id; } - else - device - ) folderDevices - else if builtins.isAttrs folderDevices then - mapAttrsToList (deviceName: deviceValue: - deviceValue // { deviceId = cfg.settings.devices.${deviceName}.id; } - ) folderDevices - else - throw "Invalid type for devices in folder '${folderName}'; expected list or attrset."; + map (device: + if builtins.isString device then + { deviceId = cfg.settings.devices.${device}.id; } + else if builtins.isAttrs device then + { deviceId = cfg.settings.devices.${device.name}.id; } // device + else + throw "Invalid type for devices in folder '${folderName}'; expected list or attrset." + ) folderDevices; }) (filterAttrs (_: folder: folder.enable ) cfg.settings.folders); @@ -115,62 +110,67 @@ let # afterwards. (map (new_cfg: let - isSecret = attr: value: builtins.isString value && attr == "encryptionPassword"; - - resolveSecrets = attr: value: - if builtins.isAttrs value then - # Attribute set: process each attribute - builtins.mapAttrs (name: val: resolveSecrets name val) value - else if builtins.isList value then - # List: process each element - map (item: resolveSecrets "" item) value - else if isSecret attr value then - # String that looks like a path: replace with placeholder - let - varName = "secret_${builtins.hashString "sha256" value}"; - in - "\${${varName}}" - else - # Other types: return as is - value; - - # Function to collect all file paths from the configuration - collectPaths = attr: value: - if builtins.isAttrs value then - concatMap (name: collectPaths name value.${name}) (builtins.attrNames value) - else if builtins.isList value then - concatMap (name: collectPaths "" name) value - else if isSecret attr value then - [ value ] - else - []; - - # Function to generate variable assignments for the secrets - generateSecretVars = paths: - concatStringsSep "\n" (map (path: - let - varName = "secret_${builtins.hashString "sha256" path}"; + jsonPreSecretsFile = pkgs.writeTextFile { + name = "${conf_type}-${new_cfg.id}-conf-pre-secrets.json"; + text = lib.traceVal (builtins.toJSON new_cfg); + }; + injectSecrtsJqCmd = { + # There are no secrets in `devs`, so no massaging needed. + "devs" = "${jq} ."; + "dirs" = let + folder = new_cfg; + devicesWithSecrets = lib.pipe folder.devices [ + (lib.filter (device: (builtins.isAttrs device) && device ? encryptionPasswordFile)) + (map (device: { + deviceId = device.deviceId; + variableName = "secret_${builtins.hashString "sha256" device.encryptionPasswordFile}"; + secretPath = device.encryptionPasswordFile; + })) + ]; + # At this point, `jsonPreSecretsFile` looks something like this: + # + # { + # ..., + # "devices": [ + # { + # "deviceId": "id1", + # "encryptionPasswordFile": "/etc/bar-encryption-password", + # "name": "..." + # } + # ], + # } + # + # We now generate a `jq` command that can replace those + # `encryptionPasswordFile`s with `encryptionPassword`. + # The `jq` command ends up looking like this: + # + # jq --rawfile secret_DEADBEEF /etc/bar-encryption-password ' + # .devices[] |= ( + # if .deviceId == "id1" then + # del(.encryptionPasswordFile) | + # .encryptionPassword = $secret_DEADBEEF + # else + # . + # end + # ) + # ' + jqUpdates = map (device: '' + .devices[] |= ( + if .deviceId == "${device.deviceId}" then + del(.encryptionPasswordFile) | + .encryptionPassword = ''$${device.variableName} + else + . + end + ) + '') devicesWithSecrets; + jqRawFiles = map (device: "--rawfile ${device.variableName} ${lib.escapeShellArg device.secretPath}") devicesWithSecrets; in - '' - if [ ! -r ${path} ]; then - echo "${path} does not exist" - exit 1 - fi - ${varName}=$(<${path}) - '' - ) paths); - - resolved_cfg = resolveSecrets "" new_cfg; - secretPaths = collectPaths "" new_cfg; - secretVarsScript = generateSecretVars secretPaths; - - jsonString = builtins.toJSON resolved_cfg; - escapedJson = builtins.replaceStrings ["\""] ["\\\""] jsonString; + "${jq} ${lib.concatStringsSep " " jqRawFiles} ${lib.escapeShellArg (lib.concatStringsSep "|" (["."] ++ jqUpdates))}"; + }.${conf_type}; in '' - ${secretVarsScript} - - curl -d "${escapedJson}" -X POST ${s.baseAddress} + ${injectSecrtsJqCmd} ${jsonPreSecretsFile} | curl --json @- -X POST ${s.baseAddress} '' )) (lib.concatStringsSep "\n") @@ -445,30 +445,43 @@ in { }; devices = mkOption { - type = types.oneOf [ - (types.listOf types.str) - (types.attrsOf (types.submodule ({ name, ... }: { - freeformType = settingsFormat.type; - options = { - encryptionPassword = mkOption { - type = types.nullOr types.str; - default = null; - description = '' - Path to encryption password. If set, the file will be read during - service activation, without being embedded in derivation. - ''; + type = types.listOf ( + types.oneOf [ + types.str + (types.submodule ({ ... }: { + freeformType = settingsFormat.type; + options = { + name = mkOption { + type = types.str; + default = null; + description = '' + The name of a device defined in the + [devices](#opt-services.syncthing.settings.devices) + option. + ''; + }; + encryptionPasswordFile = mkOption { + type = types.nullOr (types.pathWith { + inStore = false; + absolute = true; + }); + default = null; + description = '' + Path to encryption password. If set, the file will be read during + service activation, without being embedded in derivation. + ''; + }; }; - }; - })) - )]; + })) + ] + ); default = []; description = '' The devices this folder should be shared with. Each device must be defined in the [devices](#opt-services.syncthing.settings.devices) option. - Either a list of strings, or an attribute set, where keys are defined in the - [devices](#opt-services.syncthing.settings.devices) option, and values are - device configurations. + A list of either strings or attribute sets, where values + are device names or device configurations. ''; }; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index d0ec0cb519aaed..1a08a4d80d69a7 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1025,7 +1025,7 @@ in { syncthing-no-settings = handleTest ./syncthing-no-settings.nix {}; syncthing-init = handleTest ./syncthing-init.nix {}; syncthing-many-devices = handleTest ./syncthing-many-devices.nix {}; - syncthing-folders = handleTest ./syncthing-folders.nix {}; + syncthing-folders = runTest ./syncthing-folders.nix; syncthing-relay = handleTest ./syncthing-relay.nix {}; sysinit-reactivation = runTest ./sysinit-reactivation.nix; systemd = handleTest ./systemd.nix {}; diff --git a/nixos/tests/syncthing-folders.nix b/nixos/tests/syncthing-folders.nix index 46985b4709a698..d0623f7194d563 100644 --- a/nixos/tests/syncthing-folders.nix +++ b/nixos/tests/syncthing-folders.nix @@ -1,24 +1,26 @@ -import ../make-test-python.nix ( - { lib, pkgs, ... }: - let - genNodeId = - name: - pkgs.runCommand "syncthing-test-certs-${name}" { } '' - mkdir -p $out - ${pkgs.syncthing}/bin/syncthing generate --config=$out - ${pkgs.libxml2}/bin/xmllint --xpath 'string(configuration/device/@id)' $out/config.xml > $out/id - ''; - idA = genNodeId "a"; - idB = genNodeId "b"; - idC = genNodeId "c"; - testPasswordFile = pkgs.writeText "syncthing-test-password" "it's a secret"; - in - { - name = "syncthing"; - meta.maintainers = with pkgs.lib.maintainers; [ zarelit ]; +{ lib, pkgs, ... }: +let + genNodeId = + name: + pkgs.runCommand "syncthing-test-certs-${name}" { } '' + mkdir -p $out + ${pkgs.syncthing}/bin/syncthing generate --config=$out + ${pkgs.libxml2}/bin/xmllint --xpath 'string(configuration/device/@id)' $out/config.xml > $out/id + ''; + idA = genNodeId "a"; + idB = genNodeId "b"; + idC = genNodeId "c"; + testPassword = "it's a secret"; +in +{ + name = "syncthing"; + meta.maintainers = with pkgs.lib.maintainers; [ zarelit ]; - nodes = { - a = { + nodes = { + a = + { config, ... }: + { + environment.etc.bar-encryption-password.text = testPassword; services.syncthing = { enable = true; openDefaultPorts = true; @@ -33,12 +35,20 @@ import ../make-test-python.nix ( }; folders.bar = { path = "/var/lib/syncthing/bar"; - devices.c.encryptionPassword = "${testPasswordFile}"; + devices = [ + { + name = "c"; + encryptionPasswordFile = "/etc/${config.environment.etc.bar-encryption-password.target}"; + } + ]; }; }; }; }; - b = { + b = + { config, ... }: + { + environment.etc.bar-encryption-password.text = testPassword; services.syncthing = { enable = true; openDefaultPorts = true; @@ -53,68 +63,73 @@ import ../make-test-python.nix ( }; folders.bar = { path = "/var/lib/syncthing/bar"; - devices.c.encryptionPassword = "${testPasswordFile}"; + devices = [ + { + name = "c"; + encryptionPasswordFile = "/etc/${config.environment.etc.bar-encryption-password.target}"; + } + ]; }; }; }; }; - c = { - services.syncthing = { - enable = true; - openDefaultPorts = true; - cert = "${idC}/cert.pem"; - key = "${idC}/key.pem"; - settings = { - devices.a.id = lib.fileContents "${idA}/id"; - devices.b.id = lib.fileContents "${idB}/id"; - folders.bar = { - path = "/var/lib/syncthing/bar"; - devices = [ - "a" - "b" - ]; - type = "receiveencrypted"; - }; + c = { + services.syncthing = { + enable = true; + openDefaultPorts = true; + cert = "${idC}/cert.pem"; + key = "${idC}/key.pem"; + settings = { + devices.a.id = lib.fileContents "${idA}/id"; + devices.b.id = lib.fileContents "${idB}/id"; + folders.bar = { + path = "/var/lib/syncthing/bar"; + devices = [ + "a" + "b" + ]; + type = "receiveencrypted"; }; }; }; }; + }; - testScript = '' - start_all() + testScript = '' + start_all() - a.wait_for_unit("syncthing.service") - b.wait_for_unit("syncthing.service") - c.wait_for_unit("syncthing.service") - a.wait_for_open_port(22000) - b.wait_for_open_port(22000) - c.wait_for_open_port(22000) + a.wait_for_unit("syncthing.service") + b.wait_for_unit("syncthing.service") + c.wait_for_unit("syncthing.service") + a.wait_for_open_port(22000) + b.wait_for_open_port(22000) + c.wait_for_open_port(22000) - # Test foo + # Test foo - a.wait_for_file("/var/lib/syncthing/foo") - b.wait_for_file("/var/lib/syncthing/foo") + a.wait_for_file("/var/lib/syncthing/foo") + b.wait_for_file("/var/lib/syncthing/foo") - a.succeed("echo a2b > /var/lib/syncthing/foo/a2b") - b.succeed("echo b2a > /var/lib/syncthing/foo/b2a") + a.succeed("echo a2b > /var/lib/syncthing/foo/a2b") + b.succeed("echo b2a > /var/lib/syncthing/foo/b2a") - a.wait_for_file("/var/lib/syncthing/foo/b2a") - b.wait_for_file("/var/lib/syncthing/foo/a2b") + a.wait_for_file("/var/lib/syncthing/foo/b2a") + b.wait_for_file("/var/lib/syncthing/foo/a2b") - # Test bar + # Test bar - a.wait_for_file("/var/lib/syncthing/bar") - b.wait_for_file("/var/lib/syncthing/bar") - c.wait_for_file("/var/lib/syncthing/bar") + a.wait_for_file("/var/lib/syncthing/bar") + b.wait_for_file("/var/lib/syncthing/bar") + c.wait_for_file("/var/lib/syncthing/bar") - a.succeed("echo plaincontent > /var/lib/syncthing/bar/plainname") + a.succeed("echo plaincontent > /var/lib/syncthing/bar/plainname") - # B should be able to decrypt, check that content of file matches - b.wait_for_file("/var/lib/syncthing/bar/plainname") - b.succeed("grep plaincontent /var/lib/syncthing/bar/plainname") + # B should be able to decrypt, check that content of file matches + b.wait_for_file("/var/lib/syncthing/bar/plainname") + file_contents = b.succeed("cat /var/lib/syncthing/bar/plainname") + assert "plaincontent\n" == file_contents, f"Unexpected file contents: {file_contents=}" - # Bar on C is untrusted, check that content is not in cleartext - c.fail("grep -R plaincontent /var/lib/syncthing/bar") - ''; - } -) + # Bar on C is untrusted, check that content is not in cleartext + c.fail("grep -R plaincontent /var/lib/syncthing/bar") + ''; +}