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 '';
388 example = {
389 options.localAnnounceEnabled = false;
390 gui.theme = "black";
391 };
392 };
393
394 guiAddress = mkOption {
395 type = types.str;
396 default = "127.0.0.1:8384";
397 description = lib.mdDoc ''
398 The address to serve the web interface at.
399 '';
400 };
401
402 systemService = mkOption {
403 type = types.bool;
404 default = true;
405 description = lib.mdDoc ''
406 Whether to auto-launch Syncthing as a system service.
407 '';
408 };
409
410 user = mkOption {
411 type = types.str;
412 default = defaultUser;
413 example = "yourUser";
414 description = mdDoc ''
415 The user to run Syncthing as.
416 By default, a user named `${defaultUser}` will be created whose home
417 directory is [dataDir](#opt-services.syncthing.dataDir).
418 '';
419 };
420
421 group = mkOption {
422 type = types.str;
423 default = defaultGroup;
424 example = "yourGroup";
425 description = mdDoc ''
426 The group to run Syncthing under.
427 By default, a group named `${defaultGroup}` will be created.
428 '';
429 };
430
431 all_proxy = mkOption {
432 type = with types; nullOr str;
433 default = null;
434 example = "socks5://address.com:1234";
435 description = mdDoc ''
436 Overwrites the all_proxy environment variable for the Syncthing process to
437 the given value. This is normally used to let Syncthing connect
438 through a SOCKS5 proxy server.
439 See <https://docs.syncthing.net/users/proxying.html>.
440 '';
441 };
442
443 dataDir = mkOption {
444 type = types.path;
445 default = "/var/lib/syncthing";
446 example = "/home/yourUser";
447 description = lib.mdDoc ''
448 The path where synchronised directories will exist.
449 '';
450 };
451
452 configDir = let
453 cond = versionAtLeast config.system.stateVersion "19.03";
454 in mkOption {
455 type = types.path;
456 description = lib.mdDoc ''
457 The path where the settings and keys will exist.
458 '';
459 default = cfg.dataDir + optionalString cond "/.config/syncthing";
460 defaultText = literalMD ''
461 * if `stateVersion >= 19.03`:
462
463 config.${opt.dataDir} + "/.config/syncthing"
464 * otherwise:
465
466 config.${opt.dataDir}
467 '';
468 };
469
470 extraFlags = mkOption {
471 type = types.listOf types.str;
472 default = [];
473 example = [ "--reset-deltas" ];
474 description = lib.mdDoc ''
475 Extra flags passed to the syncthing command in the service definition.
476 '';
477 };
478
479 openDefaultPorts = mkOption {
480 type = types.bool;
481 default = false;
482 example = true;
483 description = lib.mdDoc ''
484 Whether to open the default ports in the firewall: TCP/UDP 22000 for transfers
485 and UDP 21027 for discovery.
486
487 If multiple users are running Syncthing on this machine, you will need
488 to manually open a set of ports for each instance and leave this disabled.
489 Alternatively, if you are running only a single instance on this machine
490 using the default ports, enable this.
491 '';
492 };
493
494 package = mkOption {
495 type = types.package;
496 default = pkgs.syncthing;
497 defaultText = literalExpression "pkgs.syncthing";
498 description = lib.mdDoc ''
499 The Syncthing package to use.
500 '';
501 };
502 };
503 };
504
505 imports = [
506 (mkRemovedOptionModule [ "services" "syncthing" "useInotify" ] ''
507 This option was removed because Syncthing now has the inotify functionality included under the name "fswatcher".
508 It can be enabled on a per-folder basis through the web interface.
509 '')
510 ] ++ map (o:
511 mkRenamedOptionModule [ "services" "syncthing" "declarative" o ] [ "services" "syncthing" o ]
512 ) [ "cert" "key" "devices" "folders" "overrideDevices" "overrideFolders" "extraOptions"];
513
514 ###### implementation
515
516 config = mkIf cfg.enable {
517
518 networking.firewall = mkIf cfg.openDefaultPorts {
519 allowedTCPPorts = [ 22000 ];
520 allowedUDPPorts = [ 21027 22000 ];
521 };
522
523 systemd.packages = [ pkgs.syncthing ];
524
525 users.users = mkIf (cfg.systemService && cfg.user == defaultUser) {
526 ${defaultUser} =
527 { group = cfg.group;
528 home = cfg.dataDir;
529 createHome = true;
530 uid = config.ids.uids.syncthing;
531 description = "Syncthing daemon user";
532 };
533 };
534
535 users.groups = mkIf (cfg.systemService && cfg.group == defaultGroup) {
536 ${defaultGroup}.gid =
537 config.ids.gids.syncthing;
538 };
539
540 systemd.services = {
541 # upstream reference:
542 # https://github.com/syncthing/syncthing/blob/main/etc/linux-systemd/system/syncthing%40.service
543 syncthing = mkIf cfg.systemService {
544 description = "Syncthing service";
545 after = [ "network.target" ];
546 environment = {
547 STNORESTART = "yes";
548 STNOUPGRADE = "yes";
549 inherit (cfg) all_proxy;
550 } // config.networking.proxy.envVars;
551 wantedBy = [ "multi-user.target" ];
552 serviceConfig = {
553 Restart = "on-failure";
554 SuccessExitStatus = "3 4";
555 RestartForceExitStatus="3 4";
556 User = cfg.user;
557 Group = cfg.group;
558 ExecStartPre = mkIf (cfg.cert != null || cfg.key != null)
559 "+${pkgs.writers.writeBash "syncthing-copy-keys" ''
560 install -dm700 -o ${cfg.user} -g ${cfg.group} ${cfg.configDir}
561 ${optionalString (cfg.cert != null) ''
562 install -Dm400 -o ${cfg.user} -g ${cfg.group} ${toString cfg.cert} ${cfg.configDir}/cert.pem
563 ''}
564 ${optionalString (cfg.key != null) ''
565 install -Dm400 -o ${cfg.user} -g ${cfg.group} ${toString cfg.key} ${cfg.configDir}/key.pem
566 ''}
567 ''}"
568 ;
569 ExecStart = ''
570 ${cfg.package}/bin/syncthing \
571 -no-browser \
572 -gui-address=${cfg.guiAddress} \
573 -home=${cfg.configDir} ${escapeShellArgs cfg.extraFlags}
574 '';
575 MemoryDenyWriteExecute = true;
576 NoNewPrivileges = true;
577 PrivateDevices = true;
578 PrivateMounts = true;
579 PrivateTmp = true;
580 PrivateUsers = true;
581 ProtectControlGroups = true;
582 ProtectHostname = true;
583 ProtectKernelModules = true;
584 ProtectKernelTunables = true;
585 RestrictNamespaces = true;
586 RestrictRealtime = true;
587 RestrictSUIDSGID = true;
588 CapabilityBoundingSet = [
589 "~CAP_SYS_PTRACE" "~CAP_SYS_ADMIN"
590 "~CAP_SETGID" "~CAP_SETUID" "~CAP_SETPCAP"
591 "~CAP_SYS_TIME" "~CAP_KILL"
592 ];
593 };
594 };
595 syncthing-init = mkIf (
596 cfg.devices != {} || cfg.folders != {} || cfg.extraOptions != {}
597 ) {
598 description = "Syncthing configuration updater";
599 requisite = [ "syncthing.service" ];
600 after = [ "syncthing.service" ];
601 wantedBy = [ "multi-user.target" ];
602
603 serviceConfig = {
604 User = cfg.user;
605 RemainAfterExit = true;
606 RuntimeDirectory = "syncthing-init";
607 Type = "oneshot";
608 ExecStart = updateConfig;
609 };
610 };
611
612 syncthing-resume = {
613 wantedBy = [ "suspend.target" ];
614 };
615 };
616 };
617}