diff --git a/nixos/modules/services/networking/syncthing.nix b/nixos/modules/services/networking/syncthing.nix index 114a64dfb175..89dae7bb3f86 100644 --- a/nixos/modules/services/networking/syncthing.nix +++ b/nixos/modules/services/networking/syncthing.nix @@ -5,6 +5,57 @@ with lib; let cfg = config.services.syncthing; defaultUser = "syncthing"; + + devices = mapAttrsToList (name: device: { + deviceID = device.id; + inherit (device) name addresses introducer; + }) cfg.declarative.devices; + + folders = mapAttrsToList ( _: folder: { + inherit (folder) path id label type; + devices = map (device: { deviceId = cfg.declarative.devices.${device}.id; }) folder.devices; + rescanIntervalS = folder.rescanInterval; + fsWatcherEnabled = folder.watch; + fsWatcherDelayS = folder.watchDelay; + ignorePerms = folder.ignorePerms; + }) cfg.declarative.folders; + + # get the api key by parsing the config.xml + getApiKey = pkgs.writers.writeDash "getAPIKey" '' + ${pkgs.libxml2}/bin/xmllint \ + --xpath 'string(configuration/gui/apikey)'\ + ${cfg.configDir}/config.xml + ''; + + updateConfig = pkgs.writers.writeDash "merge-syncthing-config" '' + set -efu + # wait for syncthing port to open + until ${pkgs.curl}/bin/curl -Ss ${cfg.guiAddress} -o /dev/null; do + sleep 1 + done + + API_KEY=$(${getApiKey}) + OLD_CFG=$(${pkgs.curl}/bin/curl -Ss \ + -H "X-API-Key: $API_KEY" \ + ${cfg.guiAddress}/rest/system/config) + + # generate the new config by merging with the nixos config options + NEW_CFG=$(echo "$OLD_CFG" | ${pkgs.jq}/bin/jq -s '.[] as $in | $in * { + "devices": (${builtins.toJSON devices}${optionalString (! cfg.declarative.overrideDevices) " + $in.devices"}), + "folders": (${builtins.toJSON folders}${optionalString (! cfg.declarative.overrideFolders) " + $in.folders"}) + }') + + # POST the new config to syncthing + echo "$NEW_CFG" | ${pkgs.curl}/bin/curl -Ss \ + -H "X-API-Key: $API_KEY" \ + ${cfg.guiAddress}/rest/system/config -d @- + + # restart syncthing after sending the new config + ${pkgs.curl}/bin/curl -Ss \ + -H "X-API-Key: $API_KEY" \ + -X POST \ + ${cfg.guiAddress}/rest/system/restart + ''; in { ###### interface options = { @@ -16,6 +67,187 @@ in { available on http://127.0.0.1:8384/. ''; + declarative = { + cert = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to users cert.pem file, will be copied into the syncthing's + configDir + ''; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to users key.pem file, will be copied into the syncthing's + configDir + ''; + }; + + overrideDevices = mkOption { + type = types.bool; + default = true; + description = '' + Whether to delete the devices which are not configured via the + declarative.devices option. + If set to false, devices added via the webinterface will + persist but will have to be deleted manually. + ''; + }; + + devices = mkOption { + default = {}; + description = '' + Peers/devices which syncthing should communicate with. + ''; + example = [ + { + name = "bigbox"; + id = "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU"; + addresses = [ "tcp://192.168.0.10:51820" ]; + } + ]; + type = types.attrsOf (types.submodule ({ config, ... }: { + options = { + + name = mkOption { + type = types.str; + default = config._module.args.name; + description = '' + Name of the device + ''; + }; + + addresses = mkOption { + type = types.listOf types.str; + default = []; + description = '' + The addresses used to connect to the device. + If this is let empty, dynamic configuration is attempted + ''; + }; + + id = mkOption { + type = types.str; + description = '' + The id of the other peer, this is mandatory. It's documented at + https://docs.syncthing.net/dev/device-ids.html + ''; + }; + + introducer = mkOption { + type = types.bool; + default = false; + description = '' + If the device should act as an introducer and be allowed + to add folders on this computer. + ''; + }; + + }; + })); + }; + + overrideFolders = mkOption { + type = types.bool; + default = true; + description = '' + Whether to delete the folders which are not configured via the + declarative.folders option. + If set to false, folders added via the webinterface will persist + but will have to be deleted manually. + ''; + }; + + folders = mkOption { + default = {}; + description = '' + folders which should be shared by syncthing. + ''; + type = types.attrsOf (types.submodule ({ config, ... }: { + options = { + + path = mkOption { + type = types.str; + default = config._module.args.name; + description = '' + The path to the folder which should be shared. + ''; + }; + + id = mkOption { + type = types.str; + default = config._module.args.name; + description = '' + The id of the folder. Must be the same on all devices. + ''; + }; + + label = mkOption { + type = types.str; + default = config._module.args.name; + description = '' + The label of the folder. + ''; + }; + + devices = mkOption { + type = types.listOf types.str; + default = []; + description = '' + The devices this folder should be shared with. Must be defined + in the declarative.devices attribute. + ''; + }; + + rescanInterval = mkOption { + type = types.int; + default = 3600; + description = '' + How often the folders should be rescaned for changes. + ''; + }; + + type = mkOption { + type = types.enum [ "sendreceive" "sendonly" "receiveonly" ]; + default = "sendreceive"; + description = '' + Whether to send only changes from this folder, only receive them + or propagate both. + ''; + }; + + watch = mkOption { + type = types.bool; + default = true; + description = '' + Whether the folder should be watched for changes by inotify. + ''; + }; + + watchDelay = mkOption { + type = types.int; + default = 10; + description = '' + The delay after an inotify event is triggered. + ''; + }; + + ignorePerms = mkOption { + type = types.bool; + default = true; + description = '' + Whether to propagate permission changes. + ''; + }; + + }; + })); + }; + }; + guiAddress = mkOption { type = types.str; default = "127.0.0.1:8384"; @@ -151,6 +383,23 @@ in { RestartForceExitStatus="3 4"; User = cfg.user; Group = cfg.group; + ExecStartPre = mkIf (cfg.declarative.cert != null || cfg.declarative.key != null) + "+${pkgs.writers.writeBash "syncthing-copy-keys" '' + mkdir -p ${cfg.configDir} + chown ${cfg.user}:${cfg.group} ${cfg.configDir} + chmod 700 ${cfg.configDir} + ${optionalString (cfg.declarative.cert != null) '' + cp ${toString cfg.declarative.cert} ${cfg.configDir}/cert.pem + chown ${cfg.user}:${cfg.group} ${cfg.configDir}/cert.pem + chmod 400 ${cfg.configDir}/cert.pem + ''} + ${optionalString (cfg.declarative.key != null) '' + cp ${toString cfg.declarative.key} ${cfg.configDir}/key.pem + chown ${cfg.user}:${cfg.group} ${cfg.configDir}/key.pem + chmod 400 ${cfg.configDir}/key.pem + ''} + ''}" + ; ExecStart = '' ${cfg.package}/bin/syncthing \ -no-browser \ @@ -159,6 +408,17 @@ in { ''; }; }; + syncthing-init = { + after = [ "syncthing.service" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + User = cfg.user; + RemainAfterExit = true; + Type = "oneshot"; + ExecStart = updateConfig; + }; + }; syncthing-resume = { wantedBy = [ "suspend.target" ]; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index c31d9d78da60..5be7c4292e5f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -231,6 +231,7 @@ in strongswan-swanctl = handleTest ./strongswan-swanctl.nix {}; sudo = handleTest ./sudo.nix {}; switchTest = handleTest ./switch-test.nix {}; + syncthing-init = handleTest ./syncthing-init.nix {}; syncthing-relay = handleTest ./syncthing-relay.nix {}; systemd = handleTest ./systemd.nix {}; systemd-confinement = handleTest ./systemd-confinement.nix {}; diff --git a/nixos/tests/syncthing-init.nix b/nixos/tests/syncthing-init.nix new file mode 100644 index 000000000000..811a466ff941 --- /dev/null +++ b/nixos/tests/syncthing-init.nix @@ -0,0 +1,30 @@ +import ./make-test.nix ({ lib, pkgs, ... }: let + + testId = "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU"; + +in { + name = "syncthing-init"; + meta.maintainers = with pkgs.stdenv.lib.maintainers; [ lassulus ]; + + machine = { + services.syncthing = { + enable = true; + declarative = { + devices.testDevice = { + id = testId; + }; + folders.testFolder = { + path = "/tmp/test"; + devices = [ "testDevice" ]; + }; + }; + }; + }; + + testScript = '' + $machine->waitForUnit("syncthing-init.service"); + $machine->succeed("cat /var/lib/syncthing/config.xml") =~ /${testId}/ or die; + $machine->succeed("cat /var/lib/syncthing/config.xml") =~ /testFolder/ or die; + ''; +}) +