Skip to content

Commit

Permalink
Demonstration of an alternate way to embed secrets into syncthing config
Browse files Browse the repository at this point in the history
Note: I have intentionally not run `nixfmt` against this to try to keep
the diff comprehensible.
  • Loading branch information
jfly committed Feb 19, 2025
1 parent 181420e commit c5f68f3
Show file tree
Hide file tree
Showing 2 changed files with 123 additions and 120 deletions.
151 changes: 69 additions & 82 deletions nixos/modules/services/networking/syncthing.nix
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,14 @@ let
devices = 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);
Expand Down Expand Up @@ -115,62 +110,41 @@ let
# afterwards.
(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
jsonPreSecrets = pkgs.writeTextFile {
name = "${conf_type}-${new_cfg.id}-conf-pre-secrets.json";
text = builtins.toJSON new_cfg;
};
injectSecrtsJqCmd = (
if conf_type == "dirs" then
let
varName = "secret_${builtins.hashString "sha256" value}";
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;
}))
];
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
"\${${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 ]
"${jq} ${lib.concatStringsSep " " jqRawFiles} ${lib.escapeShellArg (lib.concatStringsSep "|" (["."] ++ jqUpdates))}"
else
[];

# Function to generate variable assignments for the secrets
generateSecretVars = paths:
concatStringsSep "\n" (map (path:
let
varName = "secret_${builtins.hashString "sha256" path}";
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} ."
);
in
''
${secretVarsScript}
curl -d "${escapedJson}" -X POST ${s.baseAddress}
${injectSecrtsJqCmd} ${jsonPreSecrets} | curl --json @- -X POST ${s.baseAddress}
''
))
(lib.concatStringsSep "\n")
Expand Down Expand Up @@ -445,30 +419,43 @@ in {
};

devices = mkOption {
type = types.oneOf [
(types.listOf types.str)
(types.attrsOf (types.submodule ({ name, ... }: {
freeformType = settingsFormat.type;
options = {
encryptionPassword = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Path to encryption password. If set, the file will be read during
service activation, without being embedded in derivation.
'';
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.
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.
'';
};

Expand Down
92 changes: 54 additions & 38 deletions nixos/tests/syncthing-folders.nix
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ../make-test-python.nix (
import ./make-test-python.nix (
{ lib, pkgs, ... }:
let
genNodeId =
Expand All @@ -11,53 +11,69 @@ import ../make-test-python.nix (
idA = genNodeId "a";
idB = genNodeId "b";
idC = genNodeId "c";
testPasswordFile = pkgs.writeText "syncthing-test-password" "it's a secret";
testPassword = "it's a secret";
in
{
name = "syncthing";
meta.maintainers = with pkgs.lib.maintainers; [ zarelit ];

nodes = {
a = {
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.c.encryptionPassword = "${testPasswordFile}";
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 = {
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.c.encryptionPassword = "${testPasswordFile}";
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;
Expand Down Expand Up @@ -111,10 +127,10 @@ import ../make-test-python.nix (
# 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")
assert "plaincontent" == b.succeed("cat /var/lib/syncthing/bar/plainname")
# Bar on C is untrusted, check that content is not in cleartext
c.fail("grep -R plaincontent /var/lib/syncthing/bar")
assert "plaincontent" not in c.succeed("cat /var/lib/syncthing/bar")
'';
}
)

0 comments on commit c5f68f3

Please sign in to comment.