Add a read-only appliance image with a dm-verity protected store

nixosModules.image builds a GPT disk image with systemd-repart: ESP with a
UKI carrying the store's verity root hash, an erofs /nix/store on a
dm-verity partition, a tmpfs root, an immutable /etc overlay and an ext4
state partition on /var. No Nix on the device; it is replaced as a whole.

The store is made with 4 KiB erofs blocks: systemd-repart passes the 512
byte sector size to mkfs.erofs, and libblkid cannot verify the checksum of
such a superblock, so udev never reported the file system and the initrd
waited forever for /dev/mapper/usr.

examples/image (nix build .#image) is a hardware test build with a debug
console password; checks.image boots the image under UEFI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 20:54:24 +01:00
co-authored by Claude Opus 5.5
parent 406196adce
commit daa70fe7ac
6 changed files with 294 additions and 0 deletions
+1
View File
@@ -1,3 +1,4 @@
result
result-*
.nixos-test-history
image
+58
View File
@@ -0,0 +1,58 @@
# A bootable appliance image for trying the verity/read-only layout on real
# hardware (`nix build .#image`, then write image.raw to a disk). Same network
# shape as examples/desktop: one NIC, LAN untagged, PPPoE on VLAN 1001.
#
# DEBUGGING ONLY: root has the console password `testtest`, readable in the
# Nix store and this repository. Remove it before any real use.
{ lib, ... }:
{
imports = [ ../../modules/image ];
networking.hostName = "router";
system.stateVersion = "26.11";
# Generic UEFI PC: storage, USB keyboard and NIC drivers in the initrd.
boot.initrd.availableKernelModules = [
"ahci"
"nvme"
"xhci_pci"
"usb_storage"
"sd_mod"
"virtio_blk"
"virtio_pci"
"virtio_scsi"
];
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHqyMLgAhEdnrRrd2CeYbphGDMPq/cBDjFJ4q4ItZusn nils@Nilss-Mac-mini.mch.stinnesbeck.com"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBFxbNNk87tvMxruuKvrryK7iAdf4xgS4xFqjKejNAI6 nils@nixtop"
];
users.users.root.password = "testtest";
warnings = [
"examples/image: root has the well-known debug console password `testtest`; remove it before production use."
];
router = {
enable = true;
wan = {
interface = "enp2s0";
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "CHANGE-ME@isp.example";
passwordFile = "/var/lib/router/pppoe-password";
};
};
};
lans.main = {
interface = "enp2s0";
internet = true;
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
};
}
+15
View File
@@ -21,6 +21,8 @@
# The installer pieces; `lib.mkInstaller` wires them up.
installer = ./modules/installer/install-script.nix;
installer-iso = ./modules/installer/iso.nix;
# Appliance disk image: dm-verity store, volatile root, state on /var.
image = ./modules/image;
};
# Installer ISO for a given nixosSystem:
@@ -66,6 +68,19 @@
];
};
# Appliance image (verity store, volatile root) for trying on hardware:
# nix build .#image -> result/*.raw
nixosConfigurations.image = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
self.nixosModules.router
self.nixosModules.image
./examples/image
];
};
packages.x86_64-linux.image = self.nixosConfigurations.image.config.system.build.image;
# `nix run .#deploy -- <flake-attr> <user@host>`: activate a new
# configuration with commit-confirm; see scripts/deploy.sh.
apps.x86_64-linux.deploy =
+115
View File
@@ -0,0 +1,115 @@
# Appliance image: a GPT disk image with a dm-verity protected, read-only
# /nix/store partition, a volatile (tmpfs) root and a small persistent state
# partition for /var. Built with systemd-repart; the unified kernel image on the
# ESP carries the hash of the store partition, so a modified store does not boot.
#
# The system cannot be rebuilt on the device (no Nix); it is replaced as a whole.
# Importing this module turns a configuration into such an image
# (`config.system.build.image`).
{
config,
lib,
pkgs,
modulesPath,
...
}:
let
cfg = config.router.image;
inherit (config.image.repart.verityStore) partitionIds;
efiArch = pkgs.stdenv.hostPlatform.efiArch;
in
{
imports = [
(modulesPath + "/image/repart.nix")
(modulesPath + "/profiles/image-based-appliance.nix")
(modulesPath + "/profiles/perlless.nix")
];
options.router.image = {
espSize = lib.mkOption {
type = lib.types.str;
default = "128M";
description = "Size of the EFI system partition (holds the unified kernel image).";
};
stateSize = lib.mkOption {
type = lib.types.str;
default = "512M";
description = "Minimum size of the persistent state partition mounted on /var.";
};
};
config = {
system.image.id = lib.mkDefault "nixos-router";
# No Nix on the device: the rollback helpers need it, and boot counting
# takes over their job in images.
router.rollback.enable = false;
boot.loader.grub.enable = false;
fileSystems = {
"/" = {
fsType = "tmpfs";
options = [
"mode=0755"
"size=50%"
];
};
# The only persistent, writable place: DHCP leases, SSH host keys,
# journal, secrets (/var/lib/router).
"/var" = {
device = "/dev/disk/by-partlabel/state";
fsType = "ext4";
options = [
"nosuid"
"nodev"
];
};
};
# /etc is a read-only overlay; users are created by userborn, which keeps
# its password files on the state partition.
system.etc.overlay.mutable = false;
services.userborn.passwordFilesLocation = "/var/lib/nixos";
# Host keys live on the state partition as well.
services.openssh.hostKeys = [
{
path = "/var/lib/ssh/ssh_host_ed25519_key";
type = "ed25519";
}
];
# /usr is the read-only verity mount; there is nothing to create there.
system.activationScripts.usrbinenv = lib.mkForce "";
image.repart = {
enable = true;
# systemd-repart passes the image's sector size (512) to mkfs.erofs as the
# block size; libblkid cannot verify the superblock checksum of an erofs
# with blocks <= 1 KiB, so udev never reports the store's file system and
# systemd waits forever for /dev/mapper/usr. Appended options win.
mkfsOptions.erofs = [ "-b4096" ];
verityStore = {
enable = true;
# The firmware's default boot path: no NVRAM entry needed.
ukiPath = "/EFI/BOOT/BOOT${lib.toUpper efiArch}.EFI";
};
partitions = {
${partitionIds.esp}.repartConfig = {
Type = "esp";
Format = "vfat";
SizeMinBytes = cfg.espSize;
};
"30-state".repartConfig = {
Type = "var";
Format = "ext4";
Label = "state";
SizeMinBytes = cfg.stateSize;
};
};
};
};
}
+1
View File
@@ -10,6 +10,7 @@
rollback = import ./rollback.nix { inherit self pkgs; };
dns = import ./dns.nix { inherit self pkgs; };
installer = import ./installer.nix { inherit self pkgs; };
image = import ./image.nix { inherit self pkgs; };
wireguard = import ./wireguard.nix { inherit self pkgs; };
lib = import ./lib.nix { inherit pkgs; };
assertions = import ./assertions.nix { inherit self pkgs; };
+104
View File
@@ -0,0 +1,104 @@
# The appliance image: boot the raw disk image under UEFI and check what makes
# it an appliance (read-only verity store, volatile root, persistent /var) while
# the router itself runs.
{ self, pkgs }:
let
common = import ./common.nix;
in
pkgs.testers.runNixOSTest {
name = "router-image";
nodes.machine =
{ lib, ... }:
{
imports = [
self.nixosModules.router
self.nixosModules.image
common.tools
];
# The VM boots the image's own disk, not the usual test plumbing.
virtualisation.fileSystems = lib.mkVMOverride { };
virtualisation.directBoot.enable = false;
virtualisation.mountHostNixStore = false;
virtualisation.useEFIBoot = true;
virtualisation.vlans = [
1
2
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
system.image.version = "1";
router = {
enable = true;
wan.interface = "eth1";
lans.main = {
interface = "eth2";
internet = true;
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
};
};
testScript =
{ nodes, ... }:
''
import os
import subprocess
import tempfile
disk = tempfile.NamedTemporaryFile()
subprocess.run([
"${nodes.machine.virtualisation.qemu.package}/bin/qemu-img", "create",
"-f", "qcow2",
"-b", "${nodes.machine.system.build.image}/${nodes.machine.image.filePath}",
"-F", "raw", disk.name,
], check=True)
os.environ["NIX_DISK_IMAGE"] = disk.name
machine.wait_for_unit("multi-user.target")
with subtest("the disk has the appliance layout"):
parts = machine.succeed("lsblk -n -o PARTLABEL /dev/vda")
for label in ("esp", "store-verity", "store", "state"):
assert label in parts, parts
with subtest("root is volatile and /nix/store is a dm-verity protected read-only partition"):
machine.succeed("findmnt --kernel --type tmpfs /")
assert "ACTIVE" in machine.succeed("dmsetup info --target verity usr")
backing = machine.succeed("df --output=source /nix/store | tail -n1").strip()
assert backing == "/dev/mapper/usr", backing
machine.fail("touch /nix/store/should-not-work")
machine.fail("touch /etc/should-not-work")
with subtest("the image identifies itself"):
machine.succeed("grep -qE '^IMAGE_VERSION=.?1.?$' /etc/os-release")
machine.succeed("grep -qE '^IMAGE_ID=.?nixos-router.?$' /etc/os-release")
with subtest("the router comes up from the image"):
machine.wait_for_unit("nftables.service")
machine.wait_for_unit("unbound.service")
machine.wait_for_unit("kea-dhcp4-server.service")
machine.wait_until_succeeds("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
with subtest("there is no Nix on the device"):
machine.fail("command -v nix-env")
with subtest("/var is on the state partition and survives a reboot, the root does not"):
src = machine.succeed("findmnt -n -o SOURCE /var").strip()
assert "vda" in src, src
machine.succeed("echo kept > /var/lib/marker; echo lost > /root/marker; sync")
machine.shutdown()
machine.start()
machine.wait_for_unit("multi-user.target")
assert machine.succeed("cat /var/lib/marker").strip() == "kept"
machine.fail("test -e /root/marker")
machine.wait_for_unit("nftables.service")
'';
}