NixOS ❄: Remote unlock of encrypted ZFS over Tailscale

This is a follow-up to my earlier post: Remote unlock of encrypted ZFS over SSH.

That setup has one limitation: the SSH server in the initrd is only reachable on my local network. If my file server reboots while I’m away, for a power cut or a kernel update, it just sits there waiting for passphrases until I get home. I could forward a port on my router, but I really don’t want an SSH server exposed to the internet, and certainly not one running in an initrd.

All my machines are already on Tailscale, so the obvious solution is to have the initrd join the tailnet as well.

The idea

The initrd joins the tailnet as its own node, server-main-elis-initrd, separate from the booted system’s server-main-elis. It has its own identity, its own address and its own name, which has a few nice consequences:

There is no ready-made NixOS option for this, but with the systemd initrd it’s just one extra service.

Registering the node

The initrd can’t do an interactive login, so the node is registered once beforehand and its state file is then baked into the initrd. I did this on the server itself, next to its regular tailscaled.

First, allow yourself to assign a tag to it in the Tailscale ACL policy:

"tagOwners": {
  "tag:initrd": ["autogroup:admin"],
},

Tagged devices have key expiry disabled by default, which is exactly what we want here. Otherwise the initrd would get locked out of the tailnet after a few months.

Then start a second tailscaled with its own state directory and socket, using userspace networking and a random UDP port so it doesn’t collide with the one that is already running:

mkdir -p /root/initrd-ts
tailscaled \
  --tun=userspace-networking \
  --port=0 \
  --statedir=/root/initrd-ts \
  --state=/root/initrd-ts/tailscaled.state \
  --socket=/root/initrd-ts/tailscaled.sock

In another terminal, bring it up with the hostname and the tag:

tailscale --socket=/root/initrd-ts/tailscaled.sock up \
  --hostname=server-main-elis-initrd \
  --advertise-tags=tag:initrd

Open the login URL it prints and approve the node. Then stop tailscaled with Ctrl-C. Don’t run tailscale logout, that invalidates the node key and the state file becomes useless.

What’s left is /root/initrd-ts/tailscaled.state, which contains the node’s identity, including the hostname and tag. I encrypted it with agenix, like the initrd SSH host key, and removed the plaintext copies.

Configuration

The state file goes into the initrd with boot.initrd.secrets, which copies it in when the bootloader is installed. Then a service starts tailscaled in the initrd:

{ config, lib, pkgs, ... }: {
  # Decrypted by agenix on the booted system, then copied into the
  # initrd when the bootloader is installed.
  age.secrets.initrd-tailscaled-state = {
    file = ./secrets/initrd-tailscaled-state.age;
    path = "/persistent/etc/initrd/tailscaled.state";
    symlink = false;
  };

  boot.initrd.secrets."/var/lib/tailscale/tailscaled.state" =
    config.age.secrets.initrd-tailscaled-state.path;

  # Only the Go binary goes into the initrd, see below.
  boot.initrd.systemd.extraBin =
    let
      tailscaled = pkgs.runCommand "tailscaled-initrd" { } ''
        install -Dm755 ${pkgs.tailscale}/bin/.tailscaled-wrapped $out/bin/tailscaled
      '';
    in
    {
      tailscaled = "${tailscaled}/bin/tailscaled";
      # It's a multicall binary, called as tailscale it's the CLI.
      tailscale = "${tailscaled}/bin/tailscaled";
    };

  boot.initrd.systemd.services.tailscaled = {
    description = "Tailscale node agent in the initrd";
    wantedBy = [ "initrd.target" ];
    after = [ "network.target" ];
    # Stop before switching root, so the real tailscaled starts cleanly.
    conflicts = [ "initrd-switch-root.target" ];
    before = [ "initrd-switch-root.target" ];
    unitConfig.DefaultDependencies = false;
    serviceConfig = {
      ExecStart = lib.concatStringsSep " " [
        "/bin/tailscaled"
        "--tun=userspace-networking"
        "--statedir=/var/lib/tailscale"
        "--state=/var/lib/tailscale/tailscaled.state"
      ];
      Restart = "on-failure";
    };
  };

  # And move the initrd SSH server to the normal port.
  boot.initrd.network.ssh.port = 22;
}

This builds on the configuration from the previous post, which sets up networking, the SSH server and the password agent in the initrd.

Deploy the secret first

There’s a chicken and egg problem in the configuration above. boot.initrd.secrets reads the decrypted state file from the booted system when the bootloader is installed. But when switching to a new configuration, NixOS installs the bootloader before it runs the activation, which is where agenix decrypts the secrets. So on the first deploy the file doesn’t exist yet, and installing the bootloader fails.

The solution is to deploy in two steps. First deploy only the age.secrets.initrd-tailscaled-state part, so the decrypted file is in place. Then add boot.initrd.secrets and the tailscaled service and deploy again.

Userspace networking

With --tun=userspace-networking, tailscaled runs its own network stack instead of creating a tailscale0 interface. This means the initrd needs no tun kernel module and no firewall rules, and incoming connections from the tailnet are simply forwarded to the same port on localhost, where the initrd’s SSH server is listening.

Only the binary

The tailscale package in nixpkgs wraps tailscaled to put iptables, iproute2 and a few other tools in its PATH. Those are only needed for the kernel networking mode, so I copy the unwrapped Go binary into its own derivation to keep them out of the initrd.

The store path gotcha

My first attempt referenced the binary directly in ExecStart, which is what you’d normally do in a systemd service on NixOS. It built just fine, but on boot the initrd journal said:

tailscaled.service: Unable to locate executable '/nix/store/…-tailscaled-initrd/bin/tailscaled': No such file or directory
tailscaled.service: Failed with result 'exit-code'.

Store paths that are only referenced from a unit in the systemd initrd are not copied into it. They have to be added explicitly, with boot.initrd.systemd.storePaths or, as above, with boot.initrd.systemd.extraBin, which also puts them in /bin. Having tailscale in /bin is handy too, it lets you run tailscale status from the initrd shell when debugging.

Port 22

In the previous post the initrd SSH server listened on port 2222, so it didn’t share an address and port with the booted system’s SSH server but with a different host key. Over Tailscale that’s no longer an issue, since the initrd is its own host.

On the local network the address is still shared, so it will present either the initrd’s or the booted system’s host key depending on the boot stage. OpenSSH accepts any of the keys listed for a host, so I list both keys for the LAN address in programs.ssh.knownHosts. On machines where known hosts aren’t managed, add the initrd’s key by hand once, otherwise SSH refuses to connect with the scary “REMOTE HOST IDENTIFICATION HAS CHANGED” warning.

Locking it down

The state file is copied into the initrd, and the initrd lives unencrypted on the boot partition. So anyone with access to the disk can read it and join the tailnet as server-main-elis-initrd. The initrd SSH host key has exactly the same problem, it’s inherent to running anything before the disks are unlocked.

That’s where the tag comes in. Only allow incoming SSH to it, and nothing from it:

"acls": [
  // ...
  { "action": "accept", "src": ["autogroup:member"], "dst": ["tag:initrd:22"] },
],

Since no rule has tag:initrd as its source, a stolen state file only gets you a node that can be reached on port 22, and can’t reach anything itself.

Using it

When the server boots and is waiting for the ZFS passphrases, SSH to the initrd’s node from anywhere on the tailnet:

ssh root@server-main-elis-initrd

The password agent from the previous post starts right away and prompts for each pool’s passphrase. Once the last one is entered, the initrd stops its tailscaled, switches to the real root and the booted system comes up as server-main-elis on the tailnet. The initrd node then just shows as offline until the next reboot.