Engineering blog · community feedback · fixes shipped

EuroOS now updates itself.

Until today, getting a newer EuroOS meant downloading a new image. Since this afternoon an installed EuroOS looks for updates itself: a signed manifest, a signed kernel over HTTPS, an A/B slot partition, and a loader that verifies before it boots. This post is the proof log, what was built, and the three bugs that only showed up because the whole chain finally ran for real.

What happens now, in one boot

Ninety seconds after an installed EuroOS reaches the desktop (the network needs a DHCP lease first), the kernel does this on its own, and again every six hours. This is the serial log of the test machine, unedited:

[euroupdate] boot from slot A (gen 1, 0 attempt(s) left, A=Good B=Empty)
[euroupdate] slot A confirmed GOOD (boot succeeded)
[euroupdate] euroupdate check: https://euro-os.eu:443/update channel 'stable' (Ed25519-signed manifest)…
[euroupdate]   manifest OK (signature valid): version 20260908, running 20260907
[euroupdate]   image 53421056 B fetched, sha256 pinned by manifest ✓
[euroupdate] euroupdate: 53421056 bytes written to the EuroSlot-B partition + read-back ✓
[euroupdate] euroupdate: image (53421056 bytes) verified + written to slot B
[euroupdate]   next boot tries slot B (3 attempts, then automatic rollback)
[euroupdate]   policy: ask

A notification appears on the desktop: version 20260908 is verified and staged, restart to apply. On the next boot the two-stage loader takes over:

[loader] on_boot: B tries 3 → 2
[loader] slot_config → boot slot B
[loader] kernel image from the slot partition (header + sha256 verified)
[loader] kernel image loaded — LoadImage + StartImage...
[euroupdate] boot from slot B (gen 2, 1 attempt(s) left, A=Good B=Trying)
[euroupdate] slot B confirmed GOOD (boot succeeded)

Had the new kernel failed to boot three times, the loader would have gone back to slot A by itself. The user's files live on a separate EuroFS partition and are never touched.

The security model

It is the APT model, not the app-store model. The channel manifest is Ed25519-signed and refused before anything is downloaded if the signature does not verify. The manifest pins the SHA-256 of the image; the image carries its own Ed25519 signature; both are checked against the key baked into the running kernel. The transport is EuroTLS 1.3 with the bundled root store (Let's Encrypt's ISRG roots included), so a hostile mirror or a man in the middle can at worst serve nothing. On disk, the slot image sits behind a header with its length and hash, and the loader recomputes that hash before it hands the image to the firmware. A torn write leaves a header that does not match, never a header pointing at garbage: the image is written first, the header last.

What was built today

The three bugs the chain found

None of these were visible while the pieces were tested one at a time. All three appeared the moment an installed disk really tried to update itself.

1. Two truths about which slot to boot

The loader reads \slot_config from the ESP. The kernel kept its own copy on a reserved raw block and mirrored it to the filesystem. They were never the same file. A staged update or a mark-good in the kernel never reached the loader, and a rollback decided by the loader never reached the kernel. Now the ESP copy is the one both act on, with the raw block and the filesystem as mirrors behind it.

2. The self-tests staged garbage into the real slot

The boot self-tests exercise the real staging code with a 70-byte test pattern. Before, that pattern landed in the real slot partition but the loader never looked there, so nobody noticed. Once the loader trusted a headered slot image and read the same slot_config, a fresh install went straight into three failed boot attempts of a "kernel" that was a test string. The self-tests now run in a dry mode that verifies and decides for real but never writes to a slot or a slot_config.

3. An exclusive protocol open kills your own filesystem

Both the loader and the installer opened the disk's Block I/O protocol exclusively, just to count disks. In UEFI an exclusive open disconnects every other driver on that handle, including the FAT driver serving the ESP you are booting from. The loader could no longer read the kernel file it had just decided to boot, and the installer saw no filesystem to read its media from. A non-exclusive open has no side effects.

The fourth thing was not a bug but a limit: building a 256 MiB FAT image in RAM to write it out is not something a 384 MiB kernel heap survives. The FAT builder now streams sectors straight to the disk and references the kernel bytes instead of copying them.

Update, later the same day

Three of the limits below did not survive the evening. The check now runs as a background job: one non-blocking slice per desktop iteration, so the desktop keeps drawing while the kernel comes in over TLS (the first version froze the screen for the duration of the download). The slot and ESP writes go through the disk that was actually booted, so the same install now updates and reboots on virtio-blk, AHCI/SATA and NVMe; all three were run against the live channel. And the kernel now trusts two keys: the daily key that signs releases, and a rotation key that lives encrypted on the build server with its passphrase only in a password manager. A leaked daily key is retired by shipping one rotation-signed release with a new one, without a second machine. The procedure is in docs/UPDATES.md.

Honest limits

The test that proved all of this is three QEMU runs, install, update, reboot, against the live server, repeated for the three disk buses. The serial logs are in docs/proof/ota-2026-09-08/ in the repository.