A minimal x86-64 kernel that runs isolated, unprivileged tasks which talk only by messages. Drivers, storage and attestation are ordinary tasks, and the system is fixed at build time.
Part one explains each layer from scratch, for readers who haven't built
an operating system before. Part two is the reference: what each subsystem
does, its interfaces, and the tests that prove it. Part three is for working on
the code, by people or by coding agents: commands, file locations, recipes, rules and fixes.
Agents should start at 18. Agent quick reference or fetch
llms.txt.
Part one: guide
1Introduction#
vanir-os is a small kernel written in stable Rust and booted by Limine. Its design follows Oxide's Hubris: a fixed set of tasks is declared at build time, each runs in its own address space in ring 3, and they cooperate through synchronous send/receive/reply messages. Things a larger kernel does internally, such as device drivers, restarting failed programs, storage and talking to a host, are separate tasks here.
The kernel provides:
- serial output, GDT/TSS, IDT, and an ELF loader that enforces W^X
- a page-table tree per process, with SMEP, SMAP and NX
- a 100 Hz local APIC timer, round-robin preemption and CPU budgets
- synchronous IPC with a per-task allowlist of who may send to whom
- supervisor notifications and task restarts with generation-checked ids
- PCI device grants, MSI-X interrupts and DMA regions for driver tasks
- a kernel event log, signature checks on task images, and boot measurements into a TPM
- notifications a task posts to another without waiting, and a supervisor-only halt
On top of that, as tasks: virtio console, vsock, block and network drivers; telemetry streamed to a host agent; TPM remote attestation; encrypted per-task storage whose key the host releases only to a boot it has attested; and TCP services behind separate data and management network stacks, with routing and draining between instances.
Static
No fork or exec. The manifest fixes every task, its budget and who it may talk to.
Isolated
Each task has its own page tables. A crash ends that task, not the system.
Mechanism, not policy
The kernel reports deaths; the supervisor task decides what to do.
Verifiable
Signed images, Secure Boot, measured boot and TPM quotes, all checked by tests.
2Quick start#
Install the dependencies (26), then:
make run # build the ISO and boot it in QEMU
make test # boot all 123 scenarios and check each one
make test ACCEL=kvm # the same under KVM
make run APP=tests/apps/two-spinners.toml # boot a different set of tasks
make run-service ACCEL=kvm # one service on 127.0.0.1:8080 (15.8)
A normal boot prints the banner, the kernel's setup steps, then each task's output, and ends when nothing is left to run:
[kernel] GDT, TSS and IDT loaded
...
[kernel] pci 00:03.0 1af4:1043 virtio-console
[kernel] pid 1 (hello): 8704-byte ELF, signed version 1, entry 0x400000
[kernel] tpm: none found; the boot is not measured
[kernel] entering ring 3
hello world
[kernel] pid 1 (hello) exited with code 0 (called from CPL 3)
[kernel] no process can run:
[kernel] pid 1 (hello): exited 0, 1 ticks
[kernel] frames in use: 3 (all task memory freed)
3The layers, from scratch#
An operating system is a stack of layers, each depending on the one below. This part walks up the stack once, saying in plain terms what each layer is for and how vanir-os does it. The reference in part two goes into detail.
+---------------------------------------------------------------+ | ring 3 tasks: hello, supervisor, drivers, storage, attest | unprivileged; one | (each in its own address space) | address space each +--------------------------- int 0x80 ---------------------------+ | ring 0 vanir kernel: traps, page tables, scheduler, IPC, | privileged; small | device grants, event log, measurements | +---------------------------------------------------------------+ | Limine boot loader: loads the kernel and task images | runs once +---------------------------------------------------------------+ | firmware SeaBIOS (BIOS) or OVMF (UEFI with Secure Boot) | runs once +---------------------------------------------------------------+ | QEMU / KVM virtual machine: CPU, RAM, virtio devices, TPM | the "hardware" +---------------------------------------------------------------+
3.1The machine#
A computer is a processor that runs instructions from memory, plus devices (disk, network, serial port) it talks to through special addresses. When it powers on, it runs firmware burned into the board, which finds a boot loader on a disk, which loads the kernel into memory and jumps to it. From then on the kernel is in charge.
vanir-os runs in QEMU, which emulates the machine in software (TCG) or uses the host CPU's virtualization (KVM). The firmware is SeaBIOS or OVMF; the boot loader is Limine, which also loads every task's ELF file and signature as "modules" next to the kernel.
3.2Privilege#
x86 processors have privilege levels called rings. Code in ring 0 can do anything: change memory maps, talk to hardware, turn off interrupts. Code in ring 3 can't, and if it tries, the processor stops it and hands control to ring 0. The kernel runs in ring 0; programs run in ring 3. That's what keeps a buggy or hostile program from taking over the machine.
The kernel sets this up with a GDT (segment descriptors for each ring), a TSS (which stack to switch to when ring 3 traps into ring 0), and CPU features: SMEP stops the kernel running user code, SMAP stops it reading or writing user memory except inside explicit copy routines, NX marks data pages non-executable, and CR0.WP makes read-only pages read-only for the kernel too.
3.3Virtual memory#
Programs don't see physical memory directly. Every address they use goes through page tables, a tree the processor walks to translate a virtual address to a physical one, 4 KiB at a time. Each page also has permissions: present, writable, user-visible, executable. Give each program its own page tables and it can't even name another program's memory.
virtual address (48 bits used)
+---------+---------+---------+---------+--------------+
| PML4 idx| PDPT idx| PD idx | PT idx | page offset |
| 9 bits | 9 bits | 9 bits | 9 bits | 12 bits |
+----+----+----+----+----+----+----+----+------+-------+
| | | | |
CR3 -+-> PML4 -+-> PDPT -+-> PD ---+-> PT -----+-> 4 KiB frame + offset
(one tree per task; the kernel half is shared, supervisor-only)
3.4Traps: syscalls, faults, interrupts#
There are three ways into the kernel. A syscall is a program asking for
something on purpose (here, with the int 0x80 instruction). A
fault is the processor reporting a mistake, such as touching unmapped memory.
An interrupt is a device or timer asking for attention. All three save where
the program was, switch to ring 0, and run a handler listed in the IDT.
ring 3 task ring 0 kernel
----------- --------------------------------------------
int 0x80 / fault / IRQ ----> trap stub: push registers (TrapFrame)
dispatch: syscall | fault | timer | device IRQ
maybe switch task: swap TrapFrame, load CR3
continues (same or other) <--- zero scratch registers, iretq
The timer interrupt fires 100 times a second. It's how the kernel takes the CPU back from a task that never gives it up (preemption) and how it counts CPU budgets.
3.5Tasks and messages#
A task (or process) is a program with its own memory and saved registers. The kernel switches between them quickly so they seem to run at once. Isolated tasks still need to cooperate, so they send each other messages: a client sends a request and waits; the server receives it, does the work and replies. The kernel copies the bytes between their memories and checks who is allowed to talk to whom.
vanir-os declares every task in an app manifest. The kernel never creates tasks at run time; it can only restart one, when the supervisor task asks.
3.6Devices#
Software controls a device by writing to its registers, which appear as memory addresses (MMIO). Fast devices also read and write main memory themselves: DMA, direct memory access. When they finish, they raise an interrupt. A driver is the code that knows a device's registers and protocol.
Here a driver is an ordinary ring-3 task. The kernel maps only that device's registers into the driver, gives it a fixed DMA region, and turns the device's interrupts into messages. A crashing driver is just a crashing task.
3.7Trust: signatures, measurements, keys#
Isolation protects tasks from each other, but how do you know the right code is running at all? Each boot stage checks a signature on the next before running it. Each stage also measures (hashes) what it runs into a TPM, a small security chip that keeps a running hash it can't be made to forget. Later the TPM can sign a quote of those hashes, so a remote host can verify what booted. Only then does the host hand over secrets, such as the key that decrypts the disk.
Part two: reference
4Boot#
Limine (base revision 3) loads the higher-half kernel at 0xffffffff80000000,
plus one module per task image and one per signature, and jumps to kmain:
kmain
|-- serial::init, banner COM1 at 0x3f8: the boot log
|-- cpu::enable_protections SMEP, SMAP, NX, CR0.WP
|-- gdt::init, interrupts::init GDT + TSS, IDT (one trap path)
|-- modules::init task images + .sig files from Limine
|-- memory::init frame allocator over the memory map, HHDM
|-- apic::init local APIC timer, 100 Hz (calibrated on the PIT)
|-- pci::init discover devices, size BARs, set up MSI-X
'-- process::start(kernel file)
|-- verify each task image Ed25519 signature, min_version
|-- measure into PCR 12 kernel, manifest, each task (if a TPM exists)
|-- load ELFs, build page tables W^X per segment, stack, device + DMA mappings
'-- enter ring 3 first task runs; the scheduler takes over
- files
kernel/src/main.rs,limine.conf,kernel/linker.ld,kernel/src/modules.rs- debug
make run-debuglogs interrupts and CPU resets toqemu.log(use it for triple faults)- note
- Task images are found by module string = program name;
build/limine.confis generated by the Makefile
5Memory and address spaces#
Physical memory is handed out in 4 KiB frames from Limine's memory map, with a free list so frames return when a task dies. The kernel reaches physical memory through the HHDM (Limine's direct map). Each task gets its own PML4; the kernel half is shared and supervisor-only.
0xffff_ffff_8000_0000 +---------------------------+ kernel image (shared, supervisor-only)
| ... |
HHDM offset +---------------------------+ all physical memory (supervisor-only)
: :
0x0000_8000_0000_0000 +===========================+ USER_END: nothing user-visible above
0x7fff_ffff_f000 +---------------------------+ user stack top (16 pages, 64 KiB, NX)
: :
0x30_0000_0000 +---------------------------+ TPM CRB page (tpm = true task only)
0x20_0000_0000 +---------------------------+ DMA region (dma_pages, pinned frames)
0x10_0000_0000 +---------------------------+ device BARs, uncached
| | (0x1000_0000 per device, 0x200_0000 per BAR)
0x40_0000 +---------------------------+ ELF segments: code R-X, rodata R--, data RW-
0x0 +---------------------------+ unmapped (null page faults)
Two software bits in page-table entries record ownership: DEVICE (bit 9, MMIO,
never freed) and PINNED (bit 10, DMA frames owned by the task slot, kept across
restarts). Everything else a task maps is freed when it dies; the no-leak and
restart-no-leak tests check the frame count returns to its baseline.
The kernel touches user memory only through copy_from_user and friends, which
open a SMAP window (stac/clac) and recover from page faults through an
exception table, so a bad pointer in a syscall returns an error instead of crashing the
kernel.
- files
kernel/src/memory.rs(frames, page tables),kernel/src/usercopy.rs,kernel/src/elf.rs, constants at the top ofkernel/src/process.rs- rule
- Never dereference a user pointer directly; check ranges with the address space's
check_rangeand copy withusercopy - tests
poke-kernel,bad-pointer,exec-stack,write-code,smap,address-spaces,no-leak
6Tasks and scheduling#
- Static task table. The tasks are fixed at build time by an app manifest (7). Tasks get ids 1, 2, … in manifest order.
- One kernel stack. A trap saves the user's registers on it. A context
switch stores that frame in the current task, copies the next task's saved frame over it,
switches
CR3, and returns through the normaliretq. Kernel code never waits in the middle of a syscall: a task that must wait is marked blocked with its registers saved, and whoever completes the operation writes the results into those saved registers. - Round-robin preemption on the 100 Hz timer. A task with a
budgetis killed after that many ticks of CPU. - Outcomes. A task ends by
exit, by faulting, or by using up its budget; the others keep running. When nothing can run, the kernel prints a summary and halts with a status (20). Tasks waiting inrecvcount as idle servers; tasks stuck insendmean a deadlock.
scheduled send / recv / sleep
+-------+ ----------> +---------+ ----------------------------> +-----------------+
| Ready | | Running | | SendBlocked |
+-------+ <---------- +---------+ <---------------------------- | ReplyBlocked |
^ timer tick | | reply / message / wake-up | RecvBlocked |
| | | | Sleeping |
| restart(id) | +-- exit(code) -----> Exited(code) +-----------------+
+---------------------+----- fault ----------> Faulted(vector)
(supervisor only) '----- budget spent ---> OverBudget
7App manifests#
An app manifest is a TOML file listing every task. kernel/build.rs turns it
into the kernel's task table at build time. Pick one with make run APP=…
(default apps/default.toml; tests use tests/apps/).
[[task]]
name = "client" # unique task name; also the program, unless `program` is set
program = "hello" # optional: build/<program>.elf (any program under user/)
budget = 50 # optional: CPU budget in 10 ms ticks (default: unlimited)
supervisor = true # optional: told when other tasks die (at most one)
sends = ["echo"] # optional: tasks this one may send IPC messages to
devices = ["virtio-console"] # optional: PCI devices to drive (kernel/src/pci_kinds.rs)
dma_pages = 16 # optional: contiguous DMA region in 4 KiB pages (max 256)
events = true # optional: may read the kernel event log and stats (at most one)
min_version = 2 # optional: refuse images signed with an older version
tpm = true # optional: gets the TPM mapped, for attestation (at most one)
listen = [{ port = 80, max_connections = 64, max_per_source = 16 }]
# optional: TCP ports it may listen on (max_connections default 64,
# max_per_source default max_connections; at most 8 entries)
service = "web" # optional: the network service it is an instance of (default: its name)
routes = true # optional: may route, drain and blocklist in the network stacks
loads = true # optional: may load service images into slots (at most one: the agent)
connects = true # optional: may open outbound connections through a network stack
runtime_log = 1024 # optional, top level: run-time measurement log entries
[[slot_class]] # optional: generic slots for services loaded at run time (15.1)
name = "small"
count = 2
memory_pages = 256
image_pages = 128
The build fails on unknown keys, duplicate names, unknown sends targets or
device names, more than one supervisor, event reader, TPM task or loader, more than 64 tasks
and slots, or a port declared by two different services (instances of one service may share
it). Each devices
entry claims the next unclaimed device of that kind; a missing device is logged and the task
runs without it.
Names with meaning
| Name | Found by | Role |
|---|---|---|
uplink | telemetry, control | host link driver (e.g. virtio-vsock); otherwise telemetry uses virtio-console |
telemetry | supervisor, storage | receives log lines and storage events |
blockdev | storage | the virtio-blk driver |
disk | clients, keys, attest | the storage task (or, in blk* tests, the driver itself) |
keys | storage | key server that gives disk the master key |
attest | control, storage | TPM quotes; opens the host's released key |
net, nic | services, net | the data network's stack and its NIC driver (14) |
mgmt-net, nic-mgmt | the agent, mgmt-net | the management network's stack and its NIC driver |
control | telemetry | the only task that may set the counter snapshot interval |
- files
kernel/build.rs(parser,knownkeys),apps/README.md,apps/*.toml,tests/apps/*.toml- remember
- A task may only
sendto tasks in itssends;lookup(name)succeeds regardless, and a refused send logs anipc_refusedevent - attestation
- The manifest is measured into PCR 12: editing a manifest changes the expected PCR value
8IPC#
Synchronous send/receive/reply, as in Hubris. A client's send waits until the
server has received the message and replied. The kernel copies messages and replies between
address spaces, up to 4 KiB each. A task may only send to the tasks listed in its manifest
sends.
client kernel server
| | |
|- send(server, op, msg) -->| check sends allowlist |
| [SendBlocked] | queued until server recvs |
| |<-- recv(buf) --------------------|
| [ReplyBlocked] |--------------------- copy msg -->| (sender, op, len)
| | | ... does the work ...
| |<-- reply(sender, code, data) ----|
|<-- (code, reply_len) -----| | reply never blocks
| | |
Task ids carry a generation (slot + 1 | generation << 16)
that changes on restart. A client holding an id from before a restart gets DEAD
instead of silently reaching a fresh server that lost its state; it can lookup
the name again and resend. Kernel notifications arrive as messages from sender 0.
| Error (top bit set) | Meaning |
|---|---|
DEAD | the target has died, or was restarted since the id was obtained |
NOT_ALLOWED | the target is not in the sender's sends (or: supervisor-only call) |
BAD_TARGET | no such task, or sending to yourself |
BAD_BUFFER | a buffer is unmapped, read-only where it must be written, or not in user memory |
TOO_LONG | a message or reply is over 4 KiB or bigger than the receiving buffer |
BAD_ARGUMENT | a reply code with the top bit set; restart of a live or already-restarted task |
- files
- kernel:
send/recv/replyinkernel/src/process.rs; user:send,recv,recv_flags,recv_timeout,replyinuser/lib/src/lib.rs - pattern
- Servers loop on
recvand alwaysreply, even with an error code; reply codes are the server's protocol (top bit clear) - pattern
- Clients of restartable servers retry on
DEADafterlookupreturns a different id (seewith_diskinuser/blktest) - tests
ipc-*,stale-id
9Supervisor#
One task may be marked supervisor = true (user/supervisor). The
kernel reports every other task's death to it as a message from sender 0 with
NOTIFY_DEATH and a death record: how it ended, exit code or fault vector,
rip, cr2, error code and ticks used. The supervisor decides what to
do and may call restart(id), which reloads the task's ELF into fresh memory with a
new generation. The policy lives in the supervisor, not the kernel. If the supervisor itself
dies, the kernel halts.
The policy (RFD 2, P5): a task that exits 0 is done. A task that faults, exits non-zero or
uses up its CPU budget is always restarted, however often it crashes, so a request that crashes
a service can't take it down for good. Restarts back off so a crash loop can't eat the CPU:
10 ms after a crash, doubling to at most 30 s, and back to 10 ms once the task has run 60 s
since its last restart. The supervisor waits with recv's timeout, so a pending
restart costs nothing. Each crash goes to telemetry as a crash event with the
restart count and the backoff (crash-loop, backoff-reset). The agent
asks it to stop a task (supervisor::OP_STOP: the kernel's stop, reported
as death kind 5, never restarted) or to start a dead one again (OP_START, with its
backoff reset), for deployments (15.4). Test
features: give-up-after-3 and give-up-after-3000 stop restarting so a
run made only of a crash loop can end, backoff-fast restarts at once,
reset-after-2s shortens the reset window, and halt-on-exit stops the
system when any task exits.
task faults ---> kernel: free its memory, mark Faulted, log "fault" event
|
'--> NOTIFY_DEATH {kind, code, rip, cr2, ...} --> supervisor
|
exited 0: done | fault / non-zero exit / over budget: restart after a backoff
|
new generation <--- kernel: reload ELF, fresh page tables, id' = id + (1 << 16)
clients holding the old id get DEAD; they lookup() the name again
10Drivers#
Device drivers are ordinary tasks. The kernel discovers PCI devices at boot and a manifest
grants one to a task (devices = ["virtio-console"]). The driver gets:
- the device's register BARs mapped into its address space (uncached), except the BAR holding the MSI-X table: the kernel programs interrupt routing itself;
- read-only access to the device's PCI configuration space;
- a physically contiguous DMA region (
dma_pages), the same physical pages for the life of the task slot and zeroed on every restart; - the device's interrupts as notifications in
recv(sender 0,NOTIFY_IRQ). A driver with work in flight passesRECV_EXPECT_IRQso the kernel idles for it instead of ending the run.
driver task (ring 3) device (virtio PCI)
+-----------------------------+ +-----------------------------+
| BAR window 0x10_0000_0000 |-- MMIO -->| common cfg, notify, ISR |
| DMA region 0x20_0000_0000 | | |
| virtqueues + buffers |<-- DMA ---| reads requests, writes data |
| recv(): NOTIFY_IRQ <--+ | +---------------+-------------+
+-----------------------+-----+ | MSI-X
'---- kernel irq.rs <-------------'
clients ---- IPC (block / console protocol) ----> driver
Drivers: virtio-console, virtio-vsock (one stream to host CID 2,
port 5000), virtio-blk and virtio-net (14), all on the shared user/virtio crate
(virtio 1.x modern PCI, split virtqueues). A crashing driver is restarted by the supervisor; it
resets the device and carries on, and its clients see DEAD and retry against the
new id (console-restart, blk-restart, nic-restart).
A driver that must tell its client something without being asked (frames arrived) posts a
notification with notify(client, bits) rather than sending: only the client sends,
so the two can't deadlock. The client gets the bits in recv as sender 0,
NOTIFY_POST.
There is no IOMMU yet. A driver programs its device with physical addresses, so a malicious
driver could make the device read or write any memory, kernel included. Driver tasks are
isolated from mistakes (bad pointers, crashes, runaway loops), not from malice. Step 7 plans
VT-d DMA remapping (plans/step-7-iommu.md).
- files
kernel/src/pci.rs,kernel/src/pci_kinds.rs,kernel/src/irq.rs,user/virtio/,user/virtio-*/- syscalls
device_bar,pci_config_read,dma_info;recv_flags(.., RECV_EXPECT_IRQ)- tests
device-*,dma-*,console-*,blk-*,nic-*
11Telemetry#
The kernel logs events at its control points into a 128-entry ring: task start, exit, fault
(vector, rip, cr2), CPU-budget kill, restart, refused IPC sends and
rejected images. The task with events = true (user/telemetry)
forwards them as newline-delimited JSON through the uplink driver, with a heartbeat every
second, log lines from other tasks, storage events, and network events and
counter snapshots (14.5).
kernel event ring --read_events--+
(128 entries, seq numbered) |
supervisor --OP_LOG---------------+--> telemetry --console write--> uplink driver --> host
disk (storage) --OP_STORAGE-------+ (JSON lines, (virtio-console |
heartbeat 1/s) or virtio-vsock) v
tools/host-agent.py
{"seq":5,"t":1,"task":"crasher","id":"2","event":"fault","vector":14,"rip":"0x400182","cr2":"0x10"}
{"seq":6,"t":2,"task":"crasher","id":"2.1","event":"restart","previous":"2"}
{"t":2,"task":"supervisor","id":"1","event":"crash","of":"crasher","index":2,"kind":"fault","code":14,"restarts":1,"backoff_ms":20}
{"t":5,"event":"dropped","count":37}
{"t":102,"event":"heartbeat","n":1,"frames":89,"frames_free":28500,"irqs":3,"events":11,"tasks":[...]}
A heartbeat gives the frames in use and still free (4 KiB each) and each task's CPU ticks so
far, so the CPU used over a second is the change in the tasks' ticks over the change in
t (load tests do this, 20).
t is in 10 ms ticks since boot; ids are slot or
slot.generation. Kernel events carry seq; if the ring wraps before
telemetry reads it, a dropped record says how many were lost. The uplink is the
virtio console by default (QEMU writes it to build/telemetry.log), or vsock with
TRANSPORT=vsock and an uplink task running
virtio-vsock.
tools/host-agent.py build/telemetry.log # console uplink
tools/host-agent.py --listen-vsock 5000 & # vsock: listen first, then boot
make run TRANSPORT=vsock ACCEL=kvm APP=tests/apps/telemetry-vsock.toml
host-agent.py checks that every line is JSON with an event, that
sequence gaps are covered by dropped, and heartbeat spacing;
--check turns problems into the exit status and --expect EVENT=N
requires counts. --set-interval MS sets the counter snapshot interval once the
guest connects (vsock), and --expect-interval TICKS checks that snapshots then
arrive that far apart.
- files
kernel/src/events.rs,user/telemetry/src/main.rs(event_line,storage_line),tools/host-agent.py- gotcha
- telemetry never exits on its own; tests build it with
telemetry/exit-after-3-heartbeatsso the run can end, or the supervisor withhalt-on-exit - gotcha
- vsock needs
/dev/vhost-vsock(thekvmgroup) and KVM
12Boot chain and attestation#
With BOOT=uefi-secure every link checks the next before running it:
OVMF (Secure Boot on, snakeoil keys)
| checks sbsign signature on BOOTX64.EFI ............. refuses: sb-unsigned-loader
v measures --> PCR 0-7
Limine
| checks its own config hash (enroll-config) ......... refuses: sb-edited-config
| checks kernel BLAKE2b hash pinned in limine.conf .. refuses: sb-tampered-kernel
v
vanir kernel measures --> PCR 12: kernel, manifest, tasks
| checks each task image: Ed25519 sig, name, version refuses: sig-*
v
tasks (ring 3)
attest: TPM quote of PCR 0,4,7,12 over host nonce --> host verifies --> releases key
Task images ship as boot modules beside the kernel, each with a .sig file
(VNRSIG01, program name, version and SHA-256 of the ELF, signed with Ed25519). The
kernel refuses an image that is unsigned, changed, signed for another program, signed by
another key, or older than the manifest's min_version. A refused task never runs;
the supervisor and telemetry are told why. Images can be updated without rebuilding the kernel,
as long as they are signed.
Measured boot (TPM=1, swtpm): the firmware measures the boot
loader and Secure Boot state into PCRs 0–7. Before any task runs, the kernel extends
PCR 12 with SHA-256 of the kernel file, of the app manifest, and of each
task's signed statement (zeros for a refused image). tools/expected-pcrs.py
computes PCR 12 from the build output alone.
Attestation: the attest task (tpm = true) holds
an attestation key in the TPM (restricted ECDSA P-256 under the endorsement hierarchy). When
the host sends attest <nonce> over the vsock uplink, control
asks attest, which returns a TPM quote of PCRs 0, 4, 7, 12 and 13 (13: images loaded
into slots since boot, 15.3).
tools/verify-quote.py checks it is genuine TPM output, over this nonce, correctly
signed, consistent with the reported PCRs and the kernel's measurement log, and equal to an
approved reference.
make image BOOT=uefi-secure APP=tests/apps/attest.toml USER_FEATURES=control/one-shot
tools/host-agent.py --listen-vsock 5000 --save build/telemetry.log --attest &
make run BOOT=uefi-secure ACCEL=kvm TPM=1 TRANSPORT=vsock APP=tests/apps/attest.toml USER_FEATURES=control/one-shot
tools/verify-quote.py --record build/attest-reference.json \
--expected-pcr12 $(tools/expected-pcrs.py --app tests/apps/attest.toml) # known-good boot
tools/verify-quote.py --reference build/attest-reference.json # later boots
- Development keys. Secure Boot uses Debian's snakeoil keys, whose private
key is public, and the image key is a local dev key in
keys/. - Trust on first use. The attestation key isn't tied to the TPM's endorsement certificate yet, so the verifier trusts the key recorded with the reference.
- Recorded firmware values. PCRs 0, 4 and 7 are compared with a recorded known-good boot, not computed independently; only PCR 12 is.
- No IOMMU. A signed but malicious driver can still use DMA after measurement.
- files
kernel/src/verify.rs,kernel/src/measure.rs,kernel/src/tpm.rs,user/tpm2/,user/attest/,user/control/,tools/sign-image.py,tools/verify-quote.py,tools/vanir_attest.py,tools/expected-pcrs.py- gotcha
- Any change to the kernel, a manifest or a task image changes PCR 12; the
attest-recordandattest-storage-recordcases re-record references, so run attestation cases in suite order - gotcha
- TPM state persists in
build/tpm; delete it if the TPM reports lockout
13Storage#
DISK=1 attaches build/disk.img (64 MiB, kept between runs) as a
virtio block device. Three tasks share the work:
client-a ---+ +--> keys (key server)
client-b ---+--block IPC--> disk (storage) --OP_MASTER_KEY-+
| per-volume | encrypt / decrypt '--< attest (OP_UNLOCK, after
| block numbers | XChaCha20-Poly1305 the host releases the key)
| v
| blockdev (virtio-blk) --DMA--> disk image (ciphertext only)
x
only "disk" may send to "blockdev" (storage-no-driver)
| Task | Program | Role |
|---|---|---|
blockdev | virtio-blk | the driver: reads and writes raw 4 KiB blocks; only disk may send to it |
disk | storage | gives every client its own encrypted volume |
keys or attest | unlocker, attest | hands disk the master key, and services' volume keys (13.6) |
Clients send disk block requests (vanir_user::block: read, write,
flush, info) with block numbers relative to their volume. There is no file system: a volume is
256 numbered 4 KiB blocks (1 MiB). The volume is chosen by the client's manifest name, which
the storage task gets from the kernel (task_name of the sender), so a client can
neither name nor reach another task's volume. A task's first request creates its volume.
13.1On-disk format#
block 0 superblock: "VNRSTOR3", next free block plain text
block 1 volume table: 64 x {name[32], start, size, kind} plain text
blocks 2..260 volume "client-a"
+-- header block: {counter u64, tag[16]} in plain text, then sealed
| {"VNRVOL01", kind, name, size, epoch, lease}
+-- 2 metadata blocks: per data block {counter u64, tag[16]}
'-- 256 data blocks: XChaCha20-Poly1305 ciphertext
blocks 261.. volume "client-b" (same layout), in creation order ...
Block b of a volume is encrypted with the volume key, nonce = b
‖ counter, and associated data = volume name ‖ b ‖ counter. The
counter goes up on every write, so a nonce is never reused. Ciphertext that was changed, moved
to another block or volume, or paired with another counter fails to decrypt: the client gets
INTEGRITY and telemetry an integrity_error. A block never written
(counter 0) reads as zeros. A disk without the magic is formatted on first use, and so is a
disk in an older format (VNRSTOR2), with a message saying so.
The header is sealed the same way, as block number 264−1, and holds the
volume's kind, name and size, and its writer epoch and lease (RFD 3; recorded, not yet used).
On a volume's first read or write after each unlock, disk opens the header and
checks it against the table; if it doesn't open or doesn't match, every read and write of that
volume gets INTEGRITY. Volumes are of two kinds: a fixed task's, and (from RFD 3)
a service's. The kind is in the table, so a task and a service with the same name get different
volumes, and in the key's salt (below), so their ciphertext never opens in each other's.
13.2Keys#
master key (32 bytes; only in disk's memory, never on the disk)
|
+-- HKDF-SHA256(salt "vanir-storage-v1", info "client-a") --> volume key A
+-- HKDF-SHA256(salt "vanir-storage-v1", info "client-b") --> volume key B
service key (32 bytes, one per service; from the control plane, RFD 3, P2; 13.6)
+-- HKDF-SHA256(salt "vanir-service-v2", info service) --> the service's volume key
|
per block: XChaCha20-Poly1305(key, nonce = block || counter,
aad = name || block || counter)
The storage task starts locked, answering LOCKED to every
request for a task's volume until it has the master key, and to every request for a service's
volume until it has that service's key (13.6). The master
key comes from one of two places:
- The key server (
keys,user/unlocker): a test stand-in for a local key store, with the key compiled in. Ifdiskmay send tokeys, it asks for the key when it starts.keysgives it only to the task nameddisk. - The host, after attestation (
tests/apps/attest-storage.toml). The key lives on the host (keys/master.key, made byhost-agent.py, never in the image).
13.3Key release#
host-agent.py control attest (TPM) disk
| ----------- "attest <nonce>" --> |---- OP_QUOTE -->| |
| | | new X25519 pair |
| | | quote(PCR 0,4,7,12, |
| <-- quote JSON + kx_pub -------- |<----------------| SHA256(nonce||kx_pub)) |
| | | |
verify quote vs reference; | | |
mismatch -> "key_refused", stop | | |
seal: X25519(eph, kx_pub) | | |
-> HKDF(salt = nonce, | | |
"vanir key release") | | |
-> ChaCha20-Poly1305( | | |
master, aad = nonce) | | |
| --- "key <eph_pub||ct||tag>" --> |--- OP_UNWRAP -->| open with kx secret |
| | |--- OP_UNLOCK(master) -->| unlocked
| <-- "unlocked" ----------------- | | |
A boot that measures differently (a changed kernel, manifest or task image) gets no key, and
the sealed key is useless to anything but that boot's attest task.
OP_UNLOCK is accepted only from tasks named keys or
attest.
13.4Restarts and events#
The supervisor restarts disk and blockdev like any task. A
restarted blockdev resets the device; disk sees DEAD,
finds the new driver and resends, keeping its key (storage-blk-restart). A
restarted disk starts locked and must get the key again: from keys it
asks for it itself (storage-restart); clients see DEAD and resend.
If disk may send to telemetry, it reports:
{"t":6,"task":"disk","id":"3","event":"storage_locked"}
{"t":6,"task":"disk","id":"3","event":"storage_unlocked","by":"keys"}
{"t":7,"task":"disk","id":"3","event":"volume_created","volume":"client-a","start":2}
{"t":9,"task":"disk","id":"3","event":"integrity_error","volume":"client-a","block":0}
{"t":196,"task":"disk","id":"3","scope":"storage","event":"counters","blocks_read":5,"blocks_written":8,"flushes":3}
The counters (blocks read and written through the driver, and flushes, since it started) come
once per snapshot interval while requests arrive. task comes from the kernel, so no other task can report storage events in
disk's name.
make run DISK=1 DISK_RESET=1 APP=tests/apps/storage.toml # fresh disk: two clients write and check
make run DISK=1 APP=tests/apps/storage.toml # same disk: they find their data
make run DISK=1 DISK_CORRUPT=client-a:0 APP=... # flip a byte of client-a's block 0 first
13.5Service volumes and writers#
A service image loaded into a slot (15) can declare a volume in its
signed descriptor (volume = { blocks = 16 }, at most 65536). The kernel returns the
size with the rest of the descriptor (task_listen), and disk gives an
instance in a slot its service's volume, named after the service, whichever slot it
runs in. An instance whose descriptor declares no volume gets NOT_ALLOWED. The
version that makes the volume fixes its size; an instance that declares another size gets
OUT_OF_RANGE and can't open it (RFD 3 leaves resizing to RFD 4).
A service volume has one writer (RFD 3, P1). Any instance of the service may
read it; writes and flushes from any other get FENCED. The writer is named by an
epoch, which the control plane assigns with each deployment and the agent
hands to disk (block::OP_EPOCH: epoch, lease, writer). Only the task
with loads = true may send it. disk records a higher epoch in the
volume's header and flushes it before replying. The same epoch again is accepted only while
there is no writer, or the writer is unchanged. Anything lower is refused, across reboots too,
since the header is on the disk. The writer is a slot, kept in memory: a writer that
crashes and is restarted in its slot stays the writer, and a restarted disk knows
no writer until the agent names it again. The lease is recorded, not enforced (RFD 3,
determination 8).
agent: deploy counter v2 into small-1, epoch 2 counter v1 (small-0) counter v2 (small-1)
load, start ......................................... writes OK reads OK, writes FENCED
OP_HANDOFF(counter, small-1); route; drain small-0 .. drains reads, writes wait
small-0 drained: OP_EPOCH(2, lease, small-1) ........ writer bit; held
requests answered;
writes OK
stop small-0
Handoff (RFD 3, P3). Between the routing change and the old instance
draining, the new instance's requests wait rather than fail. Just before routing to
it, the agent names it the handoff's candidate (block::OP_HANDOFF: service,
candidate). From then on disk holds the candidate's reads, writes and flushes
without answering them; the kernel lets a server answer a blocked caller later. When
OP_EPOCH makes the candidate the writer, disk posts it the
writer bit (block::NOTIFY_WRITER, which every new writer gets) and
then serves the held requests in order. Until an instance has seen the bit, it must not trust
anything it cached while read-only; from the bit on, nobody else writes the volume. If the
epoch is refused, the agent calls the handoff off (candidate 0) and the held requests are
served as they are: reads succeed, writes get FENCED
(volume-handoff-off). A client blocked in a call has one request outstanding, so
disk holds at most one per slot (8 entries; BUSY, which the client
retries, if ever full). A restarted disk loses what it held (its callers get
DEAD), and the agent sends the handoff again with the epochs.
counter (user/counter, a service on the runtime, 15.8; tests/slots/counter-v*.toml) uses the volume its
descriptor declares: /count reads a count from block 0, adds one and writes it
back, or with the setting persist_ticks counts in memory and writes it every that
many ticks and before it drains or exits. It opens the volume before it says it is ready, and
never says so if that fails. A task named like a service still gets a volume of its own: the table records each
volume's kind, and their keys use different salts (volume-kinds).
Events: epoch_changed (volume, epoch, writer slot), write_fenced
(volume, slot, epoch; the first refusal per slot and epoch) and handoff (volume,
requests held, longest wait in ticks, whether it was called off).
13.6Service keys#
Each service's volume has its own key (RFD 3, P2). The control plane makes it on first use,
keeps it (build/cp-volume-keys/<service> for the mock), and sends it only to an
attested node that runs the service. The agent forwards it but can't read it: the key is sealed
to attest's per-boot X25519 key, which the handshake's quote binds along with the
agent's own keys. The agent can dial out and load images, so a compromised agent shouldn't also
be able to read every volume on the node.
control plane agent attest disk
| <-- handshake: quote over SHA-256(e || s || seal_pub) ---| (OP_QUOTE_FOR) |
check quote; for each service with a volume it runs here: | |
seal: X25519(fresh, seal_pub) | |
-> HKDF(salt = fresh pub, "vanir-volume-key-v1") | |
-> ChaCha20-Poly1305(key, aad = service name) | |
| -- K: service, fresh pub || ct || tag -->| | |
| |-- OP_UNWRAP_VOLUME -->| open |
| | |-- OP_SERVICE_KEY -------->| service volume opens
| | keeps the blob for a restarted disk |
| commit, no instance left here: |-- OP_FORGET_KEY -------------------------->| locked again
- Only for that service. The service name is the seal's associated data, so
a blob moved to another service doesn't open (
agent-volume-key-swap); the agent reportskey-refused <service>. - Only to attested nodes. A node that said
no-tpmgets no keys, so a service with a volume never gets ready there (agent-volume-unattested). - Without its key a service's volume answers
LOCKED; task volumes and other services are unaffected, and so isOP_EPOCHfor other services. The instance never says it is ready, and the deployment fails (volume-no-key). - Forgotten. Once a commit leaves no instance of a service on the node, the
agent has
diskforget its key and drops the blob (volume-forget). A node only holds the keys of what it runs. - The stand-in.
keysserves a test key per service as well (HKDF of a fixed seed and the name), anddiskasks it when it needs one, so the one-node tests run without a TPM or a control plane. An app withkeysaccepts either source; the attested app (agent-volumes-tpm.toml) has none.
OP_SERVICE_KEY is accepted only from keys and attest,
OP_FORGET_KEY only from the task with loads = true. A volume written
under V1's keys (salt vanir-service-v1, from the master key) no longer opens.
- files
user/storage/src/main.rs,user/virtio-blk/,user/unlocker/,user/blktest/,block/keys/telemetrymodules inuser/lib/src/lib.rs,tools/corrupt-disk.py- protocol
op = code | block << 8; ops READ 1, WRITE 2, FLUSH 3, INFO 4, UNLOCK 5, EPOCH 6, SERVICE_KEY 7, FORGET_KEY 8, HANDOFF 9, EXPORT 10, EXPORT_ENTRY 11, EXPORT_META 12, EXPORT_HEADER 13, IMPORT_HEADER 14, IMPORT_ENTRY 15, IMPORT 16, MIRROR 17, MIRROR_PENDING 18, MIRROR_ACK 19, HAND_OVER 20, DIGEST 21, DROP 22 (10 to 22: the agent only, moving a volume, 15.7); notify bit WRITER 1; volume kinds TASK 1, SERVICE 2, FREE 3; codes OK 0, BUSY 1, BAD_OP 2, OUT_OF_RANGE 3, IO_ERROR 4, LOCKED 5, INTEGRITY 6, NOT_ALLOWED 7, FENCED 8- constants
MAGIC "VNRSTOR3",TASK_BLOCKS 256,META_ENTRY 24, header sealed as blocku64::MAX; changing the format needs a new magic and an update totools/corrupt-disk.py- gotcha
storage-*cases depend on the disk image the previous case left;DISK_RESET=1starts clean
14Networking#
Services serve TCP from tasks of their own, as RFD 1 (rfd/0001-network-services.md)
proposes. Each network has a NIC driver task and a network stack task, and the management and
data networks are separate: a flood or a compromise of the data stack can't reach the stack the
control plane uses. Everything is declared in the manifest; nothing here loads at run time.
host / clients
| data network | management network
v v
nic (virtio-net) --frames-- net nic-mgmt (virtio-net) --frames-- mgmt-net
DMA ring smoltcp DMA ring smoltcp
sockets, routing, |
limits, counters mgmt-echo (stands in
| | \ for the node agent)
web-1 web-2 '--counters, events--> telemetry
(live) (draining)
^
switcher (routes = true): route, drain, blocklist
14.1Drivers and stacks#
user/virtio-net moves Ethernet frames between a virtio-net device and one
client, and knows nothing about IP (vanir_user::nic): OP_SEND_FRAME
takes one frame, OP_RECV_FRAMES returns as many waiting frames as fit in 4 KiB and
never waits, OP_INFO gives the MAC and the interface counters. It negotiates only
VERSION_1 and MAC: no checksum offload, so smoltcp computes checksums.
Sixteen 2 KiB receive buffers stay posted. The receive interrupt is on only after the client
has emptied the ring; the driver then posts NOTIFY_RX to it with
notify, turns the interrupt off, and the client polls until the ring is empty
again (as Linux's NAPI does).
user/net runs smoltcp 0.12
(ARP, IPv4, TCP, DHCP) with no heap: 96 sockets with 4 KiB buffers each, in fixed arrays. Only
net sends to the driver, and the driver only posts notifications back, so the two
can't deadlock. The same program runs both stacks: the task named net drives
nic, and mgmt-net drives nic-mgmt. Both use DHCP; the
net/static-addr test feature gives the data stack 10.0.9.2/24 instead, for the
dgram backend, which has no DHCP server.
net spends at most half of each tick on received frames. Past that, frames wait
in the driver's ring until the next tick and the device drops what doesn't fit, so a flood
can't take the CPU from the services or the management stack (mgmt-under-flood).
The budget is measured with the TSC, so time net spends preempted counts too.
14.2Sockets#
A service declares its ports in the manifest (listen, 7).
net asks the kernel which ports a client may use (task_listen), so a
task can't claim ports it wasn't given (http-undeclared-port), and the build refuses
two services declaring one port (build-http-port-clash). Instances of one service
(service = "web") may share a port.
| Op | Message → reply |
|---|---|
OP_LISTEN 1 | port → listener handle; NOT_ALLOWED if undeclared |
OP_WAIT 2 | ticks (0: no limit) → Events (handle, flags), held until there are some or the time runs out |
OP_ACCEPT 3 | listener → connection handle, client address and port; or WOULD_BLOCK |
OP_RECV 4 | handle, most bytes → bytes; WOULD_BLOCK, or CLOSED once the peer is done |
OP_SEND 5 | handle, bytes → how many were taken (an EV_WRITE follows when there is room) |
OP_CLOSE 6, OP_SHUTDOWN 7 | handle: close and free it; or send a FIN and keep reading |
OP_READY 8, OP_DONE 9 | the instance can serve; a draining instance has finished |
OP_ROUTE 10, OP_DRAIN 11, OP_BLOCK 12 | routing, for tasks with routes = true (14.3, 14.4) |
Events: EV_ACCEPT, EV_READ (data, or the peer closed),
EV_WRITE, EV_CLOSED (reset), EV_DRAIN (handle 0). Only
OP_WAIT waits, so a single-threaded service runs one event loop. Handles carry a
generation, as task ids do, so a handle kept after its connection closed can't name a newer
one. smoltcp has no accept queue (one socket holds one connection), so net keeps a
few sockets listening on each port in use and hands each connected one to the live instance of
the port's service.
user/http is a small HTTP/1.1 server on this interface: GET and
HEAD, keep-alive, fixed buffers. / answers hello from
<task> and /count the number of requests. When either side restarts,
the other recovers: HTTP finds net again and listens anew (net-restart),
and net forgets a restarted instance's connections (http-restart).
14.3Routing and draining#
Each service has one live instance, which gets its new connections; existing
connections stay where they are. An instance becomes live only after it has said
OP_READY. The first ready instance of a service becomes live by itself; after that,
a task with routes = true changes it with OP_ROUTE
(route-not-ready, route-not-allowed). In the RFD 1 tests that task is
switcher; RFD 2's agent does it too (15.4), and
asks an instance's status with OP_STATUS (starting, ready, live, draining, drained)
instead of waiting for a drain's reply (DRAIN_NOWAIT).
switcher: OP_ROUTE web-2 new connections now go to web-2
switcher: OP_DRAIN web-1, deadline 10 s (reply held)
net --EV_DRAIN--> web-1 web-1 stops accepting; answers each connection's next
request with "Connection: close"; clients reconnect to web-2
... connections left at the deadline: reset, counted as drain_forced
web-1 --OP_DONE--> net no connections left: the OP_DRAIN reply (drain_forced) goes out
Connections the draining instance never accepted go to the live instance. HTTP leaves idle
connections alone while draining: closing one just as its client sends a request would fail
that request. With eight clients requesting without pause, no request fails and every client
ends up on web-2 (swap-under-load); a connection held open without a
request is reset at the deadline and counted (drain-deadline).
14.4Limits#
Before smoltcp sees a frame, net checks it:
- sources on the blocklist are dropped first (16 ranges with expiry, set by
OP_BLOCKfrom a task withroutes = true); - IPv4 packets with a bad header checksum are dropped and counted;
- each new connection (a SYN for one of our ports) is held to the port's
max_per_sourceandmax_connections, and to 16 handshakes in progress per port. smoltcp has no SYN cookies, so at that cap a new SYN takes the place of the oldest handshake; handshakes also time out after 2 s.
HTTP limits each request: the header must fit in 4 KiB (else 431) and arrive within 5 s of its
first byte (else 408), and bodies over 16 KiB are refused (413). Connections idle for 15 s
between requests are closed by HTTP, and for 60 s by net. A 32-entry Space-Saving
sketch tracks the heaviest sources; each snapshot interval net compares SYN and
limit-drop rates and CPU budget use with fixed thresholds and reports
attack_suspected with the top talkers.
The attack tests planned in plans/step-9-net.md (dos-*: SYN flood,
connection exhaustion, slowloris, large requests, packet flood, blocklist) are not in the suite
yet. The packet flood is covered by mgmt-under-flood; the other limits are
implemented but not exercised by a test.
14.5Counters and events#
Counters are cumulative and go to telemetry as snapshots (OP_COUNTERS), one per
scope; rates are the host's to compute. Telemetry answers each snapshot with the interval for
the next: 1 s by default, and the host may set 100 ms to 60 s
(host-agent.py --set-interval MS, through the control task; net-interval).
| Scope (from) | Counters |
|---|---|
interface (net) | rx/tx packets and bytes, rx_ring_full, errors, multicast, broadcast, tx_dropped |
net (net) | sockets in use, listening, connections, frames in, IPv4 checksum failures, segments for ports nobody listens on, connections refused for want of a live instance, ticks over budget, cpu_permille |
instance (net) | per instance: live, accepted, refused, reset, open, bytes in and out, drain_forced |
protection (net) | SYNs, half-open now, handshakes dropped and timed out, drops by limit, blocklist hits and entries |
http (each HTTP task) | requests, by status class, and a latency histogram (1 ms to 1 s buckets) |
{"t":210,"task":"net","id":"3","scope":"instance","event":"counters","instance":"web","service":"web","live":1,"accepted":1,"bytes_in":3533,"bytes_out":5806,...}
{"t":13,"task":"net","id":"3","event":"route_changed","service":"web","live":"web","previous":"none"}
Events (OP_NET): route_changed, drain_started,
drained, listen_refused, limit_tripped,
attack_suspected, blocklist_added. Records travel as
name key=value ... text and telemetry turns them into JSON, adding the sender's
name from the kernel. net-counters checks the counters against a known transfer,
byte for byte (tools/check-net-counters.py). smoltcp doesn't count retransmits, so
neither do we.
14.6Running it#
make run APP=tests/apps/http.toml NET=user # then: curl http://127.0.0.1:8080/
make run APP=tests/apps/mgmt.toml NET=user NET_MGMT=user # plus: nc 127.0.0.1 8081 (echo)
make run APP=tests/apps/http.toml NET=dgram USER_FEATURES=net/static-addr # raw frames
NET adds the data NIC and NET_MGMT the management NIC, at PCI
00:10.0 and 00:11.0 (declare the data driver first in the manifest: each
devices entry claims the next device of its kind). user is QEMU's NAT
with DHCP, and forwards 127.0.0.1:NET_PORT (8080) to the guest's port 80 and
NET_MGMT_PORT (8081) to port 7. dgram carries raw Ethernet frames in UDP
datagrams between QEMU (NET_DGRAM_PORT, 18090) and tools/net-peer.py
(the next port), which can send any frame it likes. Tests run net-peer.py next to
QEMU (PEER=<scenario>); since servers never go idle, they build the
supervisor with halt-on-exit and end the run by asking HTTP for
/exit (http/exit-on-request).
Nothing here should face a real network before the IOMMU (step 7): the NIC driver handles
bytes from the network and can make its device DMA anywhere. Every byte is also copied twice
through IPC (driver to net, net to the service), at most 4 KiB a
message; under TCG that serves about 1,400 keep-alive requests a second.
- files
user/virtio-net,user/net(main.rs: tables, requests, routing, snapshots;device.rs: smoltcp device over IPC;filter.rs,guard.rs: frame checks, blocklist, top talkers),user/http,user/mgmt-echo,user/switcher,nic/net/telemetrymodules inuser/lib,tools/net-peer.py,tools/check-net-counters.py- names
net/nicandmgmt-net/nic-mgmtpair by name; services find their stack withlookup("net")- gotcha
- services and
netkeep big tables: put them in statics, not on the 64 KiB stack - gotcha
- host ports are fixed per run; to run network cases while another suite runs, set
NET_PORT,NET_MGMT_PORT,NET_DGRAM_PORT,NET_MGMT_DGRAM_PORTin the environment - tests
nic-*,http-*,net-*,mgmt-*,swap-under-load,drain-deadline,route-*
15Deployment#
Services are deployed while a node runs, and move between nodes (RFD 2,
rfd/0002-deployment.md; the plan is plans/step-10-deploy.md). A node's
manifest can't name every service that might land on it, so it declares a pool of
generic slots. The agent (user/agent, the only
task with loads = true) loads signed service images into them at run time. Until
there is a control plane, test scripts built into the agent stand in for it.
15.1Slots and descriptors#
[[slot_class]]
name = "small" # slots are named small-0, small-1, ... after the tasks
count = 2
memory_pages = 256 # most frames a service may use: image, stack, page tables
image_pages = 128 # image area: the largest image a slot can hold (512 KiB)
budget = 20 # optional: CPU budget, as for tasks
sends = ["net"] # optional: tasks its services may send to (by task name)
Each slot is a task table entry that starts empty. At boot the kernel sets aside its image area (contiguous, never freed, like a DMA region) and checks that free memory covers every slot at its class's limit. Tasks' memory is fixed once loaded (there is no syscall to grow it), so after that check a load can't fail for want of memory. The manifest decides the table's size: tasks plus slots, at most 64.
What a service may do comes with its image, in a descriptor signed with it
(tests/slots/*.toml):
name = "web" # the service
version = 2
min_version = 1 # oldest version that may still run
slot_class = "small"
listen = [{ port = 80, max_connections = 64 }]
drain = { default_secs = 30, max_secs = 300 }
volume = { blocks = 256 } # optional: the service's volume (RFD 3, 13.5)
abi = "1.0" # optional: the ABI it was built against (default: vanir-abi's)
format = 2 # optional: 1 writes the older 256-byte format (no abi, no config)
[config] # optional: up to 12 settings (RFD 9, P5)
persist_ticks = 1000 # keys: 1..16 of a-z, 0-9, _ (a letter first); integer values
tools/sign-image.py sign-slot KEY ELF DESC.toml OUT compiles it to the layout
the kernel reads (OUT.desc; vanir_abi::descriptor: format 2,
VNRDSC02, 512 bytes, or format 1, VNRDSC01, 256 bytes; the first 160
bytes are the same in both) and writes a VNRSIG02
signature file (OUT.sig, 176 bytes) over the name, version, and SHA-256 of both the
image and the descriptor, with the boot images' key. The Makefile builds the test images
(SLOT_IMAGES) into build/slots/, then the agent, which carries them.
task_listen answers for a slot from its descriptor, so net lets a
service listen only on the ports it was signed with; it also returns the version, and
http in a slot calls itself e.g. web v1 on small-0.
The ABI version and settings (RFD 9). A format-2 descriptor records the ABI
version the image was built against (sign-image.py reads
ABI_MAJOR.ABI_MINOR from abi/src/lib.rs), and the kernel
loads it only if the major version is its own and the minor is no newer (else
REJECTED: "built for an ABI this kernel doesn't serve"; slot-abi-refused).
A format-1 descriptor counts as 1.0 with no settings. The settings are signed with the image,
checked at load (valid keys, each once, the unused entries zero), and handed to the instance
through task_listen (ListenInfo::setting("persist_ticks"));
http prints its slot's settings at start (slot-settings). New settings
mean a new descriptor, so a new signed statement and a new image name: changing them is a
deployment, never a change to a running instance.
15.2Loading an image#
The agent copies the image into an empty slot's image area in pieces
(slot_stage, so it never needs a buffer the image's size), then calls
slot_load with the signature file and descriptor. The kernel, in order:
- checks the signature, and that it covers this image and this descriptor;
- checks the version against the descriptor's
min_version; - checks the descriptor names the slot's class;
- checks the image was built for an ABI it serves (same major, minor no newer);
- builds the address space, and refuses if it needs more frames than the class allows;
- measures it (below), or fails with
BUSYorLOG_FULLhaving changed nothing; - records the descriptor and starts it as a new generation of the slot.
A refused image fails with REJECTED and the reason, is logged as an
image_rejected event, and is wiped from the image area. A crashed service is
restarted from the image area like any task (9), without being
measured again. slot_unload empties a slot whose service has ended: it zeroes the
image area and measures the unload. A slot holding an image, running or not, can't be staged
over.
15.3Measuring what runs#
Each load and unload is extended into PCR 13 and recorded in the kernel's
run-time log: 80-byte entries (operation, slot, service name, version, SHA-256
of the signed statement), and PCR 13 is extended with each entry's SHA-256. The kernel keeps the
log, as it does the boot log, so a crash of any task can't make the node unattestable. It has a
fixed length (runtime_log in the manifest, default 1024 entries, about 80 KiB); when
it is full, loads fail with LOG_FULL until the node reboots.
The attestation task sends TPM commands itself, so it takes a lock the kernel holds
(tpm_lock/tpm_unlock) around each sequence of commands, and the kernel
extends PCR 13 only while nobody holds it: a load meets BUSY instead of waiting.
The kernel drops the lock if the task dies. Quotes cover PCRs 0, 4, 7, 12 and 13 and say how
many run-time entries they cover; control then forwards those entries
(attest::OP_RUNTIME_LOG, a JSON line of up to 20 at a time), and
tools/verify-quote.py replays them to PCR 13 and lists what was loaded.
tools/expected-pcrs.py --runtime load:small-0:web-v1 ... computes PCR 13 from the
build. RFD 8 lists what this leaves open (rollback through an old descriptor, one key for boot
and slot images, a task that holds the TPM lock forever).
- files
kernel/src/process.rs(slot syscalls),kernel/src/verify.rs(check_slot, descriptor layout),kernel/src/measure.rs(PCR 13, run-time log),user/agent,tests/slots/- test scripts
- agent features
script-*andtry-*(user/agent/Cargo.toml) - remember
- A slot's id gets a new generation on every load; look it up by name (
small-0). Slots aren't measured at boot, so they don't change PCR 12
15.4The lifecycle and the local swap#
The agent owns every slot's state (RFD 2, P4) and reports each transition as an
instance event (slot, service, version, from, to) to telemetry:
EMPTY --load--> STARTING --ready--> READY --promote--> LIVE ^ | | | timeout (10 s) drain | v v +------------ FAILED DRAINING '-- unload <-- STOPPED <-- stop <-- DRAINED <--------'
user/agent/src/lifecycle.rs)A local swap deploys a new version of a service into an empty slot while the
old one serves: the agent loads it, waits until net says it is ready
(net::OP_STATUS), makes it live (OP_ROUTE), and drains the old
instance without waiting on net (OP_DRAIN with
DRAIN_NOWAIT, then OP_STATUS until it is drained). The drain deadline
is the descriptor's default, or what the deployment asks for capped at the descriptor's maximum.
Once drained, the agent asks the supervisor to stop it (supervisor::OP_STOP): the
kernel ends it (stop, death kind 5) and the supervisor never restarts a stopped
task. Its slot keeps the image, so a rollback is the same flow with the roles
reversed: the supervisor starts the stopped slot again (OP_START, backoff reset)
and the newer version drains. Commit unloads the stopped slots of a service. A
service that exits 0 by itself is done and isn't restarted. Nothing in the lifecycle blocks:
each deployment advances a step at a time from the agent's loop. For a service with a volume, a
deployment carries a writer epoch, which the agent hands to disk before routing to
the new instance if there is no old one, and otherwise once the old one has drained and before
it is stopped (13.5). If disk refuses it, the new
instance stays live without it, and the agent reports the refusal.
Tests: deploy-under-load and rollback-under-load keep eight clients
requesting through the swap (and back) with no failed request; deploy-not-ready
leaves the old version live when the new one never says it is ready;
drain-deadline-cap, deploy-commit, slot-exit-clean.
15.5The agent and the control plane#
The agent dials the control plane through the management stack (mgmt-net; it
has connects = true, and net::OP_CONNECT opens the connection), at the
address built in (CP_ADDR, by default 10.0.2.2:7000: the host, through
slirp). It reconnects with backoff (100 ms doubling to 10 s) whenever the connection drops;
deployments go on meanwhile (user/agent/src/control.rs).
agent control plane (tools/control-plane.py)
| -- e, es, s, ss + quote over SHA-256(e || s) -----> pinned key? quote binds e and s?
| <-- e, ee, se ------------------------------------ (Noise IK, X25519, ChaChaPoly, SHA-256)
| -- R report: generation, each slot's state ------->
| <-- S desired state, Ed25519-signed, generation N - applied only if N is newer
| -- G image <hash> into <slot> ------------------>
| <-- C chunks (staged into the slot), E sig + desc - then the kernel checks and loads it
| -- T telemetry lines (pulled from telemetry) ----->
| <-- K a service's volume key, sealed to attest --- attested nodes only (13.6)
- Who it talks to. The control plane's Noise static key is pinned in the
agent at build time (
keys/cp-noise.pub), so the agent talks to no one else (agent-wrong-cp-key). The agent's static key is made once per boot and its ephemeral key for each connection (RDRAND). The static key is also how another node's agent knows this one when a volume moves (15.7), so it stays the same across reconnections. - Who the node is. The first handshake message carries a TPM quote from
attest(OP_QUOTE_FOR) over SHA-256(ephemeral key || static key ||seal_pub), whereseal_pubisattest's own per-boot key that service keys are sealed to (13.6): the keys of this handshake belong to a measured boot of a known image, and the private keys never leave the agent. The control plane checks it (agent-attested) and refuses a quote that binds another key (agent-unbound-key). A node without a TPM saysno-tpm;--require-attestationrefuses it. - What it wants. Desired state is a text document signed with a second pinned
key (
keys/cp-sign.pub), with a generation the agent only lets rise, so a replayed or reordered command does nothing (agent-signed-state). Lines:live <service> <slot> <image hash> [drain <s>] [epoch <n> <lease ms>],stop <service> [drain <s>],commit <service>(andend, for tests). Images are named by the SHA-256 of their signed statement. For a service with a volume,epochnames its writer (13.5): the agent gives it to the new instance in a deployment, or to the live instance if only the epoch changed. The mock control plane raises it whenever the live instance changes. - Reconciliation. The agent keeps moving towards the last state it applied:
fetching an image it lacks straight into the named slot, deploying it, rolling back to a
stopped slot that already holds the wanted image, draining, committing. It reports what it
has after the handshake, on every change and every 5 s, so a dropped connection costs
nothing but time (
agent-reconnect,agent-deploy,agent-drain). A slot whose instance holds its volume's epoch reportsepoch <n>, andepoch-refused <n>whilediskrefuses the one asked for (agent-epoch-stale). Inagent-volume, four clients keep counting through a deployment ofcounter: every request is answered, and the counts are exactly 1 to N, none twice and none missing. - Telemetry goes to the control plane through the agent. With no uplink
driver, the telemetry task keeps its lines in a 16 KiB ring and the agent pulls them
(
telemetry::OP_PULL): if telemetry pushed to the agent while the agent sends tonetandnetto telemetry, the three could deadlock.
The Noise code (user/noise, no heap) and the host's
(tools/vanir_noise.py, written separately from the spec) are both checked against
the cacophony test vector in tests/vectors/noise-ik.json (noise-vectors,
a host check). tools/control-plane.py gen-keys keys makes the dev keys on the first
build; tests run it with CP=<scenario> (20).
15.6Moves, evacuation and updates#
A move takes a service to another node with the same pieces as a swap,
driven by the control plane: it makes the service live on the new node, sends new connections
there (its load balancer), then drains and stops the old instance and commits. Clients of the old
node get Connection: close on their next request and reconnect through the load
balancer to the new one (move-under-load: eight clients, no failed request).
A node's own image (kernel, drivers, net, the agent) only changes when it runs
no instances (RFD 2, determination 1). The desired-state line update is refused by a
node with instances, and its report says how many (agent-update-refused). The
control plane evacuates it first, moving each instance away; an empty node then
asks the supervisor to reboot it (supervisor::OP_REBOOT, the kernel's
reboot: the 8042 reset line), and comes back with the image installed now. Its
reports carry the node's name and its own signed version, so the control plane sees which image
it rejoined with (evacuate-update: node a refuses, is evacuated to node b, reboots
into an image signed as version 2, rejoins, and the service moves back, all under load).
tests/two-nodes.sh runs these cases: two images from one tree (agent built with
NODE=a or b; with UPDATE=1 a second one for node a with
SIGN_VERSION=2), two QEMUs (data ports 8080 and 8090), the control plane with its
load balancer on port 8000, and the peer through it. QEMU runs with -no-reboot, so a
reboot ends it with status 0, and the harness boots node a again from the updated image, standing
in for an A/B update of its disk. Staging that image on a real disk is out of scope (RFD 2's
plan). With DISK=1 each node gets its own copy of the disk the build made
(build/disk-a.img, build/disk-b.img).
15.7Moving a volume#
A service with a volume takes its volume along when it moves (RFD 3, P4 and P5). The volume
goes directly from the source node to the target node, not through the control plane, which
only sets the transfer up (user/agent/src/transfer.rs).
control plane
transfer counter send b <b's key> transfer counter receive a 10.0.2.2:8081 <a's key>
epoch 2 expires 60 \ / epoch 2 expires 60
v v
node a (source) agent, listening on node b (target) agent
mgmt port 7 while it has the ticket <-- dials; Noise IK, prologue "vanir-transfer 1",
its first message names the service
- Tickets are lines in each node's signed desired state:
transfer <service> send <peer> <peer key> epoch <n> expires <s>for the source, andtransfer <service> receive <peer> <address:port> <peer key> epoch <n> expires <s>for the target. The peer's key is the static key of its agent's handshake with the control plane, which its quote binds; the expiry counts from when the agent applied the document. A ticket missing from a later document is withdrawn, and the connection closes. One transfer at a time per node. - The target dials the source at the address in its ticket (the mock
control plane is told each node's with
--node-addr; in the tests, node a's management port 7 is the host's 8081, reached as10.0.2.2). The source listens only while it holds asendticket, and accepts one connection (listen = [{ port = 7 }]for the agent inagent-volumes.toml). - Both check the other. The target knows the source's key from its ticket
(Noise IK), and the source closes the connection unless the target's key and the service in
its first message are the ones its ticket names (
transfer-connect,transfer-wrong-peer). The control plane already holds the volume keys, so trusting its ticket for the peer adds no trust. - Reports carry a line for the transfer:
transfer <service> send|receive <phase> [detail], with phaseswaiting,connected,copying,mirroring, thenhanding-overandhanded-overon the source andtakenon the target, orrefused-digeston both, oraborted(with why).
source agent target agent target disk
| -- H size, header (96 bytes) -----> | -- OP_IMPORT_HEADER -----------> | made or emptied; header must open
| -- B b, counter, tag, ciphertext -> | -- OP_IMPORT_ENTRY, OP_IMPORT --> | must open with the service key
| ... every block written ... | |
| -- R blocks in the round ---------> | |
| rounds until one sends <= 2 | |
OP_MIRROR on; a last round; M -------> | |
a write by the writer: made, held | |
| -- W the write -------------------> | -- OP_IMPORT ------------------> |
| <-- A stored ---------------------- | |
OP_MIRROR_ACK: the writer is answered | |
- Blocks travel as they are on disk: ciphertext, write counter and tag
(
block::OP_EXPORT,OP_EXPORT_ENTRY, kept together when read). The seal binds only the volume name, the block number within the volume and the counter, so a block is valid on any disk with the service's key, and the target'sdiskchecks each one opens before storing it (OP_IMPORT;transfer-corrupt: a bit changed on the way is refused, and the target drops its copy). - Catching up. The source compares each block's counter
(
OP_EXPORT_META) with the one it last sent, and sends what changed, in rounds, until a round sends at most 2 blocks. - Mirroring. The source's
diskthen makes each write by the writer and holds its reply (OP_MIRROR); the agent finds it each turn (OP_MIRROR_PENDING), sends it, and answers it (OP_MIRROR_ACK) once the target says it is stored. A last round catches what changed before mirroring began. A write is acknowledged only once both nodes have it (transfer-mirror: four clients counting, every count answered, none lost). - Giving up. If the target doesn't store a mirrored write within 2 s, the
source stops mirroring, answers the held write (it is on the source) and writes alone
(
transfer-stall). Either side's agent tells the other it is giving up (Q), and a ticket withdrawn or expired ends the transfer too: the source stops mirroring and the target drops what it received (OP_DROP: header and counters wiped, so every block reads as never written, and the space kept for the next volume of the same size). - Limits. One transfer at a time per node; volumes of at most 4096 blocks; no rate limit (RFD 3, P5 question 1); a dropped transfer starts again from the beginning, with new tickets.
control plane node a (source) node b (target)
live counter small-1 ............................................ starts counter only once
epoch 2 (to b) mirrored; routed to it:
OP_HANDOFF, requests wait
load balancer -> b
stop counter (to a) -> drains, stops its instance
OP_HAND_OVER: read-only, flushed
OP_DIGEST -- D digest ----------------> OP_DIGEST: equal?
<-- Y ---------------------- OP_EPOCH(2): writer bit,
handed-over held requests answered
commit counter (to a) -> OP_DROP: its copy wiped, space freed
- The target's instance starts only once the volume is mirrored (the agent
holds back a
liveline for a service whose volume is still coming). When it is routed to, its requests are held (13.5's handoff), and it doesn't get its epoch from theliveline: the transfer gives it. - The handoff starts on the source once no instance of the service runs
there: the control plane has moved its load balancer to the target, then drained and stopped
the source's instance, whose last writes were mirrored. The source's
diskmakes the volume read-only (OP_HAND_OVER: no writer, writesFENCED, the same epoch can't be asserted again; only a higher one makes it writable) and the agent sends its digest (OP_DIGEST: SHA-256 over the header and every counter and tag). If the target's is the same, its instance is offered the epoch itsliveline names (the ticket's), as any live instance is, and that answers what was held (move-volume-under-load: four clients counting through the move, every count answered, none lost, none twice). - Different digests mean the target's copy isn't the source's: it says so,
calls its handoff off (its instance's held writes get
FENCED), and drops its copy when its ticket is withdrawn. The source's volume stays read-only until the control plane gives it a higher epoch, which the mock does by deploying the service there again (move-volume-digest). - The commit on the source drops its copy (
OP_DROP), then its key is forgotten (13.6). The ticket's expiry no longer matters once the handoff is decided. - Not covered (RFD 3, determination 11): if the link fails after the source has handed over and before it hears the answer, neither node writes until the control plane decides; and nothing recovers a target that fails between the handoff and the commit. Both are for a follow-up RFD.
15.8Writing a service (vanir-rt)#
A service in a slot is written against vanir-rt (user/rt, RFD 9,
P3), not against vanir_user: it implements Service, and the runtime
owns the event loop. user/counter is the example:
#![cfg_attr(target_os = "none", no_std, no_main)] // a slot's image, or host tests
use vanir_rt::{Ctx, Error, Request, Response, Service, Volume};
struct Counter { volume: Volume, persist_ticks: u64, /* ... */ }
impl Service for Counter {
const CONFIG: &'static [(&'static str, u64)] = &[("persist_ticks", 0)];
fn start(ctx: &mut Ctx) -> Result<Self, Error> {
Ok(Counter { volume: ctx.volume()?, persist_ticks: ctx.config("persist_ticks") })
}
fn request(&mut self, ctx: &mut Ctx, req: &Request, res: &mut Response) { /* write!(res, ...) */ }
fn tick(&mut self, ctx: &mut Ctx) -> Option<u64> { /* persist; ticks until the next call */ }
fn drain(&mut self, ctx: &mut Ctx) { /* persist before done */ }
fn fenced(&mut self, ctx: &mut Ctx) { /* drop the cached count */ }
}
vanir_rt::service!(Counter, connections = 64);
- The runtime owns the lifecycle. It finds
net, listens on the port the descriptor declares, says it is ready oncestartreturns Ok (never, if it returns Err: the agent fails the deployment), answers HTTP/1.1 with keep-alive and the header and idle deadlines, and sendshttptelemetry counters. On a drain it stops accepting, closes connections as their requests finish, callsdrain(), then says it is done; a service can't skip a step or reorder them. It findsnetagain if the network stack restarts. - Requests:
requestgets each GET or HEAD (the runtime answers anything else: 405, 413, 501, ...), and writes a status and body (at most 448 bytes). - Settings (15.1):
CONFIGlists the service's settings and defaults;ctx.config(key)reads one. A descriptor with a setting the service doesn't list, or allowing more connections thanservice!made room for, never becomes ready, and says why. - The volume:
ctx.volume()opens the one the descriptor declares (Error::NotDeclaredwithout), waiting while storage is locked.Volume::read,write,flushreturnvanir_rt::Error(Fenced,Locked,Integrity,OutOfRange, ...). A write or flush refused asFencedmakes the runtime callfenced()once the callback returns; when the instance becomes the writer, the handle prints it. - Sizes are fixed at build time: no heap; the connection table is a static
of
service!'s size (stable Rust can't size an array from a trait's constant). - Escape hatch:
vanir_rt::raw(featureunstable) gives the task's raw syscalls and IPC;counter'svolume-probetest feature uses it to check it can't name itself the writer.
http stays the plain web server of the RFD 1 and 2 tests (web), with
its own loop and test features; it shares the runtime's request parser
(vanir_rt::http). The load tests (20) measure the runtime:
moving counter from http to vanir-rt changed its
throughput at 8 clients from 1687 to 1644 requests/s (a write per request) and from 14775 to
14376 (cached): 2–3%, about the spread between runs of the same build (1687 to 1742).
Running one (RFD 9, R4). make run-service SERVICE=counter boots
a node for one service (apps/service.toml: the network stack, storage and the
agent): the program SERVICE, signed with SERVICE_DESC (default
apps/services/<program>.toml), is slot image service, and the
agent (feature script-serve) deploys it into small-0, as its volume's
writer (epoch 1) if it has one. The descriptor's first port is forwarded to
127.0.0.1:SERVICE_PORT (8080):
make run-service ACCEL=kvm # ... agent: counter v1 is serving from small-0
curl http://127.0.0.1:8080/count # 1, 2, 3, ... (Ctrl-C in the QEMU terminal ends it)
The volume lives in build/service-disk.img, kept across runs (the count carries
on) and apart from the tests' build/disk.img; SERVICE_RESET=1 starts it
from zeros. A new program must be in the workspace and USER_PROGS, with a
build.rs like counter's, and its descriptor must name slot class
small (256 frames, a 512 KiB image). The test run-service boots the
same node and counts twice through the forwarded port.
Testing one on the host (RFD 9, open question 5). On any target but
x86_64-unknown-none, vanir-rt is a test backend:
vanir_rt::host::Host calls the service as the runtime in a slot does
(start, then the first tick; tick when due; the same
checks before request; fenced after a call whose write was refused;
drain before an exit), with the clock moved by the test and the volume in memory.
No network and no QEMU: requests are bytes.
let mut h = Host::<Counter>::new("counter").volume(16).setting("persist_ticks", 100);
h.start().unwrap();
assert_eq!(h.get("/count").body, "1\n");
h.get("/count");
h.advance(100); // the timer's tick writes the count
assert_eq!(h.block(0)[0], 2);
h.restart().unwrap(); // a crash: no drain, the volume kept
h.fence(true); // another instance is the writer now
cargo test -p counter --target x86_64-unknown-linux-gnu runs
counter's tests; the host check rt-host runs them with the backend's
own (user/rt/tests/host.rs). Ctx, Response and the request
and settings checks are one copy, used by both backends. The backend fakes only
Fenced: not Locked, I/O errors, the network, or several instances
at once.
16Syscalls#
int 0x80. Number in rax; arguments in rdi, rsi, rdx, r10,
r8, r9; results in rax (then rdx, rsi). Simple
calls return u64::MAX on error; IPC calls return the IPC error codes.
The numbers, error codes, notifications, flags, task states and the records the kernel
writes into a task (Stats, ListenInfo, Event,
DeathRecord) are defined once, in the vanir-abi crate
(abi/src/lib.rs, RFD 9), which the kernel and vanir_user both use. It
also holds the IPC protocols between tasks (vanir_abi::proto: block,
net, nic, console, telemetry,
supervisor, attest, keys), which the kernel doesn't read.
vanir_user re-exports all of it under the paths programs use
(vanir_user::err, vanir_user::block::OP_READ, ...). The crate is data
only: constants, #[repr(C)] records, and safe functions that read them (accessors,
names of codes); no syscalls, no unsafe, no dependencies. The host check
abi-snapshot (abi/tests/snapshot.rs) pins every value and every
record's size and field offsets, so a change shows up there before it shows up in a boot.
Versions. vanir_abi::ABI_MAJOR.ABI_MINOR is 1.0.
A minor version may only add: syscalls, codes, flags, task states, rejection reasons, fields at
the end of a record read by size, descriptor fields in reserved space. Anything else is a new
major version. Slot images carry the version they were built for (15.1).
Records are read by size. stats, task_listen and
the supervisor's death notice copy as much of their record as fits in the caller's buffer and
return the record's whole size (the death notice is as long as what was copied), so a record can
grow at its end: an older program gets the fields it knows, and a newer one on an older kernel
finds the new fields zero. ListenInfo grew this way from 136 to 440 bytes (the ABI
version and settings); abi-read-by-size reads it into 64, 136 and 600 bytes.
Records read as arrays (Event, the measurement and run-time log entries) can't
grow.
| Syscall | No. | Notes |
|---|---|---|
write(ptr, len) | 1 | to the serial port; bad pointers return an error |
sched_yield() | 24 | give up the rest of the time slice |
getpid() | 39 | task id: 1, 2, … in manifest order (generation in bits 16+) |
exit(code) | 60 | |
send(target, op, msg, len, reply_buf, reply_cap) | 100 | → (code, reply_len) |
recv(buf, cap) | 101 | → (sender, op, len); sender 0 is the kernel |
reply(sender, code, buf, len) | 102 | never blocks |
restart(id) | 103 | supervisor only → new id |
task_state(id) | 104 | supervisor only → (state, current id) |
lookup(name, len) | 105 | → id of the task with that manifest name |
sleep(ticks) | 106 | uses no CPU while waiting |
uptime() | 107 | ticks (10 ms) since boot |
task_name(id, buf, cap) | 108 | → length of the task's manifest name |
device_bar(device, bar) | 110 | → (address, size) of a granted device's mapped BAR |
pci_config_read(device, offset) | 111 | read-only, granted devices only |
dma_info() | 112 | → (address, physical address, length) of the DMA region |
read_events(buf, cap) | 113 | event reader only → (count, dropped) |
stats(buf, cap) | 114 | event reader only: uptime, memory, interrupts, per-task state; read by size → the record's size |
measurements(buf, cap) | 115 | TPM task only → (count, TPM address); the boot measurement log |
notify(target, bits) | 116 | post bits, without waiting, to a task in sends or one that has the caller in its own sends (a server to its client); it gets them in recv as NOTIFY_POST, or from posted(), which only polls |
halt(code) | 117 | supervisor only: stop the system, reporting every task's state |
task_listen(id, buf, cap) | 118 | a task's service, flags (routes, connects), declared ports and signed version (ListenInfo); for a slot, from its descriptor, with its ABI version and settings; read by size → the record's size |
slot_stage(slot, offset, buf, len) | 119 | loader only: copy part of an image into an empty slot's image area |
slot_load(slot, sig, sig_len, desc, desc_len) | 120 | loader only: check, measure and start the staged image → new id; REJECTED (reason in rdx), BUSY, LOG_FULL |
slot_unload(slot) | 121 | loader only: empty a slot whose service has ended; measured |
tpm_lock(), tpm_unlock() | 122, 123 | TPM task only: hold the TPM around commands |
runtime_log(buf, cap, start) | 124 | TPM task only → (count, total) run-time log entries |
stop(id) | 125 | supervisor only: end a live task on purpose; reported as stopped (death kind 5) |
reboot() | 126 | supervisor only: reset the machine (QEMU with -no-reboot exits with status 0) |
recv flags (in rdx): RECV_EXPECT_IRQ (1),
RECV_TIMEOUT (2, ticks in r10; then sender 0,
NOTIFY_TIMER; 0 ticks only takes what is already waiting). Kernel notifications
come as sender 0 with op NOTIFY_DEATH (1, supervisor), NOTIFY_IRQ (2,
drivers), NOTIFY_TIMER (3) or NOTIFY_POST (4, bits from
notify).
17Protections#
| Protection | What it stops | Test |
|---|---|---|
| Supervisor-only kernel pages | user code reading kernel memory | poke-kernel |
SMAP (stac/clac around user copies, clac on every trap) | the kernel touching user memory by accident | smap |
| SMEP | the kernel executing user pages | (enabled, no test) |
Fault-tolerant copy_from_user | a bad syscall pointer crashing the kernel | bad-pointer |
| NX on non-code pages | executing data or stack | exec-stack |
W^X segments, CR0.WP | modifying code | write-code |
Registers zeroed before iretq | kernel values leaking into ring 3 | every run |
| Page-table tree per process | processes reading or writing each other's memory | address-spaces |
| Kill on fault, not halt | one process's crash stopping the others | fault-isolated |
| Timer preemption | a process that never yields hogging the CPU | preemption |
| CPU budgets | a runaway process running forever | cpu-budget |
IPC sends allowlist | tasks talking to services they weren't given | ipc-not-allowed |
| Kernel-checked IPC copies | a bad buffer crashing the kernel or the server | ipc-bad-buffer |
| Restart generations in task ids | clients silently reaching a restarted server | stale-id |
| Device grants; MSI-X table not mapped | drivers touching other devices or redirecting interrupts | device-no-grant, device-msix |
| Per-slot DMA regions, zeroed on restart | a dead driver's device scribbling on reused memory | dma-region |
Signed task images, min_version | unsigned, modified, swapped or rolled-back programs | sig-* |
| Secure Boot chain | a modified boot loader, config or kernel | sb-* |
| Measured boot + TPM quotes | a host trusting a VM that booted something else | measured-*, attest-* |
| Only the storage task may use the block driver | tasks reading the raw disk | storage-no-driver |
| A volume per task, chosen by kernel-checked name | tasks reading or writing each other's data | storage-isolation |
| A service's volume, from its signed descriptor; one writer, named by an epoch only the agent can assign; the candidate's requests held during a handoff | two instances writing one volume; an old writer writing after a handoff; a new instance reading data the old one is still changing | volume-fence, volume-persist, volume-none, volume-kinds, volume-handoff-off, agent-volume |
| Encryption at rest (XChaCha20-Poly1305) | plain text on the disk image | storage-at-rest |
| Authenticated blocks, bound to volume, block and counter | changed or moved ciphertext read as data | storage-tampered, storage-bad-block, storage-header-tampered |
| Key per volume; locked until the key arrives | reading the disk without the key | storage-wrong-key, storage-locked |
| Key released only to an attested boot | a modified system getting the key | attest-unlock-refused |
A key per service, sealed to attest for one service, sent only to attested nodes that run it, forgotten when they stop | the agent (or anything but attest and disk) reading a volume key; a key opening another service's volume; nodes holding keys of services they don't run | agent-volume-key, agent-volume-key-swap, agent-volume-unattested, volume-no-key, volume-forget |
| Frames freed on death | memory exhaustion from restart loops | restart-no-leak |
| Slot images signed with their descriptor; checked, sized and measured at load | unsigned, changed, rolled-back or oversized services; a service claiming ports or a slot class it wasn't signed for | slot-* |
| Run-time log and PCR 13 kept by the kernel | a node hiding what it loaded since boot | slot-measured, slot-attest |
| Swap and rollback through ready checks and drains | failed requests during a deployment; traffic sent to an instance that never got ready | deploy-under-load, rollback-under-load, deploy-not-ready |
| Control plane key pinned; Noise IK; node keys bound to a TPM quote | a node taking orders from someone else; a control plane trusting an unattested node | agent-wrong-cp-key, agent-attested, agent-unbound-key |
| Desired state signed, with a generation that only rises | forged, replayed or reordered commands | agent-signed-state |
| A node with instances refuses to update; moves through drains | a node update cutting traffic; failed requests during a move | agent-update-refused, move-under-load, evacuate-update |
| A volume transfer only between the two nodes the control plane named, each checking the other's key | a node fetching a volume it wasn't given a ticket for; a stranger posing as the target | transfer-connect, transfer-wrong-peer |
| Volumes travel sealed, and the target checks every block; a write is acknowledged only once both nodes have it; a target that stalls costs at most 2 s of writes | a block changed in transit stored on the target; a write lost in a move; a move stopping the service | transfer-corrupt, transfer-mirror, transfer-stall |
| A move's handoff only when both copies match, after the source turned read-only; the source's copy dropped at the commit | two writers across nodes; a target taking a copy that differs; a volume lingering on a node it left | move-volume-under-load, move-volume-digest |
| Restart forever, with exponential backoff | crashing a service to take it down for good; a crash loop eating the CPU | crash-loop, backoff-reset |
Ports declared in the manifest, checked by net through the kernel | a service listening where it wasn't given | http-undeclared-port, build-http-port-clash |
| Separate management stack, NIC and driver | a data-network flood or compromise cutting off the control plane | mgmt-separate, mgmt-under-flood |
CPU budget for received frames in net | a packet flood starving every other task | mgmt-under-flood |
Routing only by tasks with routes = true, only to ready instances | a service hijacking traffic; traffic to an instance that can't serve | route-not-allowed, route-not-ready |
| Drain deadline | a connection that never ends keeping an old instance alive | drain-deadline |
18Limitations#
Stated plainly, in one place:
- No IOMMU. Driver tasks can point their devices at any memory, including the storage task's key. Planned for step 7.
- No KPTI. The kernel and the physical-memory map are mapped (supervisor-only) into every task. On Meltdown-affected CPUs (such as the development host's i7-7500U) user code could read them.
- The hypervisor sees everything. Keys and plain text are in guest memory, which the host can read. Protecting against the host needs AMD SEV-SNP or Intel TDX.
- Development keys and trust on first use in the boot chain (12).
- No storage rollback protection. An older data block written back with its older counter and tag still decrypts. Fixing it needs a root of freshness, e.g. a Merkle root in TPM NV storage.
- Plain-text volume table. Task names, volume positions and which blocks were ever written are visible; editing the table is a denial of service.
- Storage isn't crash-consistent. Data is written before its metadata; a crash in between leaves that block failing its integrity check until rewritten. Nothing is guaranteed durable before a flush.
- Volumes are only freed when they move away (a dropped copy), and the space is reused only by a volume of the same size. A fixed task's volume is always 256 blocks. A service's volume is sized by its descriptor, and that size can't change.
- Writer leases are recorded, not enforced. A move recovers from failures before its handoff only: a link lost during the handoff, or a target lost before the commit, needs the control plane and a follow-up RFD (15.7). A node's handed-over state is kept in memory, so a source that reboots before the commit has a writable copy again at the epoch it had (the control plane must not give it that epoch again).
- The control plane holds every service's key (RFD 8, vector 7), in plain
files for the mock (
build/cp-volume-keys/). Keys are never rotated. - One key release per boot. In the attested flow the host says
byeafter releasing the key, so adiskrestarted later stays locked until the next boot. - One CPU, no IPC priorities, messages copied (no memory lending). Network bytes are copied twice through IPC.
- Network limits without attack tests. The connection caps, half-open cap
and blocklist in
netare implemented but the planneddos-*tests aren't written yet (14.4). - TCP over IPv4 only, no TLS, no SYN cookies (smoltcp has none), 10 ms TCP timer resolution, and no retransmit counter.
- Restarting is the only crash policy. A service that crashes on every request is restarted forever, at most every 30 s; rolling back or blocking the client that triggers it is RFD 7.
- Known gaps in deployment (RFD 8): the version floor is each image's own
descriptor, so the control plane or a compromised agent can load an old release with its old
descriptor; boot and slot images are signed with one key, so a signature doesn't say which
kind of image it is for;
attestkeeps its raw TPM mapping, so a compromisedattestcan hold the TPM lock forever and block every load; anything that can trigger loads can fill the run-time log, after which the node refuses loads until it reboots; a service that can be made to exit 0 stays down; the control plane's keys are in the image, so rotating them means updating every node. - A mock control plane.
tools/control-plane.pystands in for the real one (scheduling, load balancers, the fleet view are a later RFD). Nodes are named by a name built into the agent (NODE), not by their TPM's key, and the two-node tests boot an updated node from a new ISO rather than writing it to an A/B boot disk. - Fixed slot classes, stateless services. Slots come in classes fixed by the manifest (sizing them per service is RFD 4).
Part three: working on vanir-os
19Agent quick reference#
Dense facts for coding agents (and people in a hurry). Every command runs from the repository root.
- language
- stable Rust,
no_std, targetx86_64-unknown-none(set in.cargo/config.toml); no nightly features - build
make image APP=<manifest> [VARS]— builds user programs, signs them, builds the kernel with the manifest baked in, makes the ISO or ESP- run
make run APP=tests/apps/<name>.toml [USER_FEATURES="pkg/feature ..."] [KERNEL_FEATURES=...]- one test
ONLY='^storage-restart$' make test(regex over case names)- all tests
make test ACCEL=tcg && make test ACCEL=kvm— both must pass before committing- pass/fail
- a case passes when QEMU's exit status and the expected output both match; output is serial log +
build/telemetry.logafter a--- build/telemetry.log ---marker - exit status
- 1 clean, 3 a task exited non-zero, 5 a task was killed, 7 kernel panic or supervisor died, 9 deadlock, 124 timeout
- new program
- see 23.1; must be in workspace
Cargo.tomland MakefileUSER_PROGS - secrets
keys/(signing key, storage master key) andbuild/are git-ignored; never commit them- serial log
- kernel lines start
[kernel]; tasks print<program>: ...; tests match these strings, so changing a message can break a test
Where to change what
| To change… | Edit | Then check |
|---|---|---|
| a syscall | number in abi/src/lib.rs (sys), dispatch in kernel/src/syscall.rs, handler in kernel/src/process.rs, wrapper in user/lib/src/lib.rs | abi-snapshot, docs table 16 |
an ABI value or record (error code, flag, task state, Stats, ...) or an IPC protocol's ops and codes | abi/src/lib.rs only; the kernel and vanir_user pick it up | abi-snapshot (update abi/tests/snapshot.rs in the same commit, on purpose) |
| scheduling, budgets, IPC, restarts | kernel/src/process.rs | ONLY='^(ipc|supervisor|restart|stale|sleep|cpu|preemption)' |
| page tables, frames | kernel/src/memory.rs | no-leak, restart-no-leak, address-spaces |
| a manifest key | kernel/build.rs, Task in kernel/src/process.rs | apps/README.md, docs 7 |
| a device kind | kernel/src/pci_kinds.rs, QEMU flags in Makefile | device-* |
| a kernel event | kind in abi/src/lib.rs (events), logged from the kernel (events::log), user/telemetry event_line | abi-snapshot, telemetry* |
| supervisor policy | user/supervisor/src/main.rs | supervisor-*, *-restart |
| image signing | tools/sign-image.py, kernel/src/verify.rs | sig-* |
| measurements | kernel/src/measure.rs, tools/expected-pcrs.py (keep in sync) | measured-*, attest-* |
| storage format or crypto | user/storage, tools/corrupt-disk.py | ONLY='^(storage|blk|attest-)' |
| host tooling | tools/*.py (Python 3.11+, cryptography) | vsock/attest cases need KVM + kvm group |
| networking | user/net, user/virtio-net, user/http, tools/net-peer.py | ONLY='^(nic|http|net|mgmt|swap|drain|route|build)' |
| the agent's lifecycle, deployments | user/agent/src/lifecycle.rs, user/supervisor (OP_STOP/OP_START), net OP_STATUS, tools/net-peer.py under_load | ONLY='^(deploy|rollback|drain-deadline-cap|slot-exit)' |
| the agent's connection, the control plane | user/agent/src/control.rs, user/noise, tools/control-plane.py, tools/vanir_noise.py (keep the frame types and desired-state format in sync) | ONLY='^(noise|agent-)' |
| moves, two nodes | tests/two-nodes.sh, tools/control-plane.py (scenarios, load balancer) | ONLY='^(move|evacuate)', slow |
| slots, loading, PCR 13 | kernel/src/process.rs (slot syscalls), kernel/src/verify.rs, kernel/src/measure.rs, tools/expected-pcrs.py, user/agent | ONLY='^slot-' |
20Build, run, test#
make # build build/vanir-os.iso
make run # boot in QEMU, serial output on the terminal
make test # boot every scenario in tests/run.sh; PASS/FAIL each
make test ACCEL=kvm # same, under KVM (needs /dev/kvm)
tests/run.sh --bench # also the load tests (BENCH=1 cases); or BENCH=1 make test
ONLY='^storage' make test # only cases whose names match
make run APP=tests/apps/budget.toml # choose the app manifest
make run USER_FEATURES=hello/poke-kernel # turn on a test feature
make run-debug # also log interrupts and CPU resets to qemu.log
make clean # remove build outputs (distclean also removes limine/)
Make variables
| Variable | Default | Effect |
|---|---|---|
APP | apps/default.toml | app manifest baked into the kernel |
USER_FEATURES | cargo features for user programs, pkg/feature space-separated | |
KERNEL_FEATURES | cargo features for the kernel (e.g. smap-selftest) | |
ACCEL | tcg | kvm uses -enable-kvm -cpu host |
BOOT | bios | uefi-secure: OVMF with Secure Boot and the signed chain |
TPM | 1: swtpm (state in build/tpm) via tools/with-swtpm.sh | |
TRANSPORT | console | vsock adds vhost-vsock (guest CID 3) |
DISK | 1: attach build/disk.img (64 MiB, persistent) | |
DISK_RESET | 1: recreate the disk image from zeros | |
DISK_CORRUPT | volume:block: flip a byte of that data block before boot; volume:header: of its header | |
DISK_OLD | 1: stamp an older format's superblock (VNRSTOR2) on the disk before boot | |
SIGN_VERSION | 1 | version signed into every task image |
SIGNING_KEY, TRUSTED_PUB | keys/dev-signing.* | key images are signed with / key compiled into the kernel |
TAMPER, UNSIGNED, SWAP_SIGS | signature tests: modify after signing, ship unsigned, swap a:b | |
SB_TAMPER_KERNEL, SB_EDIT_CONFIG, SB_UNSIGNED_LOADER | boot-chain tests | |
NET, NET_MGMT | user (NAT, DHCP, port forwarding) or dgram (raw frames to net-peer.py): data and management NICs | |
NET_PORT, NET_MGMT_PORT | 8080, 8081 | host ports forwarded to guest ports 80 and 7 (environment overrides) |
NET_DGRAM_PORT, NET_MGMT_DGRAM_PORT | 18090, 18092 | QEMU's end of the dgram backend; the peer uses the next port |
Test-only settings in a case's variables column: TIMEOUT=<s> (default 60),
ATTEST=1 (host agent challenges the guest), RELEASE=<reference>
(host agent releases keys/master.key if the quote matches), INTERVAL=<ms>
(host agent sets the snapshot interval), PEER=<scenario> (run
tools/net-peer.py next to QEMU; its output follows a --- peer --- line),
ATTEST_DELAY=<s> (wait before the challenge), CP=<scenario>
(run tools/control-plane.py next to QEMU, with options from
CP_ARGS=a,b,c; its output follows a --- control plane --- line and the
node's telemetry lands in build/cp-telemetry.log),
SLOW=1 (skipped with SLOW=0 make test), BENCH=1 (a load
test: run only with tests/run.sh --bench or BENCH=1). A BUILD_FAILS list
before the cases names manifests that must not build, with the error they must give, and
HOST_CHECKS runs commands on the host alone (the Noise test vectors, and
abi-snapshot: the ABI's values and layouts; descriptor-format:
sign-image.py still writes the fixtures abi/tests/descriptor.rs reads).
Load tests
Load tests are skipped unless asked for, since they take a minute and report numbers rather
than check behaviour (though a failed request, or a lost or repeated count, still fails them).
counter-load boots tests/apps/counter-bench.toml: counter (http with a
volume) in small-0, with telemetry on the virtio console. The peer
(net-peer.py count-load) runs keep-alive clients asking for /count and,
every second, samples the request rate, the number of clients, latency (p50, p99), and from the
guest's telemetry its CPU (all tasks, and each task), memory and I/O (disk blocks read and
written, network bytes). The samples go to build/bench/counter-load.csv, and
tools/bench-chart.py draws them, time along the x axis, as
build/bench/counter-load.svg.
counter-cached-load is the same with counter-v1-cached: the same
program, signed with persist_ticks = 1000 (15.1), so
counter keeps the count in memory once it has read (and written) it on the volume, and writes
it back every 10 s and before it drains or exits. A crash loses up to 10 s of counts; a write that fails
(FENCED) drops the cached count, so the next request goes to the volume.
agent-volume-cached (the control plane with --image-suffix=-cached,
deploying counter-v1-cached and counter-v2-cached) checks that a
deployment loses no count with it: v1 writes its
count before it says it has drained, and v2's first request, held until v2 is the writer, reads
it. One run under KVM (60 s, 1 to 32 clients):
| Clients | counter-load (disk per request) | counter-cached-load |
|---|---|---|
| 1 | 1554/s, p99 1.2 ms, CPU 48% | 7588/s, p99 0.5 ms, CPU 50% |
| 8 | 1687/s, p99 15.7 ms, CPU 55% | 14775/s, p99 1.3 ms, CPU 98% |
| 32 | 1549/s, p99 43.5 ms, CPU 55% | 12758/s, p99 5.9 ms, CPU 99% |
With a disk write per request, disk serves one request at a time (3 block reads
and 2 writes each) and waits on the device, so more clients add only latency. Cached, the disk
sees 2 block writes every 10 s and the CPU is the limit, mostly net.
BENCH=1 ACCEL=kvm ONLY=counter-load tests/run.sh # 60 s, ramping 1, 2, 4, 8, 16, 32 clients
LOAD_CLIENTS=8 LOAD_SECONDS=30 BENCH=1 ACCEL=kvm ONLY=counter-load tests/run.sh # 8 clients throughout
LOAD_PATH=/ BENCH=1 ACCEL=kvm ONLY=counter-load tests/run.sh # without the volume, for comparison
Guest CPU is sampled at the timer's 100 Hz (a tick is charged to the task it interrupts), so a second's figure is good to about 1%. Time the CPU spends halted, e.g. waiting for the disk, counts as idle.
Exit status
QEMU exits with (code << 1) | 1 through the isa-debug-exit
device:
| Status | Meaning |
|---|---|
| 1 | every process exited 0 (make run treats this as success) |
| 3 | some process exited with a non-zero code |
| 5 | some process was killed by a fault or its CPU budget |
| 7 | kernel fault or panic, or the supervisor died |
| 9 | deadlock: processes waiting on each other with nothing left to run |
| 124 | timeout (from timeout in the test harness; expected for refused Secure Boot) |
Test case format
Each row of CASES in tests/run.sh:
"name | app | user features | kernel features | make variables | expected status | expected text"
Expected text is a fixed string; with re:, a Perl regex over the whole output
(grep -Pz, so [\s\S]* spans lines; use lookaheads for order-independent
matches; avoid quotes); with cmd:, a shell command that reads the output on stdin
and must succeed.
21Test catalogue#
141 cases (and two load tests with --bench), one build check and three host checks (noise-vectors, abi-snapshot, descriptor-format), run in this order. Order matters: later cases reuse the disk image, TPM state and
attestation references earlier ones leave behind.
| Area | Cases | Needs |
|---|---|---|
| isolation, faults | normal bad-pointer poke-kernel abi-read-by-size exec-stack write-code smap | |
| scheduling | timer-ticks preemption address-spaces cpu-budget no-leak fault-isolated | |
| IPC | ipc-echo ipc-queued ipc-bad-buffer ipc-not-allowed ipc-server-dies ipc-deadlock | |
| supervisor, sleep | supervisor-restart restart-no-leak crash-loop backoff-reset stale-id sleep-idle sleep-busy | |
| devices | device-grant device-no-grant device-msix dma-region dma-none console-write console-restart | |
| telemetry | telemetry telemetry-flood telemetry-vsock | vsock: KVM + kvm group |
| signatures | sig-tamper sig-unsigned sig-swapped sig-rogue-key sig-rollback sig-supervised | |
| Secure Boot | sb-boot sb-tampered-kernel sb-edited-config sb-unsigned-loader | KVM, OVMF |
| measured boot, attestation | measured-boot measured-rejected no-tpm attest-record attest-verify attest-replay attest-tampered attest-other-version | KVM, swtpm, vsock |
| block | blk-rw blk-persist blk-persist-negative blk-restart | |
| storage | storage-isolation storage-old-format storage-persist storage-at-rest storage-tampered storage-header-tampered storage-wrong-key storage-locked storage-no-driver storage-events storage-bad-block storage-restart storage-blk-restart | |
| service volumes | volume-fence volume-persist volume-wrong-key volume-size-fixed volume-none volume-no-key volume-forget volume-handoff-off counter-load counter-cached-load (load tests, --bench) volume-kinds volume-storage-restart volume-writer-restart | volume-persist and volume-wrong-key use the disk volume-fence leaves |
| key release | attest-storage-record attest-unlock attest-unlock-persist attest-unlock-refused | KVM, swtpm, vsock |
| NIC | nic-arp-ping nic-restart nic-no-grant | free UDP ports 18090-18091 |
| sockets, HTTP | build-http-port-clash http-get http-undeclared-port net-restart http-restart | free TCP port 8080 |
| management network | mgmt-separate mgmt-under-flood | port 8081 too |
| network telemetry | net-counters net-interval | vsock for net-interval |
| routing, draining | swap-under-load drain-deadline route-not-ready route-not-allowed | |
| slots: loading | slot-load slot-unsigned slot-tampered-image slot-tampered-desc slot-boot-sig slot-rogue-key slot-below-floor slot-class slot-abi-refused slot-settings slot-memory slot-image-size slot-not-allowed slot-occupied slot-ports slot-restart slot-exit-clean slot-unload-no-leak slot-log-full | port 8080 for slot-load, slot-ports |
| deployments | deploy-under-load rollback-under-load deploy-not-ready drain-deadline-cap deploy-commit slot-exit-clean | port 8080; the first three are slow |
| agent and control plane | noise-vectors agent-connect agent-wrong-cp-key agent-signed-state agent-deploy agent-volume agent-volume-cached agent-epoch-stale agent-reconnect agent-drain agent-attested agent-unbound-key agent-volume-key agent-volume-key-swap agent-volume-unattested | port 7000; KVM, swtpm for agent-attested to agent-volume-key-swap |
| two nodes | agent-update-refused move-under-load evacuate-update transfer-connect transfer-mirror transfer-stall transfer-corrupt move-volume-under-load move-volume-digest transfer-wrong-peer | ports 7000, 8000, 8080-8081, 8090-8091; the last two are slow |
| slots: measurement | slot-measured slot-tpm-busy slot-attest | KVM, swtpm; vsock for slot-attest |
Cases needing vsock are skipped (printed as SKIP) when /dev/vhost-vsock
isn't writable even through sg kvm.
22Repository map#
kernel/build.rs generates the task table from the app manifest
kernel/linker.ld higher half at 0xffffffff80000000, Limine request sections
kernel/src/main.rs Limine requests, kernel entry, halt/panic, banner
kernel/src/cpu.rs enables SMEP, SMAP, NX, CR0.WP
kernel/src/apic.rs local APIC timer (calibrated against the PIT), PIC remap
kernel/src/serial.rs COM1 serial output
kernel/src/gdt.rs GDT + TSS (kernel stack for ring 3 -> 0 transitions)
kernel/src/interrupts.rs IDT and the shared trap entry path
kernel/src/memory.rs frame allocator, per-process page tables, kernel MMIO mappings
kernel/src/elf.rs ELF loader (per-segment permissions, W^X)
kernel/src/usercopy.rs copy_from_user with page-fault recovery
kernel/src/process.rs task table, scheduler, budgets, IPC, supervisor and device calls
kernel/src/pci.rs PCI discovery, BAR sizing, MSI-X (device kinds in pci_kinds.rs)
kernel/src/irq.rs device interrupts to task notifications
kernel/src/events.rs kernel event log
kernel/src/modules.rs task images and signatures as Limine boot modules
kernel/src/verify.rs Ed25519 task image signatures; slot images and their descriptors
kernel/src/tpm.rs TPM 2.0 (CRB): PCR extend/read
kernel/src/measure.rs measured boot into PCR 12; run-time log and PCR 13; the TPM lock
kernel/src/syscall.rs int 0x80 dispatch
abi/ vanir-abi: the ABI and IPC protocols, defined once (kernel and user); tests/snapshot.rs pins them
user/lib/ shared runtime (vanir_user): entry!, syscall wrappers, println!, protocol clients; re-exports vanir-abi
user/hello/ greeting plus the attack tests (features)
user/spinner/ isolation/ runaway/ sleeper/ scheduling and isolation tests
user/echo/ client/ deadlock/ IPC server, client, deadlock
user/supervisor/ crasher/ restart policy; a task that faults at once
user/devprobe/ dmatest/ device grant and DMA region tests
user/virtio/ virtio 1.x PCI transport and split virtqueues
user/virtio-console/ console driver; crash-after-50 test feature
user/virtio-vsock/ vsock driver: one stream to host CID 2 port 5000
user/virtio-blk/ block driver; crash-after-10 test feature
user/chatter/ writes 200 numbered lines through the console driver
user/telemetry/ event log + heartbeats + log/storage events -> JSON lines -> uplink
user/tpm2/ TPM 2.0 client library: attestation key, quote, PCR read
user/attest/ quotes; opens the released key and unlocks storage
user/control/ host commands from the uplink (attest, key, bye)
user/storage/ encrypted volume per task; crash-after-16 test feature
user/blktest/ block client: writes, reads back and checks a pattern
user/unlocker/ key server "keys" (test stand-in)
user/virtio-net/ NIC driver: frames in and out; crash-after-10-frames test feature
user/nictest/ answers ARP and ping by hand (driver test, no stack)
user/net/ network stack (smoltcp): sockets, routing, limits, counters
user/http/ HTTP/1.1 server (web); exit-on-request, crash and listen-81 test features
user/rt/ vanir-rt: the service runtime (Service trait, event loop, HTTP, volume, settings);
on the host, a test backend (src/host.rs)
user/counter/ counter: a service on vanir-rt with a volume; persist_ticks setting
user/mgmt-echo/ TCP echo on the management stack (stands in for the agent)
user/switcher/ routing scripts for tests (RFD 1)
user/agent/ the node agent: control plane connection, reconciliation, lifecycle, slot loading; test scripts (features)
user/noise/ Noise IK initiator (no heap), checked against tests/vectors/noise-ik.json
apps/ app manifests and their format (apps/README.md)
tests/apps/ manifests the tests boot
tests/slots/ descriptors of the test slot images (built into build/slots/)
tests/two-nodes.sh runs a case on two nodes, with the control plane's load balancer
tests/vectors/ the Noise IK test vector (cacophony)
tests/run.sh the test harness
tools/host-agent.py receives and checks telemetry; attests; releases the key
tools/bench-chart.py draws a load test's per-second samples (CSV) as an SVG chart
tools/vanir_attest.py quote checks and key sealing (shared)
tools/verify-quote.py verifies a TPM quote against a reference
tools/expected-pcrs.py computes PCR 12 (and PCR 13 for given loads) from the build output
tools/sign-image.py makes the signing key; signs task images and slot images with descriptors
tools/check-backoff.py checks the supervisor's restart backoff from a run's output
tools/control-plane.py mock control plane: Noise responder, quote checks, signed desired state, images, scenarios
tools/vanir_noise.py Noise IK (both sides) for the host tools
tools/with-swtpm.sh runs QEMU with a software TPM
tools/corrupt-disk.py flips a byte of a volume's block in the disk image
tools/check_lines.py checks numbered lines arrived in order
tools/net-peer.py host side of the network tests: HTTP, echo, raw frames, floods
tools/check-net-counters.py compares network counters with a known transfer
plans/ step plans: step-7-iommu.md, step-8-bench.md (not built), step-9-net.md, step-10-deploy.md
rfd/ requests for discussion: network services, deployment, volumes, ...
keys/ signing key and storage master key (generated, git-ignored)
docs/ this manual (GitHub Pages) and llms.txt
23Recipes#
23.1Add a user program#
- Copy
user/hello/touser/<name>/(keepbuild.rs: it appliesuser/linker.ld). Rename the package and[[bin]]. - Write
src/main.rs:#![no_std] #![no_main] use vanir_user::println; vanir_user::entry!(main); fn main() -> u64 { println!("<name>: hello"); 0 // exit code } - Add
"user/<name>"tomembersin the rootCargo.tomland<name>toUSER_PROGSin theMakefile(that also makes it a boot module and signs it). - Reference it from a manifest:
program = "<name>".
23.2Add a test case#
- Add a manifest in
tests/apps/if no existing one fits. - Put test-only behaviour behind a cargo feature in the program's
Cargo.toml, with a comment saying what it tests. - Add a row to
CASESintests/run.sh, after the cases whose state it depends on. - Run it alone (
ONLY='^name$' make test), then break the protection on purpose and confirm the case fails, then restore and run both full suites. - Add it to 21 and, for a protection, 17.
23.3Add a syscall#
- Pick an unused number; add
SYS_<NAME>tovanir_abi::sys(abi/src/lib.rs) and toabi/tests/snapshot.rs, and adispatcharm inkernel/src/syscall.rs. - Implement the handler (in
kernel/src/process.rsif it touches the task table). Read arguments from theTrapFrame; write results torax/rdx/rsi. Validate every user pointer with the address space'scheck_rangeand copy throughusercopy. Never block inside the handler: mark the task blocked and switch. - Add a wrapper to
user/lib/src/lib.rs(the constant is already there, throughvanir-abi). A record the call fills goes invanir-abitoo, with its layout in the snapshot. - Restrict privileged calls to the right task (as
restartis supervisor-only) and returnNOT_ALLOWEDotherwise. - Document it in 16.
23.4Add a kernel or storage event#
- Kernel event: a constant in
vanir_abi::events(document whata,b,chold; add it to the snapshot),events::log(...)at the control point in the kernel, and a format arm inevent_lineinuser/telemetry. - Storage event: a kind constant in
vanir_abi::proto::telemetry, a format arm instorage_line, andself.event(kind, block, name)inuser/storage. - Check it with
tools/host-agent.py - --quiet --check --expect <event>=1in acmd:test.
23.5Add a device kind or driver#
- Add
(name, vendor, &[device ids])toKINDSinkernel/src/pci_kinds.rs(shared withbuild.rs, which validates manifests). - Add the QEMU device to
QEMU_FLAGS(behind a variable, asDISKis) withdisable-legacy=onfor virtio. - Write the driver on
user/virtio; take requests by IPC; handleNOTIFY_IRQ; useRECV_EXPECT_IRQwhile work is in flight. - Add a crash feature and a restart test, as
virtio-blk/crash-after-10.
23.6Add a manifest key#
- Add it to
knowninkernel/build.rs, parse and validate it, and emit it in the generatedTaskliteral. - Add the field to
Taskinkernel/src/process.rs. - Document it in
apps/README.mdand 7.
24Invariants#
Rules the code relies on. Breaking one is a bug even when the tests pass.
- Every commit passes the full suite under both TCG and KVM.
- Stable Rust only. No nightly features or
-Zflags. - The ABI is defined once, in
vanir-abi. The kernel and user code never define their own copy of a syscall number, code, flag or shared record, andvanir-abistays data only (no syscalls, nounsafe, no dependencies). - The kernel never blocks mid-syscall. Waiting means saving registers, marking the task blocked, and writing results into its saved frame later.
- User pointers are untrusted. Range-checked and copied with fault recovery under SMAP; never dereferenced directly.
- Identity comes from the kernel. Servers decide who a client is with
task_name(sender), never from message contents. - W^X everywhere. No mapping is both writable and executable.
- Mechanism in the kernel, policy in tasks. Restart decisions belong to the
supervisor; key decisions to
keys/attest/the host. - Default deny. No IPC without
sends; no device without a grant; storage locked until keyed. - Secrets stay out of git and out of images.
keys/is ignored; the storage master key is never on the disk. - Crypto uses the portable backends. The
--cfgflags in.cargo/config.tomlselect them; removing them makes rustc crash (SIGILL) on this target. - The measured set is the source of truth. Kernel, manifest and task
statements go into PCR 12;
tools/expected-pcrs.pymust compute the same value.
25Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
| QEMU resets in a loop or exits at once | triple fault in early kernel code | make run-debug, read qemu.log for the last exception |
| Status 9 with "DEADLOCKED in send" | a send cycle, or a server that never replies | every recv path must reply; check sends for cycles |
Status 7 with [kernel] PANIC | kernel bug or failed assertion; or the supervisor died | read the panic message and the last [kernel] lines |
| rustc crashes with SIGILL building crypto | SIMD backends selected | keep the poly1305_backend/chacha20_backend cfg in .cargo/config.toml |
vsock cases SKIP or "permission denied" | no access to /dev/vhost-vsock | join the kvm group; older shells need sg kvm or a new login |
| TPM errors after many killed runs | dictionary-attack lockout in swtpm state | rm -rf build/tpm |
attest-* refuse after you changed code | PCR 12 changed; reference is stale | run the suite in order so attest-record/attest-storage-record re-record |
storage-* fail with INTEGRITY or wrong contents | disk state from another case or manual run | start from DISK_RESET=1 or run the cases in order |
| Cases fail at random during a suite run | sources edited while the suite builds them | don't edit the tree during make test |
| Run never ends (timeout) | a task sleeps or waits forever (e.g. telemetry without exit-after-3-heartbeats) | make long-running tasks exit in tests or wait only in recv |
| Network cases fail at once, or QEMU says it can't bind | a host port in use (another suite, an old QEMU) | set NET_PORT and friends in the environment, or stop the other user |
| Network case times out with the peer saying "never answered" | no DHCP lease, or the service never listened | read the net: lines in the serial log; dgram needs net/static-addr |
| "Limine base revision 3 not supported" | wrong Limine binaries | make distclean; the Makefile fetches branch v11.x-binary |
26System dependencies#
| Dependency | Used for | Debian/Ubuntu package |
|---|---|---|
Rust (stable) via rustup | building kernel and user programs | — |
x86_64-unknown-none target | bare-metal compilation | — |
make, git, cc | build; fetching and building the Limine host tool | make, git, gcc |
objcopy | stripping user ELFs | binutils |
xorriso | the bootable ISO | xorriso |
qemu-system-x86_64 | running it (-cpu max or KVM) | qemu-system-x86 |
Python 3.11+ with cryptography | signing, quote verification, test tools | python3-cryptography |
| OVMF | UEFI firmware for BOOT=uefi-secure | ovmf |
sbsign, openssl, b2sum | signing the boot loader, pinning hashes | sbsigntool, openssl, coreutils |
swtpm | software TPM for TPM=1 | swtpm, swtpm-tools |
sudo apt install make git gcc binutils xorriso qemu-system-x86
sudo apt install python3-cryptography ovmf sbsigntool swtpm swtpm-tools # boot chain, TPM
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # if Rust isn't installed
rustup target add x86_64-unknown-none
Nightly Rust isn't required. Limine (branch v11.x-binary) is cloned into
limine/ on the first build.
27Glossary#
| Term | Meaning |
|---|---|
| AEAD | authenticated encryption: encrypts and detects any change (XChaCha20-Poly1305, ChaCha20-Poly1305) |
| BAR | base address register: where a PCI device's registers appear in memory |
| CR3 | the register holding the current page-table root |
| DMA | a device reading or writing main memory by itself |
| Agent | the node's task that loads service images into slots, keeps each instance's lifecycle, and answers to the control plane (user/agent) |
| Candidate | an instance holding the version being deployed, before it is made live |
| Commit | the control plane declaring a deployment or move final; the old version's slot is then unloaded |
| Control plane | the off-node system that decides what runs where; in tests, tools/control-plane.py |
| Descriptor | what a service may do (name, version, floor, slot class, ports, drain deadlines), signed with its image |
| Desired state | a signed document from the control plane saying which version of each service should be live where; applied only if its generation is newer |
| Drain | stop giving an instance new connections and let it finish its current ones, up to a deadline |
| Generation | restart counter in a task id's upper bits; stale ids get DEAD |
| Evacuate | move every instance off a node, so it can be updated |
| HHDM | Limine's higher-half direct map of physical memory |
| HKDF | key derivation: turns one key into many independent keys |
| Instance | a task running a network service; the live instance gets the service's new connections |
| IOMMU | hardware that restricts which memory each device can DMA to (Intel VT-d) |
| Manifest | the TOML file that fixes the task set at build time |
| Move | a service changing nodes: live on the new one, then drained and stopped on the old one |
| MSI-X | interrupts delivered as memory writes, routed by the kernel |
| Noise IK | the handshake pattern of the agent's connection: the agent already knows the control plane's static key and sends its own in the first message |
| PCR | TPM platform configuration register: a hash chain of measurements |
| Quote | TPM-signed statement of PCR values over a caller's nonce |
| Ring 0 / ring 3 | x86 privilege levels: kernel / user |
| Run-time log | the kernel's record of images loaded into and unloaded from slots, replayed by verifiers to PCR 13 |
| slirp | QEMU's user-mode NAT (-netdev user): DHCP and port forwarding, TCP ended on the host |
| Slot | a generic task table entry of a fixed class that runs whatever signed service image the agent loads into it |
| smoltcp | the TCP/IP stack in net: no heap, sockets in fixed buffers |
| SMAP / SMEP | CPU features stopping the kernel accessing / executing user pages |
| TCG | QEMU's software CPU emulator (no KVM) |
| TrapFrame | the saved registers of a task, pushed on every trap |
| virtio | standard paravirtual device interface (queues of buffers in shared memory) |
| W^X | write xor execute: a page is writable or executable, never both |
