{ config, lib, pkgs, ... }: with lib; let cfg = config.services.syncoid; inherit (config.networking) nftables; # Extract local dataset names (so no datasets containing "@") localDatasetName = d: optionals (d != null) ( let m = builtins.match "([^/@]+[^@]*)" d; in optionals (m != null) m ); # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html escapeUnitName = name: concatMapStrings (s: if isList s then "-" else s) (builtins.split "[^a-zA-Z0-9_.\\-]+" name); in { # Interface options.services.syncoid = { enable = mkEnableOption (lib.mdDoc "Syncoid ZFS synchronization service"); package = lib.mkPackageOptionMD pkgs "sanoid" { }; nftables.enable = mkEnableOption (lib.mdDoc '' maintaining an nftables set of the active syncoid UIDs. This can be used like so (assuming `output-net` is being called by the output chain): ``` networking.nftables.ruleset = "table inet filter { chain output-net { skuid @nixos-syncoid-uids meta l4proto tcp accept } }"; ``` ''); interval = mkOption { type = types.str; default = "hourly"; example = "*-*-* *:15:00"; description = lib.mdDoc '' Run syncoid at this interval. The default is to run hourly. The format is described in {manpage}`systemd.time(7)`. ''; }; sshKey = mkOption { type = types.nullOr types.str; default = null; description = lib.mdDoc '' SSH private key file to use to login to the remote system. It can be overridden in individual commands. It is loaded using `LoadCredentialEncrypted=` when its path is prefixed by a credential name and colon, otherwise `LoadCredential=` is used. For more SSH tuning, you may use syncoid's `--sshoption` in {option}`services.syncoid.commonArgs` and/or in the `extraArgs` of a specific command. ''; }; localSourceAllow = mkOption { type = types.listOf types.str; # Permissions snapshot and destroy are in case --no-sync-snap is not used default = [ "bookmark" "hold" "send" "snapshot" "destroy" ]; description = lib.mdDoc '' Permissions granted for the syncoid user for local source datasets. See for available permissions. ''; }; localTargetAllow = mkOption { type = types.listOf types.str; default = [ "change-key" "compression" "create" "destroy" "mount" "mountpoint" "receive" "rollback" ]; example = [ "create" "mount" "receive" "rollback" ]; description = lib.mdDoc '' Permissions granted for the syncoid user for local target datasets. See for available permissions. Make sure to include the `change-key` permission if you send raw encrypted datasets, the `compression` permission if you send raw compressed datasets, and so on. For remote target datasets you'll have to set your remote user permissions by yourself. ''; }; commonArgs = mkOption { type = types.listOf types.str; default = [ ]; example = [ "--no-sync-snap" ]; description = lib.mdDoc '' Arguments to add to every syncoid command, unless disabled for that command. See for available options. ''; }; service = mkOption { type = types.attrs; default = { }; description = lib.mdDoc '' Systemd configuration common to all syncoid services. ''; }; commands = mkOption { type = types.attrsOf (types.submodule ({ name, ... }: { options = { source = mkOption { type = types.str; example = "pool/dataset"; description = lib.mdDoc '' Source ZFS dataset. Can be either local or remote. Defaults to the attribute name. ''; }; target = mkOption { type = types.str; example = "user@server:pool/dataset"; description = lib.mdDoc '' Target ZFS dataset. Can be either local («pool/dataset») or remote («user@server:pool/dataset»). ''; }; recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets''); sshKey = mkOption { type = types.nullOr types.str; description = lib.mdDoc '' SSH private key file to use to login to the remote system. Defaults to {option}`services.syncoid.sshKey` option. ''; }; localSourceAllow = mkOption { type = types.listOf types.str; description = lib.mdDoc '' Permissions granted for the {option}`services.syncoid.user` user for local source datasets. See for available permissions. Defaults to {option}`services.syncoid.localSourceAllow` option. ''; }; localTargetAllow = mkOption { type = types.listOf types.str; description = lib.mdDoc '' Permissions granted for the {option}`services.syncoid.user` user for local target datasets. See for available permissions. Make sure to include the `change-key` permission if you send raw encrypted datasets, the `compression` permission if you send raw compressed datasets, and so on. For remote target datasets you'll have to set your remote user permissions by yourself. ''; }; sendOptions = mkOption { type = types.separatedString " "; default = ""; example = "Lc e"; description = lib.mdDoc '' Advanced options to pass to zfs send. Options are specified without their leading dashes and separated by spaces. ''; }; recvOptions = mkOption { type = types.separatedString " "; default = ""; example = "ux recordsize o compression=lz4"; description = lib.mdDoc '' Advanced options to pass to zfs recv. Options are specified without their leading dashes and separated by spaces. ''; }; useCommonArgs = mkEnableOption (lib.mdDoc '' configured common arguments to this command '') // { default = true; }; service = mkOption { type = types.attrs; default = { }; description = lib.mdDoc '' Systemd configuration specific to this syncoid service. ''; }; extraArgs = mkOption { type = types.listOf types.str; default = [ ]; example = [ "--sshport 2222" ]; description = lib.mdDoc "Extra syncoid arguments for this command."; }; }; config = { source = mkDefault name; sshKey = mkDefault cfg.sshKey; localSourceAllow = mkDefault cfg.localSourceAllow; localTargetAllow = mkDefault cfg.localTargetAllow; }; })); default = { }; example = literalExpression '' { "pool/test".target = "root@target:pool/test"; } ''; description = lib.mdDoc "Syncoid commands to run."; }; }; # Implementation config = mkIf cfg.enable { assertions = [ { assertion = cfg.nftables.enable -> config.networking.nftables.enable; message = "config.networking.nftables.enable must be set when config.services.syncoid.nftables.enable is set"; } ]; systemd.services = mapAttrs' (name: c: let sshKeyCred = builtins.split ":" c.sshKey; in nameValuePair "syncoid-${escapeUnitName name}" (mkMerge [ { description = "Syncoid ZFS synchronization from ${c.source} to ${c.target}"; after = [ "zfs.target" ]; startAt = cfg.interval; # Here we explicitly use the booted system to guarantee the stable API needed by ZFS. # Moreover syncoid may need zpool to get feature@extensible_dataset. path = [ "/run/booted-system/sw" ]; # Prevents missing snapshots during DST changes environment.TZ = "UTC"; # A custom LD_LIBRARY_PATH is needed to access in `getent passwd` # the systemd's entry about the DynamicUser=, # so that ssh won't fail with: "No user exists for uid $UID". environment.LD_LIBRARY_PATH = config.system.nssModules.path; serviceConfig = { ExecStartPre = # Recursively remove any residual permissions # given on local+descendant datasets (source, target or target's parent) # to any currently unknown (hence unused) systemd dynamic users (UID/GID range 61184…65519), # which happens when a crash has occurred # during any previous run of a syncoid-*.service (not only this one). map (dataset: "+" + pkgs.writeShellScript "zfs-unallow-unused-dynamic-users" '' set -eu zfs allow "$1" | sed -ne 's/^\t\(user\|group\) (unknown: \([0-9]\+\)).*/\1 \2/p' | { declare -a uids while read -r role id; do if [ "$id" -ge 61184 ] && [ "$id" -le 65519 ]; then case "$role" in (user) uids+=("$id");; esac fi done zfs unallow -r -u "$(printf %s, "''${uids[@]}")" "$1" } '' + " " + escapeShellArg dataset ) (localDatasetName c.source ++ localDatasetName c.target ++ map builtins.dirOf (localDatasetName c.target)) ++ # For a local source, allow the localSourceAllow ZFS permissions. map (dataset: "+/run/booted-system/sw/bin/zfs allow $USER " + escapeShellArgs [ (concatStringsSep "," c.localSourceAllow) dataset ] ) (localDatasetName c.source) ++ # For a local target, check if the dataset exists before delegating permissions, # and if it doesn't exist, delegate it to the parent dataset. # This should solve the case of provisioning new datasets. map (dataset: "+" + pkgs.writeShellScript "zfs-allow-target" '' dataset="$1" # Run a ZFS list on the dataset to check if it exists zfs list "$dataset" >/dev/null 2>/dev/null || dataset="$(dirname "$dataset")" zfs allow "$USER" ${escapeShellArg (concatStringsSep "," c.localTargetAllow)} "$dataset" '' + " " + escapeShellArg dataset ) (localDatasetName c.target) ++ # Adding a user to an nftables set will not persist across a reboot, # hence there is no need to cleanup residual dynamic users remaining in it after a crash. optional cfg.nftables.enable "+${pkgs.nftables}/bin/nft add element inet filter nixos-syncoid-uids { $USER }"; ExecStopPost = let zfsUnallow = dataset: "+/run/booted-system/sw/bin/zfs unallow $USER " + escapeShellArg dataset; in map zfsUnallow (localDatasetName c.source) ++ # For a local target, unallow both the dataset and its parent, # because at this stage we have no way of knowing if the allow command # did execute on the parent dataset or not in the ExecStartPre=. # We can't run the same if-then-else in the post hook # since the dataset should have been created at this point. concatMap (dataset: [ (zfsUnallow dataset) (zfsUnallow (builtins.dirOf dataset)) ]) (localDatasetName c.target) ++ optional cfg.nftables.enable "+${pkgs.nftables}/bin/nft delete element inet filter nixos-syncoid-uids { $USER }"; ExecStart = lib.escapeShellArgs ([ "${cfg.package}/bin/syncoid" ] ++ optionals c.useCommonArgs cfg.commonArgs ++ optional c.recursive "--recursive" ++ optionals (c.sshKey != null) [ "--sshkey" "\${CREDENTIALS_DIRECTORY}/${if length sshKeyCred > 1 then head sshKeyCred else "sshKey"}" ] ++ c.extraArgs ++ [ "--sendoptions" c.sendOptions "--recvoptions" c.recvOptions "--no-privilege-elevation" c.source c.target ]); DynamicUser = true; # Prevent SSH control sockets of different syncoid services from interfering PrivateTmp = true; # Permissive access to /proc because syncoid # calls ps(1) to detect ongoing `zfs receive`. ProcSubset = "all"; ProtectProc = "default"; # The following options are only for optimizing: # systemd-analyze security | grep syncoid-'*' AmbientCapabilities = ""; CapabilityBoundingSet = ""; DeviceAllow = [ "/dev/zfs" ]; LockPersonality = true; MemoryDenyWriteExecute = true; NoNewPrivileges = true; PrivateDevices = true; PrivateMounts = true; PrivateNetwork = mkDefault false; PrivateUsers = true; ProtectClock = true; ProtectControlGroups = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectSystem = "strict"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; RootDirectory = "/run/syncoid/${escapeUnitName name}"; RootDirectoryStartOnly = true; BindPaths = [ "/dev/zfs" ]; BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh" ]; # Avoid useless mounting of RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. InaccessiblePaths = [ "-+/run/syncoid/${escapeUnitName name}" ]; MountAPIVFS = true; # Create RootDirectory= in the host's mount namespace. RuntimeDirectory = [ "syncoid/${escapeUnitName name}" ]; RuntimeDirectoryMode = "700"; SystemCallFilter = [ "@system-service" # Groups in @system-service which do not contain a syscall listed by: # perf stat -x, 2>perf.log -e 'syscalls:sys_enter_*' syncoid … # awk >perf.syscalls -F "," '$1 > 0 {sub("syscalls:sys_enter_","",$3); print $3}' perf.log # systemd-analyze syscall-filter | grep -v -e '#' | sed -e ':loop; /^[^ ]/N; s/\n //; t loop' | grep $(printf ' -e \\<%s\\>' $(cat perf.syscalls)) | cut -f 1 -d ' ' "~@aio" "~@chown" "~@keyring" "~@memlock" "~@privileged" "~@resources" "~@setuid" ]; SystemCallArchitectures = "native"; # This is for BindPaths= and BindReadOnlyPaths= # to allow traversal of directories they create in RootDirectory=. UMask = "0066"; } // ( if length sshKeyCred > 1 then { LoadCredentialEncrypted = [ c.sshKey ]; } else { LoadCredential = [ "sshKey:${c.sshKey}" ]; } ); } cfg.service c.service ])) cfg.commands; networking.nftables.ruleset = optionalString cfg.nftables.enable (mkBefore '' table inet filter { # A set containing the dynamic UIDs of the syncoid services currently active set nixos-syncoid-uids { type uid; } } ''); }; meta.maintainers = with maintainers; [ julm lopsided98 ]; }