Demonstration of an alternate way to embed secrets into syncthing config

Changed files
+183 -153
nixos
doc
manual
release-notes
modules
services
networking
tests
-2
nixos/doc/manual/release-notes/rl-2411.section.md
···
- `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}
### `sound` options removal {#sec-release-24.11-migration-sound}
+99 -82
nixos/modules/services/networking/syncthing.nix
···
let
folderDevices = folder.devices;
in
-
if builtins.isList folderDevices then
-
map (
-
device:
-
if builtins.isString device then { deviceId = cfg.settings.devices.${device}.id; } else device
-
) folderDevices
-
else if builtins.isAttrs folderDevices then
-
mapAttrsToList (
-
deviceName: deviceValue: deviceValue // { deviceId = cfg.settings.devices.${deviceName}.id; }
-
) folderDevices
-
else
-
throw "Invalid type for devices in folder '${folderName}'; expected list or attrset.";
+
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);
···
(map (
new_cfg:
let
-
isSecret = attr: value: builtins.isString value && attr == "encryptionPassword";
-
-
resolveSecrets =
-
attr: value:
-
if builtins.isAttrs value then
-
# Attribute set: process each attribute
-
builtins.mapAttrs (name: val: resolveSecrets name val) value
-
else if builtins.isList value then
-
# List: process each element
-
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:
+
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
-
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
-
''
-
if [ ! -r ${path} ]; then
-
echo "${path} does not exist"
-
exit 1
-
fi
-
${varName}=$(<${path})
-
''
-
) paths
-
);
-
-
resolved_cfg = resolveSecrets "" new_cfg;
-
secretPaths = collectPaths "" new_cfg;
-
secretVarsScript = generateSecretVars secretPaths;
-
-
jsonString = builtins.toJSON resolved_cfg;
-
escapedJson = builtins.replaceStrings [ "\"" ] [ "\\\"" ] jsonString;
+
"${jq} ${lib.concatStringsSep " " jqRawFiles} ${
+
lib.escapeShellArg (lib.concatStringsSep "|" ([ "." ] ++ jqUpdates))
+
}";
+
}
+
.${conf_type};
in
''
-
${secretVarsScript}
-
-
curl -d "${escapedJson}" -X POST ${s.baseAddress}
+
${injectSecretsJqCmd} ${jsonPreSecretsFile} | curl --json @- -X POST ${s.baseAddress}
''
))
(lib.concatStringsSep "\n")
···
};
devices = mkOption {
-
type = types.oneOf [
-
(types.listOf types.str)
-
(types.attrsOf (
-
types.submodule (
-
{ name, ... }:
+
type = types.listOf (
+
types.oneOf [
+
types.str
+
(types.submodule (
+
{ ... }:
{
freeformType = settingsFormat.type;
options = {
-
encryptionPassword = mkOption {
-
type = types.nullOr types.str;
+
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
···
};
};
}
-
)
-
))
-
];
+
))
+
]
+
);
default = [ ];
description = ''
The devices this folder should be shared with. Each device must
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
-
[devices](#opt-services.syncthing.settings.devices) option, and values are
-
device configurations.
+
A list of either strings or attribute sets, where values
+
are device names or device configurations.
'';
};
+1 -1
nixos/tests/all-tests.nix
···
syncthing-no-settings = handleTest ./syncthing-no-settings.nix { };
syncthing-init = handleTest ./syncthing-init.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 { };
sysinit-reactivation = runTest ./sysinit-reactivation.nix;
systemd = handleTest ./systemd.nix { };
+83 -68
nixos/tests/syncthing-folders.nix
···
-
import ../make-test-python.nix (
-
{ 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";
-
testPasswordFile = pkgs.writeText "syncthing-test-password" "it's a secret";
-
in
-
{
-
name = "syncthing";
-
meta.maintainers = with pkgs.lib.maintainers; [ zarelit ];
+
{ 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 = {
+
nodes = {
+
a =
+
{ config, ... }:
+
{
+
environment.etc.bar-encryption-password.text = testPassword;
services.syncthing = {
enable = true;
openDefaultPorts = true;
···
};
folders.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 = {
enable = true;
openDefaultPorts = true;
···
};
folders.bar = {
path = "/var/lib/syncthing/bar";
-
devices.c.encryptionPassword = "${testPasswordFile}";
+
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";
-
};
+
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()
+
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)
+
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
+
# Test foo
-
a.wait_for_file("/var/lib/syncthing/foo")
-
b.wait_for_file("/var/lib/syncthing/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.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")
+
a.wait_for_file("/var/lib/syncthing/foo/b2a")
+
b.wait_for_file("/var/lib/syncthing/foo/a2b")
-
# Test bar
+
# 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.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")
+
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")
-
b.succeed("grep 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")
-
'';
-
}
-
)
+
# Bar on C is untrusted, check that content is not in cleartext
+
c.fail("grep -R plaincontent /var/lib/syncthing/bar")
+
'';
+
}