diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index e609253950f14..d5e279bfd536e 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -518,6 +518,8 @@ - `nix.channel.enable = false` no longer implies `nix.settings.nix-path = []`. Since Nix 2.13, a `nix-path` set in `nix.conf` cannot be overriden by the `NIX_PATH` configuration variable. +- `services.syncthing` now accepts an `attrSet` for `folders[].devices`, 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 94ff838b50e04..7995238ab8d47 100644 --- a/nixos/modules/services/networking/syncthing.nix +++ b/nixos/modules/services/networking/syncthing.nix @@ -34,12 +34,22 @@ let The options services.syncthing.settings.folders..{rescanInterval,watch,watchDelay} were removed. Please use, respectively, {rescanIntervalS,fsWatcherEnabled,fsWatcherDelayS} instead. '' { - devices = map (device: - if builtins.isString device then - { deviceId = cfg.settings.devices.${device}.id; } + 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 - device - ) folder.devices; + throw "Invalid type for devices in folder '${folderName}'; expected list or attrset."; }) (filterAttrs (_: folder: folder.enable ) cfg.settings.folders); @@ -103,9 +113,66 @@ let # don't exist in the array given. That's why we use here `POST`, and # only if s.override == true then we DELETE the relevant folders # afterwards. - (map (new_cfg: '' - curl -d ${lib.escapeShellArg (builtins.toJSON new_cfg)} -X POST ${s.baseAddress} - '')) + (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}"; + 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; + in + '' + ${secretVarsScript} + + curl -d "${escapedJson}" -X POST ${s.baseAddress} + '' + )) (lib.concatStringsSep "\n") ] /* If we need to override devices/folders, we iterate all currently configured @@ -378,11 +445,30 @@ in { }; devices = mkOption { - type = types.listOf types.str; + 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. + ''; + }; + }; + })) + )]; 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. ''; }; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 6c9ff9fb9c200..7ce5818b50ef3 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -941,10 +941,11 @@ in { switchTestNg = handleTest ./switch-test.nix { ng = true; }; sx = handleTest ./sx.nix {}; sympa = handleTest ./sympa.nix {}; - syncthing = handleTest ./syncthing.nix {}; - syncthing-no-settings = handleTest ./syncthing-no-settings.nix {}; - syncthing-init = handleTest ./syncthing-init.nix {}; - syncthing-many-devices = handleTest ./syncthing-many-devices.nix {}; + syncthing = handleTest ./syncthing/default.nix {}; + 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-relay = handleTest ./syncthing-relay.nix {}; sysinit-reactivation = runTest ./sysinit-reactivation.nix; systemd = handleTest ./systemd.nix {}; diff --git a/nixos/tests/syncthing.nix b/nixos/tests/syncthing/default.nix similarity index 97% rename from nixos/tests/syncthing.nix rename to nixos/tests/syncthing/default.nix index aff1d87441308..996ceb9c24c7c 100644 --- a/nixos/tests/syncthing.nix +++ b/nixos/tests/syncthing/default.nix @@ -1,4 +1,4 @@ -import ./make-test-python.nix ({ lib, pkgs, ... }: { +import ../make-test-python.nix ({ lib, pkgs, ... }: { name = "syncthing"; meta.maintainers = with pkgs.lib.maintainers; [ chkno ]; diff --git a/nixos/tests/syncthing/folders.nix b/nixos/tests/syncthing/folders.nix new file mode 100644 index 0000000000000..46985b4709a69 --- /dev/null +++ b/nixos/tests/syncthing/folders.nix @@ -0,0 +1,120 @@ +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 ]; + + nodes = { + a = { + services.syncthing = { + enable = true; + openDefaultPorts = true; + cert = "${idA}/cert.pem"; + key = "${idA}/key.pem"; + settings = { + devices.b.id = lib.fileContents "${idB}/id"; + devices.c.id = lib.fileContents "${idC}/id"; + folders.foo = { + path = "/var/lib/syncthing/foo"; + devices = [ "b" ]; + }; + folders.bar = { + path = "/var/lib/syncthing/bar"; + devices.c.encryptionPassword = "${testPasswordFile}"; + }; + }; + }; + }; + b = { + services.syncthing = { + enable = true; + openDefaultPorts = true; + cert = "${idB}/cert.pem"; + key = "${idB}/key.pem"; + settings = { + devices.a.id = lib.fileContents "${idA}/id"; + devices.c.id = lib.fileContents "${idC}/id"; + folders.foo = { + path = "/var/lib/syncthing/foo"; + devices = [ "a" ]; + }; + folders.bar = { + path = "/var/lib/syncthing/bar"; + devices.c.encryptionPassword = "${testPasswordFile}"; + }; + }; + }; + }; + 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() + + 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 + + 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.wait_for_file("/var/lib/syncthing/foo/b2a") + b.wait_for_file("/var/lib/syncthing/foo/a2b") + + # 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.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") + + # Bar on C is untrusted, check that content is not in cleartext + c.fail("grep -R plaincontent /var/lib/syncthing/bar") + ''; + } +) diff --git a/nixos/tests/syncthing-init.nix b/nixos/tests/syncthing/init.nix similarity index 92% rename from nixos/tests/syncthing-init.nix rename to nixos/tests/syncthing/init.nix index 97fcf2ad28d10..234c556546033 100644 --- a/nixos/tests/syncthing-init.nix +++ b/nixos/tests/syncthing/init.nix @@ -1,4 +1,4 @@ -import ./make-test-python.nix ({ lib, pkgs, ... }: let +import ../make-test-python.nix ({ lib, pkgs, ... }: let testId = "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU"; diff --git a/nixos/tests/syncthing-many-devices.nix b/nixos/tests/syncthing/many-devices.nix similarity index 99% rename from nixos/tests/syncthing-many-devices.nix rename to nixos/tests/syncthing/many-devices.nix index 2251bf0774533..9090a52af8a00 100644 --- a/nixos/tests/syncthing-many-devices.nix +++ b/nixos/tests/syncthing/many-devices.nix @@ -1,4 +1,4 @@ -import ./make-test-python.nix ({ lib, pkgs, ... }: +import ../make-test-python.nix ({ lib, pkgs, ... }: # This nixosTest is supposed to check the following: # diff --git a/nixos/tests/syncthing-no-settings.nix b/nixos/tests/syncthing/no-settings.nix similarity index 89% rename from nixos/tests/syncthing-no-settings.nix rename to nixos/tests/syncthing/no-settings.nix index fee122b5e35c0..addb033a508a2 100644 --- a/nixos/tests/syncthing-no-settings.nix +++ b/nixos/tests/syncthing/no-settings.nix @@ -1,4 +1,4 @@ -import ./make-test-python.nix ({ lib, pkgs, ... }: { +import ../make-test-python.nix ({ lib, pkgs, ... }: { name = "syncthing"; meta.maintainers = with pkgs.lib.maintainers; [ chkno ];