From daa70fe7ac51e9c6b477d0b1526bd43c53be9fbe Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 28 Oct 2026 20:54:24 +0100 Subject: [PATCH] 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 --- .gitignore | 1 + examples/image/default.nix | 58 +++++++++++++++++++ flake.nix | 15 +++++ modules/image/default.nix | 115 +++++++++++++++++++++++++++++++++++++ tests/default.nix | 1 + tests/image.nix | 104 +++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+) create mode 100644 examples/image/default.nix create mode 100644 modules/image/default.nix create mode 100644 tests/image.nix diff --git a/.gitignore b/.gitignore index 3c85ede..e2d4f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ result result-* .nixos-test-history +image diff --git a/examples/image/default.nix b/examples/image/default.nix new file mode 100644 index 0000000..2a2cdac --- /dev/null +++ b/examples/image/default.nix @@ -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"; + }; + }; + }; +} diff --git a/flake.nix b/flake.nix index baebecd..ccf998b 100644 --- a/flake.nix +++ b/flake.nix @@ -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 -- `: activate a new # configuration with commit-confirm; see scripts/deploy.sh. apps.x86_64-linux.deploy = diff --git a/modules/image/default.nix b/modules/image/default.nix new file mode 100644 index 0000000..3326f56 --- /dev/null +++ b/modules/image/default.nix @@ -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; + }; + }; + }; + }; +} diff --git a/tests/default.nix b/tests/default.nix index bdce0e1..0b2a7f7 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -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; }; diff --git a/tests/image.nix b/tests/image.nix new file mode 100644 index 0000000..fab5299 --- /dev/null +++ b/tests/image.nix @@ -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") + ''; +}