Files
nixos-router/modules/image/default.nix
T
nilsandClaude Opus 5.5 daa70fe7ac 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>
2026-10-28 20:54:24 +01:00

116 lines
3.3 KiB
Nix

# 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;
};
};
};
};
}