Demonstration of an alternate way to embed secrets into syncthing config

This commit is contained in:
Jeremy Fleischman 2025-01-12 19:17:54 -08:00
parent 715ba701fa
commit 1b7b89c4ef
No known key found for this signature in database
4 changed files with 183 additions and 153 deletions

View file

@ -933,8 +933,6 @@
- `buildNimSbom` was added as an alternative to `buildNimPackage`. `buildNimSbom` uses [SBOMs](https://cyclonedx.org/) to generate packages whereas `buildNimPackage` uses a custom JSON lockfile format. - `buildNimSbom` was added as an alternative to `buildNimPackage`. `buildNimSbom` uses [SBOMs](https://cyclonedx.org/) to generate packages whereas `buildNimPackage` uses a custom JSON lockfile format.
- `services.syncthing.folders.<name>.devices` now accepts an `attrset`, allowing to set `encryptionPassword` file for a device.
## Detailed Migration Information {#sec-release-24.11-migration} ## Detailed Migration Information {#sec-release-24.11-migration}
### `sound` options removal {#sec-release-24.11-migration-sound} ### `sound` options removal {#sec-release-24.11-migration-sound}

View file

@ -59,17 +59,15 @@ let
let let
folderDevices = folder.devices; folderDevices = folder.devices;
in in
if builtins.isList folderDevices then map (
map ( device:
device: if builtins.isString device then
if builtins.isString device then { deviceId = cfg.settings.devices.${device}.id; } else device { deviceId = cfg.settings.devices.${device}.id; }
) folderDevices else if builtins.isAttrs device then
else if builtins.isAttrs folderDevices then { deviceId = cfg.settings.devices.${device.name}.id; } // device
mapAttrsToList ( else
deviceName: deviceValue: deviceValue // { deviceId = cfg.settings.devices.${deviceName}.id; } throw "Invalid type for devices in folder '${folderName}'; expected list or attrset."
) folderDevices ) folderDevices;
else
throw "Invalid type for devices in folder '${folderName}'; expected list or attrset.";
} }
) (filterAttrs (_: folder: folder.enable) cfg.settings.folders); ) (filterAttrs (_: folder: folder.enable) cfg.settings.folders);
@ -142,68 +140,74 @@ let
(map ( (map (
new_cfg: new_cfg:
let let
isSecret = attr: value: builtins.isString value && attr == "encryptionPassword"; jsonPreSecretsFile = pkgs.writeTextFile {
name = "${conf_type}-${new_cfg.id}-conf-pre-secrets.json";
resolveSecrets = text = builtins.toJSON new_cfg;
attr: value: };
if builtins.isAttrs value then injectSecretsJqCmd =
# Attribute set: process each attribute {
builtins.mapAttrs (name: val: resolveSecrets name val) value # There are no secrets in `devs`, so no massaging needed.
else if builtins.isList value then "devs" = "${jq} .";
# List: process each element "dirs" =
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 let
varName = "secret_${builtins.hashString "sha256" path}"; 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 in
'' "${jq} ${lib.concatStringsSep " " jqRawFiles} ${
if [ ! -r ${path} ]; then lib.escapeShellArg (lib.concatStringsSep "|" ([ "." ] ++ jqUpdates))
echo "${path} does not exist" }";
exit 1 }
fi .${conf_type};
${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 in
'' ''
${secretVarsScript} ${injectSecretsJqCmd} ${jsonPreSecretsFile} | curl --json @- -X POST ${s.baseAddress}
curl -d "${escapedJson}" -X POST ${s.baseAddress}
'' ''
)) ))
(lib.concatStringsSep "\n") (lib.concatStringsSep "\n")
@ -513,16 +517,30 @@ in
}; };
devices = mkOption { devices = mkOption {
type = types.oneOf [ type = types.listOf (
(types.listOf types.str) types.oneOf [
(types.attrsOf ( types.str
types.submodule ( (types.submodule (
{ name, ... }: { ... }:
{ {
freeformType = settingsFormat.type; freeformType = settingsFormat.type;
options = { options = {
encryptionPassword = mkOption { name = mkOption {
type = types.nullOr types.str; 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; default = null;
description = '' description = ''
Path to encryption password. If set, the file will be read during Path to encryption password. If set, the file will be read during
@ -531,17 +549,16 @@ in
}; };
}; };
} }
) ))
)) ]
]; );
default = [ ]; default = [ ];
description = '' description = ''
The devices this folder should be shared with. Each device must The devices this folder should be shared with. Each device must
be defined in the [devices](#opt-services.syncthing.settings.devices) option. 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 A list of either strings or attribute sets, where values
[devices](#opt-services.syncthing.settings.devices) option, and values are are device names or device configurations.
device configurations.
''; '';
}; };

View file

@ -1219,7 +1219,7 @@ in
syncthing-no-settings = handleTest ./syncthing-no-settings.nix { }; syncthing-no-settings = handleTest ./syncthing-no-settings.nix { };
syncthing-init = handleTest ./syncthing-init.nix { }; syncthing-init = handleTest ./syncthing-init.nix { };
syncthing-many-devices = handleTest ./syncthing-many-devices.nix { }; syncthing-many-devices = handleTest ./syncthing-many-devices.nix { };
syncthing-folders = handleTest ./syncthing-folders.nix { }; syncthing-folders = runTest ./syncthing-folders.nix;
syncthing-relay = handleTest ./syncthing-relay.nix { }; syncthing-relay = handleTest ./syncthing-relay.nix { };
sysinit-reactivation = runTest ./sysinit-reactivation.nix; sysinit-reactivation = runTest ./sysinit-reactivation.nix;
systemd = handleTest ./systemd.nix { }; systemd = handleTest ./systemd.nix { };

View file

@ -1,24 +1,26 @@
import ../make-test-python.nix ( { lib, pkgs, ... }:
{ lib, pkgs, ... }: let
let genNodeId =
genNodeId = name:
name: pkgs.runCommand "syncthing-test-certs-${name}" { } ''
pkgs.runCommand "syncthing-test-certs-${name}" { } '' mkdir -p $out
mkdir -p $out ${pkgs.syncthing}/bin/syncthing generate --config=$out
${pkgs.syncthing}/bin/syncthing generate --config=$out ${pkgs.libxml2}/bin/xmllint --xpath 'string(configuration/device/@id)' $out/config.xml > $out/id
${pkgs.libxml2}/bin/xmllint --xpath 'string(configuration/device/@id)' $out/config.xml > $out/id '';
''; idA = genNodeId "a";
idA = genNodeId "a"; idB = genNodeId "b";
idB = genNodeId "b"; idC = genNodeId "c";
idC = genNodeId "c"; testPassword = "it's a secret";
testPasswordFile = pkgs.writeText "syncthing-test-password" "it's a secret"; in
in {
{ name = "syncthing";
name = "syncthing"; meta.maintainers = with pkgs.lib.maintainers; [ zarelit ];
meta.maintainers = with pkgs.lib.maintainers; [ zarelit ];
nodes = { nodes = {
a = { a =
{ config, ... }:
{
environment.etc.bar-encryption-password.text = testPassword;
services.syncthing = { services.syncthing = {
enable = true; enable = true;
openDefaultPorts = true; openDefaultPorts = true;
@ -33,12 +35,20 @@ import ../make-test-python.nix (
}; };
folders.bar = { folders.bar = {
path = "/var/lib/syncthing/bar"; path = "/var/lib/syncthing/bar";
devices.c.encryptionPassword = "${testPasswordFile}"; devices = [
{
name = "c";
encryptionPasswordFile = "/etc/${config.environment.etc.bar-encryption-password.target}";
}
];
}; };
}; };
}; };
}; };
b = { b =
{ config, ... }:
{
environment.etc.bar-encryption-password.text = testPassword;
services.syncthing = { services.syncthing = {
enable = true; enable = true;
openDefaultPorts = true; openDefaultPorts = true;
@ -53,68 +63,73 @@ import ../make-test-python.nix (
}; };
folders.bar = { folders.bar = {
path = "/var/lib/syncthing/bar"; path = "/var/lib/syncthing/bar";
devices.c.encryptionPassword = "${testPasswordFile}"; devices = [
{
name = "c";
encryptionPasswordFile = "/etc/${config.environment.etc.bar-encryption-password.target}";
}
];
}; };
}; };
}; };
}; };
c = { c = {
services.syncthing = { services.syncthing = {
enable = true; enable = true;
openDefaultPorts = true; openDefaultPorts = true;
cert = "${idC}/cert.pem"; cert = "${idC}/cert.pem";
key = "${idC}/key.pem"; key = "${idC}/key.pem";
settings = { settings = {
devices.a.id = lib.fileContents "${idA}/id"; devices.a.id = lib.fileContents "${idA}/id";
devices.b.id = lib.fileContents "${idB}/id"; devices.b.id = lib.fileContents "${idB}/id";
folders.bar = { folders.bar = {
path = "/var/lib/syncthing/bar"; path = "/var/lib/syncthing/bar";
devices = [ devices = [
"a" "a"
"b" "b"
]; ];
type = "receiveencrypted"; type = "receiveencrypted";
};
}; };
}; };
}; };
}; };
};
testScript = '' testScript = ''
start_all() start_all()
a.wait_for_unit("syncthing.service") a.wait_for_unit("syncthing.service")
b.wait_for_unit("syncthing.service") b.wait_for_unit("syncthing.service")
c.wait_for_unit("syncthing.service") c.wait_for_unit("syncthing.service")
a.wait_for_open_port(22000) a.wait_for_open_port(22000)
b.wait_for_open_port(22000) b.wait_for_open_port(22000)
c.wait_for_open_port(22000) c.wait_for_open_port(22000)
# Test foo # Test foo
a.wait_for_file("/var/lib/syncthing/foo") a.wait_for_file("/var/lib/syncthing/foo")
b.wait_for_file("/var/lib/syncthing/foo") b.wait_for_file("/var/lib/syncthing/foo")
a.succeed("echo a2b > /var/lib/syncthing/foo/a2b") a.succeed("echo a2b > /var/lib/syncthing/foo/a2b")
b.succeed("echo b2a > /var/lib/syncthing/foo/b2a") b.succeed("echo b2a > /var/lib/syncthing/foo/b2a")
a.wait_for_file("/var/lib/syncthing/foo/b2a") a.wait_for_file("/var/lib/syncthing/foo/b2a")
b.wait_for_file("/var/lib/syncthing/foo/a2b") b.wait_for_file("/var/lib/syncthing/foo/a2b")
# Test bar # Test bar
a.wait_for_file("/var/lib/syncthing/bar") a.wait_for_file("/var/lib/syncthing/bar")
b.wait_for_file("/var/lib/syncthing/bar") b.wait_for_file("/var/lib/syncthing/bar")
c.wait_for_file("/var/lib/syncthing/bar") c.wait_for_file("/var/lib/syncthing/bar")
a.succeed("echo plaincontent > /var/lib/syncthing/bar/plainname") a.succeed("echo plaincontent > /var/lib/syncthing/bar/plainname")
# B should be able to decrypt, check that content of file matches # B should be able to decrypt, check that content of file matches
b.wait_for_file("/var/lib/syncthing/bar/plainname") b.wait_for_file("/var/lib/syncthing/bar/plainname")
b.succeed("grep plaincontent /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 # Bar on C is untrusted, check that content is not in cleartext
c.fail("grep -R plaincontent /var/lib/syncthing/bar") c.fail("grep -R plaincontent /var/lib/syncthing/bar")
''; '';
} }
)