cfg = config.services.syncoid;
inherit (config.networking) nftables;
- # Extract local dasaset names (so no datasets containing "@")
+ # 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:
- lib.concatMapStrings (s: if lib.isList s then "-" else s)
+ concatMapStrings (s: if isList s then "-" else s)
(builtins.split "[^a-zA-Z0-9_.\\-]+" name);
-
- # Function to build "zfs allow" commands for the filesystems we've
- # delegated permissions to. It also checks if the target dataset
- # exists before delegating permissions, if it doesn't exist we
- # delegate it to the parent dataset. This should solve the case of
- # provisoning new datasets.
- buildAllowCommand = permissions: dataset: (
- "-+${pkgs.writeShellScript "zfs-allow-${dataset}" ''
- set -eux
- # Run a ZFS list on the dataset to check if it exists
- if zfs list ${lib.escapeShellArg dataset} >/dev/null 2>/dev/null; then
- zfs allow "$USER" ${lib.escapeShellArgs [
- (concatStringsSep "," permissions)
- dataset
- ]}
- else
- zfs allow "$USER" ${lib.escapeShellArgs [
- (concatStringsSep "," permissions)
- # Remove the last part of the path
- (builtins.dirOf dataset)
- ]}
- fi
- ''}"
- );
-
- # Function to build "zfs unallow" commands for the filesystems we've
- # delegated permissions to. Here we unallow both the target and
- # the parent dataset because at this stage we have no way of
- # knowing if the allow command did execute on the parent dataset or
- # not in the pre-hook. We can't run the same if-then-else in the post hook
- # since the dataset should have been created at this point.
- buildUnallowCommand = dataset: (
- "-+${pkgs.writeShellScript "zfs-unallow-${dataset}" ''
- set -eux
- zfs unallow "$USER" ${lib.escapeShellArg dataset}
- zfs unallow "$USER" ${lib.escapeShellArg (builtins.dirOf dataset)}
- ''}"
- );
in
{
# Interface
options.services.syncoid = {
- enable = mkEnableOption "Syncoid ZFS synchronization service";
+ 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 = ''
+ description = lib.mdDoc ''
Run syncoid at this interval. The default is to run hourly.
The format is described in
- <citerefentry><refentrytitle>systemd.time</refentrytitle>
- <manvolnum>7</manvolnum></citerefentry>.
+ {manpage}`systemd.time(7)`.
'';
};
sshKey = mkOption {
- type = types.nullOr types.path;
- # Prevent key from being copied to store
- apply = mapNullable toString;
+ type = types.nullOr types.str;
default = null;
- description = ''
- SSH private key file to use to login to the remote system. Can be
- overridden in individual commands.
- For more SSH tuning, you may use syncoid's <literal>--sshoption</literal>
- in <link linkend="opt-services.syncoid.commonArgs">commonArgs</link>
- and/or in the <literal>extraArgs<literal> of a specific command.
+ 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.
'';
};
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 = ''
+ description = lib.mdDoc ''
Permissions granted for the syncoid user for local source datasets.
- See <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+ See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
'';
};
localTargetAllow = mkOption {
type = types.listOf types.str;
- default = [ "change-key" "compression" "create" "mount" "mountpoint" "receive" "rollback" ];
+ default = [ "change-key" "compression" "create" "destroy" "mount" "mountpoint" "receive" "rollback" ];
example = [ "create" "mount" "receive" "rollback" ];
- description = ''
+ description = lib.mdDoc ''
Permissions granted for the syncoid user for local target datasets.
- See <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+ See <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
- Make sure to include the <literal>change-key</literal> permission if you send raw encrypted datasets,
- the <literal>compression</literal> permission if you send raw compressed datasets, and so on.
+ 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.
'';
};
type = types.listOf types.str;
default = [ ];
example = [ "--no-sync-snap" ];
- description = ''
+ description = lib.mdDoc ''
Arguments to add to every syncoid command, unless disabled for that
command. See
- <link xlink:href="https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options"/>
+ <https://github.com/jimsalterjrs/sanoid/#syncoid-command-line-options>
for available options.
'';
};
service = mkOption {
type = types.attrs;
default = { };
- description = ''
+ description = lib.mdDoc ''
Systemd configuration common to all syncoid services.
'';
};
source = mkOption {
type = types.str;
example = "pool/dataset";
- description = ''
+ 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 = ''
+ description = lib.mdDoc ''
Target ZFS dataset. Can be either local
- (<replaceable>pool/dataset</replaceable>) or remote
- (<replaceable>user@server:pool/dataset</replaceable>).
+ («pool/dataset») or remote
+ («user@server:pool/dataset»).
'';
};
- recursive = mkEnableOption ''the transfer of child datasets'';
+ recursive = mkEnableOption (lib.mdDoc ''the transfer of child datasets'');
sshKey = mkOption {
- type = types.nullOr types.path;
- # Prevent key from being copied to store
- apply = mapNullable toString;
- description = ''
+ 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> option.
+ Defaults to {option}`services.syncoid.sshKey` option.
'';
};
localSourceAllow = mkOption {
type = types.listOf types.str;
- description = ''
- Permissions granted for the <option>services.syncoid.user</option> user
+ description = lib.mdDoc ''
+ Permissions granted for the {option}`services.syncoid.user` user
for local source datasets. See
- <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+ <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
- Defaults to <option>services.syncoid.localSourceAllow</option> option.
+ Defaults to {option}`services.syncoid.localSourceAllow` option.
'';
};
localTargetAllow = mkOption {
type = types.listOf types.str;
- description = ''
- Permissions granted for the <option>services.syncoid.user</option> user
+ description = lib.mdDoc ''
+ Permissions granted for the {option}`services.syncoid.user` user
for local target datasets. See
- <link xlink:href="https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html"/>
+ <https://openzfs.github.io/openzfs-docs/man/8/zfs-allow.8.html>
for available permissions.
- Make sure to include the <literal>change-key</literal> permission if you send raw encrypted datasets,
- the <literal>compression</literal> permission if you send raw compressed datasets, and so on.
+ 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.
'';
};
type = types.separatedString " ";
default = "";
example = "Lc e";
- description = ''
+ description = lib.mdDoc ''
Advanced options to pass to zfs send. Options are specified
without their leading dashes and separated by spaces.
'';
type = types.separatedString " ";
default = "";
example = "ux recordsize o compression=lz4";
- description = ''
+ description = lib.mdDoc ''
Advanced options to pass to zfs recv. Options are specified
without their leading dashes and separated by spaces.
'';
};
- useCommonArgs = mkEnableOption ''
- configured common arguments to this command
- '' // { default = true; };
+ useCommonArgs = mkEnableOption
+ (lib.mdDoc ''
+ configured common arguments to this command
+ '') // { default = true; };
service = mkOption {
type = types.attrs;
default = { };
- description = ''
+ description = lib.mdDoc ''
Systemd configuration specific to this syncoid service.
'';
};
type = types.listOf types.str;
default = [ ];
example = [ "--sshport 2222" ];
- description = "Extra syncoid arguments for this command.";
+ description = lib.mdDoc "Extra syncoid arguments for this command.";
};
};
config = {
};
}));
default = { };
- example = literalExample ''
+ example = literalExpression ''
{
"pool/test".target = "root@target:pool/test";
}
'';
- description = "Syncoid commands to run.";
+ 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}";
# 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 =
- (map (buildAllowCommand c.localSourceAllow) (localDatasetName c.source)) ++
- (map (buildAllowCommand c.localTargetAllow) (localDatasetName c.target)) ++
- optional nftables.enable "+${pkgs.nftables}/bin/nft add element inet filter nixos-syncoid-uids { $USER }";
+ # 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 =
- (map buildUnallowCommand (localDatasetName c.source)) ++
- (map buildUnallowCommand (localDatasetName c.target)) ++
- optional nftables.enable "+${pkgs.nftables}/bin/nft delete element inet filter nixos-syncoid-uids { $USER }";
- ExecStart = lib.escapeShellArgs ([ "${pkgs.sanoid}/bin/syncoid" ]
+ 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}/ssh-key" ]
+ ++ optionals (c.sshKey != null) [ "--sshkey" "\${CREDENTIALS_DIRECTORY}/${if length sshKeyCred > 1 then head sshKeyCred else "sshKey"}" ]
++ c.extraArgs
++ [
"--sendoptions"
c.target
]);
DynamicUser = true;
- LoadCredential = [ "ssh-key:${c.sshKey}" ];
# Prevent SSH control sockets of different syncoid services from interfering
PrivateTmp = true;
# Permissive access to /proc because syncoid
RootDirectory = "/run/syncoid/${escapeUnitName name}";
RootDirectoryStartOnly = true;
BindPaths = [ "/dev/zfs" ];
- BindReadOnlyPaths = [ builtins.storeDir "/etc" "/run" "/bin/sh"
- # 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".
- # Unfortunately, Bash is incompatible with libnss_systemd.so:
- # https://www.mail-archive.com/bug-bash@gnu.org/msg24306.html
- # Hence the wrapping of ssh is done here as a mounted path,
- # because Nixpkgs' wrapping of syncoid enforces the use
- # of the ${pkgs.openssh}/bin/ssh path.
- # This problem does not arise on NixOS systems where stdenv.hostPlatform.libc == "musl",
- # because then Bash is built with --without-bash-malloc
- ("${pkgs.writeShellScript "ssh-with-support-for-DynamicUser" ''
- export LD_LIBRARY_PATH="${config.system.nssModules.path}"
- exec -a ${pkgs.openssh}/bin/ssh /bin/ssh "$@"
- ''}:${pkgs.openssh}/bin/ssh")
- "${pkgs.openssh}/bin/ssh:/bin/ssh"
- ];
+ 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;
"~@privileged"
"~@resources"
"~@setuid"
- "~@timer"
];
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 = ''
- # A set containing the dynamic UIDs of the syncoid services currently active
- add set inet filter nixos-syncoid-uids { type uid; }
- # Example of use (assuming fw2net is being called by the output chain):
- #add rule inet filter fw2net meta skuid @nixos-syncoid-uids meta l4proto tcp accept
- '';
+
+ 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 ];