Loading nixos/modules/services/networking/syncthing.nix +124 −8 Original line number Diff line number Diff line Loading @@ -55,10 +55,19 @@ let were removed. Please use, respectively, {rescanIntervalS,fsWatcherEnabled,fsWatcherDelayS} instead. '' { devices = map ( devices = let folderDevices = folder.devices; in map ( device: if builtins.isString device then { deviceId = cfg.settings.devices.${device}.id; } else device ) folder.devices; 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); Loading Loading @@ -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") ] /* Loading Loading @@ -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. ''; }; Loading nixos/tests/all-tests.nix +1 −0 Original line number Diff line number Diff line Loading @@ -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 { }; Loading nixos/tests/syncthing-folders.nix 0 → 100644 +135 −0 Original line number Diff line number Diff line { 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") ''; } Loading
nixos/modules/services/networking/syncthing.nix +124 −8 Original line number Diff line number Diff line Loading @@ -55,10 +55,19 @@ let were removed. Please use, respectively, {rescanIntervalS,fsWatcherEnabled,fsWatcherDelayS} instead. '' { devices = map ( devices = let folderDevices = folder.devices; in map ( device: if builtins.isString device then { deviceId = cfg.settings.devices.${device}.id; } else device ) folder.devices; 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); Loading Loading @@ -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") ] /* Loading Loading @@ -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. ''; }; Loading
nixos/tests/all-tests.nix +1 −0 Original line number Diff line number Diff line Loading @@ -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 { }; Loading
nixos/tests/syncthing-folders.nix 0 → 100644 +135 −0 Original line number Diff line number Diff line { 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") ''; }