Unverified Commit 84049bd2 authored by lassulus's avatar lassulus Committed by GitHub
Browse files

nixos/syncthing: define and handle encryptionPassword option (#383442)

parents 51597746 1b7b89c4
Loading
Loading
Loading
Loading
+124 −8
Original line number Diff line number Diff line
@@ -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);

@@ -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.
                        '';
                      };

+1 −0
Original line number Diff line number Diff line
@@ -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 { };
+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")
  '';
}