zfs-native-encryption-keys¶
Modules
Unlock ZFS native encryption on non-root pools from a runtime key file (agenix / sops-nix / systemd-creds / a ramfs drop), with the ordering that makes it safe: the key loads after the pool is imported, the datasets mount after the key loads, and your services run after the mounts — or not at all.
Ships with a NixOS VM test (test.nix) that boots an encrypted pool, proves the
unlock happened on that boot, proves the data is genuinely inaccessible without
the key, reboots, and asserts the fail-closed path on both key-delivery
mechanisms. See Testing.
Why this exists — what nixpkgs does not do¶
nixpkgs knows exactly one way to load a ZFS key: as a side effect of importing
the pool, from whatever keylocation property the dataset happens to carry on
disk. See nixos/modules/tasks/filesystems/zfs.nix:
| Upstream | Where | What it means for you |
|---|---|---|
createImportService embeds the key-loading loop in zfs-import-<pool>.service |
zfs.nix:152, loop at :214-238 |
There is no unit whose job is "the keys are loaded". You cannot order anything against key availability, only against import. |
| Same service is generated for non-root pools | zfs.nix:942 (map createImportService' dataPools) |
A data pool's keys are handled in a unit with DefaultDependencies=no that runs before almost everything — including anything that produces your key at runtime. |
boot.zfs.requestEncryptionCredentials defaults to true |
zfs.nix:385 |
"Prompt for every encrypted dataset on import", data pools included. |
prompt keylocation → systemd-ask-password --timeout=${boot.zfs.passwordTimeout} |
zfs.nix:227, option at :402 |
passwordTimeout defaults to 0, which waits forever — on a headless box that is a silent, permanent stall of the pool's mount units, not a failure. |
| The key path is a dataset property, not NixOS config | on disk | Your config cannot say where the key is. Whatever you set at zpool create time is what boot uses, forever, until someone runs zfs set by hand. |
This module adds the missing piece: a named, orderable
zfs-load-key-<pool>.service that loads the key from a path declared in your
NixOS config, using zfs load-key -L <location> to override the on-disk
keylocation for that one call.
Usage¶
{
imports = [ ./zfs-native-encryption-keys ];
services.zfsNativeKeys = {
enable = true;
# Stop nixpkgs' import units from also trying to unlock things.
# Here: only the root pool is handled by the boot prompt.
restrictImportCredentialsTo = [ "rpool" ];
# Every dataset of this pool uses mountpoint=legacy and is declared below,
# so the blanket `zfs mount -a` has nothing to do but race us.
disableZfsMountService = true;
pools.tank = {
datasets = [ "tank/archive" "tank/media" ]; # encryption ROOTS only
keyFile = config.age.secrets.tank-key.path; # a RUNTIME path, not a store path
mounts = {
"/tank/archive" = "tank/archive";
"/tank/media" = "tank/media";
};
# Hard dependency: these do not start if the key never loads.
requiredBy = [ "nginx.service" ];
# Ordering only: this starts anyway, just not before us.
before = [ "docker.service" ];
};
};
}
Generated for the example above:
# zfs-load-key-tank.service
After=zfs-import-tank.service
Before=docker.service nginx.service
Type=oneshot
RemainAfterExit=true
LoadCredential=zfs-key:/run/agenix/tank-key
# fileSystems."/tank/media".options
[ "zfsutil" "x-systemd.requires=zfs-load-key-tank.service"
"x-systemd.after=zfs-load-key-tank.service" ]
Options¶
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Turn the module on. |
package |
config.boot.zfs.package |
ZFS userland for the units. Keeps the CLI from drifting from the loaded kernel module. |
disableZfsMountService |
false |
Disable zfs-mount.service (zfs mount -a). |
restrictImportCredentialsTo |
null |
Pin boot.zfs.requestEncryptionCredentials to a dataset list (usually just the root pool). null leaves it alone. |
pools.<pool>.datasets |
[ "<pool>" ] |
Encryption roots to unlock. |
pools.<pool>.keyFile |
— (required) | Runtime path to the key/passphrase. A str, not a path, on purpose. |
pools.<pool>.useCredential |
true |
Pass the key via systemd LoadCredential= instead of reading the path directly. |
pools.<pool>.preflight |
true |
With useCredential = false, check readability first and exit with a clear message. |
pools.<pool>.after |
[ "zfs-import-<pool>.service" ] |
What the unit runs after. |
pools.<pool>.before / .requiredBy |
[ ] |
Ordering-only vs hard-dependency consumers. |
pools.<pool>.mounts |
{ } |
mountpoint → dataset; generates fileSystems entries wired to the key unit. |
pools.<pool>.mountOptions |
[ "zfsutil" ] |
Base options for those entries. |
pools.<pool>.mountAll |
false |
Append zfs mount -a for ZFS-managed (non-legacy) mountpoints. |
pools.<pool>.unitName / .description / .wantedBy |
derived | Cosmetics / boot wiring. |
The traps¶
1. grep -q available also matches unavailable¶
This is the bug that motivated the recipe. The obvious hand-rolled unit is:
# WRONG — do not copy this
if ! zfs get -H -o value keystatus "$ds" | grep -q "available"; then
zfs load-key -L "file://$KEY" "$ds"
else
echo "key already loaded for $ds"
fi
keystatus has exactly two values, available and unavailable, and
unavailable contains available as a substring. So the locked case —
the only case the unit exists for — matches, ! inverts it, and the script
takes the "already loaded" branch and never loads the key. It exits 0, the
unit goes green, the journal says "key already loaded", and the pool stays
locked. Nothing fails. A unit like that can sit in a fleet for years looking
healthy because the pool is actually being unlocked by something else
(upstream's import service, or a human), and the day that something else stops,
the "working" unit does not save you.
This module uses an exact match and treats anything that is neither
available nor unavailable (- for an unencrypted dataset, empty for a
missing one) as a hard error:
status=$(zfs get -H -o value keystatus "$dataset" || true)
case "$status" in
available) echo "key already loaded for $dataset"; continue ;;
unavailable) zfs load-key -L "file://…" "$dataset" ;;
*) echo "unexpected keystatus '$status'" >&2; exit 1 ;;
esac
If you ever write the grep form anyway, grep -qx available (whole-line) is
correct; plain grep -q available is not.
2. Fail closed, never hang — and never ConditionPathExists¶
Three ways a missing key can end, only one of which is acceptable:
- Hang (upstream default).
keylocation=prompton a data pool + the defaultboot.zfs.requestEncryptionCredentials = truemeans the pool's import unit runssystemd-ask-password --timeout=0(zfs.nix:227,passwordTimeoutdefault 0 = wait forever,zfs.nix:402). On a headless machine that request is never answered, the import unit never finishes, and every mount ordered after it waits with it. Boot "succeeds"; the data is simply never there. SetrestrictImportCredentialsToso upstream stops trying. - Fail open (the tempting fix). Guarding the key unit with
unitConfig.ConditionPathExists = keyFilelooks defensive. It is the opposite: a failedCondition*marks a unit successfully skipped, soRequires=on it is satisfied, consumers start, and they write into the bare mountpoint directory — see trap 3. Never gate a key-load unit on a Condition. - Fail closed (this module).
LoadCredential=makes systemd itself refuse to start the unit when the source is missing or unreadable; the unit goes tofailedin milliseconds,Requires=consumers do not start, and mount units wired withx-systemd.requires=do not attempt the mount. WithuseCredential = falsean explicit readability preflight does the same job with a legible message instead ofKey load error: Failed to open key material file.
3. What "not ordered" actually costs you: the shadowed mountpoint¶
If a service starts before /tank/media is mounted, it does not fail. It
happily creates /tank/media/... on the root dataset, underneath the
future mountpoint. When the mount finally lands, those files vanish from view —
still on disk, still consuming root-pool space, invisible to every tool that
looks at the mounted path. You discover it months later as an inexplicably full
root pool, and du /tank will not show it (you have to unmount to see it).
The chain that prevents it, all of it generated by this module:
zfs-import-tank.service (nixpkgs)
↓ After=
zfs-load-key-tank.service (this module)
↓ x-systemd.requires= / x-systemd.after=
tank-media.mount (generated from `mounts`)
↓ RequiresMountsFor= / Requires= + Before=
your service
Anything that already declares RequiresMountsFor=/tank/media (or has a
WorkingDirectory= there) joins the chain automatically. For everything else,
list it in requiredBy (hard) or before (ordering only).
4. zfs mount -a is not on your side¶
zfs-mount.service runs a blanket zfs mount -a. It races your declared mount
units, and it exits non-zero the moment any dataset's key is not loaded — so on
a host with a key-file pool it is either a no-op or a permanently red unit.
When every dataset you care about is mountpoint=legacy and declared in
mounts, set disableZfsMountService = true.
Related, if this pool is also your root: an all-ZFS root has no single block
device to dissect, and since nixpkgs #441777 systemd-gpt-auto-generator's
failure is fatal (drops to rescue). Mask it with
systemd.generators.systemd-gpt-auto-generator = "/dev/null".
5. -L overrides keylocation without rewriting it¶
zfs load-key -L file:///run/secrets/tank.key tank uses that location for
this call only. The dataset property is untouched — it can stay prompt
forever, meaning nothing written to the disk points at where the key lives. The
usual bootstrap (and what disko's postCreateHook does) is:
rootFsOptions = {
encryption = "aes-256-gcm";
keyformat = "passphrase";
keylocation = "file:///tmp/secret.key"; # only during `zpool create`
};
postCreateHook = ''zfs set keylocation="prompt" zroot'';
The test asserts this: after unlocking from a file,
zfs get -H -o value keylocation testpool still reads prompt.
Conversely, if you do leave keylocation=file:///run/secrets/tank.key on
disk, upstream's import service will load the key for you — and this module's
unit becomes a no-op that reports "key already loaded". That is a legitimate
configuration; just know which of the two you are running, because they fail
very differently.
6. One passphrase for a mirror — the encryption-root rule¶
zfs load-key operates on encryption roots, not disks and not datasets.
A pool created with zpool create -O encryption=aes-256-gcm has exactly one
encryption root (the pool), which every child inherits, no matter how many disks
the vdev has. A two-disk mirror therefore takes one passphrase entry for the
whole pool.
Contrast LUKS-under-ZFS: one dm-crypt mapper per member disk, so a two-disk
mirror produces two independent passphrase requests. That is a real,
reproduced boot stall — remote unlock answers the pending request and exits
(see remote-luks-unlock, systemd-tty-ask-password-agent --query), the second
request appears afterwards, nobody answers it, and the boot sits there. The VM
test that covers the native mirror asserts exactly this: a single
send_chars("…\n") reaches multi-user.target.
If you actually want per-dataset keys, create the children with their own
-o encryption / -o keyformat so each becomes its own encryption root, then
list them all in datasets. zfs load-key on an inheriting child fails with
Keys must be loaded for encryption root. Check with
zfs get -r encryptionroot <pool>.
Two more consequences of encryption living above the vdev layer:
- Mirrors encrypt once. ZFS native encrypts in the ZIO pipeline and writes the same ciphertext to both members. LUKS encrypts per device, so an n-way mirror pays n× the crypto cost on every write.
zfs send --rawworks. You can replicate encrypted datasets to a backup host that never holds the key. LUKS gives you nothing comparable.
ZFS native vs LUKS — which to use¶
This collection also ships remote-luks-unlock. They solve different halves of
the problem and compose:
ZFS native (zfs load-key) |
LUKS (cryptsetup) |
|
|---|---|---|
| Granularity | per dataset (encryption root) | per block device |
| Mirror of N disks | 1 encryption root → 1 passphrase | N mappers → N passphrases |
| Crypto cost on a mirror | once per write | once per write per disk |
| Encrypted replication | zfs send --raw, key never leaves the source |
not available |
| Hides pool layout / dataset names / sizes | no (metadata is plaintext) | yes |
| Works for non-ZFS filesystems | no | yes |
| Per-dataset key rotation | zfs change-key (re-wraps the master key; no data rewrite) |
cryptsetup luksChangeKey per device |
Rules of thumb:
- Root pool, headless machine → ZFS native with
keylocation=prompt, plusremote-luks-unlockto answer that prompt over initrd SSH. Despite the name it works verbatim for ZFS: nixpkgs asks viasystemd-ask-password(zfs.nix:227), which is the same agent--queryanswers. The same three-attempt lockout applies on both sides — nixpkgs retries the ZFS prompttries=3(zfs.nix:224),systemd-cryptsetupalso gives up after 3 — after which only a reboot gets you a fresh prompt. - Data pool that must come up unattended → this module, with the key from a secret manager. No prompt, fail-closed, orderable.
- Non-ZFS filesystem, or you must hide that the data exists at all → LUKS.
Testing¶
test.nix is a NixOS VM test of the module itself. Run it standalone:
or from a flake: pkgs.callPackage ./modules/zfs-native-encryption-keys/test.nix { }.
It imports the module directly (./default.nix) — no adapter, no
colmenaBasePath — plus the secretsStub fixture from
lib/nixos-test-topology, which materialises
the key at the real secret provider's path convention
(config.age.secrets.<name>.path). If you vendor this recipe on its own, either
copy that lib too or replace the stub with an environment.etc entry.
mkTopology is deliberately not used: the two nodes never exchange a
packet, so there is no address assignment to take away from the framework.
It creates aes-256-gcm pools on scratch disks, flips keylocation to
prompt, exports and re-imports with -N so the key is genuinely
unavailable, and then asserts that:
- the unit unlocked the pool on that boot — the journal must show
loading key for …and must not showkey already loaded. This is the assertion that makes the rest mean anything:zpool createleaves the key loaded, so a test that skips the export/import step is green against a module that does nothing at all; - the unlock came from the runtime key file even though the on-disk
keylocationisprompt, and-Ldid not rewrite that property; - the datasets are actually mounted, writable, and readable back;
- the generated
.mountunit formountscarriesRequires=+After=the key unit and mounts the dataset once the key is loaded (see trap 7 below for how the entry is smuggled pastqemu-vm.nix); requiredByconsumers getRequires=+After=and do run on the happy path;disableZfsMountServicemaskszfs-mount.service, andmountAllmounts the ZFS-managed datasets;- locking is real: unmount +
zfs unload-key -amid-test makes the data inaccessible,zfs mount -acannot bring it back, and the declared mount refuses — then restarting the key unit alone restores everything. Without this control, "keystatus available" is just a string; - after a full
shutdown()/start()the pool comes back locked and is unlocked again, with the marker files intact; - fail closed on both key-delivery paths, on a machine where the pools exist and are imported and only the key files are missing — so the failure cannot be blamed on a missing pool:
useCredential = true→ systemd refuses to start the unit (243/CREDENTIALS);useCredential = false→ thepreflightcheck exits non-zero with its own message;- in both cases the unit ends up
failedwithResult=exit-codeandConditionResult=yes— not "successfully skipped", which is what the temptingConditionPathExistsfix produces and which is fail-OPEN; - the datasets stay
unavailable, the declared mount refuses to mount, therequiredByconsumers never run (asserted by a marker file they would have created), and the machine still reachesmulti-user.targetinstead of hanging on a prompt nobody can answer.
The test has been seen to fail¶
Assertions nobody has watched fail are claims, not evidence. Three mutations
were applied to default.nix and the test was rebuilt each time:
| mutation | result |
|---|---|
drop the -L <keyfile> override, so load-key uses the stored keylocation=prompt |
FAIL — unit "zfs-load-key-testpool.service" reached state "failed" in subtest 1 |
add unitConfig.ConditionPathExists = keyFile (the fail-OPEN trap the preflight docs warn about) |
FAIL — systemctl is-failed … returns inactive, i.e. the unit was successfully skipped |
stop propagating requiredBy to the generated unit |
FAIL — systemctl show -p Requires <consumer> no longer names the key unit |
Restoring the module produced a byte-identical test derivation and a green run.
Trap 7 (found by writing this test): a VM test cannot see your fileSystems¶
nixos/modules/virtualisation/qemu-vm.nix sets
fileSystems = mkVMOverride config.virtualisation.fileSystems, and
mkVMOverride (priority 10) beats mkForce. So every fileSystems entry a
module declares silently disappears inside a nixosTest — the entry is not in
the guest's /etc/fstab, systemd-fstab-generator never makes a .mount unit,
and systemctl show -p Requires <x>.mount reports LoadState=not-found with
empty properties. An assertion written against it fails in a way that looks like
the module is broken when it is not.
The fix used here has two halves. First, evaluate the module in a plain,
non-VM evaluation (nixos/lib/eval-config.nix with just the module) inside
test.nix's let block and assert on the generated entry, so the test
derivation refuses to build if the options change. Second — and this is what
gives the feature real runtime coverage — feed that same generated value back
into the VM through virtualisation.fileSystems, which is the option
qemu-vm.nix overrides from. The guest then builds a genuine .mount unit
out of the module's own output, and the test can assert on its Requires= /
After= and on whether it actually mounts. Nothing is re-typed by hand.
Three smaller gotchas from the same sessions, in case you extend the test:
zfsutilandmountpoint=legacyare mutually exclusive.mount.zfs -o zfsutildelegates tozfs mount, which refuses a legacy dataset outright (filesystem '…' cannot be mounted using 'zfs mount'). Sincemountsdefaults tozfsutil, a test dataset must carry a realmountpoint=— usecanmount=noautoto keepzfs mount -afrom racing the.mountunit for it.-
Declaring a pool's dataset in
fileSystemsmakes nixpkgs generatezfs-import-<pool>.servicefor it, andboot.zfs.requestEncryptionCredentialsdefaults totrue— i.e. asystemd-ask-passwordin front of akeylocation=promptdataset, which hangs the VM. SetrestrictImportCredentialsTo = [ ](or a root-pool-only list) on the node. -
The test driver runs commands under
set -o pipefail, andsystemctl is-enabledexits non-zero for a masked unit. Sosystemctl is-enabled zfs-mount.service | grep -qx maskedfails even when the output is exactlymasked. Usetest "$(systemctl is-enabled … || true)" = masked. zfs get -H -o value keystatusneedsgrep -qx, notgrep -q, for the same reason as trap 1 — a test written withgrep -q availablepasses against a locked pool.
Notes and caveats¶
- Key material.
keyFileis astr, not apath, so nobody can accidentally copy the key into the world-readable Nix store. Forkeyformat=passphrasethe file must hold 8–512 characters; a stray trailing newline is not stripped by every tool in the chain — generate it withprintfortr -d '\n', notecho. - Rotation.
zfs change-key -o keylocation=… <encryption root>re-wraps the master key; no data is rewritten and it is instant. But an old key plus an oldzfs send --rawbackup still decrypts that old data — rotation limits future exposure, not past. - Metadata is not secret. Dataset names, snapshot names, sizes, and most properties are readable on an unmounted, locked pool. If the existence of the data is the secret, native encryption is the wrong tool.
- Version bounds. Developed against OpenZFS 2.4.x and systemd 261 on
nixpkgs 26.11 (
unstable).LoadCredential=needs systemd ≥ 247. Native encryption has had genuine raw-send/receive corruption bugs in its history; stay on OpenZFS ≥ 2.2 and scrub after any raw-receive-based restore. - The unit is idempotent.
RemainAfterExit=trueplus thekeystatuscheck means anixos-rebuild switchrestart re-runs it harmlessly. To force a reload after rotating the key on disk:zfs unload-key <ds>(all datasets unmounted first), thensystemctl restart zfs-load-key-<pool>.
Source¶
modules/zfs-native-encryption-keys/default.nix
# zfs-native-encryption-keys — load ZFS *native* encryption keys for non-root
# pools from a runtime secret, ordered so nothing touches the mountpoints
# before the datasets are actually mounted.
#
# ZFS native encryption is per-dataset (per "encryption root"), not per block
# device, so unlocking is `zfs load-key`, not `cryptsetup open`. nixpkgs only
# ever loads keys as a side effect of importing a pool, from whatever
# `keylocation` the dataset carries on disk. That is fine for a root pool
# answered by a boot prompt, and wrong for a data pool whose key comes from a
# secret manager: you want the key path to live in the NixOS config, not
# baked into an on-disk property, you want a missing key to FAIL rather than
# hang on a console prompt nobody is watching, and you want consumers ordered
# after the datasets are mounted.
#
# `zfs load-key -L <location>` overrides the stored `keylocation` for that one
# call without rewriting the property, which is what makes it possible to keep
# `keylocation=prompt` on disk (nothing on the disk points at the key) and
# still unlock from a runtime file.
#
# See README.md for the traps, including the `grep -q available` substring bug
# that silently turns a hand-rolled version of this unit into a no-op.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.zfsNativeKeys;
credentialName = "zfs-key";
poolOpts =
{ name, ... }:
{
options = {
datasets = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ name ];
defaultText = lib.literalExpression ''[ "<pool name>" ]'';
example = [
"tank/archive"
"tank/media"
];
description = ''
Encryption roots to unlock, in order. These must be encryption
ROOTS — `zfs load-key` on an inheriting child fails with
"Keys must be loaded for encryption root". Check with
`zfs get -r encryptionroot <pool>`.
A pool created with `zpool create -O encryption=...` has exactly one
encryption root (the pool itself), no matter how many disks the vdev
has; list just the pool name in that case.
'';
};
keyFile = lib.mkOption {
type = lib.types.str;
example = "/run/secrets/tank.key";
description = ''
Path to the raw key / passphrase, as it exists at RUNTIME on the
host. Deliberately a string, not a `path`: a `path` would copy the
key into the world-readable Nix store. Point this at a secret
manager's output (agenix, sops-nix, systemd-creds, a ramfs file).
The contents must match the dataset's `keyformat` — for
`keyformat=passphrase` that is 8..512 characters with no trailing
newline.
'';
};
useCredential = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Hand the key to the unit through systemd's `LoadCredential=`
instead of letting `zfs` read {option}`keyFile` directly.
Two things come for free: systemd reads the source as root at unit
start and exposes it 0400 in a per-unit tmpfs, and a missing or
unreadable source makes systemd refuse to start the unit — a
fail-closed check you do not have to write.
Turn this off only if the key source is not readable at unit start
(e.g. it appears on a filesystem this unit itself must unlock).
'';
};
preflight = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
When {option}`useCredential` is off, test the key file for
readability first and exit with a clear message instead of letting
`zfs load-key` fail with "Key load error: Failed to open key
material file".
Do NOT replace this with `unitConfig.ConditionPathExists` on the key
file: a failed Condition marks the unit *successfully skipped*, so
the boot proceeds with the datasets locked and consumers write into
the bare mountpoint. That is fail-OPEN. This check is fail-closed.
'';
};
unitName = lib.mkOption {
type = lib.types.str;
default = "zfs-load-key-${name}";
defaultText = lib.literalExpression ''"zfs-load-key-<pool name>"'';
description = "Name of the generated systemd unit (without `.service`).";
};
description = lib.mkOption {
type = lib.types.str;
default = "Load encryption key for ${name} ZFS pool";
defaultText = lib.literalExpression ''"Load encryption key for <pool name> ZFS pool"'';
description = "systemd unit description.";
};
wantedBy = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "multi-user.target" ];
description = "Targets that pull the key-load unit in at boot.";
};
after = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "zfs-import-${name}.service" ];
defaultText = lib.literalExpression ''[ "zfs-import-<pool name>.service" ]'';
description = ''
Units this must run after. The default is nixpkgs' generated import
unit for the pool — a key cannot be loaded into a pool that is not
imported yet. If the key itself lives on another encrypted pool, add
that pool's key-load unit here too.
'';
};
before = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "nginx.service" ];
description = ''
Units ordered after the key is loaded. Ordering ONLY: these still
start if the key never loads. Use {option}`requiredBy` for consumers
that must not run at all against locked datasets.
'';
};
requiredBy = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "nginx.service" ];
description = ''
Units that hard-depend on the key being loaded: they get
`Requires=` on this unit and are ordered after it, so a failed key
load stops them from starting instead of letting them write into an
empty mountpoint.
'';
};
mounts = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = {
"/tank/media" = "tank/media";
};
description = ''
Mountpoint -> dataset. Generates `fileSystems` entries with
`zfsutil` plus `x-systemd.requires=` / `x-systemd.after=` on the
key-load unit, so the mount cannot be attempted while the dataset is
still locked. Anything that declares `RequiresMountsFor=` on one of
these paths then inherits the whole chain.
'';
};
mountOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [
"zfsutil"
"noauto"
"x-systemd.wanted-by=multi-user.target"
];
example = [
"zfsutil"
"noauto"
"x-systemd.wanted-by=multi-user.target"
];
description = ''
Base mount options for the generated {option}`mounts` entries; the
`x-systemd.requires=` / `x-systemd.after=` ordering options are
appended.
The default deliberately keeps these mounts OUT of
`local-fs.target`, and that is load-bearing. An ordinary
`systemd.services` unit carries `DefaultDependencies=yes`, hence
`After=basic.target`, and `basic.target` is ordered after
`sysinit.target` after `local-fs.target`. Wire a *local-fs* mount to
`x-systemd.requires=` this unit and you close a loop:
local-fs.target -> <your>.mount -> zfs-load-key-<pool>.service
-> basic.target -> sysinit.target -> local-fs.target
systemd does not refuse to boot on that. It breaks the loop by
DELETING jobs — in practice `local-fs.target` itself, plus whatever
else it picks — logs `Found ordering cycle` to the journal, and
carries on looking healthy. `noauto` drops the
`Before=local-fs.target` edge and `x-systemd.wanted-by=` puts the
mount back on the boot path late, where it cannot cycle.
Do NOT "fix" the cycle by setting `DefaultDependencies=no` on the
key-load unit instead: that pulls it in front of everything that
produces the key at runtime, which is the failure in the table at
the top of the README.
'';
};
mountAll = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Run `zfs mount -a` at the end of the unit, for pools whose datasets
use ZFS-managed (non-`legacy`) mountpoints and are therefore not
declared in {option}`mounts`. Prefer {option}`mounts`: declared
mount units are what lets other units order against them.
'';
};
};
};
zfsBin = "${cfg.package}/sbin/zfs";
keyUrl =
pool:
if pool.useCredential then
''"file://$CREDENTIALS_DIRECTORY/${credentialName}"''
else
''"file://${pool.keyFile}"'';
poolScript = pool: ''
${lib.optionalString (!pool.useCredential && pool.preflight) ''
if [ ! -r ${lib.escapeShellArg pool.keyFile} ]; then
echo "key file ${pool.keyFile} is missing or unreadable; refusing to continue with locked datasets" >&2
exit 1
fi
''}
for dataset in ${lib.escapeShellArgs pool.datasets}; do
status=$(${zfsBin} get -H -o value keystatus "$dataset" || true)
case "$status" in
available)
echo "key already loaded for $dataset"
continue
;;
unavailable)
echo "loading key for $dataset"
${zfsBin} load-key -L ${keyUrl pool} "$dataset"
;;
*)
echo "$dataset: unexpected keystatus '$status' (not an encryption root, or dataset missing)" >&2
exit 1
;;
esac
done
${lib.optionalString pool.mountAll "${zfsBin} mount -a"}
'';
unitFor = pool: {
name = pool.unitName;
value = {
inherit (pool) description wantedBy after;
before = lib.unique (pool.before ++ pool.requiredBy);
requiredBy = pool.requiredBy;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
}
// lib.optionalAttrs pool.useCredential {
LoadCredential = "${credentialName}:${pool.keyFile}";
};
script = poolScript pool;
};
};
mountsFor =
pool:
lib.mapAttrs (_mountpoint: dataset: {
device = dataset;
fsType = "zfs";
options = pool.mountOptions ++ [
"x-systemd.requires=${pool.unitName}.service"
"x-systemd.after=${pool.unitName}.service"
];
}) pool.mounts;
pools = lib.attrValues cfg.pools;
in
{
options.services.zfsNativeKeys = {
enable = lib.mkEnableOption "loading ZFS native encryption keys for non-root pools from a runtime key file";
package = lib.mkOption {
type = lib.types.package;
default = config.boot.zfs.package;
defaultText = lib.literalExpression "config.boot.zfs.package";
description = ''
ZFS userland used by the generated units. Defaults to the same build
the rest of the system uses, so the CLI can never drift away from the
loaded kernel module across an upgrade.
'';
};
disableZfsMountService = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Disable nixpkgs' `zfs-mount.service` (`zfs mount -a`).
Set this when every dataset you care about is declared in
{option}`mounts` (i.e. `mountpoint=legacy`): the blanket `zfs mount -a`
then has nothing legitimate to do, but still runs early enough to race
the declared mount units and fails outright on any dataset whose key is
not loaded yet. Leave it alone if other datasets on the host rely on
ZFS-managed mountpoints.
'';
};
restrictImportCredentialsTo = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
example = [ "rpool" ];
description = ''
Convenience for pinning {option}`boot.zfs.requestEncryptionCredentials`
to a specific dataset list, so nixpkgs' import units stop trying to
unlock the pools this module manages.
It defaults to `true` upstream, which means "ask for every encrypted
dataset at import" — for a dataset carrying `keylocation=prompt` that is
a `systemd-ask-password` with `boot.zfs.passwordTimeout` (default 0 =
wait forever) in front of the pool's mount units. Set this to just the
root pool's datasets and let this module handle the rest.
`null` (default) leaves the option untouched.
'';
};
pools = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule poolOpts);
default = { };
description = ''
Pools to unlock, keyed by POOL NAME (the attribute name is used to
derive the default import unit to order after, and the default dataset
list).
'';
example = lib.literalExpression ''
{
tank = {
datasets = [ "tank/archive" "tank/media" ];
keyFile = "/run/secrets/tank.key";
mounts = {
"/tank/archive" = "tank/archive";
"/tank/media" = "tank/media";
};
requiredBy = [ "nginx.service" ];
};
}
'';
};
};
config = lib.mkIf (cfg.enable && cfg.pools != { }) {
assertions =
lib.mapAttrsToList (poolName: pool: {
assertion = lib.all (ds: ds == poolName || lib.hasPrefix "${poolName}/" ds) pool.datasets;
message = ''
services.zfsNativeKeys.pools.${poolName}: every dataset must belong to
pool "${poolName}" (the attribute name IS the pool name, and is what
the default `after = [ "zfs-import-${poolName}.service" ]` is built
from). Got: ${lib.concatStringsSep ", " pool.datasets}
'';
}) cfg.pools
++ lib.mapAttrsToList (poolName: pool: {
assertion = pool.keyFile != "";
message = "services.zfsNativeKeys.pools.${poolName}.keyFile must be set to a runtime path holding the key.";
}) cfg.pools;
systemd.services = lib.mkMerge [
(lib.listToAttrs (map unitFor pools))
(lib.mkIf cfg.disableZfsMountService { zfs-mount.enable = false; })
];
fileSystems = lib.mkMerge (map mountsFor pools);
boot.zfs.requestEncryptionCredentials = lib.mkIf (
cfg.restrictImportCredentialsTo != null
) cfg.restrictImportCredentialsTo;
};
}