1{ config, lib, options, pkgs, ... }:
2
3with lib;
4
5let
6 cfg = config.services.syncthing;
7 opt = options.services.syncthing;
8 defaultUser = "syncthing";
9 defaultGroup = defaultUser;
10 settingsFormat = pkgs.formats.json { };
11 cleanedConfig = converge (filterAttrsRecursive (_: v: v != null && v != {})) cfg.settings;
12
13 isUnixGui = (builtins.substring 0 1 cfg.guiAddress) == "/";
14
15 # Syncthing supports serving the GUI over Unix sockets. If that happens, the
16 # API is served over the Unix socket as well. This function returns the correct
17 # curl arguments for the address portion of the curl command for both network
18 # and Unix socket addresses.
19 curlAddressArgs = path: if isUnixGui
20 # if cfg.guiAddress is a unix socket, tell curl explicitly about it
21 # note that the dot in front of `${path}` is the hostname, which is
22 # required.
23 then "--unix-socket ${cfg.guiAddress} http://.${path}"
24 # no adjustements are needed if cfg.guiAddress is a network address
25 else "${cfg.guiAddress}${path}"
26 ;
27
28 devices = mapAttrsToList (_: device: device // {
29 deviceID = device.id;
30 }) cfg.settings.devices;
31
32 folders = mapAttrsToList (_: folder: folder //
33 throwIf (folder?rescanInterval || folder?watch || folder?watchDelay) ''
34 The options services.syncthing.settings.folders.<name>.{rescanInterval,watch,watchDelay}
35 were removed. Please use, respectively, {rescanIntervalS,fsWatcherEnabled,fsWatcherDelayS} instead.
36 '' {
37 devices = map (device:
38 if builtins.isString device then
39 { deviceId = cfg.settings.devices.${device}.id; }
40 else
41 device
42 ) folder.devices;
43 }) (filterAttrs (_: folder:
44 folder.enable
45 ) cfg.settings.folders);
46
47 jq = "${pkgs.jq}/bin/jq";
48 updateConfig = pkgs.writers.writeBash "merge-syncthing-config" (''
49 set -efu
50
51 # be careful not to leak secrets in the filesystem or in process listings
52 umask 0077
53
54 curl() {
55 # get the api key by parsing the config.xml
56 while
57 ! ${pkgs.libxml2}/bin/xmllint \
58 --xpath 'string(configuration/gui/apikey)' \
59 ${cfg.configDir}/config.xml \
60 >"$RUNTIME_DIRECTORY/api_key"
61 do sleep 1; done
62 (printf "X-API-Key: "; cat "$RUNTIME_DIRECTORY/api_key") >"$RUNTIME_DIRECTORY/headers"
63 ${pkgs.curl}/bin/curl -sSLk -H "@$RUNTIME_DIRECTORY/headers" \
64 --retry 1000 --retry-delay 1 --retry-all-errors \
65 "$@"
66 }
67 '' +
68
69 /* Syncthing's rest API for the folders and devices is almost identical.
70 Hence we iterate them using lib.pipe and generate shell commands for both at
71 the sime time. */
72 (lib.pipe {
73 # The attributes below are the only ones that are different for devices /
74 # folders.
75 devs = {
76 new_conf_IDs = map (v: v.id) devices;
77 GET_IdAttrName = "deviceID";
78 override = cfg.overrideDevices;
79 conf = devices;
80 baseAddress = curlAddressArgs "/rest/config/devices";
81 };
82 dirs = {
83 new_conf_IDs = map (v: v.id) folders;
84 GET_IdAttrName = "id";
85 override = cfg.overrideFolders;
86 conf = folders;
87 baseAddress = curlAddressArgs "/rest/config/folders";
88 };
89 } [
90 # Now for each of these attributes, write the curl commands that are
91 # identical to both folders and devices.
92 (mapAttrs (conf_type: s:
93 # We iterate the `conf` list now, and run a curl -X POST command for each, that
94 # should update that device/folder only.
95 lib.pipe s.conf [
96 # Quoting https://docs.syncthing.net/rest/config.html:
97 #
98 # > PUT takes an array and POST a single object. In both cases if a
99 # given folder/device already exists, it’s replaced, otherwise a new
100 # one is added.
101 #
102 # What's not documented, is that using PUT will remove objects that
103 # don't exist in the array given. That's why we use here `POST`, and
104 # only if s.override == true then we DELETE the relevant folders
105 # afterwards.
106 (map (new_cfg: ''
107 curl -d ${lib.escapeShellArg (builtins.toJSON new_cfg)} -X POST ${s.baseAddress}
108 ''))
109 (lib.concatStringsSep "\n")
110 ]
111 /* If we need to override devices/folders, we iterate all currently configured
112 IDs, via another `curl -X GET`, and we delete all IDs that are not part of
113 the Nix configured list of IDs
114 */
115 + lib.optionalString s.override ''
116 stale_${conf_type}_ids="$(curl -X GET ${s.baseAddress} | ${jq} \
117 --argjson new_ids ${lib.escapeShellArg (builtins.toJSON s.new_conf_IDs)} \
118 --raw-output \
119 '[.[].${s.GET_IdAttrName}] - $new_ids | .[]'
120 )"
121 for id in ''${stale_${conf_type}_ids}; do
122 curl -X DELETE ${s.baseAddress}/$id
123 done
124 ''
125 ))
126 builtins.attrValues
127 (lib.concatStringsSep "\n")
128 ]) +
129 /* Now we update the other settings defined in cleanedConfig which are not
130 "folders" or "devices". */
131 (lib.pipe cleanedConfig [
132 builtins.attrNames
133 (lib.subtractLists ["folders" "devices"])
134 (map (subOption: ''
135 curl -X PUT -d ${lib.escapeShellArg (builtins.toJSON cleanedConfig.${subOption})} ${curlAddressArgs "/rest/config/${subOption}"}
136 ''))
137 (lib.concatStringsSep "\n")
138 ]) + ''
139 # restart Syncthing if required
140 if curl ${curlAddressArgs "/rest/config/restart-required"} |
141 ${jq} -e .requiresRestart > /dev/null; then
142 curl -X POST ${curlAddressArgs "/rest/system/restart"}
143 fi
144 '');
145in {
146 ###### interface
147 options = {
148 services.syncthing = {
149
150 enable = mkEnableOption "Syncthing, a self-hosted open-source alternative to Dropbox and Bittorrent Sync";
151
152 cert = mkOption {
153 type = types.nullOr types.str;
154 default = null;
155 description = ''
156 Path to the `cert.pem` file, which will be copied into Syncthing's
157 [configDir](#opt-services.syncthing.configDir).
158 '';
159 };
160
161 key = mkOption {
162 type = types.nullOr types.str;
163 default = null;
164 description = ''
165 Path to the `key.pem` file, which will be copied into Syncthing's
166 [configDir](#opt-services.syncthing.configDir).
167 '';
168 };
169
170 overrideDevices = mkOption {
171 type = types.bool;
172 default = true;
173 description = ''
174 Whether to delete the devices which are not configured via the
175 [devices](#opt-services.syncthing.settings.devices) option.
176 If set to `false`, devices added via the web
177 interface will persist and will have to be deleted manually.
178 '';
179 };
180
181 overrideFolders = mkOption {
182 type = types.bool;
183 default = true;
184 description = ''
185 Whether to delete the folders which are not configured via the
186 [folders](#opt-services.syncthing.settings.folders) option.
187 If set to `false`, folders added via the web
188 interface will persist and will have to be deleted manually.
189 '';
190 };
191
192 settings = mkOption {
193 type = types.submodule {
194 freeformType = settingsFormat.type;
195 options = {
196 # global options
197 options = mkOption {
198 default = {};
199 description = ''
200 The options element contains all other global configuration options
201 '';
202 type = types.submodule ({ name, ... }: {
203 freeformType = settingsFormat.type;
204 options = {
205 localAnnounceEnabled = mkOption {
206 type = types.nullOr types.bool;
207 default = null;
208 description = ''
209 Whether to send announcements to the local LAN, also use such announcements to find other devices.
210 '';
211 };
212
213 localAnnouncePort = mkOption {
214 type = types.nullOr types.int;
215 default = null;
216 description = ''
217 The port on which to listen and send IPv4 broadcast announcements to.
218 '';
219 };
220
221 relaysEnabled = mkOption {
222 type = types.nullOr types.bool;
223 default = null;
224 description = ''
225 When true, relays will be connected to and potentially used for device to device connections.
226 '';
227 };
228
229 urAccepted = mkOption {
230 type = types.nullOr types.int;
231 default = null;
232 description = ''
233 Whether the user has accepted to submit anonymous usage data.
234 The default, 0, mean the user has not made a choice, and Syncthing will ask at some point in the future.
235 "-1" means no, a number above zero means that that version of usage reporting has been accepted.
236 '';
237 };
238
239 limitBandwidthInLan = mkOption {
240 type = types.nullOr types.bool;
241 default = null;
242 description = ''
243 Whether to apply bandwidth limits to devices in the same broadcast domain as the local device.
244 '';
245 };
246
247 maxFolderConcurrency = mkOption {
248 type = types.nullOr types.int;
249 default = null;
250 description = ''
251 This option controls how many folders may concurrently be in I/O-intensive operations such as syncing or scanning.
252 The mechanism is described in detail in a [separate chapter](https://docs.syncthing.net/advanced/option-max-concurrency.html).
253 '';
254 };
255 };
256 });
257 };
258
259 # device settings
260 devices = mkOption {
261 default = {};
262 description = ''
263 Peers/devices which Syncthing should communicate with.
264
265 Note that you can still add devices manually, but those changes
266 will be reverted on restart if [overrideDevices](#opt-services.syncthing.overrideDevices)
267 is enabled.
268 '';
269 example = {
270 bigbox = {
271 id = "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU";
272 addresses = [ "tcp://192.168.0.10:51820" ];
273 };
274 };
275 type = types.attrsOf (types.submodule ({ name, ... }: {
276 freeformType = settingsFormat.type;
277 options = {
278
279 name = mkOption {
280 type = types.str;
281 default = name;
282 description = ''
283 The name of the device.
284 '';
285 };
286
287 id = mkOption {
288 type = types.str;
289 description = ''
290 The device ID. See <https://docs.syncthing.net/dev/device-ids.html>.
291 '';
292 };
293
294 autoAcceptFolders = mkOption {
295 type = types.bool;
296 default = false;
297 description = ''
298 Automatically create or share folders that this device advertises at the default path.
299 See <https://docs.syncthing.net/users/config.html?highlight=autoaccept#config-file-format>.
300 '';
301 };
302
303 };
304 }));
305 };
306
307 # folder settings
308 folders = mkOption {
309 default = {};
310 description = ''
311 Folders which should be shared by Syncthing.
312
313 Note that you can still add folders manually, but those changes
314 will be reverted on restart if [overrideFolders](#opt-services.syncthing.overrideFolders)
315 is enabled.
316 '';
317 example = literalExpression ''
318 {
319 "/home/user/sync" = {
320 id = "syncme";
321 devices = [ "bigbox" ];
322 };
323 }
324 '';
325 type = types.attrsOf (types.submodule ({ name, ... }: {
326 freeformType = settingsFormat.type;
327 options = {
328
329 enable = mkOption {
330 type = types.bool;
331 default = true;
332 description = ''
333 Whether to share this folder.
334 This option is useful when you want to define all folders
335 in one place, but not every machine should share all folders.
336 '';
337 };
338
339 path = mkOption {
340 # TODO for release 23.05: allow relative paths again and set
341 # working directory to cfg.dataDir
342 type = types.str // {
343 check = x: types.str.check x && (substring 0 1 x == "/" || substring 0 2 x == "~/");
344 description = types.str.description + " starting with / or ~/";
345 };
346 default = name;
347 description = ''
348 The path to the folder which should be shared.
349 Only absolute paths (starting with `/`) and paths relative to
350 the [user](#opt-services.syncthing.user)'s home directory
351 (starting with `~/`) are allowed.
352 '';
353 };
354
355 id = mkOption {
356 type = types.str;
357 default = name;
358 description = ''
359 The ID of the folder. Must be the same on all devices.
360 '';
361 };
362
363 label = mkOption {
364 type = types.str;
365 default = name;
366 description = ''
367 The label of the folder.
368 '';
369 };
370
371 devices = mkOption {
372 type = types.listOf types.str;
373 default = [];
374 description = ''
375 The devices this folder should be shared with. Each device must
376 be defined in the [devices](#opt-services.syncthing.settings.devices) option.
377 '';
378 };
379
380 versioning = mkOption {
381 default = null;
382 description = ''
383 How to keep changed/deleted files with Syncthing.
384 There are 4 different types of versioning with different parameters.
385 See <https://docs.syncthing.net/users/versioning.html>.
386 '';
387 example = literalExpression ''
388 [
389 {
390 versioning = {
391 type = "simple";
392 params.keep = "10";
393 };
394 }
395 {
396 versioning = {
397 type = "trashcan";
398 params.cleanoutDays = "1000";
399 };
400 }
401 {
402 versioning = {
403 type = "staggered";
404 fsPath = "/syncthing/backup";
405 params = {
406 cleanInterval = "3600";
407 maxAge = "31536000";
408 };
409 };
410 }
411 {
412 versioning = {
413 type = "external";
414 params.versionsPath = pkgs.writers.writeBash "backup" '''
415 folderpath="$1"
416 filepath="$2"
417 rm -rf "$folderpath/$filepath"
418 ''';
419 };
420 }
421 ]
422 '';
423 type = with types; nullOr (submodule {
424 freeformType = settingsFormat.type;
425 options = {
426 type = mkOption {
427 type = enum [ "external" "simple" "staggered" "trashcan" ];
428 description = ''
429 The type of versioning.
430 See <https://docs.syncthing.net/users/versioning.html>.
431 '';
432 };
433 };
434 });
435 };
436
437 copyOwnershipFromParent = mkOption {
438 type = types.bool;
439 default = false;
440 description = ''
441 On Unix systems, tries to copy file/folder ownership from the parent directory (the directory it’s located in).
442 Requires running Syncthing as a privileged user, or granting it additional capabilities (e.g. CAP_CHOWN on Linux).
443 '';
444 };
445 };
446 }));
447 };
448
449 };
450 };
451 default = {};
452 description = ''
453 Extra configuration options for Syncthing.
454 See <https://docs.syncthing.net/users/config.html>.
455 Note that this attribute set does not exactly match the documented
456 xml format. Instead, this is the format of the json rest api. There
457 are slight differences. For example, this xml:
458 ```xml
459 <options>
460 <listenAddress>default</listenAddress>
461 <minHomeDiskFree unit="%">1</minHomeDiskFree>
462 </options>
463 ```
464 corresponds to the json:
465 ```json
466 {
467 options: {
468 listenAddresses = [
469 "default"
470 ];
471 minHomeDiskFree = {
472 unit = "%";
473 value = 1;
474 };
475 };
476 }
477 ```
478 '';
479 example = {
480 options.localAnnounceEnabled = false;
481 gui.theme = "black";
482 };
483 };
484
485 guiAddress = mkOption {
486 type = types.str;
487 default = "127.0.0.1:8384";
488 description = ''
489 The address to serve the web interface at.
490 '';
491 };
492
493 systemService = mkOption {
494 type = types.bool;
495 default = true;
496 description = ''
497 Whether to auto-launch Syncthing as a system service.
498 '';
499 };
500
501 user = mkOption {
502 type = types.str;
503 default = defaultUser;
504 example = "yourUser";
505 description = ''
506 The user to run Syncthing as.
507 By default, a user named `${defaultUser}` will be created whose home
508 directory is [dataDir](#opt-services.syncthing.dataDir).
509 '';
510 };
511
512 group = mkOption {
513 type = types.str;
514 default = defaultGroup;
515 example = "yourGroup";
516 description = ''
517 The group to run Syncthing under.
518 By default, a group named `${defaultGroup}` will be created.
519 '';
520 };
521
522 all_proxy = mkOption {
523 type = with types; nullOr str;
524 default = null;
525 example = "socks5://address.com:1234";
526 description = ''
527 Overwrites the all_proxy environment variable for the Syncthing process to
528 the given value. This is normally used to let Syncthing connect
529 through a SOCKS5 proxy server.
530 See <https://docs.syncthing.net/users/proxying.html>.
531 '';
532 };
533
534 dataDir = mkOption {
535 type = types.path;
536 default = "/var/lib/syncthing";
537 example = "/home/yourUser";
538 description = ''
539 The path where synchronised directories will exist.
540 '';
541 };
542
543 configDir = let
544 cond = versionAtLeast config.system.stateVersion "19.03";
545 in mkOption {
546 type = types.path;
547 description = ''
548 The path where the settings and keys will exist.
549 '';
550 default = cfg.dataDir + optionalString cond "/.config/syncthing";
551 defaultText = literalMD ''
552 * if `stateVersion >= 19.03`:
553
554 config.${opt.dataDir} + "/.config/syncthing"
555 * otherwise:
556
557 config.${opt.dataDir}
558 '';
559 };
560
561 databaseDir = mkOption {
562 type = types.path;
563 description = ''
564 The directory containing the database and logs.
565 '';
566 default = cfg.configDir;
567 defaultText = literalExpression "config.${opt.configDir}";
568 };
569
570 extraFlags = mkOption {
571 type = types.listOf types.str;
572 default = [];
573 example = [ "--reset-deltas" ];
574 description = ''
575 Extra flags passed to the syncthing command in the service definition.
576 '';
577 };
578
579 openDefaultPorts = mkOption {
580 type = types.bool;
581 default = false;
582 example = true;
583 description = ''
584 Whether to open the default ports in the firewall: TCP/UDP 22000 for transfers
585 and UDP 21027 for discovery.
586
587 If multiple users are running Syncthing on this machine, you will need
588 to manually open a set of ports for each instance and leave this disabled.
589 Alternatively, if you are running only a single instance on this machine
590 using the default ports, enable this.
591 '';
592 };
593
594 package = mkPackageOption pkgs "syncthing" { };
595 };
596 };
597
598 imports = [
599 (mkRemovedOptionModule [ "services" "syncthing" "useInotify" ] ''
600 This option was removed because Syncthing now has the inotify functionality included under the name "fswatcher".
601 It can be enabled on a per-folder basis through the web interface.
602 '')
603 (mkRenamedOptionModule [ "services" "syncthing" "extraOptions" ] [ "services" "syncthing" "settings" ])
604 (mkRenamedOptionModule [ "services" "syncthing" "folders" ] [ "services" "syncthing" "settings" "folders" ])
605 (mkRenamedOptionModule [ "services" "syncthing" "devices" ] [ "services" "syncthing" "settings" "devices" ])
606 (mkRenamedOptionModule [ "services" "syncthing" "options" ] [ "services" "syncthing" "settings" "options" ])
607 ] ++ map (o:
608 mkRenamedOptionModule [ "services" "syncthing" "declarative" o ] [ "services" "syncthing" o ]
609 ) [ "cert" "key" "devices" "folders" "overrideDevices" "overrideFolders" "extraOptions"];
610
611 ###### implementation
612
613 config = mkIf cfg.enable {
614
615 networking.firewall = mkIf cfg.openDefaultPorts {
616 allowedTCPPorts = [ 22000 ];
617 allowedUDPPorts = [ 21027 22000 ];
618 };
619
620 systemd.packages = [ pkgs.syncthing ];
621
622 users.users = mkIf (cfg.systemService && cfg.user == defaultUser) {
623 ${defaultUser} =
624 { group = cfg.group;
625 home = cfg.dataDir;
626 createHome = true;
627 uid = config.ids.uids.syncthing;
628 description = "Syncthing daemon user";
629 };
630 };
631
632 users.groups = mkIf (cfg.systemService && cfg.group == defaultGroup) {
633 ${defaultGroup}.gid =
634 config.ids.gids.syncthing;
635 };
636
637 systemd.services = {
638 # upstream reference:
639 # https://github.com/syncthing/syncthing/blob/main/etc/linux-systemd/system/syncthing%40.service
640 syncthing = mkIf cfg.systemService {
641 description = "Syncthing service";
642 after = [ "network.target" ];
643 environment = {
644 STNORESTART = "yes";
645 STNOUPGRADE = "yes";
646 inherit (cfg) all_proxy;
647 } // config.networking.proxy.envVars;
648 wantedBy = [ "multi-user.target" ];
649 serviceConfig = {
650 Restart = "on-failure";
651 SuccessExitStatus = "3 4";
652 RestartForceExitStatus="3 4";
653 User = cfg.user;
654 Group = cfg.group;
655 ExecStartPre = mkIf (cfg.cert != null || cfg.key != null)
656 "+${pkgs.writers.writeBash "syncthing-copy-keys" ''
657 install -dm700 -o ${cfg.user} -g ${cfg.group} ${cfg.configDir}
658 ${optionalString (cfg.cert != null) ''
659 install -Dm400 -o ${cfg.user} -g ${cfg.group} ${toString cfg.cert} ${cfg.configDir}/cert.pem
660 ''}
661 ${optionalString (cfg.key != null) ''
662 install -Dm400 -o ${cfg.user} -g ${cfg.group} ${toString cfg.key} ${cfg.configDir}/key.pem
663 ''}
664 ''}"
665 ;
666 ExecStart = ''
667 ${cfg.package}/bin/syncthing \
668 -no-browser \
669 -gui-address=${if isUnixGui then "unix://" else ""}${cfg.guiAddress} \
670 -config=${cfg.configDir} \
671 -data=${cfg.databaseDir} \
672 ${escapeShellArgs cfg.extraFlags}
673 '';
674 MemoryDenyWriteExecute = true;
675 NoNewPrivileges = true;
676 PrivateDevices = true;
677 PrivateMounts = true;
678 PrivateTmp = true;
679 PrivateUsers = true;
680 ProtectControlGroups = true;
681 ProtectHostname = true;
682 ProtectKernelModules = true;
683 ProtectKernelTunables = true;
684 RestrictNamespaces = true;
685 RestrictRealtime = true;
686 RestrictSUIDSGID = true;
687 CapabilityBoundingSet = [
688 "~CAP_SYS_PTRACE" "~CAP_SYS_ADMIN"
689 "~CAP_SETGID" "~CAP_SETUID" "~CAP_SETPCAP"
690 "~CAP_SYS_TIME" "~CAP_KILL"
691 ];
692 };
693 };
694 syncthing-init = mkIf (cleanedConfig != {}) {
695 description = "Syncthing configuration updater";
696 requisite = [ "syncthing.service" ];
697 after = [ "syncthing.service" ];
698 wantedBy = [ "multi-user.target" ];
699
700 serviceConfig = {
701 User = cfg.user;
702 RemainAfterExit = true;
703 RuntimeDirectory = "syncthing-init";
704 Type = "oneshot";
705 ExecStart = updateConfig;
706 };
707 };
708
709 syncthing-resume = {
710 wantedBy = [ "suspend.target" ];
711 };
712 };
713 };
714}