Roadmap
How Janus was built, phase by phase - what each phase delivered and proved, and what’s planned. The design itself is in the architecture.
-
Phase 0 (done): repo structure, gRPC contract, CI/CD skeleton, build-system placeholders.
-
Phase 1 (boot proof done): a from-allnoconfig, 1610-line explicit kernel config (
kernel/configs/janus_defconfig- no network, no disk/block drivers, no ACPI, initramfs-only) boots under QEMU withrootfs/init- a plainCGO_ENABLED=0Go binary - as PID 1. Verified end-to-end viamake qemu-boot-test(kernel/Dockerfile’sbuild/exportstages +hack/build-initramfs.sh+hack/qemu-run.sh), both locally and via the identical Docker build onjanus-runner01(image-build.yml). Turned out not to needpkgs/musl-toolchainorpkgs/busyboxat all: a statically-linked Go binary needs no libc, so there’s nothing for PID 1 to link against. (Thepkgs/busyboxplaceholder - andpkgs/bird|keepalived|nftables, superseded byextensions/- were removed on 2026-10-03;pkgs/musl-toolchainbecame the arm64 cross-toolchain.) Still open for Phase 1: real rootfs assembly beyond a single init binary (rootfs/assemble.shstill a stub) isn’t needed yet either, since the initramfs is the whole rootfs for this boot-proof milestone. -
Phase 2 (done): HAProxy integration (static musl build, supervised by
janusd),HAProxyService’s core RPCs implemented and reachable inside the QEMU-booted kernel itself - the kernel config grew real networking (virtio-net,CONFIG_UNIX/INET, DHCP via kernel-builtinIP_PNP- no userspace network tooling needed), androotfs/initnow supervisesjanusd(which superviseshaproxy) instead of just proving the boot chain. Verified withmake qemu-network-test: a host port forwarded to the guest’s HAProxy actually answers real HTTP, both locally and viaimage-build.ymlonjanus-runner01. mTLS is now mandatory on every connection (see “mTLS / PKI” above) -credentials.NewTLSon the gRPC server, no plaintext fallback, verified with a real mismatched-CA connection attempt being rejected (unit test + manual check) and a freshGenerateClientConfiguration-issued cert authenticating successfully. Role enforcement (os:admin/os:reader) is implemented and verified too - see “mTLS / PKI” above.HAProxyService’s runtime map/ACL/certificate RPCs are also implemented now, against real, empirically-verified HAProxy runtime API behavior rather than assumed syntax (probed a live instance’shelpoutput and tested each command directly before writing the Go code - seeinternal/haproxy/runtime_maps.go/runtime_certs.go). Two things worth remembering if you touch this:MapList/ACLUpdateonly see file-backed maps/ACLs (map(<path>)/acl ... -f <path>in the running config - inline ones aren’t addressable via the runtime API at all), and HAProxy’sset mapdoes not upsert - it errors on a missing key - soMapUpdate/ACLUpdate’s “set” path is really delete-then-add.CertificateUpload/CertificateList/CertificateDeletemanage HAProxy’s certificate store (new/set/commit/del ssl cert) and - now,CertificateUpload’s optionalcrt_list/snifields - can also bind an uploaded cert into acrt-lista runningbind ... ssl crt-list <path>already references (add ssl crt-list, with SNI filters), making it actually reachable by TLS clients instead of just sitting in the store reporting “Unused”.CertificateDelete’s matchingcrt_listfield unbinds first - HAProxy refusesdel ssl certon anything still bound (“in use, can’t be deleted!”). Runtime changes only live in the process that received them, so janusd keeps every uploaded certificate and its binding in a store on STATE (/etc/haproxy/runtime-certs, keys 0600) and puts them back into each new HAProxy process right after a reload, restart or reboot (internal/haproxy/certstore.go). One real constraint worth remembering: HAProxy refuses to even start abind ... ssl crt-list <path>whose crt-list file is empty (“no SSL certificate specified”) - a crt-list-backed listener needs at least one seed certificate already in the file at boot, uploaded certs are additional entries, not the first one. Verified with real TLS handshakes, SNI included: uploaded + bound a second certificate under a distinct SNI name, confirmedopenssl s_client -servername <name>gets that certificate while a plain connection with no SNI still gets the original seed certificate, then unbound + deleted it. This was the one Phase 2 gap left open before - Phase 2 is now fully done.The other two gaps this phase had are closed:
Restart-on-crash.
rootfs/initno longer just reaps zombies forever -rootfs/init/supervisor.go’sSupervisorrestartsjanusdevery time it exits, with a backoff that grows (capped at 30s) on fast repeated crashes and resets to the 1s minimum once an instance has stayed up 60s (so one old crash loop doesn’t leave a later, unrelated crash waiting the full backoff to recover). There’s deliberately no give-up threshold -janusdis the only way to reach a node at all (see “no shell” above), so stopping restarts after N failures - systemd’s default - would leave the node permanently unmanageable with no fallback the way SSH would be for a normal box. All child-reaping happens through a single sharedwait4(-1, ...)loop, matching the pattern (never callexec.Cmd.Wait()when a supervisor loop also reaps children directly)internal/haproxy. Manageralready relied on forhaproxy’s own-sfreload; a second, independent reap loop would race the first to collect the same pid. Verified with real subprocesses, not mocks (rootfs/init/supervisor_test.go): three consecutive crashes produce three distinct new pids, the backoff sequence is exactly right for a fast crash loop and resets correctly after a stable run, an unrelated decoy child exiting is never mistaken for the supervised process, and a process that fails to even start doesn’t hang the loop.HAProxy privilege drop. The bootstrap
haproxy.cfgnow setschroot /var/empty+ numericuid 1000/gid 1000(no/etc/passwdon this rootfs to resolve nameduser/groupagainst, and HAProxy doesn’t need one for numeric ids).janusdcreates/var/emptyitself (-haproxy-chroot-dir, mode0000- genuinely empty and inaccessible, since nothing is ever opened from inside it: every file HAProxy touches - config, maps, ACLs, certs, the stats socket bind - is opened before it chroots and drops privileges). HAProxy’s own “started as root without chroot” warning is gone. Verified end-to-end with privilege dropping actually active, not just config-parses-cleanly: confirmed the livehaproxyprocess’s real UID/GID (ps), and reran the full ApplyConfig/map/ACL/cert test sequence against it to confirm none of that broke under chroot + dropped privileges - it doesn’t, since all the file access those need happens pre-chroot. HAProxy’s own metrics keep using its built-in Prometheus exporter (internal/haproxyjust proxies the runtime socket/config, it doesn’t reimplement metrics export) -internal/exporter(Janus’s own, system-level, built on top of the gRPC API) is explicitly deferred past Phase 2, not part of this phase. -
Phase 3 (build side started): real immutability - A/B, dm-verity, UKI, Secure Boot,
LifecycleService.Install/Upgrade/Rollback.rootfs/assemble.shbuilds a real squashfs image of the rootfs (same content as Phase 2’s initramfs - init, janusd, static haproxy, bootstrap config --all-rootsince there’s no/etc/passwdto resolve any other owner against) and computes its dm-verity hash tree viaveritysetup format, requiring neither step to run as root. The kernel config grewSQUASHFS/DM_VERITYsupport (both nested behind gating menus -MISC_FILESYSTEMSandMDrespectively - thatmerge_config.shdoesn’t warn about if you forget them, it just silently drops the symbol; caught by grepping the merged.configafterward, not by trusting a clean merge). Verified two waysveritysetup verifyactually enforces integrity, not just that the happy path works: accepts the real image against its own root hash, and rejects a deliberately single-byte-tampered copy of it, reporting the exact corrupted block position. A real build-time bug caught along the way:mksquashfs, run as a non-root build user, silently drops any directory it can’topen()to traverse - including one this project itselfchmod 000’d on purpose (the HAProxy chroot jail,/var/empty) - with only a one-line, easy-to-miss “Could not open … skipping” warning. Fixed by defining that entry as anmksquashfspseudo-file (-p "var/empty D 0 0000 0 0") instead of a real chmod’d directory in the build tree, so it never needs to be traversed at all.mktemp -d’s default0700leaking into the squashfs root directory’s own mode was a second, related “build user’s own environment quietly changes the image” bug, fixed with an explicit-root-mode 0755. The kernel now boots root directly from that dm-verity-protected squashfs image - no initramfs, no userspace verity setup at all - via thedm-mod.create=cmdline parameter (CONFIG_DM_INIT, built for exactly this: “allow mounting rootfs without requiring an initramfs”). Two virtio-blk drives (squashfs data + verity hash tree), the kernel assembles and verifies/dev/dm-0itself before mounting it read-only and running/sbin/initstraight out of the verified image (seehack/qemu-verity-boot-test.sh). Getting there needed three more kernel config additions, each found by a real boot failing first, not by reading docs in advance:CONFIG_VIRTIO_BLK(itself gated behinddrivers/block’s ownmenuconfig BLK_DEV, the same silently-dropped-symbol trap asSQUASHFS/DM_VERITYabove -CONFIG_BLK_DEV=yfirst);CONFIG_DM_INITfor the cmdline parameter itself; andCONFIG_CRYPTO_SHA256-DM_VERITY’s ownselect CRYPTO_HASHdoesn’t pull in an actual sha256 implementation reachable by name through the crypto API, only the separateCRYPTO_LIB_SHA256helper other kernel code already needed - the first boot attempt got as far as constructing the dm-verity target and failed with “Cannot initialize hash function (-2)”. The exact dm-verity table string (field order, and in particularhash_start_block=1- the hash tree always starts one hash-block afterveritysetup’s own superblock) was derived and confirmed with a realdmsetup create --readonlyagainst loop devices - including deliberately corrupting the underlying data device in place and confirming a live, mounted dm-verity device throws a real I/O error on the next read - before ever putting it in a kernel cmdline./dev/vda//dev/vdbpath references work indm-mod.create=(CONFIG_DEVTMPFS_MOUNT=ygets/devpopulated in time for it), so there was no need to fall back to the kernel doc’s major:minor form. The boot test also reboots against a single-byte-corrupted copy of the image and confirms the kernel refuses to mount it - corrupting the squashfs superblock specifically (offset 0), since a random deeper offset (the one the userspaceveritysetup verifytamper test above uses) can land in a file/sbin/initonly reads after printing its own boot marker, letting a real corruption slip past a naive “did the marker print” check.rootfs/init/main.go’smountEphemeralnow gives the verified root a writable layer, entirely tmpfs-backed:/runand/tmpmounted empty (nothing pre-existing there needs to survive - janusd’s/run/janus, HAProxy’s stats socket/pid file,Manager.Validate’s tmpfile);/etcneeds its bootstraphaproxy.cfgbytes read before the tmpfs overmount and rewritten after, since that file (unlike/run//tmp) isn’t empty on a freshly-booted node - this is what makes both PKI bootstrap (/etc/janus/pki) and a liveApplyConfigRPC (same path) actually work./varis deliberately left alone, still squashfs-backed: the only thing under it is/var/empty, HAProxy’s chroot jail, which must keep the exact immutable mode-0000 baked into the image, not a fresh writable one.hack/qemu-verity-boot-test.sh’s “good” boot now adds virtio-net + DHCP like Phase 2’s own test and asserts real HTTP 200 from HAProxy, not just the boot marker - proving the whole chain (PKI bootstrap, HAProxy startup, config read) genuinely works from a dm-verity-booted, read-only node, not just that the kernel got as far as running/sbin/init. A real persistent STATE partition now backs both/etc/janus/pkiand applied HAProxy config:rootfs/state-image.shpre-formats a small, blank ext4 image at build time (mkfs.ext4- no mkfs binary ships on the target, matching the “no package manager on the node” rule; neededCONFIG_EXT4_FSin the kernel, pulled in cleanly viaselectwith no gating-menu surprise this time), androotfs/init/main.go’s newmountStatemounts it once, at/etc/.state(has to live inside the tmpfsmountEphemeralalready put at/etc- a fresh path like/mnt/statedoesn’t exist on the read-only squashfs root and can’t be created there; caught by a real boot silently regenerating a new CA every time despitemountStaterunning, since every step past the failedMkdirAlljust logged and moved on rather than aborting the boot), then bind-mounts itspki/andhaproxy/subdirectories over/etc/janus/pkiand/etc/haproxyrespectively./etc/haproxyneeds first-boot seeding the same waymountEphemeralalready seeds/etcitself: the bootstraphaproxy.cfgbytes are copied into the persistenthaproxy/subdirectory only if it’s still empty, so a later boot after a realApplyConfignever gets overwritten back to the bootstrap default. Bothcmd/janusd’s PKI bootstrap andinternal/haproxy.Manager.Applynow callsyscall.Sync()right after writing, so durability doesn’t depend on QEMU’s own shutdown-time cache flush. Proven with a real three-boot test (hack/qemu-state-persist-test.sh, not just “the mount didn’t error”), against the samestate.imgeach time: boot 1 must log janusd’s “first boot - generated a new CA” line (fresh bootstrap) and serve on the bootstrap default’s:8080; boot 2 must not log that line again (loaded, not regenerated); between boot 2 and boot 3 the script directly injects a newhaproxy.cfgintostate.imgviadebugfs -w- no mount, no loop device, no root - bound to:8081instead, standing in for a realApplyConfigRPC (a realmount -o loopwas the first thing tried here, and failed outright onjanus-runner01- an unprivileged LXC container - with “failed to setup loop device”, despite working fine locally; already covered elsewhere byimage-build.yml’s own mTLS integration test; what’s under test here is specifically whetherManager.Apply’s write target actually lives on persistent storage) - and boot 3 must answer on:8081and specifically not on:8080, proving HAProxy started from the persisted config, not the squashfs’s read-only bootstrap default. A real GPT A/B partition layout now exists too:image/disk/assemble.shbuilds a single disk image with two independently bootable slots -BOOT-A-DATA/BOOT-A-HASHandBOOT-B-DATA/BOOT-B-HASH(each a squashfs + dm-verity hash tree pair, fixed-size and over-provisioned like a real A/B system, not sized to exactly fit today’s content) plusSTATE- all written directly at computed byte offsets (sgdisk -ifor the exact sector,dd seek=), no mount, no loop device, matching the lesson from the STATE-persistence test’s owndebugfsfix above.CONFIG_EFI_PARTITION(GPT table parsing) turned out to already be on by default - it’s independent ofCONFIG_EFI(the UEFI runtime services feature, still not enabled - see below), so no kernel change was needed for the kernel to recognize the partitions at all.hack/qemu-ab-boot-test.shproves both slots are actually, independently bootable, not just that the partition table looks right: boots the samedisk.imgtwice,dm-mod.create=pointed at/dev/vda1+/dev/vda2(slot A) then/dev/vda3+/dev/vda4(slot B), both must serve real HTTP. Both slots hold identical content for now- there’s no
LifecycleService.Upgradeyet to install something different into the inactive slot, so this proves the layout/dm-verity- via-partition-device mechanics, not a real upgrade workflow.rootfs/init/main.go’smountStatenow findsSTATEon this real single-disk layout too:resolveStateDevice(internal/bootslot- moved there from arootfs/init-local file onceLifecycleService. Rollbackbecame a second consumer, see later in this roadmap) parses init’s own/proc/cmdlinefor thedm-mod.create=parameter it already booted with (confirmed, by actually booting a debug init and reading it back, that/proc/cmdlinepreserves the quotes verbatim - not assumed from the kernel’s own reformatted dmesg “Command line:” line) and pulls out the verity target’s data device. If that device is itself a partition (e.g./dev/vda1- ends in a digit),STATEis derived by the fixed conventionimage/disk/assemble.sh’s own layout uses: always partition 5 on that same disk - no udev, no/dev/disk/by-partlabel/*needed, since this project controls both ends (image assembly and init) and can fix the convention rather than discover it generically. If the data device is a bare whole-disk path instead (e.g./dev/vda, no partition number at all), that’shack/qemu-verity-boot-test.sh’s/qemu-state-persist-test.sh’s older separate-virtio-blk-drives harness -resolveStateDevicefalls back to the original fixed/dev/vdc, so those two tests keep working completely unchanged (they’re deliberately kept - they cover dm-verity tamper detection and STATE persistence in isolation, whichqemu-ab-boot-test.shdoesn’t re-test).qemu-ab-boot-test.shnow reboots slot A a second time on the same disk and confirms janusd’s “first boot” line does not reappear - provingresolveStateDeviceactually works in practice, not just viacmdline_test.go’s unit tests (which cover the parsing itself, including the real cmdline string a boot produced and several malformed/foreign ones, without needing a VM for each case). The disk is no longer attached read-only in that test - only dm-verity’s ownroflag indm-mod.create=needs to protect the verity-mapped root, which it does regardless of the backing device’s own writability, soSTATE(an ordinary, unprotected partition) can be written to without weakening that. UEFI boot + a Unified Kernel Image turned out bigger than it first looked, but is now real:CONFIG_EFI(needed forCONFIG_EFI_STUB)depends on CONFIG_ACPI, so getting there meant bringing up ACPI in this kernel for the first time - resolved cleanly (CONFIG_ACPI=yalone was enough; its own dependency,ARCH_SUPPORTS_ACPI, is unconditionally selected byX86_64already), withCPU_IDLE/POWER_SUPPLY/THERMALcoming along as ACPI’s own dependents, nothing silently dropped (checked the same way as every kernel config change in this project: grep the real,olddefconfig-resolved output, not just trust a clean build).image/uki/assemble.shbuilds a genuine Unified Kernel Image withukify(systemd-ukify) rather than hand-rolling one withobjcopy: reading the actual kernel EFI stub source (drivers/firmware/efi/libstub/efi-stub-helper.c’sefi_convert_cmdline) showed it only ever reads its command line from the EFILoadOptionsthe firmware passes when an image is launched interactively (matchingDocumentation/admin-guide/efi-stub.rst’s own documented “EFI shell” usage) - it does not look for a.cmdlinePE section on its own, so a plainobjcopy-assembled UKI booted with no NVRAM entry (the UEFI spec’s removable-media fallback path, which is what this needs - no boot menu) would get no cmdline at all.ukify’s own stub (systemd-stub) does read.cmdline, then chain-loads into the kernel’s own embedded EFI stub via the EFI handover protocol (CONFIG_EFI_HANDOVER_PROTOCOL, on by default onceEFI_STUBis) - which is exactly whyCONFIG_EFI_STUBstill has to be real in the kernel too, not just present in systemd’s stub binary: the code receiving that handover call lives in the kernel.image/uki/esp-image.shbuilds the FAT32 ESP withmtools(mformat/mmd/mcopy) directly against the image file - no mount, no loop device, same reasoning asrootfs/state-image.shandimage/disk/assemble.sh.hack/qemu-uefi-boot-test.shproves the whole chain works under real OVMF UEFI firmware - no QEMU-kernel/-appendshortcut at all, unlike every other boot test in this project. First real attempt caught a genuine bug in the test itself, not the mechanism: the ESP becomes the first virtio-blk drive once attached, shifting squashfs/verity from/dev/vda+/dev/vdbto/dev/vdb+/dev/vdc- OVMF and the kernel both booted fine, dm-verity even assembled/dev/dm-0successfully, but against the ESP’s own FAT metadata instead of the real squashfs (“metadata block 1 is corrupted”), since the UKI’s baked-in cmdline still referenced the old device order. Since the exact dm-verity table computation was now duplicated across four places (this test plushack/qemu-verity-boot-test.sh/qemu-ab-boot-test.sh/qemu-state-persist-test.sh), it was factored out intohack/dm-verity-cmdline.sh, shared by all four - the other three were re-verified to still pass unchanged after the refactor, not just assumed to. The ESP now lives onimage/disk/assemble.sh’s single GPT disk too - the real, complete, single-disk shape a deployed node would have: partition 1 is the ESP, 2/3 areBOOT-A-DATA/BOOT-A-HASH, 4/5 areBOOT-B-DATA/BOOT-B-HASH, 6 isSTATE.image/disk/ activate-slot.shrewrites only the ESP partition in place - a new UKI whose cmdline points at the other slot’s data/hash partitions,dd’d at the ESP’s own offset (found viasgdisk -i 1, same no-mount/no-loop-device pattern as everywhere else) - leaving both A/B slots’ content andSTATEcompletely untouched. This is deliberately the minimal, narrow operation a realLifecycleService.Upgrade/Rollbackwill eventually need at the image level: “make the other slot the one that boots” without disturbing anything else, most importantlySTATE(PKI, applied config). Adding the ESP shifted every partition number by one, and surfaced a real bug the hard way:rootfs/init/cmdline.go’sstatePartitionDevicestill hardcodedSTATEas partition 5 (nowBOOT-B-HASH, notSTATE), somountStatewas silently mounting a dm-verity hash tree as if it were an ext4 filesystem - the mount failed,mount()only logs and falls back (never aborts the boot, see its own doc comment), so the visible symptom was PKI quietly regenerating a fresh CA on every single boot again, exactly like the first time this class of bug happened. Caught by actually switching to slot B and rebooting, not by inspection - fixed by updating the constant to6and addinghack/qemu-uefi-ab-boot-test.sh, which exists specifically to keep re-catching this: it boots slot A (fresh CA), callsactivate-slot.shto switch to slot B in place, boots again under real OVMF firmware with a single drive, and asserts both that the console’s owndm-mod.create=line now references/dev/vda4//dev/vda5(the switch actually took effect) and that janusd’s “first boot” log line does not reappear (STATE, and the CA on it, genuinely survived the switch).LifecycleService.Rollbackis now real too:internal/api/ lifecycle.gois the gRPC front end for exactly the ESP swapactivate-slot.shperforms at build/install time, except it runs on an already-booted node. The target OS has no package manager, so it can never shell out toukifythe wayactivate-slot.shdoes - this is precisely why that script now stages both slots’ UKIs on the ESP, at fixed paths (\JANUS\UKI-A.EFI,\JANUS\UKI-B.EFI), alongside the active one (\EFI\BOOT\BOOTX64.EFI): at runtime,Rollbackonly needs to mount the ESP (CONFIG_VFAT_FS-CONFIG_VFAT_FS=yalone wasn’t enough either, mounting failed outright with “codepage cp437 not found” untilCONFIG_NLS_CODEPAGE_437/CONFIG_NLS_ISO8859_1were added too - each its own separate symbol fromFAT_DEFAULT_CODEPAGE/FAT_DEFAULT_IOCHARSET, found by a real mount failing first) and copy the other slot’s already-built UKI overBOOTX64.EFI- no PE manipulation, no build tooling, on the node at all. The cmdline-parsing logic (dmVerityDataDevice/statePartitionDevice) that used to live only inrootfs/initmoved to a new shared package,internal/bootslot-rootfs/initandinternal/api/lifecycle.goboth need to answer “which slot am I running from, and where’s the rest of the disk”, and duplicating that logic a second time across a package boundary was the wrong call once there were two consumers of it, not just a hypothetical one. It also grewActiveSlot/OtherSlothelpersrootfs/initnever needed (STATE discovery doesn’t care which slot, just that the device is a slot at all) butRollbackdoes (it needs to know current vs. target). Proven with a real gRPC call, not just that the underlying mechanism works when driven directly:hack/qemu-lifecycle-rollback-test.shboots slot A, extractsca.crt/admin.crt/admin.keystraight fromdisk.img’s STATE partition viadebugfs- janusd prints all three to the console once, on first boot (seecmd/janusd/ main.go), but a script can’t watch a live console the way a real operator would - callsjanusctl lifecycle rollbackover real mTLS, and - this is the one boot test in the whole project that does not pass-no-rebootto QEMU - watches the guest genuinely reboot itself inside the same QEMU process and come back up on slot B, with the boot marker appearing exactly twice, the second boot’s own console cmdline referencing/dev/vda4, and PKI’s “first boot” line appearing exactly once (STATEsurvived a real, API-driven reboot, not just a build-tool-driven one). Secure Boot signing/enforcement is now real too, proven both directions:image/uki/assemble.shgrew two optional trailing args (signing key/cert) - given both,ukify build --secureboot-private-key/--secureboot-certificate(which shells out tosbsign) signs the UKI; given neither, unsigned exactly as before, so every other boot test here is unaffected.image/secureboot/gen-test-key.shgenerates a throwaway, self-signed RSA key + cert (never committed - a real project release key needs real key management: HSM, CI secret, offline root of trust, none of which a build script should generate on the fly) andimage/secureboot/enroll-vars.shenrolls it into a fresh OVMF vars file, Secure Boot on. A real bug caught the hard way, not by reading docs:virt-fw-vars --enroll-cert <cert>(the obvious “just enroll my cert” convenience shortcut) turned out to only populatePKandKEK- neverdb, the one list that actually authorizes boot images (PK/KEKonly govern who can update the Secure Boot variables themselves) - so a correctly signed UKI, checked independently withsbverifyand confirmed valid, still got refused with “Access Denied” by real firmware. Diagnosed by printing the resulting vars store (virt-fw-vars -p) and finding nodbvariable in it at all; fixed by switching to explicit--set-pk/--add-kek/--add-db(same cert, all three) instead of the shortcut. A second, purely environmental issue: the secboot-capable OVMF firmware binary (OVMF_CODE_4M.secboot.fd) produced zero console output at all under the plaini440fxmachine type every other boot test in this project uses - identical command, only-machine q35,smm=on -global driver=cfi.pflash01,property=secure,value=onadded, and it went from a silent hang to a normal boot; secure-boot-capable OVMF builds generally assume SMM-based flash variable protection, which needsq35.hack/qemu-secureboot-test.shproves both directions with one real key: the signed UKI must boot; an unsigned UKI, on the exact same enrolled vars, must be refused by firmware itself (greps the console for “Access Denied”), never even reaching the kernel.LifecycleService.Upgradeis now real too: it writes a genuinely new rootfs into the currently-inactive A/B slot from a local “release bundle” directory (image/release/assemble.sh:rootfs.squashfs/rootfs.verity/uki-a.efi/uki-b.efi), then switches the ESP and reboots - the same core trickRollbackuses (move an already-built UKI into place, never build one on the node), except the UKI comes from the bundle instead of from whatactivate-slot.shalready staged, because a genuinely new rootfs has a root hash nobody could have pre-staged at the original image’s build time.req.Source.Referenceis a local bundle directory path for now - real OCI/HTTPS distribution isn’t built yet, a separate, distinct concern from the actual upgrade mechanics this proves.internal/bootslotgrewSlotDataDevice/SlotHashDevice/Diskfor this - the reverse direction fromActiveSlot(given a slot, find its partitions, not “which slot is currently running”). Proven with a real gRPC call, viahack/qemu-lifecycle-upgrade-test.sh: boots slot A, builds a second rootfs with genuinely different content (different squashfs, different root hash), injects its release bundle intodisk.img’s STATE partition viadebugfsbefore the first boot (janusctlandjanusddon’t share a filesystem across this QEMU host/guest boundary, unlike this project’s usual “share a filesystem” case - and writing to STATE from the host while the guest also has it mounted read-write would corrupt it, which is exactly why the injection happens before the first boot rather than concurrently with a running one), then drives a realjanusctl lifecycle upgradecall and watches the guest genuinely reboot itself into the new content, same-no-reboot-free pattern as the Rollback test. A real bug was found writing this test - not inUpgradeitself, but in the test’s own first assumption: it gave the v2 rootfs a bootstrap HAProxy config bound to a different port, expecting that port to answer as proof v2 was running. It never did, because STATE is one partition shared by both A/B slots, not duplicated per slot, androotfs/init/main.go’sseedPersistentHaproxyCfgdeliberately never overwrites an already-persisted config - so slot B, booting after slot A already persisted its own config onto that shared STATE, just keeps serving what slot A left there. This is correct, intended behavior (an upgrade must never reset a node’s live-applied HAProxy config back to some bootstrap default) - what was wrong was the test’s verification method, not the production code. Fixed by proving genuinely new content took effect the same wayhack/qemu-uefi-ab-boot-test.shproves a slot switch did: reading the kernel’s own “Kernel command line:” log line back and checking it references the new slot’s partitions and root hash, not which HTTP port answers.wait_for_healthis now real too:Upgrade, when asked, writes a persistent “boot pending confirmation” marker to STATE (internal/bootcommit) before switching the ESP and rebooting - the marker records the slot awaiting confirmation, which slot to fall back to, and (fromUpgradeRequest.health_timeout_seconds) how long it gets. The next boot’srootfs/init(checkBootCommit) either gets that one confirmation attempt (decrementing the marker’stries_leftbefore startingjanusd, so a subsequent boot into the same slot - if this one never confirms - finds it already exhausted and reverts immediately, without giving it a third try) or, finding tries already exhausted, reverts straight away without ever startingjanusdat all this boot. Confirmation itself is now a real HAProxy-level check, not an inference from process survival:cmd/janusd, once it starts, checks for a pending marker and - in the background, so it never delays the gRPC server coming up - polls HAProxy’s own stats socket (internal/haproxy.Manager.ShowInfo) via a newinternal/bootcommit.Confirmuntil it succeeds several times in a row, then clears the marker itself. If that never happens withinHealthTimeoutSeconds,janusdreverts and reboots itself, directly - no RPC, norootfs/initinvolvement needed for this path. A first version of this piggybacked onSupervisor’s own stability tracking instead (a proactiveOnStablehook, firing once thejanusdprocess had merely stayed up for a while) - reused as the confirmation signal only briefly, and removed once real HAProxy-level confirmation landed: the two would have raced (a process-survival signal firing before, or after, the real health check, either clearing the marker prematurely on a genuinely broken HAProxy or double-reverting), and the weaker signal added nothing the stronger one didn’t already cover.rootfs/init’s ownSupervisor.GiveUpAfter/OnGiveUpstays, but narrows to the one thingcmd/janusd’s own check structurally can’t catch:janusdcrashing too fast, or too often, to ever reach the point of running its own confirmation loop at all - bounded only when a marker is pending (every other boot keeps the unconditional “restart forever” policy this package has always had, see its own doc comment). Without it, such a slot would sit unreachable forever: the cross-boottries_leftcheck only ever gets a chance to act on a later boot, which requires the machine to reboot again first. The two mechanisms are complementary, each the only one that can catch its respective failure -Supervisorhas no visibility into HAProxy’s health, andjanusdcan’t act if it never gets to run. Both revert paths share oneinternal/bootrevert.Tohelper (resolve the ESP device from/proc/cmdline,internal/espswitch. Activatethe target slot, clear the marker - stopping short of the actual reboot, sincerootfs/initblocks forever afterward as PID 1 must, whilejanusdjust issues one and lets the whole machine go down with it), itself built oninternal/espswitch- factored out ofRollback’s own inline ESP-swap logic oncerootfs/init’s local revert became a second real consumer of the identical mechanism. None of this is visible over the originalUpgradecall’s own gRPC stream: by the time a revert might happen, that connection died with the first reboot, so a caller only ever sees the eventual outcome by reconnecting later (SystemService.Version, or simply which port answers), never a"rolled-back"stream message. Proven with a real gRPC call, three ways, viahack/ qemu-lifecycle-upgrade-health-test.sh: boots slot A, then callsUpgrade(wait_for_health=true)three times against the same running node - a “good” bundle (just the existing v1 rootfs, repackaged; genuinely-new-content is what the other Upgrade test already proves, this one is purely about the health-check/revert mechanics) must show"bootcommit: confirmed healthy"and never revert; a “broken” bundle, a fresh rootfs with the host’s own dynamically-linked/bin/falsestanding in forjanusditself - since this rootfs ships no dynamic linker or libc at all (every real binary in it is statically linked, by design),Supervisordoesn’t even get as far as a successfulexec, hitting its spawn-failure path on every restart attempt, a realistic simulation of a badly built or wrong-architecture control-plane binary - must show the guest rebooting into it, then autonomously - no RPC call from the test driving it -"giving up and reverting to slot B"(Supervisor.GiveUpAfter, proving that backstop specifically); and a third, “haproxy-broken” bundle, with the realjanusdbut/bin/falsestanding in for haproxy this time, must showjanusditself coming up fine and logging its own confirmation attempt, then - again fully autonomous -"bootcommit: rebooting to complete the revert"(cmd/janusd’s own check specifically, not theSupervisorbackstop). All three reverts land back on whichever slot was active when that particular Upgrade was called, not a hardcoded fallback to the original slot A. Every boot is checked via the same real evidencehack/qemu-uefi-ab-boot-test.shestablished: the kernel’s own “Kernel command line:” log line, at specific boot numbers, referencing the expected slot’s partitions and root hash.LifecycleService.Installis now real too: unlikeRollback/Upgrade, it has no existing partition table to build on, so it can’t get away with only ever moving pre-built bytes into place - it lays out the entire disk itself, from a completely blank starting point.sgdisk/mtools/mkfs.ext4don’t exist on the target OS any more than they do at runtime forRollback/Upgrade, so this needed a genuinely Go-native GPT/FAT32/ext4 writer - found ingithub.com/diskfs/go-diskfsrather than hand-rolled: confirmed, empirically, before writing any production code, by building a full six-partition disk with it (GPT table, a real ext4 STATE filesystem, a real FAT32 ESP with a real UKI written into it) and booting the result under real OVMF - HTTP 200, PKI bootstrapped onto the go-diskfs-created STATE filesystem, on the first try after fixing one real thing that verification actually caught:gpt.Table.ProtectiveMBRdefaults tofalse, and leaving it unset produces a GPT disksgdisk -preports as having a “corrupt MBR” (in practice: no protective MBR at all, all zero bytes at LBA 0) - an easy one-line fix (ProtectiveMBR: true) once caught, but exactly the kind of gap a library’s own example code doesn’t warn about.internal/diskimage.Computeis the pure-arithmetic half (given a disk’s total byte size, lay out ESP/BOOT-A-DATA/BOOT-A-HASH/ BOOT-B-DATA/BOOT-B-HASH sequentially and sector-aligned, matchingimage/disk/assemble.sh’s own fixed sizes and convention exactly, then give STATE whatever’s left - unlike build time’s fixedSTATE_MB, meant for a deliberately small QEMU test disk, a real target disk’s remaining space is put to use) - kept separate frominternal/api/install.go’s actual go-diskfs calls specifically so the layout math has real unit tests without needing a disk or root to run them, the same reasoninginternal/bootslot’s pure cmdline parsing was kept apart from the syscalls that act on what it parses.Installreads both slots’ UKIs from the same kind of release bundleUpgradealready reads from (image/release/assemble.sh) - it never builds one either, just picks pre-built bytes for both slots this time instead of one. Both A/B slots get identical content on purpose: there’s no “other slot” yet to leave untouched, the same starting pointimage/disk/assemble.shitself produces at build time. Refuses two disks outright: one that already has a partition carrying one of Janus’s own conventional GPT names (ESP,STATE, …) - “looks like an existing install, use Upgrade/Rollback instead” - and the disk this node is itself currently booted from (repartitioning that out from under a running system would be catastrophic, and it’sUpgrade’s territory anyway) - the latter reusesinternal/bootslot.Diskagainst/proc/cmdline, the same parsingRollback/Upgradealready trust. Doesn’t reboot anything on success: the disk it just wrote isn’t necessarily the one this node runs from at all (see the proto’s owndiskfield comment), so getting a machine to actually boot from it is the caller/operator’s job. Proven with a real gRPC call, viahack/lifecycle-install-test.sh- the one lifecycle test in this project where the RPC call itself doesn’t run inside a VM:Installhas no A/B/STATE machinery of its own to depend on (it’s what creates that machinery), sojanusdruns natively on the test host, the same patternimage-build.yml’s own “HAProxy gRPC API integration test” step already uses, reading its bootstrapped PKI creds straight off-pki-dirrather than scraping a QEMU console log. Only the result is checked under a real VM - the same standard every other “is this actually bootable” claim in this project is held to. The test callsInstallagainst a plain pre-allocated file (go-diskfs works identically against a real block device or a file, confirmed during the library verification above), confirms a secondInstallagainst the now-installed file is refused, boots the result under real OVMF and confirms real HTTP 200 plus a fresh PKI bootstrap, then - from inside that now-running instance, which has a genuinedm-mod.create=cmdline to check against, unlike the native process - confirmsInstallagainst/dev/vda(the disk it’s actually booted from) is refused too. A bonusRollbackcall proves slot B’s identical copy is genuinely valid, not just slot A’s. A real production signing key now exists too, closing out Phase 3: no HSM in this project’s threat model (single maintainer, self-hosted CI), so the simplest thing genuinely safer than committing a key to git is what’s used -image/secureboot/gen-production-key.shgenerates it once, offline, by a human (20-year validity, unlikegen-test-key.sh’s 10 - rotating it means re-enrolling every already- deployed node’s firmware by hand, so it’s deliberately long-lived rather than something to renew casually); the private key lives only as a GitHub Actions encrypted secret (SECUREBOOT_SIGNING_KEY), never in the repo; the certificate half isn’t sensitive (it has to be enrolled into every node’s firmwaredbanyway) and is committed straight in atimage/secureboot/production-cert.pem.image-build.yml’s own “production-signed release bundle” step needed no new signing code at all -image/release/assemble.sh’s existing optional[signing-key] [signing-cert]args (already built forUpgrade’s own release bundles) were enough, given a decoded, step-scoped temp copy of the secret. Runs only when the secret exists (skipped on a fork PR, or before the one-time key ceremony happens), and independently verifies both slots’ signed UKIs withsbverifyagainst the committed certificate before declaring success - proven for real locally before ever touching CI: both UKIs signed with the actual production key andsbverify-confirmed valid, using the exact same command the workflow step now runs.
- there’s no
-
Phase 4 (started): kernel/CIS hardening pass is real, SELinux policy is still open. Most of what a CIS benchmark’s kernel-adjacent controls call out (loadable-module restrictions,
/dev/mem, kexec, hibernation, USB/Firewire/staging drivers) was already true by construction fromallnoconfig- this pass is specifically what was left:kernel/configs/janus_defconfiggrew a KSPP-style self-protection block (STACKPROTECTOR_STRONG,SLAB_FREELIST_ RANDOM/_HARDENED,HARDENED_USERCOPY,FORTIFY_SOURCE,INIT_ON_ALLOC/_FREE_DEFAULT_ON,CONFIG_SECURITY+SECURITY_ YAMA) verified, the same way every other Kconfig addition in this project has been, by diffing the full resolved config after merging- not just trusting a clean
merge_config.shrun - to confirmCONFIG_SECURITY=y(the master gate every LSM needs, previously unset entirely) didn’t silently pull in some other LSM along with Yama; the only side effects were an inertCONFIG_INTEGRITY=yframework dependency and a cosmeticCONFIG_LSM=default-ordering string naming LSMs that aren’t actually compiled in.INIT_ON_FREE’s real, non-trivial perf cost (memory-zeroing on every kernel-side free, more noticeable thanINIT_ON_ALLOC’s under allocation-heavy workloads) is accepted for now rather than pre-emptively tuned away, documented as the first thing to reconsider if a future real-traffic benchmark shows this project’s network fast path regressing.rootfs/init/main.go’s newhardenSysctlsis the runtime half - the tunable values (not features to compile in or out) that have no Kconfig home and need writing to/proc/sysat boot instead, since there’s nosysctl(8)/procps on this rootfs at all:kernel. dmesg_restrict/kptr_restrict(hide the ring buffer and kernel pointers from anything without the matching capability - the unprivilegedhaproxyworker, uid 1000/chroot, specifically),kernel.yama.ptrace_scope=2(onlyCAP_SYS_PTRACEcan attach -janusd, root, still can; the worker no longer can, at all), and a set of anti-spoofing/anti-redirect/SYN-flood network sysctls chosen for what they mean to a reverse proxy specifically (tcp_ syncookiesisn’t a generic checklist item on a box whose entire purpose is accepting inbound internet connections). A real gap was caught writing this, not assumed away:net.ipv4.tcp_syncookiesdoesn’t exist as a/proc/sysnode at all withoutCONFIG_SYN_ COOKIES, which nothing else in the defconfig had pulled in - a real boot’s console log showed exactly that one write failing (“no such file or directory”) while every other sysctl, and the boot itself, looked completely fine either way, since a failed write here is logged but non-fatal by design (same tolerant patternmount()already used for a missing STATE drive) - fixed by adding the one missing Kconfig symbol, not by working around the gap in Go.hack/qemu-hardening-test.shexists specifically to keep re-catching this class of bug: it boots for real and checks every expected"init: sysctl ..."console line individually, and separately fails on any sysctl write that logged an error at all, expected or not - a passing HTTP check and a clean-looking boot log both proved insufficient on their own to catch the realtcp_syncookiesgap. SELinux, first slice: kernel-side enablement only, deliberately scoped small and separate from writing an actual policy (a much larger, more specialized undertaking - type-enforcement rules for a from-scratch OS with no existing distro policy to build on - and one whose marginal value here is genuinely smaller than usual, given how much of what SELinux typically defends against, arbitrary shell/code execution and package tampering, this project’s architecture already eliminates structurally: no shell, no package manager, dm-verity- verified read-only root).CONFIG_SECURITY_SELINUXturned out todepend on SECURITY_NETWORK && AUDIT && NET && INETin this kernel version -CONFIG_AUDITwasn’t just missing, it was explicitly unset, found by grepping the real dependency line rather than assumed - and pulling it in broughtAUDITSYSCALL/FSNOTIFYalong automatically via their ownselects, same asNETWORK_SECMARKcame along via SELinux’s own. Verified via the same full-resolved- config-diff discipline as every prior Kconfig change: no other LSM (smack/apparmor/tomoyo) and no XFRM/IPSec surface got pulled in alongside it. Both filesystems that will ever hold a labeled file got their xattr support turned on too -SQUASHFS_XATTRfor the read-only rootfs (mksquashfsalready preserves source-file xattrs by default, so this is the only kernel-side piece that read-only half needs) andEXT4_FS_SECURITYfor the writable STATE partition (distinct from the still-offEXT4_FS_POSIX_ACL) - though nothing writes asecurity.selinuxxattr onto either filesystem yet, since there’s no policy to derive one from.SECURITY_SELINUX_DEVELOPkeeps the kernel in permissive mode (log, don’t deny) by default, exactly right for a slice with no policy to enforce;SECURITY_SELINUX_BOOTPARAMadds aselinux=0cmdline escape hatch for the plain-kernel/-appendboot tests (not for a real UKI boot - the cmdline there is baked in permanently at build time, no boot menu to edit it from). Proven on a real boot, not assumed from the Kconfig alone: the console log showsLSM: initializing lsm=capability,yama,selinuxandSELinux: Initializing., andmake qemu-network-test/qemu-hardening-test/qemu-verity-boot- test/qemu-state-persist-testall still pass unchanged - the new LSM, with no policy loaded, is currently a true no-op.
SELinux, second slice: a real, hand-written minimal policy (
selinux/classes.conf+selinux/policy.conf), compiled bycheckpolicy(selinux/Dockerfile,make selinux-policy) and loaded byrootfs/init/main.go’sloadSELinuxPolicyright after mounting sysfs, writing the compiled bytes straight to/sys/fs/selinux/load- nolibselinux/load_policy/policy-store userspace at all, matching the “no shell, no package manager” rule for the target. Not adapted from refpolicy - refpolicy assumes a systemd/udev-shaped, package- managed distro this project structurally isn’t, and stripping it down would cost more than writing three domains from scratch. The bootstrap tool isscripts/selinux/mdp/mdp, a small program shipped in the kernel source tree itself, purpose-built for exactly this situation (a system with no existing distro policy to inherit class/permission definitions from) -selinux/classes.confis its generated output (plain TE, no MLS -mdp’s-mflag enables MLS, which this project doesn’t need: a single sensitivity level with real type-enforcement rules is enough, and skips ~2000 lines of per-classmlsconstrainboilerplate MLS would otherwise require).Three real domains for the three real processes this rootfs ever runs:
init_t(PID 1, broadly privileged by necessity - mounts every filesystem, loads this very policy, is the last-resort revert path),janusd_t(also root, moderately broad), andhaproxy_t- the one domain confinement effort actually went into, since it’s the only one that ever parses untrusted, internet-facing input: noself:capabilitywildcard, only the specific capabilities its own chroot/privilege-drop sequence needs. Labeling is xattr-based in principle (fs_use_xattrfor both real filesystems, matching whatmdpitself generates as correct for xattr-capable filesystems - both now carryCONFIG_SQUASHFS_XATTR/CONFIG_EXT4_FS_SECURITYfrom the first SELinux slice) but only the three real executables ever get an explicit xattr -fs_use_xattr’s own fallback (an inode with nosecurity.selinuxxattr gets the statement’s own default context) means everything else on either filesystem just falls through tosquashfs_t/state_t, no whole-rootfs labeling pass needed. The ESP (FAT, can’t carry xattrs at all) isgenfscon-labeled wholesale instead.Two real, non-obvious build-tooling gaps, both found by a real build failing, not by reading documentation: (1) Debian trixie’s
checkpolicy(3.8.1) links alibsepolthat doesn’t recognize fivepolicycapnames this kernel’s ownmdpoutput includes (genfs_seclabel_symlinks,ioctl_skip_cloexec,netif_wildcard,genfs_seclabel_wildcard,functionfs_seclabel) - real kernel/ toolchain version skew, fixed by dropping those five (all optional kernel-side conveniences, never required for enforcement) rather than chasing a newercheckpolicy; (2) labeling the three executables bysetfattr-ing$WORKDIRbeforemksquashfsruns - the obvious first approach - silently produced an unlabeled image every time: a realsetfattron the security namespace reports success and even reads back correctly with a directgetfattr, butmksquashfsitself, run as build user or root, never picks the xattr up into the built image at all (confirmed with an isolated single-file reproduction). Fixed by usingmksquashfs’s own pseudo-filexaction (-p "path x security.selinux=<context>") instead - the same mechanism/var/empty’s mode-0000 entry already used to sidestep an analogous “can’t read this back off a real inode” gap - which sets the attribute directly while writing the image rather than reading it off a source-tree inode first.The actual allow-rule set was never going to be right by construction
- it was arrived at the same way every other piece of this project
has been, by booting for real (permissive mode - the kernel’s own
SECURITY_SELINUX_DEVELOP=ydefault) and reading realavc: deniedlines out of dmesg, fixing exactly what each one named, and reboot-and-recheck, five rounds deep before reaching a genuinely clean, zero-denial boot with real HTTP 200 traffic flowing. None of what turned up is guessable from reading a rule set in the abstract:self:fd use(a domain needs explicit permission to use its own already-open file descriptors, including ones inherited across an exec transition from a different domain -allow haproxy_t init_t:fd use;, since haproxy’s console fd was opened two domain-hops earlier byinit_tand never reopened);filesystem:associateon every singlefs_use-labeled type against itself (not implicit just because source and target match - the very firsttmpfsmount failed on exactly this); the process- transition permissionsnoatsecure/rlimitinh/siginh, needed on everyinit_t -> janusd_t -> haproxy_thop; and, once real network traffic started flowing,peer/packet/netif/nodeclass rules forpolicycap always_check_network- inbound packets with no netlabel/secmark policy configured (this project has none) get the “unlabeled” initial SID’s context on every single packet, checked against the receiving netif/node’s own type, not just once at bind/connect time.hack/qemu-selinux-test.sh(make qemu-selinux-test) is what keeps re-proving this: two boots of the identical image, the shipped permissive default and a realenforcing=1override, both must show the policy loading, real HTTP 200, and grep clean foravc:.*denied- a permissive-mode denial doesn’t block anything, so only the second boot actually proves the rule set complete rather than merely quiet. Both did, on the firstenforcing=1boot tried, once the permissive rounds above reached zero denials.
SELinux enforcing by default, third slice:
image/uki/assemble.sh’s baked-in cmdline now carriesenforcing=1permanently - the actual production default, since there’s no boot menu to add it from later and every real node boots via this exact UKI.kernel/configs/ janus_defconfig’sSECURITY_SELINUX_DEVELOP=ydeliberately stays on regardless (keeps/sys/fs/selinux/enforcetoggleable for debugging, and the kernel’s own compiled-in default without this cmdline override would still be the safer permissive one - this UKI cmdline change is what actually makes enforcing real, not a kernel rebuild). Flipping this surfaced a real gap the earlier, narrowerhack/qemu-selinux-test.shboot (squashfs+verity only, no STATE drive attached at all) had never exercised: every one of this project’s other real boot tests goes through a real, single-disk, genuinely-partitioned STATE ext4 mount instead, and that mount failed outright under realenforcing=1the first time it was tried end-to-end (hack/qemu-lifecycle-rollback-test.sh, caught before any of the others were even retried). The real cause was a wrong assumption aboutfs_use_xattr’s own fallback semantics: an inode with nosecurity.selinuxxattr set (every inode onrootfs/state-image.sh’s freshly-mkfs.ext4’d image, since nothing ever wrote one) does not fall back to thefs_use_xattrstatement’s own default context - it falls back to the “file” initial SID (SECINITSID_FILE, mapped tosquashfs_tin this policy) instead, which only ever looked correct for the read-only rootfs because that mapping happened to already besquashfs_tthere. The very firstos.MkdirAll("/etc/.state/pki")on a freshly-mounted STATE partition was denied as a write tosquashfs_t, notstate_t-"avc: denied { write } ... tcontext=...squashfs_t ... permissive=0". Fixed properly, not worked around:rootfs/init/main.go’smountStatenow mounts STATE with the SELinuxcontext=mount option (mountData, a new small variant of the sharedmount()helper that also passes a mount-options string), forcing every file on that mount to a single fixedstate_tcontext outright - the same rolegenfsconalready plays for the ESP’s non-xattr-capable FAT, used here instead of relying onfs_use_xattr’s per-inode fallback at all. That itself needed one more real permission (filesystem:{relabelfrom,relabelto}onstate_tforinit_t- thecontext=option is itself a relabel operation, caught by a second real denial once the first was fixed). Every UEFI-boot-dependent test in this project -qemu-uefi-boot-test,qemu-uefi-ab-boot-test,qemu-lifecycle-rollback-test,qemu-lifecycle-upgrade-test,qemu-lifecycle-upgrade-health-test,lifecycle-install-test,qemu-secureboot-test- was re-run locally after the fix and passes clean under realenforcing=1, not just the originalhack/qemu-selinux-test.shboot. - not just trusting a clean
-
First alpha artifact (before Phase 5):
image/kvm-proxmox/ assemble.shconvertsimage/disk/assemble.sh’s real, single-disk GPT image to qcow2 (qemu-img convert -c) - Proxmox’s own preferred import/storage format.image-build.yml’s “Build an alpha Proxmox VM image” step builds one on every manual run and uploads it as a workflow artifact. Unsigned (Proxmox’s OVMF has no cert of this project’s own enrolled by default, so Secure Boot has to stay off in the VM’s EFI disk settings regardless of whether the UKI itself is signed). Verified against the actual built qcow2, not just the raw image before conversion - real OVMF boot under-machine q35(Proxmox’s own machine type), zero AVC denials under the now-defaultenforcing=1, real HTTP 200. Seeimage/kvm-proxmox/README.mdfor the exactqm create/qm importdisksteps. At the time it needed--serial0 socket --vga serial0, since the rootfs had no screen console, only serial; bare-metal support (above) added the screen. -
Phase 5 (done):
NetworkService- bird (BGP), keepalived (VRRP), nftables, as image extensions. -
Phase 6: companion website + dedicated Proxmox-hosted backend (separate container from the runner) + remote kernel-menuconfig UI - separate repository, separate plan.
-
Phase 7 (libvirt, Proxmox VE and Terraform done): the Controller creates its own nodes on hypervisors (Hypervisors: libvirt over SSH with a polkit policy, Proxmox VE with an API token scoped to a pool) and the Janus Terraform provider drives it (Terraform / OpenTofu); next, VMware, then Hyper-V (which needs the kernel’s Hyper-V drivers and a VHDX image first).