nixos/syncthing: define and handle encryptionPassword option (#383442)
This commit is contained in:
@@ -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.
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { };
|
||||
|
||||
@@ -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")
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user