diff --git a/flake.lock b/flake.lock index 36d821b..1fcb204 100644 --- a/flake.lock +++ b/flake.lock @@ -100,11 +100,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1754974548, - "narHash": "sha256-XMjUjKD/QRPcqUnmSDczSYdw46SilnG0+wkho654DFM=", + "lastModified": 1755107032, + "narHash": "sha256-ckb/RX9rJ/FslBA3K4hYAXgVW/7JdQ50Z+28XZT96zg=", "owner": "nix-community", "repo": "home-manager", - "rev": "27a26be51ff0162a8f67660239f9407dba68d7c5", + "rev": "4b6dd06c6a92308c06da5e0e55f2c505237725c9", "type": "github" }, "original": { @@ -132,11 +132,11 @@ "homebrew-cask": { "flake": false, "locked": { - "lastModified": 1755061279, - "narHash": "sha256-DRGrgAAcMPM83bfrp3/pMIw7D3JxdBPj/wjGfB2whs0=", + "lastModified": 1755104931, + "narHash": "sha256-jtXcymAnYH/hCvEGaUlt5vpFwqx00/r5Wly9UCqy7vQ=", "owner": "homebrew", "repo": "homebrew-cask", - "rev": "bfac3b4fa44cedc18dbcc2bb1efd52e7a129fd8a", + "rev": "53dc289ce38d2561d853b482d0f03914a7f2f985", "type": "github" }, "original": { @@ -148,11 +148,11 @@ "homebrew-core": { "flake": false, "locked": { - "lastModified": 1755063843, - "narHash": "sha256-h+EckB4MLcQFVgM9sgHAP+fuvmzJOhKAvHcE8Wt2dyg=", + "lastModified": 1755104716, + "narHash": "sha256-6jp9InEQfaVJR4kSdwb0+D6603DanSUEFF3uYYPIQVM=", "owner": "homebrew", "repo": "homebrew-core", - "rev": "d70d9dc733ea84f025ee1d40d24a40b861454887", + "rev": "8aa4cd22422bcb03de07c08c24773257d928e988", "type": "github" }, "original": { @@ -240,11 +240,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1755064407, - "narHash": "sha256-kx0aQ9wtm7966jTZXkFYMk+fcr3kZ+gjdULvRiIVxRQ=", + "lastModified": 1755109924, + "narHash": "sha256-2xroOWuRFMLvqknLQJ8gmpdXuvg9t5qDUJ3KQKq5/GE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "ecaec3fd41536abe3ee2a68f3ae0958036ce92cc", + "rev": "b7f46264dd0a1891d52c9a8b919c2eebbc527638", "type": "github" }, "original": { @@ -278,11 +278,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1755043294, - "narHash": "sha256-X5q/ztJ0PHScZGF88nD1EtY/x2Ob9Dtzj/PleOtjmQg=", + "lastModified": 1755095763, + "narHash": "sha256-cFwtMaONA4uKYk/rBrmFvIAQieZxZytoprzIblTn1HA=", "owner": "nix-community", "repo": "nixvim", - "rev": "1d4816820c8efb731a8b967581db916728fbd9e2", + "rev": "ecc7880e00a2a735074243d8a664a931d73beace", "type": "github" }, "original": { diff --git a/modules/platform/darwin/default.nix b/modules/platform/darwin/default.nix index 96b2b6b..85605f8 100644 --- a/modules/platform/darwin/default.nix +++ b/modules/platform/darwin/default.nix @@ -10,6 +10,7 @@ imports = [ ../../core ../../networking/tailscale.nix + ../../services/syncthing-darwin.nix ../../services/syncthing.nix ./dock ./homebrew.nix diff --git a/modules/services/syncthing-darwin.nix b/modules/services/syncthing-darwin.nix new file mode 100644 index 0000000..0cb4926 --- /dev/null +++ b/modules/services/syncthing-darwin.nix @@ -0,0 +1,386 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.syncthing; + defaultUser = "syncthing"; + defaultGroup = defaultUser; + settingsFormat = pkgs.formats.json { }; + cleanedConfig = converge (filterAttrsRecursive (_: v: v != null && v != { })) cfg.settings; + + isUnixGui = (builtins.substring 0 1 cfg.guiAddress) == "/"; + + curlAddressArgs = path: + if isUnixGui + then "--unix-socket ${cfg.guiAddress} http://.${path}" + else "${cfg.guiAddress}${path}"; + + devices = mapAttrsToList (_: device: device // { deviceID = device.id; }) cfg.settings.devices; + anyAutoAccept = builtins.any (dev: dev.autoAcceptFolders) devices; + + folders = mapAttrsToList (_: folder: folder // { + devices = let + folderDevices = folder.devices; + in + map (device: + if builtins.isString device then + { deviceId = cfg.settings.devices.${device}.id; } + else if builtins.isAttrs device then + { deviceId = cfg.settings.devices.${device.name}.id; } // device + else + throw "Invalid type for devices in folder; expected list or attrset." + ) folderDevices; + }) (filterAttrs (_: folder: folder.enable) cfg.settings.folders); + + jq = "${pkgs.jq}/bin/jq"; + updateConfig = pkgs.writers.writeBash "merge-syncthing-config" ( + '' + set -efu + umask 0077 + + curl() { + while + ! ${pkgs.libxml2}/bin/xmllint \ + --xpath 'string(configuration/gui/apikey)' \ + ${cfg.configDir}/config.xml \ + >"$TMPDIR/api_key" + do sleep 1; done + (printf "X-API-Key: "; cat "$TMPDIR/api_key") >"$TMPDIR/headers" + ${pkgs.curl}/bin/curl -sSLk -H "@$TMPDIR/headers" \ + --retry 1000 --retry-delay 1 --retry-all-errors \ + "$@" + } + '' + + (lib.pipe { + 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"; + }; + } [ + (mapAttrs (conf_type: s: + lib.pipe s.conf [ + (map (new_cfg: + let + jsonPreSecretsFile = pkgs.writeTextFile { + name = "${conf_type}-${new_cfg.id}-conf-pre-secrets.json"; + text = builtins.toJSON new_cfg; + }; + injectSecretsJqCmd = { + "devs" = "${jq} ."; + "dirs" = let + folder = new_cfg; + devicesWithSecrets = lib.pipe folder.devices [ + (lib.filter (device: (builtins.isAttrs device) && device ? encryptionPasswordFile)) + (map (device: { + deviceId = device.deviceId; + variableName = "secret_${builtins.hashString "sha256" device.encryptionPasswordFile}"; + secretPath = device.encryptionPasswordFile; + })) + ]; + jqUpdates = map (device: '' + .devices[] |= ( + if .deviceId == "${device.deviceId}" then + del(.encryptionPasswordFile) | + .encryptionPassword = ''$${device.variableName} + else + . + end + ) + '') devicesWithSecrets; + jqRawFiles = map (device: "--rawfile ${device.variableName} ${lib.escapeShellArg device.secretPath}") devicesWithSecrets; + in + "${jq} ${lib.concatStringsSep " " jqRawFiles} ${lib.escapeShellArg (lib.concatStringsSep "|" ([ "." ] ++ jqUpdates))}"; + }.${conf_type}; + in + '' + ${injectSecretsJqCmd} ${jsonPreSecretsFile} | curl --json @- -X POST ${s.baseAddress} + '' + )) + (lib.concatStringsSep "\n") + ] + + 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 + >&2 echo "Deleting stale device: $id" + curl -X DELETE ${s.baseAddress}/$id + done + '' + )) + builtins.attrValues + (lib.concatStringsSep "\n") + ]) + + (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") + ]) + + '' + if curl ${curlAddressArgs "/rest/config/restart-required"} | + ${jq} -e .requiresRestart > /dev/null; then + curl -X POST ${curlAddressArgs "/rest/system/restart"} + fi + '' + ); +in +{ + options = { + services.syncthing = { + 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 configDir."; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the key.pem file, which will be copied into Syncthing's configDir."; + }; + + overrideDevices = mkOption { + type = types.bool; + default = true; + description = "Whether to delete the devices which are not configured via the devices option."; + }; + + overrideFolders = mkOption { + type = types.bool; + default = !anyAutoAccept; + description = "Whether to delete the folders which are not configured via the folders option."; + }; + + settings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + options = { + options = mkOption { + default = { }; + description = "The options element contains all other global configuration options"; + type = types.submodule { + freeformType = settingsFormat.type; + options = { + localAnnounceEnabled = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to send announcements to the local LAN."; + }; + globalAnnounceEnabled = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to send announcements to the global discovery servers."; + }; + 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."; + }; + }; + }; + }; + + devices = mkOption { + default = { }; + description = "Peers/devices which Syncthing should communicate with."; + 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."; + }; + autoAcceptFolders = mkOption { + type = types.bool; + default = false; + description = "Automatically create or share folders that this device advertises at the default path."; + }; + }; + })); + }; + + folders = mkOption { + default = { }; + description = "Folders which should be shared by Syncthing."; + type = types.attrsOf (types.submodule ({ name, ... }: { + freeformType = settingsFormat.type; + options = { + enable = mkOption { + type = types.bool; + default = true; + description = "Whether to share this folder."; + }; + path = mkOption { + type = types.str; + default = name; + description = "The path to the folder which should be shared."; + }; + 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."; + }; + devices = mkOption { + type = types.listOf (types.oneOf [ + types.str + (types.submodule { + freeformType = settingsFormat.type; + options = { + name = mkOption { + type = types.str; + description = "The name of a device defined in the devices option."; + }; + encryptionPasswordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to encryption password file."; + }; + }; + }) + ]); + default = [ ]; + description = "The devices this folder should be shared with."; + }; + }; + })); + }; + }; + }; + default = { }; + description = "Extra configuration options for Syncthing."; + }; + + guiAddress = mkOption { + type = types.str; + default = "127.0.0.1:8384"; + description = "The address to serve the web interface at."; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = "The user to run Syncthing as."; + }; + + group = mkOption { + type = types.str; + default = defaultGroup; + description = "The group to run Syncthing under."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/syncthing"; + description = "The path where synchronised directories will exist."; + }; + + configDir = mkOption { + type = types.path; + default = cfg.dataDir + "/.config/syncthing"; + description = "The path where the settings and keys will exist."; + }; + + openDefaultPorts = mkOption { + type = types.bool; + default = false; + description = "Whether to open the default ports in the firewall (not applicable on Darwin)."; + }; + + package = mkPackageOption pkgs "syncthing" { }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(cfg.overrideFolders && anyAutoAccept); + message = "services.syncthing.overrideFolders will delete auto-accepted folders from the configuration, creating path conflicts."; + } + ]; + + environment.systemPackages = [ cfg.package ]; + + launchd.user.agents.syncthing = { + serviceConfig = { + ProgramArguments = [ + "${cfg.package}/bin/syncthing" + "-no-browser" + "-gui-address=${if isUnixGui then "unix://" else ""}${cfg.guiAddress}" + "-config=${cfg.configDir}" + "-data=${cfg.configDir}" + ]; + EnvironmentVariables = { + STNORESTART = "yes"; + STNOUPGRADE = "yes"; + }; + KeepAlive = true; + RunAtLoad = true; + ProcessType = "Background"; + StandardOutPath = "${cfg.configDir}/syncthing.log"; + StandardErrorPath = "${cfg.configDir}/syncthing.log"; + }; + }; + + launchd.user.agents.syncthing-init = mkIf (cleanedConfig != { }) { + serviceConfig = { + ProgramArguments = [ "${updateConfig}" ]; + RunAtLoad = true; + KeepAlive = false; + ProcessType = "Background"; + StandardOutPath = "${cfg.configDir}/syncthing-init.log"; + StandardErrorPath = "${cfg.configDir}/syncthing-init.log"; + }; + }; + + system.activationScripts.syncthing = mkIf (cfg.cert != null || cfg.key != null) '' + echo "Setting up Syncthing certificates..." + mkdir -p ${cfg.configDir} + ${optionalString (cfg.cert != null) '' + cp ${toString cfg.cert} ${cfg.configDir}/cert.pem + chmod 644 ${cfg.configDir}/cert.pem + ''} + ${optionalString (cfg.key != null) '' + cp ${toString cfg.key} ${cfg.configDir}/key.pem + chmod 600 ${cfg.configDir}/key.pem + ''} + ''; + }; +} \ No newline at end of file diff --git a/modules/services/syncthing.nix b/modules/services/syncthing.nix index ea5d51b..1f680aa 100644 --- a/modules/services/syncthing.nix +++ b/modules/services/syncthing.nix @@ -1,20 +1,39 @@ -{user, ...}: { +{ + user, + pkgs, + ... +}: { services.syncthing = { enable = true; - openDefaultPorts = true; - dataDir = "/home/${user}/.local/share/syncthing"; - configDir = "/home/${user}/.config/syncthing"; + openDefaultPorts = pkgs.stdenv.isLinux; + dataDir = + if pkgs.stdenv.isDarwin + then "/Users/${user}/.local/share/syncthing" + else "/home/${user}/.local/share/syncthing"; + configDir = + if pkgs.stdenv.isDarwin + then "/Users/${user}/.config/syncthing" + else "/home/${user}/.config/syncthing"; user = "${user}"; - group = "users"; + group = + if pkgs.stdenv.isDarwin + then "staff" + else "users"; guiAddress = "0.0.0.0:8384"; overrideFolders = true; overrideDevices = true; settings = { - devices = {}; + devices = { + "jason" = {id = "42II2VO-QYPJG26-ZS3MB2I-AOPVZ67-JJNSE76-U54CO5Y-634A5OG-ECU4YQA";}; + "tahani" = {id = "6B7OZZF-TEAMUGO-FBOELXP-Z4OY7EU-5ZHLB5T-V6Z3UDB-Q2DYR43-QBYW6QM";}; + }; folders = { "Projects" = { - path = "/home/${user}/Projects"; + path = + if pkgs.stdenv.isDarwin + then "/Users/${user}/Projects" + else "/home/${user}/Projects"; devices = []; }; }; diff --git a/overlays/syncthing-darwin.nix b/overlays/syncthing-darwin.nix new file mode 100644 index 0000000..b37221f --- /dev/null +++ b/overlays/syncthing-darwin.nix @@ -0,0 +1,383 @@ +final: prev: { + # Create a Darwin-compatible Syncthing service module + darwinModules = prev.darwinModules or {} // { + syncthing = { config, lib, pkgs, ... }: + with lib; + let + cfg = config.services.syncthing; + defaultUser = "syncthing"; + defaultGroup = defaultUser; + settingsFormat = pkgs.formats.json { }; + cleanedConfig = converge (filterAttrsRecursive (_: v: v != null && v != { })) cfg.settings; + + isUnixGui = (builtins.substring 0 1 cfg.guiAddress) == "/"; + + curlAddressArgs = path: + if isUnixGui + then "--unix-socket ${cfg.guiAddress} http://.${path}" + else "${cfg.guiAddress}${path}"; + + devices = mapAttrsToList (_: device: device // { deviceID = device.id; }) cfg.settings.devices; + anyAutoAccept = builtins.any (dev: dev.autoAcceptFolders) devices; + + folders = mapAttrsToList (_: folder: folder // { + devices = let + folderDevices = folder.devices; + in + map (device: + if builtins.isString device then + { deviceId = cfg.settings.devices.${device}.id; } + else if builtins.isAttrs device then + { deviceId = cfg.settings.devices.${device.name}.id; } // device + else + throw "Invalid type for devices in folder; expected list or attrset." + ) folderDevices; + }) (filterAttrs (_: folder: folder.enable) cfg.settings.folders); + + jq = "${pkgs.jq}/bin/jq"; + updateConfig = pkgs.writers.writeBash "merge-syncthing-config" ( + '' + set -efu + umask 0077 + + curl() { + while + ! ${pkgs.libxml2}/bin/xmllint \ + --xpath 'string(configuration/gui/apikey)' \ + ${cfg.configDir}/config.xml \ + >"$TMPDIR/api_key" + do sleep 1; done + (printf "X-API-Key: "; cat "$TMPDIR/api_key") >"$TMPDIR/headers" + ${pkgs.curl}/bin/curl -sSLk -H "@$TMPDIR/headers" \ + --retry 1000 --retry-delay 1 --retry-all-errors \ + "$@" + } + '' + + (lib.pipe { + 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"; + }; + } [ + (mapAttrs (conf_type: s: + lib.pipe s.conf [ + (map (new_cfg: + let + jsonPreSecretsFile = pkgs.writeTextFile { + name = "${conf_type}-${new_cfg.id}-conf-pre-secrets.json"; + text = builtins.toJSON new_cfg; + }; + injectSecretsJqCmd = { + "devs" = "${jq} ."; + "dirs" = let + folder = new_cfg; + devicesWithSecrets = lib.pipe folder.devices [ + (lib.filter (device: (builtins.isAttrs device) && device ? encryptionPasswordFile)) + (map (device: { + deviceId = device.deviceId; + variableName = "secret_${builtins.hashString "sha256" device.encryptionPasswordFile}"; + secretPath = device.encryptionPasswordFile; + })) + ]; + jqUpdates = map (device: '' + .devices[] |= ( + if .deviceId == "${device.deviceId}" then + del(.encryptionPasswordFile) | + .encryptionPassword = ''$${device.variableName} + else + . + end + ) + '') devicesWithSecrets; + jqRawFiles = map (device: "--rawfile ${device.variableName} ${lib.escapeShellArg device.secretPath}") devicesWithSecrets; + in + "${jq} ${lib.concatStringsSep " " jqRawFiles} ${lib.escapeShellArg (lib.concatStringsSep "|" ([ "." ] ++ jqUpdates))}"; + }.${conf_type}; + in + '' + ${injectSecretsJqCmd} ${jsonPreSecretsFile} | curl --json @- -X POST ${s.baseAddress} + '' + )) + (lib.concatStringsSep "\n") + ] + + 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 + >&2 echo "Deleting stale device: $id" + curl -X DELETE ${s.baseAddress}/$id + done + '' + )) + builtins.attrValues + (lib.concatStringsSep "\n") + ]) + + (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") + ]) + + '' + if curl ${curlAddressArgs "/rest/config/restart-required"} | + ${jq} -e .requiresRestart > /dev/null; then + curl -X POST ${curlAddressArgs "/rest/system/restart"} + fi + '' + ); + in + { + options = { + services.syncthing = { + 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 configDir."; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = "Path to the key.pem file, which will be copied into Syncthing's configDir."; + }; + + overrideDevices = mkOption { + type = types.bool; + default = true; + description = "Whether to delete the devices which are not configured via the devices option."; + }; + + overrideFolders = mkOption { + type = types.bool; + default = !anyAutoAccept; + description = "Whether to delete the folders which are not configured via the folders option."; + }; + + settings = mkOption { + type = types.submodule { + freeformType = settingsFormat.type; + options = { + options = mkOption { + default = { }; + description = "The options element contains all other global configuration options"; + type = types.submodule { + freeformType = settingsFormat.type; + options = { + localAnnounceEnabled = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to send announcements to the local LAN."; + }; + globalAnnounceEnabled = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Whether to send announcements to the global discovery servers."; + }; + 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."; + }; + }; + }; + }; + + devices = mkOption { + default = { }; + description = "Peers/devices which Syncthing should communicate with."; + 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."; + }; + autoAcceptFolders = mkOption { + type = types.bool; + default = false; + description = "Automatically create or share folders that this device advertises at the default path."; + }; + }; + })); + }; + + folders = mkOption { + default = { }; + description = "Folders which should be shared by Syncthing."; + type = types.attrsOf (types.submodule ({ name, ... }: { + freeformType = settingsFormat.type; + options = { + enable = mkOption { + type = types.bool; + default = true; + description = "Whether to share this folder."; + }; + path = mkOption { + type = types.str; + default = name; + description = "The path to the folder which should be shared."; + }; + 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."; + }; + devices = mkOption { + type = types.listOf (types.oneOf [ + types.str + (types.submodule { + freeformType = settingsFormat.type; + options = { + name = mkOption { + type = types.str; + description = "The name of a device defined in the devices option."; + }; + encryptionPasswordFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to encryption password file."; + }; + }; + }) + ]); + default = [ ]; + description = "The devices this folder should be shared with."; + }; + }; + })); + }; + }; + }; + default = { }; + description = "Extra configuration options for Syncthing."; + }; + + guiAddress = mkOption { + type = types.str; + default = "127.0.0.1:8384"; + description = "The address to serve the web interface at."; + }; + + user = mkOption { + type = types.str; + default = defaultUser; + description = "The user to run Syncthing as."; + }; + + group = mkOption { + type = types.str; + default = defaultGroup; + description = "The group to run Syncthing under."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/syncthing"; + description = "The path where synchronised directories will exist."; + }; + + configDir = mkOption { + type = types.path; + default = cfg.dataDir + "/.config/syncthing"; + description = "The path where the settings and keys will exist."; + }; + + package = mkPackageOption pkgs "syncthing" { }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(cfg.overrideFolders && anyAutoAccept); + message = "services.syncthing.overrideFolders will delete auto-accepted folders from the configuration, creating path conflicts."; + } + ]; + + environment.systemPackages = [ cfg.package ]; + + launchd.user.agents.syncthing = { + serviceConfig = { + ProgramArguments = [ + "${cfg.package}/bin/syncthing" + "-no-browser" + "-gui-address=${if isUnixGui then "unix://" else ""}${cfg.guiAddress}" + "-config=${cfg.configDir}" + "-data=${cfg.configDir}" + ]; + EnvironmentVariables = { + STNORESTART = "yes"; + STNOUPGRADE = "yes"; + }; + KeepAlive = true; + RunAtLoad = true; + ProcessType = "Background"; + StandardOutPath = "${cfg.configDir}/syncthing.log"; + StandardErrorPath = "${cfg.configDir}/syncthing.log"; + }; + }; + + launchd.user.agents.syncthing-init = mkIf (cleanedConfig != { }) { + serviceConfig = { + ProgramArguments = [ "${updateConfig}" ]; + RunAtLoad = true; + KeepAlive = false; + ProcessType = "Background"; + StandardOutPath = "${cfg.configDir}/syncthing-init.log"; + StandardErrorPath = "${cfg.configDir}/syncthing-init.log"; + }; + }; + + system.activationScripts.syncthing = mkIf (cfg.cert != null || cfg.key != null) '' + echo "Setting up Syncthing certificates..." + mkdir -p ${cfg.configDir} + ${optionalString (cfg.cert != null) '' + cp ${toString cfg.cert} ${cfg.configDir}/cert.pem + chmod 644 ${cfg.configDir}/cert.pem + ''} + ${optionalString (cfg.key != null) '' + cp ${toString cfg.key} ${cfg.configDir}/key.pem + chmod 600 ${cfg.configDir}/key.pem + ''} + ''; + }; + }; + }; +} \ No newline at end of file