diff --git a/modules/services/syncthing.nix b/modules/services/syncthing.nix index b6cebfa5..7ee26442 100644 --- a/modules/services/syncthing.nix +++ b/modules/services/syncthing.nix @@ -5,32 +5,555 @@ with lib; let cfg = config.services.syncthing; + settingsFormat = pkgs.formats.json { }; + cleanedConfig = + converge (filterAttrsRecursive (_: v: v != null && v != { })) cfg.settings; + + isUnixGui = (builtins.substring 0 1 cfg.guiAddress) == "/"; + + # Syncthing supports serving the GUI over Unix sockets. If that happens, the + # API is served over the Unix socket as well. This function returns the correct + # curl arguments for the address portion of the curl command for both network + # and Unix socket addresses. + curlAddressArgs = path: + if isUnixGui + # if cfg.guiAddress is a unix socket, tell curl explicitly about it + # note that the dot in front of `${path}` is the hostname, which is + # required. + then + "--unix-socket ${cfg.guiAddress} http://.${path}" + # no adjustements are needed if cfg.guiAddress is a network address + else + "${cfg.guiAddress}${path}"; + + devices = mapAttrsToList (_: device: device // { deviceID = device.id; }) + cfg.settings.devices; + + folders = mapAttrsToList (_: folder: + folder // { + devices = map (device: + if builtins.isString device then { + deviceId = cfg.settings.devices.${device}.id; + } else + device) folder.devices; + }) (filterAttrs (_: folder: folder.enable) cfg.settings.folders); + + jq = "${pkgs.jq}/bin/jq"; + sleep = "${pkgs.coreutils}/bin/sleep"; + printf = "${pkgs.coreutils}/bin/printf"; + cat = "${pkgs.coreutils}/bin/cat"; + curl = "${pkgs.curl}/bin/curl"; + install = "${pkgs.coreutils}/bin/install"; + syncthing = "${pkgs.syncthing}/bin/syncthing"; + + updateConfig = pkgs.writers.writeBash "merge-syncthing-config" ('' + set -efu + + # be careful not to leak secrets in the filesystem or in process listings + umask 0077 + + curl() { + # get the api key by parsing the config.xml + while + ! ${pkgs.libxml2}/bin/xmllint \ + --xpath 'string(configuration/gui/apikey)' \ + ${config.home.homeDirectory}/.local/state/syncthing/config.xml \ + >"$RUNTIME_DIRECTORY/api_key" + do ${sleep} 1; done + (${printf} "X-API-Key: "; ${cat} "$RUNTIME_DIRECTORY/api_key") >"$RUNTIME_DIRECTORY/headers" + ${curl} -sSLk -H "@$RUNTIME_DIRECTORY/headers" \ + --retry 1000 --retry-delay 1 --retry-all-errors \ + "$@" + } + '' + + + /* Syncthing's rest API for the folders and devices is almost identical. + Hence we iterate them using lib.pipe and generate shell commands for both at + the sime time. + */ + (lib.pipe { + # The attributes below are the only ones that are different for devices / + # folders. + devs = { + new_conf_IDs = map (v: v.id) devices; + GET_IdAttrName = "deviceID"; + override = cfg.overrideDevices; + conf = devices; + baseAddress = curlAddressArgs "/rest/config/devices"; + }; + dirs = { + new_conf_IDs = map (v: v.id) folders; + GET_IdAttrName = "id"; + override = cfg.overrideFolders; + conf = folders; + baseAddress = curlAddressArgs "/rest/config/folders"; + }; + } [ + # Now for each of these attributes, write the curl commands that are + # identical to both folders and devices. + (mapAttrs (conf_type: s: + # We iterate the `conf` list now, and run a curl -X POST command for each, that + # should update that device/folder only. + lib.pipe s.conf [ + # Quoting https://docs.syncthing.net/rest/config.html: + # + # > PUT takes an array and POST a single object. In both cases if a + # given folder/device already exists, it’s replaced, otherwise a new + # one is added. + # + # What's not documented, is that using PUT will remove objects that + # 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} + '')) + (lib.concatStringsSep "\n") + ] + /* If we need to override devices/folders, we iterate all currently configured + IDs, via another `curl -X GET`, and we delete all IDs that are not part of + the Nix configured list of IDs + */ + + lib.optionalString s.override '' + stale_${conf_type}_ids="$(curl -X GET ${s.baseAddress} | ${jq} \ + --argjson new_ids ${ + lib.escapeShellArg (builtins.toJSON s.new_conf_IDs) + } \ + --raw-output \ + '[.[].${s.GET_IdAttrName}] - $new_ids | .[]' + )" + for id in ''${stale_${conf_type}_ids}; do + curl -X DELETE ${s.baseAddress}/$id + done + '')) + builtins.attrValues + (lib.concatStringsSep "\n") + ]) + + /* Now we update the other settings defined in cleanedConfig which are not + "folders" or "devices". + */ + (lib.pipe cleanedConfig [ + builtins.attrNames + (lib.subtractLists [ "folders" "devices" ]) + (map (subOption: '' + curl -X PUT -d ${ + lib.escapeShellArg (builtins.toJSON cleanedConfig.${subOption}) + } ${curlAddressArgs "/rest/config/${subOption}"} + '')) + (lib.concatStringsSep "\n") + ]) + '' + # restart Syncthing if required + if curl ${curlAddressArgs "/rest/config/restart-required"} | + ${jq} -e .requiresRestart > /dev/null; then + curl -X POST ${curlAddressArgs "/rest/system/restart"} + fi + ''); defaultSyncthingArgs = [ - "${pkgs.syncthing}/bin/syncthing" + "${syncthing}" "-no-browser" "-no-restart" + "-no-upgrade" + "-gui-address=${if isUnixGui then "unix://" else ""}${cfg.guiAddress}" "-logflags=0" ]; syncthingArgs = defaultSyncthingArgs ++ cfg.extraOptions; - in { meta.maintainers = [ maintainers.rycee ]; options = { services.syncthing = { - enable = mkEnableOption "Syncthing continuous file synchronization"; + enable = mkEnableOption + "Syncthing, a self-hosted open-source alternative to Dropbox and Bittorrent Sync"; + + cert = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to the `cert.pem` file, which will be copied into Syncthing's + config directory. + ''; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Path to the `key.pem` file, which will be copied into Syncthing's + config directory. + ''; + }; + + overrideDevices = mkOption { + type = types.bool; + default = true; + description = '' + Whether to delete the devices which are not configured via the + [devices](#opt-services.syncthing.settings.devices) option. + If set to `false`, devices added via the web + interface will persist and will have to be deleted manually. + ''; + }; + + overrideFolders = mkOption { + type = types.bool; + default = true; + description = '' + Whether to delete the folders which are not configured via the + [folders](#opt-services.syncthing.settings.folders) option. + If set to `false`, folders added via the web + interface will persist and will have to be deleted manually. + ''; + }; + + settings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + options = { + # global options + options = mkOption { + default = { }; + description = '' + The options element contains all other global configuration options + ''; + type = types.submodule ({ name, ... }: { + freeformType = settingsFormat.type; + options = { + localAnnounceEnabled = mkOption { + type = types.nullOr types.bool; + default = null; + description = '' + Whether to send announcements to the local LAN, also use such announcements to find other devices. + ''; + }; + + localAnnouncePort = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + The port on which to listen and send IPv4 broadcast announcements to. + ''; + }; + + relaysEnabled = mkOption { + type = types.nullOr types.bool; + default = null; + description = '' + When true, relays will be connected to and potentially used for device to device connections. + ''; + }; + + urAccepted = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Whether the user has accepted to submit anonymous usage data. + The default, 0, mean the user has not made a choice, and Syncthing will ask at some point in the future. + "-1" means no, a number above zero means that that version of usage reporting has been accepted. + ''; + }; + + limitBandwidthInLan = mkOption { + type = types.nullOr types.bool; + default = null; + description = '' + Whether to apply bandwidth limits to devices in the same broadcast domain as the local device. + ''; + }; + + maxFolderConcurrency = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + This option controls how many folders may concurrently be in I/O-intensive operations such as syncing or scanning. + The mechanism is described in detail in a [separate chapter](https://docs.syncthing.net/advanced/option-max-concurrency.html). + ''; + }; + }; + }); + }; + + # device settings + devices = mkOption { + default = { }; + description = '' + Peers/devices which Syncthing should communicate with. + + Note that you can still add devices manually, but those changes + will be reverted on restart if [overrideDevices](#opt-services.syncthing.overrideDevices) + is enabled. + ''; + example = { + bigbox = { + id = + "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU"; + addresses = [ "tcp://192.168.0.10:51820" ]; + }; + }; + type = types.attrsOf (types.submodule ({ name, ... }: { + freeformType = settingsFormat.type; + options = { + + name = mkOption { + type = types.str; + default = name; + description = '' + The name of the device. + ''; + }; + + id = mkOption { + type = types.str; + description = '' + The device ID. See . + ''; + }; + + autoAcceptFolders = mkOption { + type = types.bool; + default = false; + description = '' + Automatically create or share folders that this device advertises at the default path. + See . + ''; + }; + + }; + })); + }; + + # folder settings + folders = mkOption { + default = { }; + description = '' + Folders which should be shared by Syncthing. + + Note that you can still add folders manually, but those changes + will be reverted on restart if [overrideFolders](#opt-services.syncthing.overrideFolders) + is enabled. + ''; + example = literalExpression '' + { + "/home/user/sync" = { + id = "syncme"; + devices = [ "bigbox" ]; + }; + } + ''; + type = types.attrsOf (types.submodule ({ name, ... }: { + freeformType = settingsFormat.type; + options = { + + enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether to share this folder. + This option is useful when you want to define all folders + in one place, but not every machine should share all folders. + ''; + }; + + path = mkOption { + type = types.str // { + check = x: + types.str.check x + && (substring 0 1 x == "/" || substring 0 2 x == "~/"); + description = types.str.description + + " starting with / or ~/"; + }; + default = name; + description = '' + The path to the folder which should be shared. + Only absolute paths (starting with `/`) and paths relative to + the user's home directory (starting with `~/`) are allowed. + ''; + }; + + id = mkOption { + type = types.str; + default = name; + description = '' + The ID of the folder. Must be the same on all devices. + ''; + }; + + label = mkOption { + type = types.str; + default = name; + description = '' + The label of the folder. + ''; + }; + + type = mkOption { + type = types.enum [ + "sendreceive" + "sendonly" + "receiveonly" + "receiveencrypted" + ]; + default = "sendreceive"; + description = '' + Controls how the folder is handled by Syncthing. + See . + ''; + }; + + devices = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + The devices this folder should be shared with. Each device must + be defined in the [devices](#opt-services.syncthing.settings.devices) option. + ''; + }; + + versioning = mkOption { + default = null; + description = '' + How to keep changed/deleted files with Syncthing. + There are 4 different types of versioning with different parameters. + See . + ''; + example = literalExpression '' + [ + { + versioning = { + type = "simple"; + params.keep = "10"; + }; + } + { + versioning = { + type = "trashcan"; + params.cleanoutDays = "1000"; + }; + } + { + versioning = { + type = "staggered"; + fsPath = "/syncthing/backup"; + params = { + cleanInterval = "3600"; + maxAge = "31536000"; + }; + }; + } + { + versioning = { + type = "external"; + params.versionsPath = pkgs.writers.writeBash "backup" ''' + folderpath="$1" + filepath="$2" + rm -rf "$folderpath/$filepath" + '''; + }; + } + ] + ''; + type = with types; + nullOr (submodule { + freeformType = settingsFormat.type; + options = { + type = mkOption { + type = enum [ + "external" + "simple" + "staggered" + "trashcan" + ]; + description = '' + The type of versioning. + See . + ''; + }; + }; + }); + }; + + copyOwnershipFromParent = mkOption { + type = types.bool; + default = false; + description = '' + On Unix systems, tries to copy file/folder ownership from the parent directory (the directory it’s located in). + Requires running Syncthing as a privileged user, or granting it additional capabilities (e.g. CAP_CHOWN on Linux). + ''; + }; + }; + })); + }; + + }; + }; + default = { }; + description = '' + Extra configuration options for Syncthing. + See . + Note that this attribute set does not exactly match the documented + xml format. Instead, this is the format of the json rest api. There + are slight differences. For example, this xml: + ```xml + + default + 1 + + ``` + corresponds to the json: + ```json + { + options: { + listenAddresses = [ + "default" + ]; + minHomeDiskFree = { + unit = "%"; + value = 1; + }; + }; + } + ``` + ''; + example = { + options.localAnnounceEnabled = false; + gui.theme = "black"; + }; + }; + + guiAddress = mkOption { + type = types.str; + default = "127.0.0.1:8384"; + description = '' + The address to serve the web interface at. + ''; + }; + + all_proxy = mkOption { + type = with types; nullOr str; + default = null; + example = "socks5://address.com:1234"; + description = '' + Overwrites the all_proxy environment variable for the Syncthing process to + the given value. This is normally used to let Syncthing connect + through a SOCKS5 proxy server. + See . + ''; + }; extraOptions = mkOption { type = types.listOf types.str; default = [ ]; - example = [ "--gui-apikey=apiKey" ]; + example = [ "--reset-deltas" ]; description = '' - Extra command-line arguments to pass to {command}`syncthing`. + Extra command-line arguments to pass to {command}`syncthing` ''; }; + package = mkPackageOption pkgs "syncthing" { }; + tray = mkOption { type = with types; either bool (submodule { @@ -78,10 +601,27 @@ in { }; Service = { + ExecStartPre = mkIf (cfg.cert != null || cfg.key != null) "+${ + pkgs.writers.writeBash "syncthing-copy-keys" '' + ${install} -dm700 ${config.home.homeDirectory}/.local/state/syncthing/ + ${optionalString (cfg.cert != null) '' + ${install} -Dm400 ${ + toString cfg.cert + } ${config.home.homeDirectory}/.local/state/syncthing/cert.pem + ''} + ${optionalString (cfg.key != null) '' + ${install} -Dm400 ${ + toString cfg.key + } ${config.home.homeDirectory}/.local/state/syncthing/key.pem + ''} + '' + }"; ExecStart = escapeShellArgs syncthingArgs; Restart = "on-failure"; SuccessExitStatus = [ 3 4 ]; RestartForceExitStatus = [ 3 4 ]; + Environment = + mkIf (cfg.all_proxy != null) { inherit (cfg) all_proxy; }; # Sandboxing. LockPersonality = true; @@ -95,6 +635,23 @@ in { Install = { WantedBy = [ "default.target" ]; }; }; + + syncthing-init = mkIf (cleanedConfig != { }) { + Unit = { + Description = "Syncthing configuration updater"; + Requires = [ "syncthing.service" ]; + After = [ "syncthing.service" ]; + }; + + Service = { + Type = "oneshot"; + ExecStart = updateConfig; + RuntimeDirectory = "syncthing-init"; + RemainAfterExit = true; + }; + + Install = { WantedBy = [ "default.target" ]; }; + }; }; launchd.agents.syncthing = { diff --git a/tests/modules/services/syncthing/common/extra-options.nix b/tests/modules/services/syncthing/common/extra-options.nix index 016850cc..aa1b2674 100644 --- a/tests/modules/services/syncthing/common/extra-options.nix +++ b/tests/modules/services/syncthing/common/extra-options.nix @@ -13,7 +13,7 @@ lib.mkMerge [ nmt.script = '' assertFileExists home-files/.config/systemd/user/syncthing.service assertFileContains home-files/.config/systemd/user/syncthing.service \ - "ExecStart='@syncthing@/bin/syncthing' '-no-browser' '-no-restart' '-logflags=0' '-foo' '-bar \"baz\"'" + "ExecStart='@syncthing@/bin/syncthing' '-no-browser' '-no-restart' '-no-upgrade' '-gui-address=127.0.0.1:8384' '-logflags=0' '-foo' '-bar \"baz\"'" ''; }) (lib.mkIf pkgs.stdenv.isDarwin {