at 24.11-pre 26 kB view raw
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 its 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}