diff --git a/nixos/modules/services/networking/syncthing.nix b/nixos/modules/services/networking/syncthing.nix index f76509d78b69..0a901148dc9c 100644 --- a/nixos/modules/services/networking/syncthing.nix +++ b/nixos/modules/services/networking/syncthing.nix @@ -55,10 +55,19 @@ let were removed. Please use, respectively, {rescanIntervalS,fsWatcherEnabled,fsWatcherDelayS} instead. '' { - devices = map ( - device: - if builtins.isString device then { deviceId = cfg.settings.devices.${device}.id; } else device - ) folder.devices; + devices = + let + folderDevices = folder.devices; + in + 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); @@ -128,9 +137,79 @@ 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 + jsonPreSecretsFile = pkgs.writeTextFile { + name = "${conf_type}-${new_cfg.id}-conf-pre-secrets.json"; + text = builtins.toJSON new_cfg; + }; + injectSecretsJqCmd = + { + # 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 + "${jq} ${lib.concatStringsSep " " jqRawFiles} ${ + lib.escapeShellArg (lib.concatStringsSep "|" ([ "." ] ++ jqUpdates)) + }"; + } + .${conf_type}; + in + '' + ${injectSecretsJqCmd} ${jsonPreSecretsFile} | curl --json @- -X POST ${s.baseAddress} + '' + )) (lib.concatStringsSep "\n") ] /* @@ -438,11 +517,48 @@ in }; devices = mkOption { - type = types.listOf types.str; + 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. + + 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 ab914de71f38..bd191334efe8 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1273,6 +1273,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 = 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 new file mode 100644 index 000000000000..d0623f7194d5 --- /dev/null +++ b/nixos/tests/syncthing-folders.nix @@ -0,0 +1,135 @@ +{ 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 = + { config, ... }: + { + environment.etc.bar-encryption-password.text = testPassword; + 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 = [ + { + name = "c"; + encryptionPasswordFile = "/etc/${config.environment.etc.bar-encryption-password.target}"; + } + ]; + }; + }; + }; + }; + b = + { config, ... }: + { + environment.etc.bar-encryption-password.text = testPassword; + 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 = [ + { + 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"; + }; + }; + }; + }; + }; + + 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") + 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") + ''; +}