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:
@@ -1,3 +1,4 @@
|
|||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
.nixos-test-history
|
.nixos-test-history
|
||||||
|
image
|
||||||
|
|||||||
@@ -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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
# The installer pieces; `lib.mkInstaller` wires them up.
|
# The installer pieces; `lib.mkInstaller` wires them up.
|
||||||
installer = ./modules/installer/install-script.nix;
|
installer = ./modules/installer/install-script.nix;
|
||||||
installer-iso = ./modules/installer/iso.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:
|
# 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
|
# `nix run .#deploy -- <flake-attr> <user@host>`: activate a new
|
||||||
# configuration with commit-confirm; see scripts/deploy.sh.
|
# configuration with commit-confirm; see scripts/deploy.sh.
|
||||||
apps.x86_64-linux.deploy =
|
apps.x86_64-linux.deploy =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
rollback = import ./rollback.nix { inherit self pkgs; };
|
rollback = import ./rollback.nix { inherit self pkgs; };
|
||||||
dns = import ./dns.nix { inherit self pkgs; };
|
dns = import ./dns.nix { inherit self pkgs; };
|
||||||
installer = import ./installer.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; };
|
wireguard = import ./wireguard.nix { inherit self pkgs; };
|
||||||
lib = import ./lib.nix { inherit pkgs; };
|
lib = import ./lib.nix { inherit pkgs; };
|
||||||
assertions = import ./assertions.nix { inherit self pkgs; };
|
assertions = import ./assertions.nix { inherit self pkgs; };
|
||||||
|
|||||||
+104
@@ -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")
|
||||||
|
'';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user