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
11 devices = mapAttrsToList (name: device: {
12 deviceID = device.id;
13 inherit (device) name addresses introducer autoAcceptFolders;
14 }) cfg.devices;
15
16 folders = mapAttrsToList ( _: folder: {
17 inherit (folder) path id label type;
18 devices = map (device: { deviceId = cfg.devices.${device}.id; }) folder.devices;
19 rescanIntervalS = folder.rescanInterval;
20 fsWatcherEnabled = folder.watch;
21 fsWatcherDelayS = folder.watchDelay;
22 ignorePerms = folder.ignorePerms;
23 ignoreDelete = folder.ignoreDelete;
24 versioning = folder.versioning;
25 }) (filterAttrs (
26 _: folder:
27 folder.enable
28 ) cfg.folders);
29
30 updateConfig = pkgs.writers.writeDash "merge-syncthing-config" ''
31 set -efu
32
33 # be careful not to leak secrets in the filesystem or in process listings
34
35 umask 0077
36
37 # get the api key by parsing the config.xml
38 while
39 ! ${pkgs.libxml2}/bin/xmllint \
40 --xpath 'string(configuration/gui/apikey)' \
41 ${cfg.configDir}/config.xml \
42 >"$RUNTIME_DIRECTORY/api_key"
43 do sleep 1; done
44
45 (printf "X-API-Key: "; cat "$RUNTIME_DIRECTORY/api_key") >"$RUNTIME_DIRECTORY/headers"
46
47 curl() {
48 ${pkgs.curl}/bin/curl -sSLk -H "@$RUNTIME_DIRECTORY/headers" \
49 --retry 1000 --retry-delay 1 --retry-all-errors \
50 "$@"
51 }
52
53 # query the old config
54 old_cfg=$(curl ${cfg.guiAddress}/rest/config)
55
56 # generate the new config by merging with the NixOS config options
57 new_cfg=$(printf '%s\n' "$old_cfg" | ${pkgs.jq}/bin/jq -c '. * {
58 "devices": (${builtins.toJSON devices}${optionalString (cfg.devices == {} || ! cfg.overrideDevices) " + .devices"}),
59 "folders": (${builtins.toJSON folders}${optionalString (cfg.folders == {} || ! cfg.overrideFolders) " + .folders"})
60 } * ${builtins.toJSON cfg.extraOptions}')
61
62 # send the new config
63 curl -X PUT -d "$new_cfg" ${cfg.guiAddress}/rest/config
64
65 # restart Syncthing if required
66 if curl ${cfg.guiAddress}/rest/config/restart-required |
67 ${pkgs.jq}/bin/jq -e .requiresRestart > /dev/null; then
68 curl -X POST ${cfg.guiAddress}/rest/system/restart
69 fi
70 '';
71in {
72 ###### interface
73 options = {
74 services.syncthing = {
75
76 enable = mkEnableOption
77 (lib.mdDoc "Syncthing, a self-hosted open-source alternative to Dropbox and Bittorrent Sync");
78
79 cert = mkOption {
80 type = types.nullOr types.str;
81 default = null;
82 description = mdDoc ''
83 Path to the `cert.pem` file, which will be copied into Syncthing's
84 [configDir](#opt-services.syncthing.configDir).
85 '';
86 };
87
88 key = mkOption {
89 type = types.nullOr types.str;
90 default = null;
91 description = mdDoc ''
92 Path to the `key.pem` file, which will be copied into Syncthing's
93 [configDir](#opt-services.syncthing.configDir).
94 '';
95 };
96
97 overrideDevices = mkOption {
98 type = types.bool;
99 default = true;
100 description = mdDoc ''
101 Whether to delete the devices which are not configured via the
102 [devices](#opt-services.syncthing.devices) option.
103 If set to `false`, devices added via the web
104 interface will persist and will have to be deleted manually.
105 '';
106 };
107
108 devices = mkOption {
109 default = {};
110 description = mdDoc ''
111 Peers/devices which Syncthing should communicate with.
112
113 Note that you can still add devices manually, but those changes
114 will be reverted on restart if [overrideDevices](#opt-services.syncthing.overrideDevices)
115 is enabled.
116 '';
117 example = {
118 bigbox = {
119 id = "7CFNTQM-IMTJBHJ-3UWRDIU-ZGQJFR6-VCXZ3NB-XUH3KZO-N52ITXR-LAIYUAU";
120 addresses = [ "tcp://192.168.0.10:51820" ];
121 };
122 };
123 type = types.attrsOf (types.submodule ({ name, ... }: {
124 options = {
125
126 name = mkOption {
127 type = types.str;
128 default = name;
129 description = lib.mdDoc ''
130 The name of the device.
131 '';
132 };
133
134 addresses = mkOption {
135 type = types.listOf types.str;
136 default = [];
137 description = lib.mdDoc ''
138 The addresses used to connect to the device.
139 If this is left empty, dynamic configuration is attempted.
140 '';
141 };
142
143 id = mkOption {
144 type = types.str;
145 description = mdDoc ''
146 The device ID. See <https://docs.syncthing.net/dev/device-ids.html>.
147 '';
148 };
149
150 introducer = mkOption {
151 type = types.bool;
152 default = false;
153 description = mdDoc ''
154 Whether the device should act as an introducer and be allowed
155 to add folders on this computer.
156 See <https://docs.syncthing.net/users/introducer.html>.
157 '';
158 };
159
160 autoAcceptFolders = mkOption {
161 type = types.bool;
162 default = false;
163 description = mdDoc ''
164 Automatically create or share folders that this device advertises at the default path.
165 See <https://docs.syncthing.net/users/config.html?highlight=autoaccept#config-file-format>.
166 '';
167 };
168
169 };
170 }));
171 };
172
173 overrideFolders = mkOption {
174 type = types.bool;
175 default = true;
176 description = mdDoc ''
177 Whether to delete the folders which are not configured via the
178 [folders](#opt-services.syncthing.folders) option.
179 If set to `false`, folders added via the web
180 interface will persist and will have to be deleted manually.
181 '';
182 };
183
184 folders = mkOption {
185 default = {};
186 description = mdDoc ''
187 Folders which should be shared by Syncthing.
188
189 Note that you can still add folders manually, but those changes
190 will be reverted on restart if [overrideFolders](#opt-services.syncthing.overrideFolders)
191 is enabled.
192 '';
193 example = literalExpression ''
194 {
195 "/home/user/sync" = {
196 id = "syncme";
197 devices = [ "bigbox" ];
198 };
199 }
200 '';
201 type = types.attrsOf (types.submodule ({ name, ... }: {
202 options = {
203
204 enable = mkOption {
205 type = types.bool;
206 default = true;
207 description = lib.mdDoc ''
208 Whether to share this folder.
209 This option is useful when you want to define all folders
210 in one place, but not every machine should share all folders.
211 '';
212 };
213
214 path = mkOption {
215 # TODO for release 23.05: allow relative paths again and set
216 # working directory to cfg.dataDir
217 type = types.str // {
218 check = x: types.str.check x && (substring 0 1 x == "/" || substring 0 2 x == "~/");
219 description = types.str.description + " starting with / or ~/";
220 };
221 default = name;
222 description = lib.mdDoc ''
223 The path to the folder which should be shared.
224 Only absolute paths (starting with `/`) and paths relative to
225 the [user](#opt-services.syncthing.user)'s home directory
226 (starting with `~/`) are allowed.
227 '';
228 };
229
230 id = mkOption {
231 type = types.str;
232 default = name;
233 description = lib.mdDoc ''
234 The ID of the folder. Must be the same on all devices.
235 '';
236 };
237
238 label = mkOption {
239 type = types.str;
240 default = name;
241 description = lib.mdDoc ''
242 The label of the folder.
243 '';
244 };
245
246 devices = mkOption {
247 type = types.listOf types.str;
248 default = [];
249 description = mdDoc ''
250 The devices this folder should be shared with. Each device must
251 be defined in the [devices](#opt-services.syncthing.devices) option.
252 '';
253 };
254
255 versioning = mkOption {
256 default = null;
257 description = mdDoc ''
258 How to keep changed/deleted files with Syncthing.
259 There are 4 different types of versioning with different parameters.
260 See <https://docs.syncthing.net/users/versioning.html>.
261 '';
262 example = literalExpression ''
263 [
264 {
265 versioning = {
266 type = "simple";
267 params.keep = "10";
268 };
269 }
270 {
271 versioning = {
272 type = "trashcan";
273 params.cleanoutDays = "1000";
274 };
275 }
276 {
277 versioning = {
278 type = "staggered";
279 fsPath = "/syncthing/backup";
280 params = {
281 cleanInterval = "3600";
282 maxAge = "31536000";
283 };
284 };
285 }
286 {
287 versioning = {
288 type = "external";
289 params.versionsPath = pkgs.writers.writeBash "backup" '''
290 folderpath="$1"
291 filepath="$2"
292 rm -rf "$folderpath/$filepath"
293 ''';
294 };
295 }
296 ]
297 '';
298 type = with types; nullOr (submodule {
299 options = {
300 type = mkOption {
301 type = enum [ "external" "simple" "staggered" "trashcan" ];
302 description = mdDoc ''
303 The type of versioning.
304 See <https://docs.syncthing.net/users/versioning.html>.
305 '';
306 };
307 fsPath = mkOption {
308 default = "";
309 type = either str path;
310 description = mdDoc ''
311 Path to the versioning folder.
312 See <https://docs.syncthing.net/users/versioning.html>.
313 '';
314 };
315 params = mkOption {
316 type = attrsOf (either str path);
317 description = mdDoc ''
318 The parameters for versioning. Structure depends on
319 [versioning.type](#opt-services.syncthing.folders._name_.versioning.type).
320 See <https://docs.syncthing.net/users/versioning.html>.
321 '';
322 };
323 };
324 });
325 };
326
327 rescanInterval = mkOption {
328 type = types.int;
329 default = 3600;
330 description = lib.mdDoc ''
331 How often the folder should be rescanned for changes.
332 '';
333 };
334
335 type = mkOption {
336 type = types.enum [ "sendreceive" "sendonly" "receiveonly" "receiveencrypted" ];
337 default = "sendreceive";
338 description = lib.mdDoc ''
339 Whether to only send changes for this folder, only receive them
340 or both. `receiveencrypted` can be used for untrusted devices. See
341 <https://docs.syncthing.net/users/untrusted.html> for reference.
342 '';
343 };
344
345 watch = mkOption {
346 type = types.bool;
347 default = true;
348 description = lib.mdDoc ''
349 Whether the folder should be watched for changes by inotify.
350 '';
351 };
352
353 watchDelay = mkOption {
354 type = types.int;
355 default = 10;
356 description = lib.mdDoc ''
357 The delay after an inotify event is triggered.
358 '';
359 };
360
361 ignorePerms = mkOption {
362 type = types.bool;
363 default = true;
364 description = lib.mdDoc ''
365 Whether to ignore permission changes.
366 '';
367 };
368
369 ignoreDelete = mkOption {
370 type = types.bool;
371 default = false;
372 description = mdDoc ''
373 Whether to skip deleting files that are deleted by peers.
374 See <https://docs.syncthing.net/advanced/folder-ignoredelete.html>.
375 '';
376 };
377 };
378 }));
379 };
380
381 extraOptions = mkOption {
382 type = types.addCheck (pkgs.formats.json {}).type isAttrs;
383 default = {};
384 description = mdDoc ''
385 Extra configuration options for Syncthing.
386 See <https://docs.syncthing.net/users/config.html>.
387 Note that this attribute set does not exactly match the documented
388 xml format. Instead, this is the format of the json rest api. There
389 are slight differences. For example, this xml:
390 ```xml
391 <options>
392 <listenAddress>default</listenAddress>
393 <minHomeDiskFree unit="%">1</minHomeDiskFree>
394 </options>
395 ```
396 corresponds to the json:
397 ```json
398 {
399 options: {
400 listenAddresses = [
401 "default"
402 ];
403 minHomeDiskFree = {
404 unit = "%";
405 value = 1;
406 };
407 };
408 }
409 ```
410 '';
411 example = {
412 options.localAnnounceEnabled = false;
413 gui.theme = "black";
414 };
415 };
416
417 guiAddress = mkOption {
418 type = types.str;
419 default = "127.0.0.1:8384";
420 description = lib.mdDoc ''
421 The address to serve the web interface at.
422 '';
423 };
424
425 systemService = mkOption {
426 type = types.bool;
427 default = true;
428 description = lib.mdDoc ''
429 Whether to auto-launch Syncthing as a system service.
430 '';
431 };
432
433 user = mkOption {
434 type = types.str;
435 default = defaultUser;
436 example = "yourUser";
437 description = mdDoc ''
438 The user to run Syncthing as.
439 By default, a user named `${defaultUser}` will be created whose home
440 directory is [dataDir](#opt-services.syncthing.dataDir).
441 '';
442 };
443
444 group = mkOption {
445 type = types.str;
446 default = defaultGroup;
447 example = "yourGroup";
448 description = mdDoc ''
449 The group to run Syncthing under.
450 By default, a group named `${defaultGroup}` will be created.
451 '';
452 };
453
454 all_proxy = mkOption {
455 type = with types; nullOr str;
456 default = null;
457 example = "socks5://address.com:1234";
458 description = mdDoc ''
459 Overwrites the all_proxy environment variable for the Syncthing process to
460 the given value. This is normally used to let Syncthing connect
461 through a SOCKS5 proxy server.
462 See <https://docs.syncthing.net/users/proxying.html>.
463 '';
464 };
465
466 dataDir = mkOption {
467 type = types.path;
468 default = "/var/lib/syncthing";
469 example = "/home/yourUser";
470 description = lib.mdDoc ''
471 The path where synchronised directories will exist.
472 '';
473 };
474
475 configDir = let
476 cond = versionAtLeast config.system.stateVersion "19.03";
477 in mkOption {
478 type = types.path;
479 description = lib.mdDoc ''
480 The path where the settings and keys will exist.
481 '';
482 default = cfg.dataDir + optionalString cond "/.config/syncthing";
483 defaultText = literalMD ''
484 * if `stateVersion >= 19.03`:
485
486 config.${opt.dataDir} + "/.config/syncthing"
487 * otherwise:
488
489 config.${opt.dataDir}
490 '';
491 };
492
493 extraFlags = mkOption {
494 type = types.listOf types.str;
495 default = [];
496 example = [ "--reset-deltas" ];
497 description = lib.mdDoc ''
498 Extra flags passed to the syncthing command in the service definition.
499 '';
500 };
501
502 openDefaultPorts = mkOption {
503 type = types.bool;
504 default = false;
505 example = true;
506 description = lib.mdDoc ''
507 Whether to open the default ports in the firewall: TCP/UDP 22000 for transfers
508 and UDP 21027 for discovery.
509
510 If multiple users are running Syncthing on this machine, you will need
511 to manually open a set of ports for each instance and leave this disabled.
512 Alternatively, if you are running only a single instance on this machine
513 using the default ports, enable this.
514 '';
515 };
516
517 package = mkOption {
518 type = types.package;
519 default = pkgs.syncthing;
520 defaultText = literalExpression "pkgs.syncthing";
521 description = lib.mdDoc ''
522 The Syncthing package to use.
523 '';
524 };
525 };
526 };
527
528 imports = [
529 (mkRemovedOptionModule [ "services" "syncthing" "useInotify" ] ''
530 This option was removed because Syncthing now has the inotify functionality included under the name "fswatcher".
531 It can be enabled on a per-folder basis through the web interface.
532 '')
533 ] ++ map (o:
534 mkRenamedOptionModule [ "services" "syncthing" "declarative" o ] [ "services" "syncthing" o ]
535 ) [ "cert" "key" "devices" "folders" "overrideDevices" "overrideFolders" "extraOptions"];
536
537 ###### implementation
538
539 config = mkIf cfg.enable {
540
541 networking.firewall = mkIf cfg.openDefaultPorts {
542 allowedTCPPorts = [ 22000 ];
543 allowedUDPPorts = [ 21027 22000 ];
544 };
545
546 systemd.packages = [ pkgs.syncthing ];
547
548 users.users = mkIf (cfg.systemService && cfg.user == defaultUser) {
549 ${defaultUser} =
550 { group = cfg.group;
551 home = cfg.dataDir;
552 createHome = true;
553 uid = config.ids.uids.syncthing;
554 description = "Syncthing daemon user";
555 };
556 };
557
558 users.groups = mkIf (cfg.systemService && cfg.group == defaultGroup) {
559 ${defaultGroup}.gid =
560 config.ids.gids.syncthing;
561 };
562
563 systemd.services = {
564 # upstream reference:
565 # https://github.com/syncthing/syncthing/blob/main/etc/linux-systemd/system/syncthing%40.service
566 syncthing = mkIf cfg.systemService {
567 description = "Syncthing service";
568 after = [ "network.target" ];
569 environment = {
570 STNORESTART = "yes";
571 STNOUPGRADE = "yes";
572 inherit (cfg) all_proxy;
573 } // config.networking.proxy.envVars;
574 wantedBy = [ "multi-user.target" ];
575 serviceConfig = {
576 Restart = "on-failure";
577 SuccessExitStatus = "3 4";
578 RestartForceExitStatus="3 4";
579 User = cfg.user;
580 Group = cfg.group;
581 ExecStartPre = mkIf (cfg.cert != null || cfg.key != null)
582 "+${pkgs.writers.writeBash "syncthing-copy-keys" ''
583 install -dm700 -o ${cfg.user} -g ${cfg.group} ${cfg.configDir}
584 ${optionalString (cfg.cert != null) ''
585 install -Dm400 -o ${cfg.user} -g ${cfg.group} ${toString cfg.cert} ${cfg.configDir}/cert.pem
586 ''}
587 ${optionalString (cfg.key != null) ''
588 install -Dm400 -o ${cfg.user} -g ${cfg.group} ${toString cfg.key} ${cfg.configDir}/key.pem
589 ''}
590 ''}"
591 ;
592 ExecStart = ''
593 ${cfg.package}/bin/syncthing \
594 -no-browser \
595 -gui-address=${cfg.guiAddress} \
596 -home=${cfg.configDir} ${escapeShellArgs cfg.extraFlags}
597 '';
598 MemoryDenyWriteExecute = true;
599 NoNewPrivileges = true;
600 PrivateDevices = true;
601 PrivateMounts = true;
602 PrivateTmp = true;
603 PrivateUsers = true;
604 ProtectControlGroups = true;
605 ProtectHostname = true;
606 ProtectKernelModules = true;
607 ProtectKernelTunables = true;
608 RestrictNamespaces = true;
609 RestrictRealtime = true;
610 RestrictSUIDSGID = true;
611 CapabilityBoundingSet = [
612 "~CAP_SYS_PTRACE" "~CAP_SYS_ADMIN"
613 "~CAP_SETGID" "~CAP_SETUID" "~CAP_SETPCAP"
614 "~CAP_SYS_TIME" "~CAP_KILL"
615 ];
616 };
617 };
618 syncthing-init = mkIf (
619 cfg.devices != {} || cfg.folders != {} || cfg.extraOptions != {}
620 ) {
621 description = "Syncthing configuration updater";
622 requisite = [ "syncthing.service" ];
623 after = [ "syncthing.service" ];
624 wantedBy = [ "multi-user.target" ];
625
626 serviceConfig = {
627 User = cfg.user;
628 RemainAfterExit = true;
629 RuntimeDirectory = "syncthing-init";
630 Type = "oneshot";
631 ExecStart = updateConfig;
632 };
633 };
634
635 syncthing-resume = {
636 wantedBy = [ "suspend.target" ];
637 };
638 };
639 };
640}