Skip to content

About

A UEFI NVMe/TCP boot extension: boot a machine from a remote image with no kernel, no initramfs and no PXE

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

642 Commits

Folders and files

Repository files navigation

stormbootx

A UEFI boot agent that attaches a remote disk over NVMe/TCP and boots it. No kernel, no initramfs, no PXE, no TFTP. It carries its own TCP/IP stack (smoltcp, on the NIC driver's EFI_SIMPLE_NETWORK) and needs nothing above the NIC driver from the firmware.

It runs from a USB stick or a virtual-media ISO. It loads any NIC drivers the media carries, works out which machine it is (a stated name, else its DNS name, else its MAC or SMBIOS serial), claims that machine's image from the storage engine, attaches it over NVMe/TCP, publishes it as EFI_BLOCK_IO_PROTOCOL, and chain-loads the \EFI\BOOT\BOOTX64.EFI on the attached disk. If any step fails, or the machine's boot intent is local, it falls through to the local disk.

media NIC drivers → smoltcp on SNP (DHCP) → names (conf | DNS name, MAC, serial) → boot intent
    → claim boothost/<name> (or default by MAC) → NVMe/TCP attach
    → publish BlockIO + device path → ConnectController
    → load \EFI\BOOT\BOOTX64.EFI from the attached ESP → StartImage

Where it sits in the boot

stormbootx is stage one of two. It decides which image and attaches it, and knows nothing about what is inside. On a stormcos image the BOOTX64.EFI it starts is stormuefi, which finds the pallets, verifies them and starts the kernel.

USB stick / ISO   stormbootx   tag → claim → attach → publish → chain-load ↓
attached clone    stormuefi    pallets → select → verify → kernel + initramfs → Linux
How the machine boots stormbootx stormuefi
over the network, from forge attaches the image boots the kernel out of it
from its own drive not involved boots the kernel out of it

Legacy-BIOS machines are served by neither. That is the planned stormboot4bios, which will link this repo's crates/nvme-tcp-initiator (#10).

What it touches at boot

At boot it reads \stormboot\local.conf and \stormboot\stormboot.conf from the volume it was loaded from (and \stormboot\state, the self-update's record), and loads any *.efi in \stormboot\drivers\ on that volume as a NIC driver (#26; absent on ordinary media). It writes to these:

What When
the attached clone on the engine the booted OS writes to its disk, and the BlockIO handle is read-write
the hardware clock (RTC), through UEFI SetTime a boot whose NTP server answers while the RTC is more than a second off (#77); UTC, time zone and daylight left as the firmware had them
volatile EFI variables, StormBootTag, StormBootHostNqn, StormBootClock, StormBootUpdate and StormBootInstallConfig (+ its chunks) every boot that names the machine, attaches an image, asks NTP, has a self-update state or finds an install-config.yaml on the media (#76, #77, #83, #79); gone at the next reset
its own boot medium: \EFI\BOOT\BOOTX64.EFI, \startup.nsh, \stormboot\stormboot.conf, \stormboot\drivers\* (with *.new/*.prev copies), \stormboot\state, and \stormboot\local.conf only to keep a name = a writable medium (USB stick, local ESP) with update =, when a newer signed release verifies, and the starts of its trial (#83, Self-update)
its own boot medium: \stormboot\last.log (this start's console, at most 64 KiB) and \stormboot\trial.log (a failed trial's last start) every start on a writable medium, before the attach and after the fall-through (#213, The start's log); never on a read-only medium or a RAM disk
one non-volatile EFI variable, StormBootMinSerial when a self-updated medium passes its trial (#83): the highest release serial this machine has run, so an older signed release can't be replayed onto it

Every line it prints is also sent over the network once a NIC holds a lease (#162, The log over the network): nothing is written for it.

Nothing is written to the platform's network configuration any more (#56; until 0.9 a STATIC IP4 policy was switched to DHCP in NVRAM). The NICs are taken from the firmware's own stack for the boot (SNP opened exclusively) and given back on the fall-through.

The boot screen

On a machine with a screen (GOP: a monitor, a BMC's KVM, pve's console), stormbootx clears it when it starts and draws one panel, redrawn in place (#144):

 stormbootx 0.28.0 (abc1234)  -  server1             2026-10-14 18:41:53 UTC (NTP)
 Supermicro SYS-5037MR-H8TRF   serial ZM142S026113

  [ OK ]  firmware handoff  American Megatrends firmware
  [ OK ]  NIC up            stormnic-ixgbe.efi ac:1f:6b:8a:a7:9c          11.2 s
  [ OK ]  DHCP              nic 0 192.168.16.100/20  MTU 1500               0.8 s
  [ OK ]  forge reached     stormblock 20.0.0 at 192.168.31.202:9090        0.1 s
  [ OK ]  boot intent       install  (under server1)                        0.0 s
  [ OK ]  claim             a clone of server1's image, nsid 1              0.4 s
  [ .. ]  image loaded      48 GiB at 4096-byte blocks, nsid 1              3.1 s
  [ .. ]  handoff           <the bootloader's latest line>                  2.0 s

            [#########.....................]  312 MiB read  110 MiB/s

 F2: every log line  -  the serial console carries the full log

Under OVMF, from tests/screen-ovmf.sh and net-ovmf's shipped boot: a network boot, a fall-through and F2's details.

A step is [ ] (not yet), [ .. ] (under way, yellow), [ OK ] (green), [FAIL] (red) or [ -- ] (skipped: a local intent claims nothing). On a fall-through the reason is under the steps, red when a step failed, with what happens next (the console offer's countdown, the local disk). The bar moves while the attached image's bootloader reads; how much it will read is not known here.

Every line the boot prints, and every line the media's NIC drivers and the attached image's bootloader print, still goes to the serial console exactly as before, and to a second screen behind F2 (Esc or F2 again goes back). Nothing is mixed into the panel. How: stormbootx puts its own EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL in the system table's ConOut, writes every line to the consoles that are not a screen (a terminal on a UART, a BMC's serial redirection), and draws the panel on the ones that are (their handle carries GOP). It hands ConOut back, with a cleared screen, before the network console, a local disk's bootloader, and any return to the firmware or the EFI Shell. The layout fits 80x25 and uses the width of a larger text mode. ui = log in stormboot.conf keeps the old scrolling console; a machine with no screen console gets it anyway.

The consoles are read again after every connect over all handles, after the NIC take-over and at the handoff (#219): a connect can stop a terminal and start a new one on the same UART, and the old one's pointer writes nowhere (forge1's serial log ended at binding the platform's own drivers first). A change is said: console : the consoles changed under a connect: ….

The handoff's guard (#219)

As the attached image's bootloader starts, stormbootx says handoff : watchdog 300 s until ExitBootServices; ExitBootServices marked … and arms three things (src/handoffguard.rs):

  • A line at ExitBootServices. When the bootloader (Linux's EFI stub) calls ExitBootServices, a BEFORE_EXIT_BOOT_SERVICES handler writes handoff : ExitBootServices called by the bootloader; the drivers stop their devices, the kernel is next to the serial consoles, and ExitBootServices: drivers stopping their devices; kernel next in the panel's handoff step. Once, and nothing allocated, so the call's map key still holds.
  • A green line along the bottom of the screen once every driver's ExitBootServices handler has returned (an EXIT_BOOT_SERVICES handler at TPL_CALLBACK, after the drivers' TPL_NOTIFY ones on EDK2; it only writes to the framebuffer).
  • The UEFI watchdog, handoff_watchdog seconds (default 300): a bootloader that hangs before ExitBootServices resets the machine. ExitBootServices turns it off; a bootloader that returns to stormbootx turns it off too (and with it whatever the boot manager had armed).

So a frozen screen says where the boot stopped:

screen stopped in
the bootloader's last line (EFI stub: Loaded initrd …), no ExitBootServices line the bootloader, before ExitBootServices (the watchdog resets it)
ExitBootServices: drivers stopping …, no green line a driver's ExitBootServices handler (a NIC driver's quiesce)
the green line the kernel, after every handler returned

Nothing after ExitBootServices can be timed by UEFI: a hang there needs a BMC watchdog the OS takes over.

What it does, step by step

src/main.rs, run():

  1. A self-update on trial (#83, src/selfupdate.rs), before anything else: counts this start, or, after two starts that never attached, puts the previous files back and chain-loads them. See Self-update.

  2. Banner: version and build stamp (STORMBOOTX_BUILD: the short commit, with -dirty if the tree had changes, set by build-boot-agent.sh; unstamped build otherwise), so the console always says which binary is talking.

  3. Identity. A machine is known to the engine by its DNS name (#23, stormblock#199); its serial and MAC are aliases.

    • name = <id> (or the older tag = <id>) in stormboot.conf wins over everything, and is the only name tried.
    • Otherwise, once the engine has been reached (step 5), the DNS name: DHCP option 12 from the lease on the interface that reached the engine (option 15 adds the domain for the console), else the PTR of that interface's address, asked of the option 6 DNS server over DNS/TCP (src/dnsname.rs; microdns answers over TCP, and TCP is the one transport this binary carries). microdns sends no option 12: it puts the reservation's name in DNS, so on microdns networks the PTR is the path. When the reply names no DNS server, or no reply was kept for that interface, dns = in stormboot.conf is asked instead; each way this comes up empty is printed. The engine knows hosts by the first label, so server3.g10.lo claims boothost/server3. The console prints name : server3.g10.lo (from DHCP). A name that is not a valid DNS name is ignored, not guessed at.
    • A reservation names the NIC, not the machine (#26): a label ending in a digit and one lowercase letter drops the letter, so server1a and server1b are both the machine server1 (dnsname::machine_label), and the console says so.
    • Otherwise SMBIOS (src/smbios.rs, via the _SM3_ or _SM_ entry in the EFI configuration table): the serial of Type 1 (System), then Type 2 (Baseboard), then Type 3 (Chassis). The first one that is not a placeholder is used.
    • A serial shared by several machines is rejected like a placeholder (#26): a Type 1 serial equal to the Type 3 serial on a multi-node chassis (chassis type multi-system, blade or blade enclosure) is the enclosure's, so Type 1 and Type 3 are skipped and Type 2 is tried. S11075924402016, the chassis serial seven Supermicro X9 blades report, is listed outright.
    • Placeholders are rejected (universal::serial_usable, host-tested against stormipmi's vectors, so both derive the same boothost/<tag>, #7): empty or NULs, none, unknown, na, n/a, empty, invalid, default string, not applicable, not specified, serial number, system serial number, chassis serial number, base board serial number, 0123456789, 123456789, anything containing to be filled or o.e.m., any string made only of 0, ., - and spaces, and one filler repeated (FFFFFFFF, XXXXXXXX, ****, 0xFF bytes).
    • No usable serial is no longer a failure: once the network stack exists (step 4) the machine's MAC identifies it, the lowest usable unicast permanent address across every NIC (net::machine_mac), so the answer does not depend on driver bind order. Only no serial and no MAC falls through.
    • The console prints the source, plus the SMBIOS model when there is one, and a mac line after step 4.
  4. No FEC step (#202; the owner, 2026-11-14: "Stormbootx calls the driver, the driver resposible for the nic getting up - speed and fec"). stormbootx knows nothing about a card but which driver binds it, chosen by PCI ID (step 4, drivers::take_over). Each stormnic driver reads its media and speed, applies the IEEE standard FEC itself (25G/50G/100G NRZ RS, 10G/40G none, PAM4 RS-544) and reports driver, speed, FEC and link per port. Nothing is handed down to it. The FEC pin (#186, #203), fec = recovery sticks, the console's and the API's fec and /api/v1/fec are gone; StormBootFecPin left in NVRAM by an older stormbootx is ignored.

  5. NIC drivers from the media, then the network (drivers::load_from_media, net::up, #56). Any *.efi in \stormboot\drivers is started first (see The network path). Then every EFI_SIMPLE_NETWORK handle is opened and smoltcp starts DHCP on all of them. With no SNP at all it connects every handle and waits up to 5 s. Then it falls through, saying the NIC needs a UEFI driver. Each NIC's driver is named before its first SNP call (nic 0: driver <name>, #88). The console prints tcp4 : smoltcp over SNP (nic 0 <mac>, …) and rng : firmware | rdrand | rndr | jitter. Then the self-update (#83): with update = on a writable medium, ask stormcentral for the current release and, if it is newer and its signature verifies, write it and restart into it (Self-update). One update : line either way.

  6. Where and which. config::resolve gives the portal from the conf, falling back to compiled defaults. Unless the conf says claim = no, it first finds the engine (/api/v1/health) on its plain boot port, else on api_port, and uses that port for the whole boot (#183, Ports), printed as engine : reached <addr>:<port> (the boot port) or (api_port). Then it reads the machine's boot intent (src/intent.rs):

    GET http://<portal>:<boot_port or api_port>/api/v1/synonyms/boothost/<name>/intent
        → 200 {"host":"server1","intent":"local","updated_at":…}  or 404
    
    (A stated name is the only one asked. Otherwise it goes down the names the
    claim tries, in the same order, and a 404 moves on to the next: the DNS
    name, the MAC's twelve hex digits for a machine that claims the default,
    then the serial. The engine resolves each through the same host table, so
    a DNS name it does not know yet can't hide an intent set on the host it
    knows by MAC.)
    
    intent what stormbootx does
    install claims and boots the image, as below
    local falls through to the local disk at once: no claim, no clone
    auto claims and boots; with local_when_bootable = true, a bootable local disk first (below)

    Set a state on the engine and power-cycle the machine to get it. Any doubt reads as auto: a 404, a non-2xx, an unreachable engine, or a body with no intent or an unknown one.

    auto claims, as every doubt does (owner, 2026-09-30): until intents work on forge (stormblock#148), every boot claims and boots or installs from the network. The rule below is in the code and off by default; local_when_bootable = true in stormboot.conf turns it on.

    With it on, auto boots what the machine has (#3). A local disk "can boot" when it is a whole, present, non-removable disk other than the media stormbootx was loaded from, and its GPT has an ESP carrying \EFI\BOOT\BOOTX64.EFI. esp.rs reads that, with no network and no firmware FAT, and the file is found, not read. The console prints local : disk N (…): ESP partition P, FATn, … and falls through, or local : nothing to boot (…); claiming with the reason for each disk. So a bare machine, or one the engine does not know yet, still claims and installs, and an installed one boots itself until someone sets install. Any bootloader counts, not only stormuefi: a disk with another OS on it boots that OS under auto. The console prints the intent and, when it defaulted, why. The contract is stormblock#148, released in stormblock v20.0.0 but not yet on forge (13.7.0 on 2026-10-02), so today every read there is a 404 and every boot is auto. Setting it is PUT …/intent {"intent":"local"} with the admin token. Resetting install back to local is the engine's job: the node's OS reports POST …/installed {volume} after the flow-over, and the claim reply carries intent for the initramfs. stormbootx never writes it.

    First it reads the engine's version from GET /api/v1/health. That is the boot's first request, so it also brings the network up and tells which interface to read the DNS name from. Then it claims the machine's image, best name first:

    POST …/api/v1/synonyms/boothost/<stated name>/claim   body {"agent":{…},"inventory":{…}}
    POST …/api/v1/synonyms/boothost/<DNS name>/claim      body {"agent":{…},"inventory":{…},"mac":"…","serial":"…"}
    POST …/api/v1/synonyms/boothost/default/claim         body {"agent":{…},"inventory":{…},"mac":"…","serial":"…"}
    POST …/api/v1/synonyms/boothost/<serial>/claim        body {"agent":{…},"inventory":{…}}
    

    A claim mints a clone, so it may take a while: it waits 90 s for the reply (every other read 30 s), printing waiting for the reply (N s) every 10 s (#157). A reply that never comes reads no reply within 90 s (the request was sent); a connection the engine closes first, the connection closed (…) with no reply after N s. Either falls through.

    Every claim names the boot agent (#90, stormcentral#286), so stormcentral can tell which stormbootx booted a machine, not only which release it installed:

    "agent": {"name":"stormbootx","version":"0.19.0","commit":"649dd07",
              "media":"rustnic ixgbe@02fcc5d mlx4@398a1a4 virtio@2dcafd3 e1000e@bd67192 igb@6990341",
              "update_serial":5,"update":"serial:5","api_port":9180,
              "last_log":"stormbootx 0.42.0 …","log":"stormbootx 0.42.0 …"}
    

    commit is the build stamp (absent on an unstamped build), media the medium's media = label, update_serial the self-update serial of the files on the medium (absent when no update wrote them), and update the StormBootUpdate value at claim time (serial:<n>, trial:<n>:<start>, failed:<n>; absent when unset). The engine keeps it on the host's last-claim record (stormblock#177). Then the console (#213, The start's log): trial_log, the failed trial's last start, only with failed:<n>; last_log, the previous start's last.log; log, this start up to the claim. Each is the log's head and tail (6, 3 and 4 KiB escaped) with a [... N bytes not kept ...] line between, and the whole agent stays within 15 KiB (the engine drops one over 16 KiB): the logs go first, never update.

    Every claim also carries the firmware inventory (#4, #167, src/inventory.rs and src/hardware.rs): everything the firmware can say with no OS and no BMC ("v":2; #20 sent only what a BMC can't, and #167, the server8 experience of a BMC that answers nothing, widened it). That is:

    • each NIC's MAC as firmware sees it, whether it has link, its MTU (the lease's, #165, else the SNP's), and which driver bound it, with media_driver true for a stormnic driver off the media, plus its PCI location and ID (SNP has no link speed; the stormnic drivers print it);
    • every mass-storage controller (PCI class 01) and its driver, if any; each is connected first, so a drive the firmware's fast boot left unconnected is still counted;
    • every whole disk with a BlockIO, the boot medium marked, and how many (disk_count). For each: the bus (nvme, sata, ata, scsi, usb, virtio), its device path (controller, port or target), model, serial, firmware revision, WWN, size, logical and physical sector size, media (ssd, hdd, nvme), and its partition table: the GPT's entries by kind (esp, linux, windows-data, … or the type GUID) with size and name, an ESP's directories under \EFI (BOOT, ubuntu, Microsoft: the installs on it), else an MBR's four entries. From EFI_DISK_INFO (ATA IDENTIFY, SCSI/USB INQUIRY, NVMe Identify Namespace), NVMe pass-thru Identify Controller, EFI_SCSI_IO VPD pages 0x80/0x83/0xB1, and the disk's own first blocks; all read-only;
    • SMBIOS: the system (Type 1: vendor, product, serial, UUID), board (Type 2), firmware (Type 0: vendor, version, date), CPU (Type 4) and memory (Type 17), and whether there is a BMC (Type 38 or 42).

    It is collected once, at the first claim, before anything is attached, so the namespace this boot attaches is never in it. The console prints it too, and so the log collector gets it (#162). net-ovmf's shipped boot:

    inventory   : 1 NIC(s), 3 storage controller(s), 4 disk(s), no BMC; 1960 bytes with the claim
      system    : QEMU Standard PC (Q35 + ICH9, 2009)
      firmware  : EDK II edk2-20260812-8.fc44 (08/12/2026)
      disk 0    : scsi  QEMU QEMU CD-ROM  fw 2.5+  5 MiB  2048 B sectors  the boot medium
                  PciRoot(0x0)/Pci(0x3,0x0)/Scsi(0x0,0x0)
                  mbr: 2 esp 16 MiB
      disk 1    : scsi  QEMU QEMU HARDDISK  serial SCSI0167  fw 2.5+  wwn naa.5000c500a1b2c3d5  256 MiB  512 B sectors  hdd
                  PciRoot(0x0)/Pci(0x3,0x0)/Scsi(0x1,0x0)
                  none: no partitions
      disk 2    : nvme  QEMU NVMe Ctrl  serial NVME0167  fw 10.2.2  wwn eui.0011223344556677  64 MiB  512 B sectors  nvme
                  PciRoot(0x0)/Pci(0x4,0x0)/NVMe(0x1,77-66-55-44-33-22-11-00)
                  gpt: 1 esp 32 MiB "EFI system" (EFI: BOOT, ubuntu), 2 linux 30 MiB
      disk 3    : sata  QEMU HARDDISK  serial SATA0167  fw 2.5+  wwn naa.5000c500a1b2c3d4  128 MiB  512 B sectors (4096 B physical)  ssd
                  PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x2,0xFFFF,0x0)
                  none: no partitions
    

    The engine keeps it beside agent and drops it, never the claim, if it is not a JSON object or is over 16 KiB; it is cut to 15 KiB here (disks last, the rest counted in disks_omitted, partitions after 16 in partitions_omitted):

    "inventory": {"v":2,"bmc":false,
      "nics":[{"mac":"52:54:00:12:34:56","link":true,"driver":"Virtio Network Driver",
               "media_driver":false,"pci":"0000:00:02.0","id":"1af4:1000","mtu":1500}],
      "storage":[{"pci":"0000:00:1f.2","id":"8086:2922","class":"01:06:01","driver":"…"}],
      "disk_count":2,
      "disks":[{"bus":"scsi","path":"PciRoot(0x0)/Pci(0x3,0x0)/Scsi(0x0,0x0)","model":"QEMU QEMU CD-ROM",
                "firmware":"2.5+","blocks":2604,"block_size":2048,"removable":true,"medium":true,
                "table":"mbr","partitions":[{"n":1,"kind":"esp","bytes":4194304}]},
               {"bus":"sata","path":"…/Sata(0x2,0xFFFF,0x0)","model":"QEMU HARDDISK","serial":"SATA0167",
                "firmware":"2.5+","wwn":"naa.5000c500a1b2c3d4","media":"ssd","blocks":262144,
                "block_size":512,"physical_block_size":4096,"removable":false,"medium":false,"table":"none"}],
      "system":{"vendor":"QEMU","product":"Standard PC (Q35 + ICH9, 2009)"},
      "firmware":{"vendor":"EFI Development Kit II / OVMF","version":"0.0.0","date":"02/06/2015"},
      "cpu":{"model":"…","sockets":1,"cores":1,"threads":1,"max_mhz":2000},
      "memory":{"mb":1024,"dimms":1}}
    

    The design for the rest of #167 (commands beside the intent, console input, the controls in stormcentral and the laptop app) is docs/remote-control.md.

    A stated name is the only one tried. Otherwise a 404 moves on to the next line, and any other failure falls back to the resolved target. The DNS claim carries the MAC and serial so the engine can tie a name it has not seen to a host it already knows (stormblock#204).

    The reply supplies the address, port, NQN and NSID. Both address/port and traddr/trsvcid spellings are accepted, with port defaulting to 4420 and NSID to 1. Any claim failure falls back to the conf's own nqn/nsid rather than failing, unless the medium says fallback = none. Every golden does (#36), so a machine no claim gives an image attaches nothing and boots its local disk (no network boot: no claim gave this machine an image, and the media names no fallback). The fallback they used to carry, nsid 2 on forge, was a 32 MB namespace with no ESP: every unnamed boot attached it and fell through anyway.

    Universal boot: one medium for every machine (#15, stormblock#200). A machine whose media states no name, and whose DNS name the engine does not know (or that has none), claims boothost/default and says which machine it is with its MAC. The engine gives it a copy-on-write clone of the default release of its own, recorded as host mac-<hex> until it is named, and the same MAC gets the same host back on every boot. The console says booting the default image as mac-<hex>, or, once the machine has a name the MAC is an alias of, booting <name>'s image. Not the SMBIOS serial: serials are not unique (seven Supermicro MicroCloud nodes report one chassis serial), and a serial claim would boot them all as one machine. The serial rides along in the body so the engine can resolve a machine it already knows by serial (C2NR0Q2) to that host (stormblock#202). stormbootx cannot check that itself, because every read but the claim needs a token.

    Only against an engine that has #200, told by the public /api/v1/health version being after 19.3.0. An engine from v17 to 19.3.0 reads default as one shared tag, and each machine's claim would release the clone another is running from. Against those, or when the version can't be read, it claims boothost/<serial> as before, and the engine (v17 and later) pins an unknown serial to boothost/default. If a #200 engine has no boothost/default to give (404) and the machine has a serial, it tries the serial claim too; with no default the engine cannot mint a serial host, so that only finds one that already exists. A stated tag = is always claimed as that tag. The default is set through stormipmi's /api/v1/machines/default (stormcentral#29). 5a. Set the hardware clock from NTP (#77, src/clock.rs, src/sntp.rs). The X9 blades have no RTC battery, and neither their BIOS nor their BMC sets the clock. Once the claim is decided the network is up and leased, so stormbootx sends one SNTP request (RFC 4330, UDP 123) to the server in DHCP option 42 of the lease on the NIC that reached the engine, else to ntp = in stormboot.conf (host[:port]), else to pool.ntp.org. A name is looked up with one A query over UDP to the lease's DNS server (or dns =). Two tries of a second each, for the lookup and for the request; a reply is believed only from a synchronised server (mode 4, LI ≠ 3, stratum 1..15) that echoes the request's random transmit timestamp. When NTP and GetTime differ by more than a second, SetTime writes UTC (Linux reads the RTC as UTC), keeping the firmware's time zone and daylight fields so EDK2 writes no NV variable. One line says what happened: clock : was 2026-01-01 00:00:05, set to 2026-10-01 21:14:03 UTC from 162.159.200.1 (step +23663638 s), clock : … UTC from …; the RTC agrees (within 1 s), or clock : NTP unreachable at … (…); left at …. It never stops the boot. A boot that falls through instead (a local intent, a failed attach) does it on the way out, before the NICs are released. ntp = off leaves the clock alone. The outcome goes to Linux as StormBootClock (step 6a): synced:<server address> when the RTC is known to be right, else unsynced. stormblock's initramfs reads it (stormblock#253): after synced it does not step the clock again (unless the clock reads before the image was built, or rd.stormblock.ntp=always), and it records how the clock was set in /run/stormblock/clock.

  7. Attach (src/nvme.rs, over crates/nvme-tcp-initiator). The host NQN is nqn.2026-09.lo.storm:host-<name>, so the target knows which machine is connecting: the engine's name for the machine from the claim reply (mac-<hex> for one it booted as the default), else the DNS name, else the tag. The console prints the namespace geometry and the transfer size. 6a. Hand the name down to Linux (#76, src/handoff.rs). The initramfs claims boothost/<name> again, and its own guess is the SMBIOS serial, which on the X9 MicroCloud blades is the chassis's: server8 booted its own image and then laid its disk from server1's (stormblock#249). So before the loader starts, stormbootx sets two volatile EFI variables under vendor GUID ab361f54-0166-44a4-a088-1ac22e98ab76, attributes BOOTSERVICE_ACCESS | RUNTIME_ACCESS (0x6, never NON_VOLATILE), ASCII with no NUL:

    • StormBootTag: the name claimed on: the claim reply's host when it named one, else the name claimed (server8, C2NR0Q2, mac-<hex>). With nothing claimed (the engine down, a local intent), only a name stormbootx was given or found: the stated tag =, else the DNS name; never a serial or a MAC guess. Set as soon as the claim is decided, so a failed attach that falls through still names the machine.
    • StormBootHostNqn: the host NQN of step 6, once the attach worked.
    • StormBootEngine (#183): <addr>:<port> where the engine answered, so the initramfs can claim from the same port (stormblock#524). stormbootx writes no kernel line, so rd.stormblock.boothost= is not its to set.
    • StormBootClock (#77): synced:<NTP server address> or unsynced, from step 5a (or the fall-through).
    • StormBootUpdate (#83): the boot medium's self-update, serial:<n>, trial:<n>:<start>, good:<n> or failed:<n> (Self-update).
    • StormBootInstallConfig (#79): the media's install-config.yaml, as a header and chunks (install-config.yaml, below). Set before step 1. A value outside [A-Za-z0-9._:-] (Linux ignores those) is not set. Each is printed as handoff : …; a failure to set is printed and not fatal. Linux reads them from /sys/firmware/efi/efivars/<Name>-<guid> (four attribute bytes, 06 00 00 00, then the value) ahead of rd.stormblock.tag= and SMBIOS (stormblock docs/boot-hooks.md, "Whose image").
  8. Publish (src/blockio.rs). BlockIO is installed with the namespace's own block size (read from FLBAS, so 4096 on a 4K namespace). A device path of one hardware vendor node (6d7a1f2e-9c34-4b8a-b1d0-5e2f7a0c9b41) goes on the same handle, then ConnectController recursively.

  9. Chain-load. It looks for \EFI\BOOT\BOOTX64.EFI on a filesystem whose device path starts with that vendor node, which means an ESP on the attached disk only. It is never a local disk. LoadImage + StartImage. Success never returns.

  10. Read the ESP itself when the firmware can't (#37, src/esp.rs). Volumes stay 4096-byte, so a 4K image's ESP is a FAT with 4096-byte sectors (Linux mounts nothing smaller than the device's block). AMI Aptio 4 mounts one and then answers NOT_FOUND for a file that is there. If step 8 loads nothing, stormbootx reads the GPT (CRC-checked), finds the ESP, reads BOOTX64.EFI from its FAT12/16/32 at any sector size from 512 to 4096 (a boot sector with no 16-bit FAT size is FAT32 whatever its cluster count, as Linux mounts it, #67), and LoadImages the bytes from the buffer, with the device path Vendor(disk)/HD(n,GPT,…)/\EFI\BOOT\BOOTX64.EFI. The console says boot : the firmware did not load it (…); reading the ESP here. stormuefi needs nothing more: it reads its pallets through whole-disk BlockIO and parses the GPT itself. Any other bootloader opens more files on its ESP (shim's grubx64.efi, systemd-boot's loader entries) through the SimpleFileSystem on its DeviceHandle, so the bridge puts up its own (#42, src/espfs.rs). This read-only EFI_SIMPLE_FILE_SYSTEM over esp.rs goes on the ESP's partition handle, after the firmware's FAT is disconnected from it (or on a new handle with that path, if the partition driver made none). The image is loaded under that path, so it becomes the image's DeviceHandle. The console says esp fs : read-only filesystem on the ESP's partition handle, the firmware's FAT disconnected from it. Writes, SetInfo and Delete answer WRITE_PROTECTED, and blockio::withdraw takes it back first. esp = firmware / esp = stormbootx picks one reader alone. It is read at start-up with the rest of stormboot.conf: nothing on the media is opened once an image is attached (#46).

  11. Report the bootloader's reads (#46). While the image's bootloader (stormuefi) reads through the published BlockIO, stormbootx prints a blockio : line for a read that fails (with the NVMe/TCP error, which the firmware only sees as DEVICE_ERROR), a single read slower than 2 s, and every 64 MiB read with its MiB/s. A boot that stalls inside the bootloader then shows whether reads crawl, fail, or stopped being asked for.

The log over the network

The serial console is a boot's only record, and a BMC's serial-over-LAN can die mid-install (server8, stormblock#476). So every line stormbootx prints, and every line printed through ConOut while it runs (the media's NIC drivers'), is also sent as a stormcast frame (#162, src/netlog.rs, src/logwire.rs): RFC 5424 over UDP, facility local0, APP-NAME stormbootx, the framing of glennswest/stormcast's docs/wire.md.

  • To the fleet's group 239.255.42.1:5514 (TTL 4) on every NIC with a lease, which mcastsyslog and stormconsole read; and, because multicast does not leave the subnet, to a unicast collector when one is named: syslog = in stormboot.conf, else a top-level syslog: in the media's install-config.yaml (host[:port], port 514 when bare; a name is looked up once at the lease's DNS server). The goldens name stormcentral's receiver, stormcentral.g8.lo:5514, so stormcentral logs --host <machine> shows the boot. Nothing named: the group alone, never an error. off: nothing is sent.
  • Kept from the first line (the banner) while no NIC holds a lease, at most 4000 lines or 512 KiB (the oldest go first, and the next send says how many), and sent at fixed points: once the machine's name is known, after the claim, before the attached image's bootloader starts, before a self-update resets, and on the fall-through before the NICs are given back. Never from inside a print, so the bootloader's own lines are not sent.
  • HOSTNAME is the best name known when a line goes: the host the claim booted, else the DNS name or a stated tag, else mac-<hex>. TIMESTAMP is the line's own time once NTP set the clock this boot (#77); before that -, and the collector keeps the arrival time.
  • The console says where it goes, once: log : sent to the stormcast group 239.255.42.1:5514 and to stormcentral.g8.lo:5514 (192.168.8.x, syslog in \stormboot\stormboot.conf) as server8.

The initramfs's and the installer's lines after it are stormblock's (stormblock#477).

install-config.yaml

A node's first-boot input (stormcos#82, stormcos docs/INSTALL-CONFIG.md: cluster name and domain, sshKey, pullSecret, apiToken, hosts[]) travels inside the boot media (owner, 2026-10-02, #79). storminstall writes it, stormbootx hands it down, the initramfs puts it in /state/config/install-config.yaml on a first boot, and stormpump applies it.

The slot (storminstall docs/config-slot.md): \stormboot\install-config.yaml on the media's ESP, the STORMBOOTX FAT volume build-boot-agent.sh makes, beside stormboot.conf. No partition of its own: the ESP is the one volume firmware reads however the media is presented (El Torito image on a CD, the isohybrid MBR 0xEF / GPT entry on a USB stick, the GPT ESP of the .img), and storminstall writes it with plain FAT file I/O. At most 256 KiB; a real one is about 1 KiB. For it to keep working, the ESP keeps its \stormboot\ directory, stormboot.conf, and some free space (the 4 MiB FAT12 ESP has ~4 MiB less any NIC drivers).

The hand-down (src/installconf.rs, handoff::set_install_config): read with the rest of the media before step 1, whatever the boot does next, into volatile EFI variables under #76's GUID, attributes 0x6. A firmware variable may be capped at 1 KiB (EDK2's default PcdMaxVariableSize), so the file goes in chunks:

variable value
StormBootInstallConfig0 … StormBootInstallConfig<N-1> the file's bytes, 768 each, the last one shorter
StormBootInstallConfig v1:<length>:<N>:<sha256, lower-case hex>

The header is set last, and a chunk the firmware refuses takes the ones already set with it, so a header means the whole file is there. A reader concatenates the N chunks (each efivarfs file is four attribute bytes and then the data) and uses the result only if its length and SHA-256 match. The console prints install cfg : none on the media (…), or handoff : StormBootInstallConfig (<length> bytes in <N> chunk(s)) = v1:…, and never the content: it carries the pull secret and the API token. An empty file, one over 256 KiB, or one the variable store can't hold is reported and not handed down. A firmware's volatile variable store is a fixed size, so the store is the real limit (#93). Fedora's OVMF takes every size up to 256 KiB (342 chunks; tests/net-ovmf.sh hands down 64, 128, 192 and 256 KiB). Real firmware is not measured yet. Where the store fills, the console says so and how much fit, for example install cfg : NOT HANDED DOWN: the firmware's volatile variable store is full (OUT_OF_RESOURCES on chunk 87 of 342); 66048 of 262144 bytes fit, so this firmware takes a file of at most about 64 KiB. The node boots without its install-config.yaml.

The variables are world-readable through efivarfs once Linux is up, so the initramfs deletes them after copying (stormblock's side). The copy is made only when /state has none, so booting old media again never overwrites a node's applied config.

Self-update

A medium written once must not stay on that version forever (#83; the X9 blades boot from USB sticks). src/selfupdate.rs, with the format and every decision in src/manifest.rs (core-only, host-tested). The design is the owner's (2026-10-02); stormcentral's half, the signing and the serving, is stormcentral#279.

  • Where. update = http://stormcentral.g8.lo/api/v1/boothelpers/<golden> in stormboot.conf, written by the golden build. stormbootx GETs <url>/current (the manifest), <url>/current.sig (an Ed25519 signature over those exact bytes, raw or hex) and <url>/files/<path>. No token. update = off pins a medium; no update = (media built by hand) checks nothing at boot, though stormbootx-stick on the running node gives such a stick its family's (#161) and the release's stormboot.conf names it from then on; a read-only medium (an ISO on virtual media) is skipped.

  • The manifest is text:

    stormbootx-manifest 1
    version 0.13.0
    commit 1f27df3
    golden golden-stormbootx-rustnic-0123456789abcdef
    serial 42
    canary 52:54:00:12:34:56          (optional, repeatable: only these MACs)
    downgrade true                    (optional: a deliberate rollback, #96)
    file <sha256> <size> EFI/BOOT/BOOTX64.EFI
    file <sha256> <size> stormboot/stormboot.conf
    file <sha256> <size> stormboot/drivers/stormnic-ixgbe.efi
    file <sha256> <size> startup.nsh
    

    Paths are only startup.nsh, EFI/BOOT/* and stormboot/* (never stormboot/state or stormboot/local.conf), and BOOTX64.EFI must be there. A media golden's media.files is exactly its file lines.

  • Authentic. The signature is checked against the Ed25519 public keys compiled in (manifest::RELEASE_KEYS, current and next) before a byte of the manifest is believed, then each fetched file's size and SHA-256. Plain HTTP is enough: whoever answers can only serve what was signed. The private key never leaves the stormcentral VM. Since v0.14.0 (#86) that is stormcentral's key, 4fe28c02…0a171bce, the one it serves open at http://stormcentral.g8.lo/api/v1/stormbootx/keys; a host test holds the bytes to that hex. A new key goes in as the next one a release before stormcentral signs with it. A test build may add one key at build time (STORMBOOTX_UPDATE_TEST_KEY, hex); no golden build sets it, and a binary that has one says so on every check.

  • Never backwards. The serial must be above every serial the medium has carried, at or above StormBootMinSerial (the machine's highest passed trial), and above any serial that failed its trial on this medium. A rollback is a new, higher serial. And the version must not be older than the running binary's (semver; a -pre/+build suffix is ignored), or a stick written from a newer build than the promotion would update itself backwards on its first boot (#96). Such a serial is declined and becomes the medium's floor (min), so the next promotion is still judged: update : current (running v0.23.0; serial 1 offers v0.14.0, which is older); not taken. A deliberate rollback to an older version says downgrade true in the manifest.

  • A/B. Only the files that differ are fetched. Each is written as *.new; then the current file becomes *.prev and the new one takes its name, the bootloader last; a loadable driver the release no longer carries becomes *.prev too. \stormboot\state records the trial before the swap, and the machine warm-resets into the new set: update : v0.12.0 -> v0.13.0 (golden-…, serial 6), 3 file(s), 280 KB; restarting into it.

  • The trial. The new binary counts its starts (update : serial 6 on trial, start 1 of 2; it must reach an attach) and passes once it has attached an image, or the engine answered a local intent (update : serial 6 passed its trial (attached); it stays). After two starts without that, the third puts every changed file back from *.prev, marks the serial failed on this medium, and chain-loads the restored binary. A boot that fails for a reason that is not the release (the engine down twice) also puts it back; the cost is a stick on the previous release until the next serial.

  • Linux is told: StormBootUpdate (volatile, #76's vendor GUID) is serial:<n>, trial:<n>:<start>, good:<n> or failed:<n>.

  • local.conf is the medium's own. It is read before stormboot.conf, so its keys win, and an update never writes it, except to keep a name = or tag = that the release's stormboot.conf would drop.

  • Room. The new files are written beside the old, so the medium needs their size free (the *.prev copies about to be replaced count); a medium without room says so and is left alone. The 4 MiB ESP of build-boot-agent.sh holds a set of binary and drivers three times over.

  • A failed trial says why (#213): the start that puts a set back keeps the previous start's last.log, the failed set's last, as \stormboot\trial.log (last log : serial 8's last start (212 line(s)) kept in \stormboot\trial.log), and the restored binary's claim carries it as trial_log beside update: failed:<n>. A restored binary from before #213 sends none.

The start's log

A trial that never reaches the network leaves nothing anywhere else: no lease means no syslog, and only installs get a BMC console capture (#213, C2NR0Q2's serial 8). So every start on a writable medium keeps its own console on it, in \stormboot\last.log (src/lastlog.rs): all of netlog's lines so far, cut to 64 KiB (the first quarter and the end). It is rewritten at fixed points, from the first, whenever the log grew: after the trial count, before each media driver starts (a driver that hangs leaves its name), after the drivers bind, after the network is up, and at every log flush (the name, the claim, the hold, the fall-through). Not once an image is attached (#46) until it is withdrawn, and never on a read-only medium (last log : not kept (the boot medium is read-only)) or a RAM disk. The previous start's file is read first, and goes with the claim as last_log; GET /api/v1/log/last and /api/v1/log/trial serve both files. On a node, the stick's FAT holds both files to read.

tests/update-ovmf.sh boots a writable GPT medium on a USB stick (qemu-xhci + usb-storage) under OVMF ten times against a stub stormcentral and a test key: stormcentral's own first promotion (tests/fixtures/, hex signature) verifies against the release key; a manifest signed by another key is refused; serial 5 is written, restarts, passes its trial on the attach; serial 5 again is current; serial 6 for another machine's MAC only is refused, then, naming this machine as a canary too, taken, retiring the driver it no longer carries to .prev; serial 7, larger than the ESP, is refused for room; serial 8, whose stormboot.conf names a dead NVMe port, fails both trial starts, and the third start puts serial 6 back, which then refuses 8. The disk is checked with mtools after each boot, last.log and trial.log too (#213): the failed trial's second start is in trial.log and in the restored binary's claim as trial_log.

Why chain-load

Publishing a disk and returning to the boot manager does not boot it. A disk that appears while a boot option is running is not in BootOrder, so the manager moves on and drops to setup. Real EDK2's PartitionDxe also skips a handle that has BlockIO but no device path. That is why step 7 installs one. OVMF is lenient here and hid it. The ESP match is strict because an earlier, looser version booted a stale Windows install off a local SAS disk.

The network path

NIC drivers from the media (#26, src/drivers.rs). Before the network is brought up, every *.efi in \stormboot\drivers\ on the boot volume is loaded (LoadImage by device path) and started, then every handle is connected. One ConnectController pass runs first, so the platform's own drivers claim every NIC they will take and a media driver only gets the ones nothing else wanted. The console prints drivers : N of M started from \stormboot\drivers and one line per file; a driver that fails is reported and skipped. This is for firmware that has no UEFI driver for its NICs, like the Supermicro X9 blades (legacy-only Intel 10G and ConnectX-3). No directory, no change.

ARP for the next hops at the lease (#168). smoltcp sends one ARP request a second per interface, and a socket whose neighbour is unknown is silenced for a second. So the first contact with a new host within a second of another cost a whole second: the reverse-DNS lookup after the engine, the NTP exchange after it (1 s each on the Dell, the X9 blades and pve). Once a NIC holds a lease, stormbootx sends its own ARP requests for the router and the lease's DNS (option 6) and NTP (option 42) servers, and for each connection's or datagram's next hop before its first packet. They are 42-byte frames through the SNP, padded to 60, once per address per lease. smoltcp caches any ARP reply aimed at its address, so the answers fill its cache and nothing waits for them.

Where the time goes (#168). Every boot prints one line before its result with the time each step took, from the banner (net-ovmf's shipped boot, OVMF under KVM):

timing      : drivers 0 ms, nics 5.15 s, lease+engine 57 ms, name 39 ms, intent 25 ms, claim 1.77 s, clock 20 ms, attach 86 ms; 7.24 s in all

The same boot with the ARP requests taken out read name 1.03 s.

media's NIC drivers and connecting every handle (a driver's Start runs in it). nics is opening the SNPs. lease+engine waits for DHCP and reads the engine's version, and claim covers the claim and its inventory. A boot that falls through ends at fall-through, which includes the network console's wait when it has one (under 300 ms on a clean local, #189), and then clock.

stormbootx's own TCP/IP (#56, src/net.rs). The firmware's EFI_TCP4 is not used on any machine. It is optional, off in setup on some machines, and missing on server3 (X9) even with a NIC driver loaded. And EDK2's own stack can't be carried instead, because since edk2-stable202405 its IPv4 drivers refuse to start without EFI_RNG, and TcpDxe without EFI_HASH2, which old firmware lacks. smoltcp 0.14 runs on each NIC's SNP instead:

  • Each SNP is opened EXCLUSIVE, so a firmware MNP bound to it lets go and can't take frames meant for smoltcp. A NIC the firmware won't release is used shared, and the console says so. On the fall-through every NIC is given back (closed and reconnected), so a later boot option finds the firmware's own stack again.
  • DHCP is smoltcp's dhcpv4 socket, on every NIC at once, re-sent every 2 s (a 25G link takes seconds to train). It asks for options 1/3/6/12/15, and keeps the reply for the name (#23). It asks for option 42 too, for the NTP server (#77). ARP and TCP are smoltcp's.
  • The MTU (#165): DHCP is asked for option 26, and once a NIC holds a lease it runs at what option 26 offers (Ethernet's 1500 with none), never above its SNP's MaxPacketSize. TCP's MSS follows it, so a 9000 network on a driver that carries 9000 sends 8960-byte segments. MaxPacketSize is the NIC driver's: EFI_SIMPLE_NETWORK has no call to raise it, so a driver that carries 1500 (every firmware SNP seen so far, the Dell's Mellanox driver among them, and the stormnic drivers until their jumbo issues land) keeps the boot at 1500 on any network. The lease line says which: nic 0: leased 192.168.30.1/20 gw 192.168.16.1 mtu 1500 (DHCP offers 9000; the SNP of Mellanox ConnectX Driver + … carries at most 1500, …). The NVMe/TCP transfer size does not change with it: that is the controller's MDTS (#6).
  • UDP carries one SNTP exchange and, for a server named by name, one A query per boot (#77), on the NIC that reached the engine.
  • A connection goes out on the NIC that reached a target last, else on the best-ranked NIC with a lease: link up first, then the larger MTU (the storage port is the jumbo one). Each try gets 5 s while another leased NIC is left. A reset is an answer (nothing listens there), not a wrong wire. The NIC table prints once, at bring-up.
  • Waiting is never silent (#88). While no NIC holds a lease, a connection prints waiting for a lease (N s): nic 0 link UP, DHCP 3 out 0 in, 14 frames in; … once a second: frames in with no DHCP in is a wire that works and a server that doesn't answer; no frames in at all, a dark wire. Every SNP call is marked, and a 1 s timer event at TPL_NOTIFY prints nic N: SNP.Receive (<driver>) has not returned after 5 s (again every 30 s) while one is stuck, since a NIC driver call that never returns is outside every deadline and can't be abandoned from one thread. A driver that hangs at TPL_NOTIFY or with interrupts off stops the timer too; the driver line before Start is then the last word (src/snpwatch.rs).
  • Timeouts are per operation without progress: 30 s for the engine API and the attach (TcpSocket::connect), 5 s for the PTR query, 8 s for the console's connect. Nagle and delayed ACKs are off, with a 256 KiB receive window (window scaling), so a 128 KiB read is one round trip.
  • Time is the TSC, calibrated against Stall at bring-up.
  • Randomness (src/entropy.rs) seeds smoltcp's TCP ISN and DHCP xid, and picks each connection's local port. It uses the first source present: the firmware's EFI_RNG_PROTOCOL (read, never installed, so the next stage never finds a weak RNG left behind), then RDSEED/RDRAND (RNDR on aarch64), then cycle-counter jitter across short stalls, hashed with SHA-256 together with the firmware time, the MAC and the SMBIOS UUID. It never refuses to boot for lack of entropy. rng = cpu or rng = jitter in stormboot.conf skips the sources above it.

Under OVMF with no firmware network stack, tests/net-ovmf.sh leases from QEMU's slirp, reads a stub engine, and attaches a stub NVMe/TCP target at 4096-byte blocks. The firmware's FAT reads a 96 MiB BOOTX64.EFI through the published BlockIO (about 105–120 MiB/s, KVM) and starts it. Its boots (NET_ONLY=<name> runs one):

boot must show
shipped 8 in flight and the NVMe stub holding at least 2 reads at once, answered out of order (#100); rng : firmware/rdrand; every claim carries agent and the firmware inventory (#4, #90), with an NVMe, a SATA and a SCSI drive's models, serials, WWNs, media and the NVMe's GPT and \EFI directories (#167); a stub SNTP server's 2031 time set in the RTC and read back by the payload (#77); a 2 KiB install-config.yaml handed down and reassembled (#79); the ISO's update = skipped as read-only (#83)
shipped (#168) the timing line, with the reverse-DNS name and the clock each under a second: the DNS server is ARPed at the lease, not a second after the engine
depth1 nvme_depth = 1: one read in flight (the stub never holds two), for its MiB/s beside shipped's 8 (#100)
jitter firmware RNG and RDRAND/RDSEED masked: rng : jitter; told install, claims the default and hands down StormBootTag = stubhost (#76); an unsynchronised SNTP answer sets nothing (StormBootClock = unsynced)
newhost no name from DHCP or PTR: claims boothost/default by MAC and boots as mac-525400123456 (#15)
noesp an attach that boots nothing: blockio : withdrawn, one local disk counted, no CPU exception in BDS after (#54)
nolease a NIC on a dead hub: one waiting for a lease line a second (#88)
intent told local: no claim POST, nothing attached (#11); no disk that boots, so the console's 5 s stays (#189)
localdisk, localdisk-key, localdisk-fail told local with a disk that boots: no wait, fall-through under 300 ms, its bootloader started; a c sent during the boot opens the console; a failed claim on that disk still waits 5 s (#189)
ebs, watchdog the handoff's guard (#219), tcp4probe told by an SMBIOS OEM string: ebs calls ExitBootServices as Linux's stub does: stormbootx's line before it, tcp4probe's own line on COM1 after it, and the screen's bottom rows green; watchdog hangs before it, and handoff_watchdog = 15 resets the VM
bridge esp = stormbootx: the payload starts from stormbootx's own read of the ESP and finds the read-only filesystem as its volume (#42)
ic64 … ic256 install-configs of 64, 128, 192 and 256 KiB, each reassembled by the payload (#93)
release no fallback namespace and the claim 404s: falls through, gives the NIC back, and the firmware's PXE on it boots the payload (#36, #68)
virtio, virtio-modern, virtio-oldkey, virtio-verbose with VIRTIO_EFI: stormnic-virtio takes a transitional and a modern-only virtio-net NIC from OVMF and attaches over it; an old prefer_media_drivers is named and ignored; nic_verbose prints the driver's trace (#108, #80, #202)
e1000e, e1000e-take with E1000E_EFI: QEMU's e1000e (82574L) with no option ROM, which OVMF has no driver for: stormnic-e1000e binds it and carries the whole boot (lease, claim, 96 MiB attach); with QEMU's iPXE ROM, the take-over takes it from iPXE (#70, #202)
igb with IGB_EFI: QEMU's igb (82576, 8086:10c9) with no option ROM, which OVMF has no driver for: stormnic-igb binds it with no key (there is no igb family) and carries the whole boot (#69)
mlx5-giveback, mlx5-none with MLX5_EFI: the real stormnic-mlx5 under the virtio family's file name declines the NIC, and the firmware gets it back and carries the boot; both drivers on one medium: virtio taken, no NIC here for stormnic-mlx5.efi (#131). QEMU has no ConnectX

The NVMe/TCP initiator

The initiator is its own crate, crates/nvme-tcp-initiator (#10): no_std with alloc, no dependencies, over any byte stream. It was ported from sbregistry's host initiator, and stormboot4bios (the legacy-BIOS loader) links the same code rather than a third copy. A loader gives it a Transport (send_all, recv_exact, and the path's MTU if it knows it) and a Platform (open a connection to the portal, wait some milliseconds). src/nvme.rs is stormbootx's: its smoltcp TcpSocket and boot::stall. cargo test -p nvme-tcp-initiator runs the crate on the host against an in-memory controller that refuses a command without PSDT=01b and an admin command before CC.EN. It answers in several C2HData PDUs and asks for write data by R2T.

  • It speaks ICReq/ICResp, then Fabrics Connect on the admin and I/O queues, then Property Get/Set, CC.EN, and Identify for the controller and the namespace. Reads use C2HData. Writes use R2T/H2CData.
  • PSDT = 01b (SGL) on every command. A zero FLAGS byte means PRPs, which do not exist over a fabric.
  • CC.EN before any admin command. Identify on a disabled controller is a Command Sequence Error.
  • Synchronous, with reads pipelined (#100). One BlockIO read keeps up to 8 commands in flight (fewer if the I/O queue is smaller), matches each C2HData and response to its command by CCCID in whatever order the target answers, and returns when all are done; a failed command is reported once the others have finished, so nothing of it is left on the queue. stormuefi reads in 1 MiB calls, so one call is eight 128 KiB commands against stormblock. One at a time held the Dell to 46–80 MiB/s on 25 GbE: a round trip and a target read per command. Writes stay one at a time. nvme_depth = 1 in stormboot.conf is the old behaviour; the attach prints transfer : 128 KiB per command, 8 in flight (…).
  • No header or data digests. A target that insists on them is refused.
  • Transfer size comes from the controller's MDTS: 2^(12+MPSMIN) × 2^MDTS, capped at 512 KiB, or 64 KiB if MDTS is 0. It is never derived from the MTU. Against stormblock (MDTS 5) that is 128 KiB per command.

The API, commands and the hold (#167)

A machine whose BMC answers nothing (server8: the web login failed TLS, SOL died seconds into a boot, the power button never took) is still reachable while stormbootx runs. So stormbootx answers HTTP on port 9180 of every NIC that holds a lease, from the moment its stack is up until the image's bootloader starts (api = <port>|off in stormboot.conf; the claim's agent says "api_port"):

request answer
GET /api/v1/status phase (booting, attaching, holding, falling through, handed off), detail (the failure when holding), the machine's names and addresses, command_id (the floor), the pending and last command with its result
GET /api/v1/inventory the firmware inventory ("v":2, as in the claim): drives first, NICs, controllers, SMBIOS, CPU, memory
GET /api/v1/log every console line so far, the banner first (kept even with syslog = off)
GET /api/v1/log/last the previous start's \stormboot\last.log (#213); 404 when the medium has none
GET /api/v1/log/trial the failed trial's last start, \stormboot\trial.log (#213); 404 when none
POST /api/v1/command a signed command: 202 {"accepted":<id>,…}, else 400/403/409 with {"error":…}

Reads are open (the log already goes to the stormcast group, and the inventory with every claim). A command counts only by its signature (the owner's choice on #167): Ed25519, by stormcentral's key compiled in (manifest::RELEASE_KEYS, the self-update's) or by a key on the media (command_key = <64 hex>, which the laptop app writes into local.conf of a stick it makes). The body (src/command.rs):

{"host":"server8","id":1792000000123,"command":"format","args":"S3Z9NB0K123456",
 "issued":"2026-10-22T03:00:00Z","by":"stormcentral","sig":"<128 hex>"}

and the signature is over the bytes stormbootx-command v1\n<host>\n<id>\n<command>\n<args>\n<issued>\n (by is not signed). host must be one of the machine's names (the claimed host, the host the intent was answered for, the DNS name, a stated tag, the SMBIOS serial, mac-<hex>; a MAC alias of server7 is server7, the name the engine's record stores and a signer signs) and id above the last one this machine took, kept in the non-volatile StormBootCommandId (vendor GUID as below) and raised before the command runs: each command is done once, on one machine. Use Unix milliseconds for id, so stormcentral and the laptop app never collide.

command does
reboot ResetSystem(EfiResetCold)
poweroff ResetSystem(EfiResetShutdown) (ACPI S5); power-on is Wake-on-LAN or the BMC
boot-next NNNN BootNext = Boot<NNNN> (it must exist; its description is said), then a reset
install claim and boot, whatever the intent says (this boot)
local boot the local disk, nothing claimed
retry run the boot again from the engine (a hold)
hold stay in stormbootx until another command
inventory collect it again
format <serial> [fast] zero the first and last MiB of the one drive with that serial (its MBR, GPT and backup GPT), never the boot medium, a read-only drive or a published one; full/secure are the maintenance image's (baremetalservices)
maint claim the maintenance image and boot it: the usual claim with "image":"maint" (stormblock#497), down the same names, answering this host's own maint-<host> clone of boothost/maint's volume. Never the media's fallback; a reply without "image":"maint" (an engine before #497, stormboot-serve) is not attached; no boothost/maint (a 404) or either of those is a failed boot, so it holds. Before the chain-load it boots again for the maintenance image

A command is taken at fixed points: before the intent is read (the API's, else the engine's host record GET …/boothost/<name>/command, stormblock#494, the same signed object), before the chain-load, and every 100 ms of a hold (the record every 5 s). One from the record is reported with POST …/command/done {"id","result"}. Each is said on the console: command : format APIDISK1 (id 100, from the API, 10.0.2.2, by api-test), signature good, then command : format (id 100): wiped ….

Holding. A failed network boot (no claim, a failed attach, a bootloader that did not start) on a machine with a lease stays in stormbootx with the API up, the panel and the log saying so, for hold = seconds (forever, or off), then falls through as before. A machine with nothing local that boots holds until a command (server1 sat at a UEFI shell on 2026-10-14). hold_retry = <minutes> runs the boot again that often while holding; L on the keyboard falls through at once. install-config.yaml's stormbootx_hold: and stormbootx_hold_retry: are read when the media says neither. The binary's default is off; the goldens say hold = 120. A local intent is not a failure and never holds. tests/api-boots.sh (sourced by tests/net-ovmf.sh) drives all of it from the host through slirp.

When it fails

Every error in run() that the hold (above) does not keep, a hold that ends, and a local intent end in fall_through:

  1. If an image was attached, it takes the disk back first (blockio::withdraw, #54): disconnect, uninstall BlockIO and the device path, drop the NVMe/TCP connection, and print blockio : withdrawn. The disk's functions live in this image, which the firmware unloads when it returns, so a disk left installed is one the boot manager calls into freed memory (pvetest1's #UD at RIP 0x47FFFFFCA). If the firmware refuses the uninstall, stormbootx says so and resets the machine rather than return. A start that finds a disk an earlier start left behind prints blockio : N handle(s) an earlier start published are still installed.
  2. It prints no network boot: <reason>.
  3. It offers a console: press c within 5 s. Silence continues, so an unattended machine never stops at a prompt. Not on the ordinary reboot (#189): when nothing failed (a local intent, a local command) and a local disk can boot, it does not wait (console : no wait, …), and only a c pressed during the boot opens the console. A local with nothing local to boot keeps the wait.
  4. It sets the clock from NTP if the boot has not yet (step 5a, #77), so the local disk's OS gets the right time too.
  5. It gives every NIC back to the firmware (net::release, #56), so a later boot option (PXE, HTTP boot) finds the firmware's own stack bound again. It says so: net : N of M NIC(s) given back to the firmware (exclusive SNP closed, reconnected), and names any NIC whose SNP would not close (#68). tests/net-ovmf.sh's fifth boot falls through with the firmware's IPv4 stack on, and the firmware's PXE on the same NIC then boots the payload over TFTP.
  6. It counts local disks (whole, non-removable BlockIO devices; the attached image is no longer one of them).
    • If there are any, it prints falling through to the local disk (N found).
    • If there are none, it says so and waits 30 s.
  7. With a local disk, it starts that disk's \EFI\BOOT\BOOTX64.EFI itself (blockio::boot_local, #145): the first whole, non-removable disk other than the boot media whose GPT ESP carries one (the disk auto asks about, printed as local : disk N (…)), through the firmware's FAT, else read by esp.rs (esp = applies, as for the attached image). Returning is not enough: where the EFI Shell started stormbootx from startup.nsh (the X9 blades, which have no boot option for it), the shell gets control back and starts stormbootx again, and the disk never boots. local_boot = firmware skips this step.
  8. If nothing started, or what started came back, it returns EFI_ABORTED, so the boot manager (or the shell) tries the next thing.

The panel is cleared when stormbootx hands over, so its last line on the screen names the build (#180): stormbootx <version> (<commit>): handing over to the local disk's bootloader, or …: returning to what started it (nothing booted).

The console (src/shell.rs):

Command Does
nics every NIC with an EFI_SIMPLE_NETWORK: MAC, MTU, link, SNP state
state the address each NIC leased on stormbootx's own stack
dhcp [secs] bring the stack up if it is not, and wait up to secs (default 10) for every NIC's lease
connect IP PORT open a TCP connection the way the attach does (8 s)
pci [all] devices on the bus, with or without a driver
reset warm reset, so firmware re-reads NV config
boot leave the console and continue the fall-through
help the list above

stormboot.conf

\stormboot\stormboot.conf on the volume the binary was loaded from, found through EFI_LOADED_IMAGE_PROTOCOL. It uses key = value lines, and # starts a comment. Each key stands alone, so a typo in one does not reset the others.

Key Default (compiled) Meaning
portal 192.168.31.202 NVMe/TCP portal, and the host the claim goes to
port 4420 NVMe/TCP port
nqn nqn.2026-09.lo.g16:stormcos subsystem NQN if the claim fails or is off
nsid 2 namespace if the claim fails or is off
fallback on none (or no/off/false/0): no nqn/nsid is attached when no claim answers; the boot falls through to the local disk (#36). Every golden writes it; build-boot-agent.sh --no-fallback
api_port 9090 engine API port on the portal host
boot_port 9089 the engine's plain boot-only port, asked first; refused, silent or not an engine, the boot uses api_port (#183, stormblock#524). off asks api_port alone
claim yes no / false / 0 / off skips the claim
name (or tag) none (DNS name, then MAC, then SMBIOS) states the identity; the only name claimed
dns none DNS server for the PTR of the machine's own address, when its DHCP reply names none or cannot be read (#26); build-boot-agent.sh --dns
ntp none (DHCP option 42, then pool.ntp.org) NTP server for the hardware clock when the lease names none: host[:port], or off to leave the clock alone (#77); build-boot-agent.sh --ntp
syslog none (install-config.yaml's syslog:, then the group alone) a unicast collector the boot's log is sent to beside the stormcast group (#162): host[:port] (port 514 when bare), or off to send nothing at all. The goldens write stormcentral.g8.lo:5514; build-boot-agent.sh --syslog
local_when_bootable false true: auto and every doubt boot a local disk whose ESP carries BOOTX64.EFI instead of claiming (#3). Off until intents work on forge
nvme_depth 8 reads kept in flight on the attached image, 1–8 (#100); 1 is one command at a time, as before #100, for an A/B or a target that mishandles a queue. Read at start-up, with esp
ui panel log: no boot panel (#144); the screen scrolls the log as before
install_source forge a stormdist release medium attached (#198, Installing from a stormdist medium): forge installs from it only when the network boot gave no image and nothing local boots; local installs from it before the network; off never looks
local_boot stormbootx firmware: the fall-through returns to whatever started stormbootx instead of starting the local disk's BOOTX64.EFI itself (#145)
esp auto who reads the attached ESP (#37): auto (firmware, then stormbootx), firmware, or stormbootx (the bridge and its read-only filesystem, #42); build-boot-agent.sh --esp
rng firmware the first entropy source tried (#56): firmware, cpu (skip EFI_RNG) or jitter (skip both); build-boot-agent.sh --rng
nic_verbose false true: the stormnic drivers print their whole bring-up trace instead of one line per NIC (#80). stormbootx sets the volatile StormnicVerbose variable (GUID ce1479a2-eab9-4176-b0ad-c909ea5b8e0b, BOOTSERVICE_ACCESS, one byte 01) before it loads \stormboot\drivers; gone at the next reset, never in NVRAM. For debugging a machine without a rebuild; no golden sets it. build-boot-agent.sh --nic-verbose. From the EFI shell instead: setvar StormnicVerbose -guid ce1479a2-eab9-4176-b0ad-c909ea5b8e0b -bs =01
prefer_media_drivers none ignored since #202 (named on the console): every stormnic driver on the media takes the NICs it matches. Before, the families that took their NICs from the firmware (#108, #131, #70)
media none the media's label, printed under the banner (media : fw); written by the golden build, build-boot-agent.sh --media
update none (no self-update) http://host[:port]/path of this medium's release on stormcentral, or off to pin the medium (#83, Self-update); written by the golden build, build-boot-agent.sh --update
api 9180 the port stormbootx's own API answers on (#167), or off
command_key none an Ed25519 public key (64 hex digits) whose signed commands are taken, beside stormcentral's (#167); the laptop app writes its own into local.conf; build-boot-agent.sh --command-key
hold off how long a failed network boot stays up with its API (#167): seconds (120, 2m), forever or off; nothing local that boots holds until a command. The goldens write 120; build-boot-agent.sh --hold. Else install-config.yaml's stormbootx_hold:
handoff_watchdog 300 seconds the UEFI watchdog gives the attached image's bootloader to reach ExitBootServices (#219); off arms none. See The handoff's guard
hold_retry 0 minutes between boots again while holding; 0 never. Else stormbootx_hold_retry:
stamp none parsed, not used
version, commit none not read by stormbootx: the stormbootx on this medium, written by every build-boot-agent.sh run (#94) so a tool reading the FAT (storminstall, which refuses media older than v0.15.0's install-config slot) needs no EFI binary run. The version is Cargo.toml's and must be in the .efi laid down, or the build fails; the commit is the binary's STORMBOOTX_BUILD stamp, or unstamped. A self-update replaces the file with the release's, so they stay true

\stormboot\local.conf, when present, takes the same keys and is read first, so its values win. It belongs to the medium: a self-update never replaces it.

Installing from a stormdist medium (#198)

stormdist's full release ISO (stormdist#10) boots on its own: El Torito UEFI/BIOS entries and a hybrid GPT, both over /boot/esp.img, the release's own ESP (stormuefi, its kernel, the stormblock initramfs). Written to a USB stick it is the same bytes. stormbootx finds such a medium and starts that ESP's bootloader, with no forge and no network; the release's initramfs then lays the disk from the medium (stormblock#442). stormbootx lays nothing (#166: one layout writer). What it adds over the firmware booting the medium: it checks it, it reports the medium's read rate, and it reaches machines whose firmware would not boot the medium (a Dell 14G refuses a hybrid ISO from a stick, #185; an X9 falls to the EFI Shell, which starts stormbootx, #60).

Before the network, every present whole disk is looked at for an ISO 9660 volume with RELEASE.toml and BOOT.toml at its root (src/distmedia.rs). On one:

  1. BOOT.sig and RELEASE.sig (64-byte Ed25519) must verify against stormcentral's release key (distmedia::DIST_KEYS, served at /api/v1/release/keys; not the self-update's boothelper key) before either file is read, and RELEASE.toml's SHA-256 must be BOOT.toml's release_sha256:

    dist        : stormdist 12.74.6 (full) on PciRoot(0x0)/…/USB(0x1,0x0) (usb, 512-byte blocks): BOOT.sig and RELEASE.sig verify, RELEASE.toml matches BOOT.toml
    
  2. When it is used, boot/esp.img is read whole and timed, which is the medium's read rate (above 40 MiB/s a stick is on a USB 3.x port), with what the release's chunks (RELEASE.toml's bytes) would take at that rate:

    dist        : read boot/esp.img: 66 MiB in 1.40 s, 47 MiB/s (usb: a USB 3.x rate); the release's 3.2 GiB at this rate: ~70 s
    
  3. Its SHA-256 must be esp_sha256. BOOTX64.EFI is read out of those checked bytes and loaded from that buffer under the device path of the firmware's own filesystem on the medium's ESP, so that filesystem is its DeviceHandle, as on a direct firmware boot. The timing : line gives dist find, dist read, dist check and dist start.

When (install_source =): by default forge first. The medium is used only when the network boot gave no image (no NIC, no lease, no engine, no claim) and no local disk boots, so an installed node with a release stick left in boots its own disk. A local intent never installs. install_source = local installs from the medium before the network; off never looks. A medium that fails a check is named and refused, and the boot goes on as if it were not there. A diff or backup ISO (no BOOT.toml) is named and not used.

tests/dist-ovmf.sh makes a real medium with stormdist make (stormdist v0.5.0, scripts/build-stormdist.sh) signed by a test key compiled into its own stormbootx build (STORMBOOTX_DIST_TEST_KEY; build-golden.sh refuses it), and boots it as a CD and as a USB stick: before the network, after a failed network boot, an installed disk that wins, a tampered ESP, another key, and off.

Booting from a laptop (#164)

A machine can boot and install with no forge, from the owner's Mac or Windows laptop. The laptop app (storminstall#20) runs stormblock's portable target (stormblock-nvmeof, stormboot-serve, stormblock#474): one release image over NVMe/TCP, with a copy-on-write overlay for each claim, and the boothost API stormbootx already speaks (health, intent, claim, installed). This is for machines whose BMC virtual media can't carry the full ISO, such as the X9 blades. They boot this small medium instead. stormbootx needs nothing different for it. The medium tells it where to claim, and the laptop answers as forge does.

What the app makes. It starts from a media golden, unchanged: stormbootx-rustnic for the X9s (their NICs need its drivers) or stormbootx, as the ISO (boot/<golden>.iso, for virtual media) or the disk image (<golden>-disk, for a USB stick). The app adds two files to the ESP (the FAT that the ISO's 0xEF MBR entry names, or the image's one partition). It touches nothing else:

  • \stormboot\local.conf, read before the golden's stormboot.conf, so its keys win. A self-update never replaces it.

    portal   = 192.168.1.50          # the laptop's IPv4 address on the node's network
    api_port = 9090                  # stormboot-serve --http-port
    boot_port = off                  # stormboot-serve has one port; saves 15 s where a firewall drops 9089
    name     = server8               # the --host it serves; leave it out to claim by MAC (--host server8=MAC)
    syslog   = 192.168.1.50:5514     # the app's log collector (#162): the boot shows live
    update   = off                   # no stormcentral here; the medium keeps the app's version
    

    portal is an address, not a name. It is also where the claim goes. The NVMe/TCP address and port come back in the claim reply (stormboot-serve --advertise, by default the address the node's request reached). Leave ntp out (DHCP option 42, then pool.ntp.org), or set ntp = off on a network with no way out.

  • \stormboot\install-config.yaml, the node's install-config (#79), handed down to the initramfs.

What the node does. It gets its lease and reads the laptop's health, which is past universal boot's gate (21.2.1 from v21.4.0). Then it reads its intent: install claims, and local falls through to its own disk. The claim mints a fresh overlay under the node's own subsystem, which admits only nqn.2026-09.lo.storm:host-<name>. The node attaches it, reads with 8 commands in flight, and starts the image's BOOTX64.EFI. StormBootTag names the host the laptop answered, so the initramfs claims the same one. A name the laptop does not serve gets a 404, and with the golden's fallback = none the node falls through to its local disk.

tests/laptop-ovmf.sh (in the sc-build command) does exactly this under OVMF against the real stormboot-serve. scripts/build-stormboot-serve.sh builds it from stormblock at the pinned commit. The test adds both files to the stormbootx golden's ISO with mtools, then boots six times: by name; by MAC with no name on the medium; --intent local; a name the laptop does not serve; the laptop's command record (stormblock#494, pinned at v22.3.0): a signed hold for server7, PUT before a by-MAC boot with command_key = in local.conf, taken before the claim and reported done, then a poweroff PUT while it holds, read from the record and done; and a signed maint from the record, which the laptop (one image, no maintenance image) answers with server7's own image: not attached, it holds. Under KVM the reads ran at 69–127 MiB/s, 512 KiB per command (the target's MDTS 8). The served image is unchanged after the boots. Not run here: the app itself, macOS or Windows as the laptop, and metal.

stormbootx-stick: a node updates its own stick

A medium updates itself at boot (Self-update), but a stick whose updater is broken never can: no stick at v0.26 or older ever updated (#148), and those sticks still boot the node into stormcos. stormbootx-stick (#151, stormcos#589) is a static musl Linux binary the running node runs (a boot.d task, after the network and daily) to do what the stick's next boot would have done:

  1. find the stick: a removable or USB disk whose FAT ESP carries EFI/BOOT/BOOTX64.EFI and stormboot/ (mounted read-only to look). Never a disk that is mounted (the root's, a pallet's), that something holds (dm, md), or that carries a stormblock slab (STRMSLAB at its start or at a partition's). Two sticks: neither is chosen;
  2. read stormboot.conf (update =, version =), local.conf and state; update = off is left alone. A stick that names no update = (hand-built, or an exe swapped in by hand: server1's, #161) takes --update, else its family's boothelper under --boothelpers (default http://stormcentral.g8.lo/api/v1/boothelpers): stormbootx-rustnic for media = rustnic … or, with no media =, a stick carrying NIC drivers; stormbootx otherwise. A stick with no version = reads as 0.0.0, older than any release;
  3. ask <update>/current and current.sig, and verify the Ed25519 signature against the compiled-in release keys before the manifest is parsed;
  4. decide with the medium's own rules: never backwards, never a serial that failed its trial on this stick (or below StormBootMinSerial, read from efivarfs), never during a trial, canaries only for their MACs;
  5. apply exactly as the self-update does: files checked against the signed size and SHA-256, written as *.new and read back, the trial in state, then the *.prev swap with the bootloader last, put back if it fails; name =/tag = carried into local.conf. The stick's next boot runs the new set on trial and puts the old one back if it never attaches;
  6. report one line per step, exit 0 unless something failed, and /run/stormbootx-stick/status.json (outcome: updated, current, declined, off, no-stick, would-update, refused, error).
stormbootx-stick [--dry-run] [--status FILE] [--update URL] [--mac MAC]
                 [--boothelpers URL] [--esp DIR] [--sys DIR] [--proc DIR] [--dev DIR] [--efivars DIR] [--mnt DIR]

--update is used only when the stick names no update =. --esp updates an ESP already mounted at DIR. The roots are for tests. The manifest, the rules and the keys are src/manifest.rs and src/sha256.rs, included by path: one implementation, the medium's. Built by deploy/build-golden.sh stormbootx-stick (golden stormbootx-stick: bin/stormbootx-stick, and bin/stormbootx-stick-arm64 for an arm64 node's stick, #172: it looks for BOOTAA64.EFI and, with no update =, takes the stormbootx-arm64 boothelper), and tested by tests/stick.sh (a test key, signed releases from a local HTTP stub, a directory as the ESP, and a machine of image files for the refusals). A stick too small to take the new set beside the old is refused for room; writing the signed raw disk image instead needs stormcentral to serve one (#154).

tcp4probe

A second binary, src/tcp4probe.rs. It reports which of the firmware's network-stack protocols are present layer by layer, runs a ConnectController pass when TCP4 is missing, and then creates and configures a TCP4 child. Since #56 stormbootx needs only the SNP line of that report. The rest describes the firmware, not a requirement. src/tcp4.rs and src/dhcp4.rs (the firmware TCP4 client stormbootx used until 0.9) are compiled only into it now. When a loader before it set them, it prints the StormBootTag, StormBootHostNqn, StormBootClock and StormBootUpdate variables and their attributes (handed down : …, #76, #77, #83), reassembles StormBootInstallConfig and checks its length and SHA-256 (install cfg : …, #79), then what the RTC reads (rtc : …); tests/net-ovmf.sh starts it as the attached image's BOOTX64.EFI and checks them. A stick that boots it is made with --probe (see Getting it onto a stick).

espprobe

A third binary, src/espprobe.rs (#37). It connects every controller, then for each whole disk except the one it booted from whose GPT has an ESP, it reports whether the firmware's FAT loads \EFI\BOOT\BOOTX64.EFI, reads the file with esp.rs, and starts it from the buffer with espboot.rs, the same code stormbootx falls back to, under the bridge's own read-only filesystem (espfs.rs, #42), which must be the started image's DeviceHandle. Then it takes the filesystem back and powers off. tests/esp-ovmf.sh boots it under OVMF against a 4096-byte virtio disk carrying a 4096-byte-sector FAT16 ESP. The payload is tcp4probe, whose boot fs : lines do what shim or systemd-boot would: list \EFI\BOOT, read and seek BOOTX64.EFI (its sha256 must match), read a loader entry by a relative path, and be refused a write. tcp4probe prints those lines on every boot, so on metal they show what a bootloader there would find on its own volume.

Build

Never on a workstation, and never as root. Push, then run sc-build from the checkout. It fetches the pushed commit onto a fresh build VM (cloned for the job, as the unprivileged stormbuild user), builds it there and destroys the VM when the job ends. Nothing persists between jobs. The default cargo build && cargo test does not suit a no_std UEFI crate, so name the command:

sc-build 'cargo build --release --target x86_64-unknown-uefi && mkdir -p t && \
  rustc --edition 2021 --test src/intent.rs -o t/intent-test && ./t/intent-test && \
  rustc --edition 2021 --test src/sha256.rs -o t/sha256-test && ./t/sha256-test && \
  rustc --edition 2021 --test src/universal.rs -o t/universal-test && ./t/universal-test && \
  rustc --edition 2021 --test src/dnsname.rs -o t/dnsname-test && ./t/dnsname-test && \
  rustc --edition 2021 --test src/esp.rs -o t/esp-test && ./t/esp-test && \
  rustc --edition 2021 --test src/sntp.rs -o t/sntp-test && ./t/sntp-test && \
  rustc --edition 2021 --test src/manifest.rs -o t/manifest-test && ./t/manifest-test && \
  rustc --edition 2021 --test src/installconf.rs -o t/installconf-test && ./t/installconf-test && \
  rustc --edition 2021 --test src/inventory.rs -o t/inventory-test && ./t/inventory-test && \
  rustc --edition 2021 --test src/pcibus.rs -o t/pcibus-test && ./t/pcibus-test && \
  rustc --edition 2021 --test src/logwire.rs -o t/logwire-test && ./t/logwire-test && \
  rustc --edition 2021 --test src/command.rs -o t/command-test && ./t/command-test && \
  cargo test -p nvme-tcp-initiator && \
  R=${CARGO_TARGET_DIR:-target}/x86_64-unknown-uefi/release && \
  tests/esp-ovmf.sh $R/espprobe.efi $R/tcp4probe.efi && \
  STORMNIC_DRIVERS="virtio mlx5 e1000e igb" scripts/build-nic-drivers.sh $PWD/t/nd && \
  VIRTIO_EFI=t/nd/stormnic-virtio.efi MLX5_EFI=t/nd/stormnic-mlx5.efi E1000E_EFI=t/nd/stormnic-e1000e.efi IGB_EFI=t/nd/stormnic-igb.efi tests/net-ovmf.sh $R/stormbootx.efi $R/tcp4probe.efi && \
  scripts/build-boot-agent.sh --iso --binary $R/stormbootx.efi --media shelltest --output $PWD/t/s.iso && \
  LAYOUT=cd-last tests/shell-ovmf.sh t/s.iso old "media       : shelltest" && \
  LAYOUT=cd-last tests/shell-ovmf.sh t/s.iso ovmf "media       : shelltest" && \
  LOCAL_EFI=$R/tcp4probe.efi LIMIT=120 tests/shell-ovmf.sh t/s.iso old "from the local disk" "handing over to the local disk" "EFI\BOOT lists" && \
  SECOND_CD=1 SCREEN_PNG=1 LIMIT=180 tests/shell-ovmf.sh t/s.iso old "returning to what started it" && \
  SECOND_CD=1 LIMIT=180 tests/shell-ovmf.sh t/s.iso ovmf "returning to what started it" && \
  tests/screen-ovmf.sh t/s.iso && \
  tests/stick.sh && deploy/build-golden.sh stormbootx-stick $PWD/t/stick && \
  STORMBOOTX_FW_MEDIA=1 deploy/build-golden.sh stormbootx $PWD/t/fw && \
  tests/usb-layout.sh t/fw/boot/stormbootx-usb.img t/fw/media && ! tests/usb-layout.sh t/fw/boot/stormbootx.img && \
  NIC=virtio tests/media-ovmf.sh t/fw/boot/stormbootx-usb.img "media       : fw" "fallback    : none" && \
  deploy/build-golden.sh stormbootx-rustnic $PWD/t/rn && tests/usb-layout.sh t/rn/boot/stormbootx-rustnic-usb.img t/rn/media && \
  NIC=virtio tests/media-ovmf.sh t/rn/boot/stormbootx-rustnic-usb.img "media       : rustnic" "; stormnic-virtio.efi drives it" && \
  NIC=e1000e tests/media-ovmf.sh t/rn/boot/stormbootx-rustnic-usb.img "e1000e@bd67192 igb@6990341" "stormnic-e1000e 0.2.0: " "nic 0: driver stormnic-e1000e.efi" && \
  NIC=igb tests/media-ovmf.sh t/rn/boot/stormbootx-rustnic-usb.img "igb@6990341" "stormnic-igb 0.2.0: " "nic 0: driver stormnic-igb.efi" && \
  deploy/build-golden.sh stormbootx-mlx5test $PWD/t/mlx5 && tests/iso-layout.sh t/mlx5/boot/stormbootx-mlx5test.iso && \
  tests/usb-layout.sh t/mlx5/boot/stormbootx-mlx5test-usb.img t/mlx5/media && \
  NIC=virtio tests/media-ovmf.sh t/mlx5/boot/stormbootx-mlx5test.iso "mlx5@d11b572 test" "stormnic drivers verbose" "stormnic-mlx5.efi started" "; stormnic-virtio.efi drives it" "ours      : no NIC here for stormnic-mlx5.efi" && \
  STORMBOOTX_FW_MEDIA=1 REPRO_MEDIA="stormbootx stormbootx-rustnic stormbootx-disk stormbootx-rustnic-disk stormbootx-mlx5test" tests/repro.sh && \
  deploy/build-golden.sh stormbootx-arm64 $PWD/t/arm && \
  EFI_NAME=BOOTAA64.EFI tests/iso-layout.sh t/arm/boot/stormbootx-arm64.iso && \
  EFI_NAME=BOOTAA64.EFI tests/usb-layout.sh t/arm/boot/stormbootx-arm64-usb.img t/arm/media && \
  deploy/build-golden.sh stormbootx-arm64-disk $PWD/t/armdisk && \
  tests/update-ovmf.sh $R/tcp4probe.efi'

That builds stormbootx.efi, tcp4probe.efi and espprobe.efi, and runs the ten host test suites and the initiator crate's. Then it boots espprobe under OVMF (tests/esp-ovmf.sh), and stormbootx itself against the stub engine and NVMe/TCP target (tests/net-ovmf.sh, with stormnic-virtio built in the job for its take-over boots). Last, it builds an ISO and boots its startup.nsh from an EFI Shell (tests/shell-ovmf.sh, #60), once with the old EDK shell and once with OVMF's own. tests/update-ovmf.sh (#83) runs last, because it rebuilds stormbootx.efi with a test key: ten self-update boots off a writable USB stick (Self-update). tests/repro.sh (#53) builds stormbootx.efi twice, from two copies of the commit at different paths with two CARGO_HOMEs, and requires the same bytes. Two things made one commit give two digests in two goldens: lld-link wrote the link time into the PE TimeDateStamp and a PDB's CodeView GUID into a debug record (build.rs links with /Brepro and /DEBUG:NONE, for every build), and panic locations carry the uefi crate's source path under CARGO_HOME. scripts/cargo-repro.sh is cargo with --remap-path-prefix for the checkout, the target dir and CARGO_HOME; deploy/build-golden.sh and build-boot-agent.sh build through it, so a golden's stormbootx.efi is the same wherever its drive mounts. A plain cargo build still carries the path. REPRO_RAW=1 tests/repro.sh shows it.

With REPRO_MEDIA (#130) it then builds each named media golden twice, with deploy/build-golden.sh in two clones at different paths, and requires every file of the two trees (the .iso/.img, media/, BUILD, SHA256SUMS) to be the same bytes; the second build runs with umask 077, TZ=Asia/Tokyo and, through unshare -r, another uid, as another machine's would. build-boot-agent.sh dates everything SOURCE_DATE_EPOCH (the commit's time unless set) and derives every identifier a tool would make up from the medium's contents: the FAT volume ID (mkfs.fat -i, --invariant), the GPT disk and partition GUIDs (label-id/uuid), the file times (staged, mcopy -m); xorriso takes its dates, UUID and hybrid GPT from SOURCE_DATE_EPOCH, and writes Rock Ridge with owner 0 (-r); TZ=UTC for both. build-nic-drivers.sh builds the stormnic drivers with the paths remapped and /Brepro /DEBUG:NONE. So a rebuild of a commit is its golden's bytes.

The binary has no host target, so a plain cargo test does not apply; cargo test -p nvme-tcp-initiator runs the one host-built crate (#10). src/sha256.rs, src/intent.rs, src/universal.rs, src/dnsname.rs, src/esp.rs, src/sntp.rs, src/manifest.rs, src/installconf.rs, src/inventory.rs and src/pcibus.rs are the exceptions: each uses only core and names no crate:: item, so each compiles as its own crate with rustc --test. esp.rs's tests build their images with mkfs.fat and mtools, which must be on the PATH. --edition 2021 is required, because bare rustc defaults to 2015, where core is not in scope.

Cargo.lock is tracked, and uefi is pinned at 0.39 / uefi-raw at 0.15, ed25519-compact at 2.6.0 (verify only, no default features, #83). Bump them deliberately, in their own commit.

UEFI HTTP boot (#172)

A machine whose firmware's PXE is broken (the Radxa Orion O6N's UEFI v1.2.1 leases and never sends TFTP) or that has no BMC for virtual media boots the medium's ISO over UEFI HTTP boot. Serve the golden's boot/<golden>.iso (stormbootx-arm64 on arm64) with Content-Type: application/vnd.efi-iso (or at least a .iso URL), and point the firmware at it: a boot option with the URI (the firmware's HTTP boot setup page), or a DHCP server answering the HTTPClient class with the URL. EDK2 downloads the ISO into a RAM disk and starts its BOOTX64.EFI / BOOTAA64.EFI, so stormboot.conf, the drivers and install-config.yaml come with it as on any medium. A bare .efi would come with none, so the ISO, not the binary, is what is served. stormbootx says media : a RAM disk (UEFI HTTP boot) and takes the NIC from the firmware's stack as it always does; the self-update leaves the RAM disk alone (the served ISO is what updates). tests/net-ovmf.sh's httpboot boot is this under OVMF, with the URI boot option written into the variable store by tests/bootvar.py. Its httpimg boot does the same with the FAT32 stick image (#185) as application/vnd.efi-img (or a .img URL), for firmware that refuses the ISO. That has run under OVMF only, not on the R6415's HTTP boot.

Getting it onto a stick

Packaging only; nothing here runs at boot. What ships is a golden (see How it ships below); these scripts are what the golden build runs, and are also usable by hand in a build. scripts/build-boot-agent.sh puts the built .efi and a stormboot.conf onto boot media: a GPT .img to dd onto a USB stick, or with --iso an El Torito .iso for BMC virtual media. The ISO is isohybrid the way Debian's netinst is (an MBR 0xef partition and a GPT entry over /esp.img), and the ESP is at mkfs.fat's own geometry (4 MiB: FAT12, 2 KiB clusters): AMI Aptio 4 hung at POST A2 on the old pure El Torito FAT16 ESP (#55); tests/iso-layout.sh ISO… checks both.

Dell 14G: use the stick image, not the ISO (#185). A PowerEdge R6415 (BIOS 1.12.2) refuses the hybrid ISO written raw to a USB stick and over UEFI HTTP boot (Boot Failed, nothing from stormbootx on the console), and starts the same binary from a GPT stick with a FAT32 ESP. Every ISO golden carries that stick as boot/<golden>-usb.img: a protective MBR, a GPT and one 64 MiB FAT32 ESP at LBA 2048 with the same files as the ISO (--size 64 --fat 32; tests/usb-layout.sh IMG [TREE] checks it). dd it whole onto a stick; it updates itself as any stick does.

Every medium carries \startup.nsh (media/startup.nsh, #60) at the root of its ESP, and of the ISO9660 tree. A machine with no boot option for the media (an X9 blade that has never booted the virtual CD in UEFI) drops to the firmware's EFI Shell, which runs it. It looks through fs0..fs7 for the volume carrying both \EFI\BOOT\BOOTX64.EFI and \stormboot\stormboot.conf, and starts that BOOTX64.EFI. The second file is the mark: a local disk's ESP has a BOOTX64.EFI too (stormuefi on an installed disk, a stale Windows), never a stormboot.conf. It starts one medium only (#180): once a stormbootx has returned, the script does not go on to the next fsN (server7's stick returned, then a BMC's v0.10.0 CD started). The script uses only what the EDK shell 2.31 (EFI 1.10 mode, what AMI Aptio 4 carries) and Shell 2.x both take. tests/shell-ovmf.sh ISO old|ovmf 'LINE' … boots an ISO this way under OVMF, with a decoy ESP mapped before the CD. old is EdkShellBinPkg's Shell_Full.efi from edk2-stable201811, fetched and checked against its pinned SHA-256.

Output defaults to tmp/images in the checkout, which in an sc-build is the job's own drive and is deleted with it. Nothing is kept.

./scripts/build-boot-agent.sh                    # one stick for every machine
./scripts/build-boot-agent.sh --iso              # same, as an ISO
./scripts/build-boot-agent.sh --iso --no-fallback  # as the goldens: no fallback namespace (#36)
./scripts/build-boot-agent.sh --pin --portal 192.168.31.202 \
    --nqn nqn.2026-09.lo.g16:stormcos --nsid 2   # one fixed namespace, no claim
./scripts/build-boot-agent.sh --probe            # boots tcp4probe instead

# NIC drivers for firmware without them (#26): the Rust stormnic drivers
./scripts/build-nic-drivers.sh tmp/drivers
./scripts/build-boot-agent.sh --iso --drivers tmp/drivers

build-nic-drivers.sh builds the Rust NIC drivers, each --locked from a pinned commit of its own repo: stormnic-ixgbe.efi (Intel 82599/X540/X552, STORMNIC_IXGBE_REF), stormnic-mlx4.efi (ConnectX-3, STORMNIC_MLX4_REF) and stormnic-virtio.efi (virtio-net, VMs only, STORMNIC_VIRTIO_REF, #108) and stormnic-e1000e.efi (Intel 82574L, 82579, I217/I218/I219, STORMNIC_E1000E_REF, #70) and stormnic-igb.efi (Intel 82575/82576/82580, I350/I354, I210/I211, STORMNIC_IGB_REF, #69), beside a STORMNIC-SOURCE.txt naming each commit and digest; stormnic-e1000e's src/ids.rs must list exactly the IDs of drivers.rs's e1000e family, or the build fails. stormnic-mlx5.efi (ConnectX-4/5/6, STORMNIC_MLX5_REF, #131) is in the default set since #73, once it passed on C2NR0Q2 and forge1 (stormnic-mlx5#1). STORMNIC_DRIVERS (default ixgbe mlx4 virtio e1000e igb mlx5) picks which; STORMNIC_DRIVERS=virtio builds one. It checks each is a PE boot-service driver (subsystem 11). There is no iPXE in it, on any medium or in any golden (owner on #81: "I dont want the ipxe code. Move to ours."; #91, #52). The iPXE intelx driver that first got the X9 blades onto the network (#26) is in the history.

--help lists the rest: --drivers, --dns, --ntp, --media, --rng, --esp, --nic-verbose, --update, --tree, --engine (the engine host without pinning), --api-port, --port, --nqn, --nsid, --size (ESP MiB, default 4), --binary, --output.

Ports, health and shipping

stormbootx is a UEFI application, so it has no daemon and no metrics. It listens on one port while it runs, its own API (#167, The API, commands and the hold): TCP 9180 on every leased NIC, api = to move or turn it off; GET /api/v1/status is its health. Everything else it reports goes to the console and the log. Its outbound connections:

To Default Set by
engine, boot verbs (health, intent read, claim) <portal>:9089, HTTP/1.1; else <portal>:9090 when that gives no health answer within 15 s of a lease (#183) boot_port, api_port
DNS server (PTR of its own address, #23) option 6 of its DHCP lease, TCP 53 DHCP, else dns
DNS server (A record of the NTP server, when it is named, #77) the same server, UDP 53 DHCP, else dns
NTP server (one SNTP exchange, #77) DHCP option 42, else pool.ntp.org, UDP 123 DHCP, else ntp
NVMe/TCP portal (attach) <portal>:4420, or what the claim returns port, or the claim reply
stormcentral (the self-update's manifest, signature and files, #83) the update = URL, HTTP/1.1; its host looked up over UDP 53 update
the stormcast group (the boot's log, #162) 239.255.42.1:5514, UDP, TTL 4, on every NIC with a lease syslog = off stops it
the engine's host record (a command, #167, stormblock#494) the port health answered on, GET …/command, POST …/command/done boot_port, api_port
a syslog collector (the boot's log, #162) none; the goldens name stormcentral.g8.lo:5514, UDP, its host looked up over UDP 53 syslog, else install-config.yaml's syslog:

The portal defaults to 192.168.31.202 (forge). Since stormblock v17.0.0 the engine API requires a token for everything except POST …/boothost/<tag>/claim and GET /api/v1/health. stormbootx sends none, because firmware has nowhere to keep one. The intent read (stormblock#148) is open in the same way; setting an intent needs the token.

How it ships: as goldens (owner, 2026-09-28, #21: everything is a golden). Nothing is kept on the build box. A machine's virtual CD is to be served from the golden (minismbd#7, open), not from a copied file. deploy/build-golden.sh <golden> OUT writes one golden's tree into OUT and does nothing else, for stormcentral to run into the volume it mounts. Our drivers only (#202): the fw goldens (stormbootx, stormbootx-disk, no NIC drivers) are retired and build only with STORMBOOTX_FW_MEDIA=1; the arm64 goldens carry no driver of ours yet (no aarch64 stormnic driver is pinned) and stay as they are:

golden tree
stormbootx (retired by #202; STORMBOOTX_FW_MEDIA=1) bin/stormbootx.efi, bin/tcp4probe.efi, boot/stormbootx.iso (BMC virtual media), boot/stormbootx.img (USB), boot/stormbootx-usb.img (USB, FAT32, #185), boot/tcp4probe.iso, media/, media.files, SHA256SUMS, BUILD
stormbootx-rustnic bin/stormbootx.efi, boot/stormbootx-rustnic.iso (BMC virtual media), media/, media.files, SHA256SUMS, BUILD
stormbootx-disk (retired by #202; STORMBOOTX_FW_MEDIA=1) boot/stormbootx-disk.img (USB stick, the stormbootx medium), SHA256SUMS, BUILD
stormbootx-rustnic-disk boot/stormbootx-rustnic-disk.img (USB stick, the stormbootx-rustnic medium), SHA256SUMS, BUILD
stormbootx-mlx5test a test medium, never promoted or released (#131): boot/stormbootx-mlx5test.iso, the rustnic medium plus stormnic-mlx5.efi, nic_verbose, no update =; bin/stormbootx.efi, media/, media.files, SHA256SUMS, BUILD
stormbootx-arm64 bin/stormbootx.efi (aarch64), boot/stormbootx-arm64.iso (\EFI\BOOT\BOOTAA64.EFI, the fw medium for arm64 hosts), media/, media.files, SHA256SUMS, BUILD (#84; registered as a media component). The ISO is also what UEFI HTTP boot serves (#172).
stormbootx-arm64-disk boot/stormbootx-arm64-disk.img (USB stick, the stormbootx-arm64 medium: 64 MiB GPT ESP, BOOTAA64.EFI), SHA256SUMS, BUILD (#172; registered as a media component)
stormbootx-stick bin/stormbootx-stick (x86_64), bin/stormbootx-stick-arm64 (aarch64, #172), static musl, SHA256SUMS, BUILD
nic-drivers bin/stormnic-ixgbe.efi, bin/stormnic-mlx4.efi, bin/stormnic-virtio.efi, bin/stormnic-e1000e.efi, bin/stormnic-igb.efi, bin/stormnic-mlx5.efi (#73), STORMNIC-SOURCE.txt, SHA256SUMS, BUILD (an EFI boothelper; no medium takes it as an input since #52, and it holds no iPXE since #91)

media/ is the medium's files as a tree (EFI/BOOT/BOOTX64.EFI, stormboot/stormboot.conf, stormboot/drivers/*, startup.nsh), and media.files one <sha256> <size> <path> line per file: what stormcentral signs and serves when it promotes the golden, for media to update themselves from (#83, stormcentral#279). Each medium's stormboot.conf carries update = http://stormcentral.g8.lo/api/v1/boothelpers/<golden>.

arm64 is its own boot helper (#84, owner 2026-10-09). The agent compiles for aarch64-unknown-uefi: its one cycle counter is entropy::ticks() (TSC, or CNTVCT_EL0), calibrated against Stall, and its bootloader path is the architecture's removable-media path, so an arm64 medium carries BOOTAA64.EFI, chain-loads the attached image's BOOTAA64.EFI and takes only a self-update manifest that names one. stormbootx-arm64 is the firmware-drivers medium (media : fw arm64): the stormnic drivers are x86 builds, so there is no arm64 rustnic. It is a separate golden so an arm64 failure never holds up the x86 ones; storminstall picks it by its manifest row's "arch": "arm64". build-boot-agent.sh --arch arm64 lays any arm64 medium down. Until stormcentral#604 the build template has no aarch64 std (the build adds it with rustup), no qemu-system-aarch64 and no AAVMF, so the arm64 binary is compiled and its medium checked (EFI_NAME=BOOTAA64.EFI tests/iso-layout.sh), but not yet booted.

The USB sticks are their own goldens (#41, owner on #35; #97), at FAT16, which the blades boot; the FAT32 stick a Dell 14G needs is boot/<golden>-usb.img inside each ISO golden (#185), which stormcentral does not keep yet (it takes only boot/<comp>.iso and media/ from a media golden). stormbootx-disk and stormbootx-rustnic-disk each hold one image, a GPT disk with a 64 MiB FAT16 ESP at 512-byte sectors carrying the same files as the family's ISO. dd it whole onto a stick. Its update = names the family's boothelper (stormbootx or stormbootx-rustnic), not its own, so a stick takes the same signed promotion as the ISO. The ESP is 64 MiB rather than the ISO's 4 because a stick is writable and updates itself, writing the new set beside the old. The sc-build check boots each image as a USB stick under OVMF (tests/media-ovmf.sh IMG …). Registering them with stormcentral is stormcentral#190.

Two media, side by side (#45, #52). stormbootx is the firmware-drivers medium: no \stormboot\drivers at all, so every NIC is the firmware's own UEFI driver's (the Dell R230), and the console says drivers : none on the media; the firmware's own NIC drivers. Since #52 it carries no iPXE (owner on #81: "I dont want the ipxe code ... we can keep the code to use built in nic firmware"); a machine whose firmware has no driver for its NICs (the X9 blades) boots stormbootx-rustnic, the same agent from the same commit with the Rust drivers: stormnic-ixgbe.efi built --locked from STORMNIC_IXGBE_REF in scripts/build-nic-drivers.sh, and no iPXE NIC driver, so the Rust driver is what a machine booting it tests. Since #51 (stormnic-ixgbe 0dd4267) it installs EFI_SIMPLE_NETWORK_PROTOCOL on a child handle after its bring-up and DMA check, so the network stack can bind to the Intel 10G. Since #63 (stormnic-ixgbe 8ea722a) its Start enables memory decode and bus mastering on AMI Aptio 4, which refuses the PCI I/O attribute calls (stormnic-ixgbe#19). Since #65 (stormnic-ixgbe 563ea8d) a reset whose EEMNGCTL.CFG_DONE never sets is logged and Start continues, as on server3's 82599 (stormnic-ixgbe#21). Since #101 (stormnic-ixgbe 728b328) its console is one line per bound NIC (MAC, link speed, or link down after N ms with LINKS/AUTOC/AUTOC2/ESDP), plus warnings, and the full bring-up trace only when the volatile EFI variable StormnicVerbose is set (stormnic-ixgbe#22 and #26); a linked boot is about 3 s faster (stormnic-ixgbe#24). Since #192 (stormnic-ixgbe 4b4813d) its SNP carries 9000-byte frames (stormnic-ixgbe#33), so a lease offering DHCP option 26 runs the Intel 10G at 9000 (#165). Since #34 it also carries stormnic-mlx4.efi for the ConnectX-3, built --locked from STORMNIC_MLX4_REF. Since #50 (stormnic-mlx4 v0.2.0) its Start brings up the ConnectX-3, keeps it, and installs EFI_SIMPLE_NETWORK_PROTOCOL on a child handle per Ethernet port (about 1.5 s per NIC plus up to 5 s for link), so the network stack can bind above it. Since #64 (stormnic-mlx4 v0.2.1) it finds the UAR's PCI I/O BAR index through GetBarAttributes, because AMI Aptio 4 numbers BARs rather than BAR registers (stormnic-mlx4#15). Since #66 (v0.2.3) it prints each port's speed, autonegotiation and module at the link wait and on every link change, and the first own frame the adapter loops back (stormnic-mlx4#21). Since #104 (v0.2.5) it prints each port's module EEPROM, PTYS link modes and forced-speed support after the link wait and at every link-down (stormnic-mlx4#17), and its console is one line per port plus warnings, with the full trace only when StormnicVerbose is set (stormnic-mlx4#16). Each Rust driver is checked to be PE subsystem 11 (EFI boot-service driver) when it is built. Since #108 it also carries stormnic-virtio.efi (stormnic-virtio 1a04808, #75) for VMs. Every OVMF's VirtioNetDxe binds a virtio-net NIC before the media drivers load, and a stormnic driver declines a NIC another driver holds, so stormbootx takes it (since #202 for every stormnic driver, unasked: "we only use our drivers going forward"). It disconnects the firmware's drivers from each virtio-net function (leaf first, up to three passes, judged by who still holds its PCI I/O) and connects it naming only stormnic-virtio. If that driver does not then hold it, the firmware's driver gets it back. The console says which:

    [  0 s] 0000:00:02.0 1af4:1000: taken from Virtio PCI Driver; stormnic-virtio.efi drives it

The fw medium carries no drivers at all, so it takes nothing.

The same take-over gives a ConnectX-4/4 Lx/5/6 (15b3:1013/1015/1017/1019/101b/101d/101f, what stormnic-mlx5 claims) held by its firmware's FlexBoot driver to stormnic-mlx5, which every rustnic medium carries since #202 (the owner, 2026-11-14: "stormbootx cannot be reliable without a proper driver"); stormbootx-mlx5test is the same medium, verbose and with no update =. The nic-drivers golden refuses it. The ixgbe, mlx4, igb and e1000e families take theirs the same way. Each family's ids are vendor:device only, never the subsystem, as Linux matches, and build-nic-drivers.sh fails when one differs from its driver's list (docs/pci-ids.md compares them with the kernel's). A medium from before #202 says prefer_media_drivers; it is named (ours : prefer_media_drivers is ignored since #202) and ignored. The driver brings the card up, speed and FEC, by its own rule (stormnic-mlx5#8). Each take is announced before the disconnect, so a Stop or Start that never returns has named itself, and the console says what the firmware's driver left:

    [  3 s] 0000:00:02.0 1af4:1000: taking it from Virtio PCI Driver for stormnic-virtio.efi
              after the disconnect: PCI I/O open by driver handle 0x3f7feb18 (attributes 0x1), PCI I/O open by Sata Controller Init Driver (attributes 0x2)
    [  3 s] 0000:00:02.0 1af4:1000: taken from Virtio PCI Driver; stormnic-virtio.efi drives it

(That is OVMF with virtio: no ConnectX has run it yet. Opens at attributes 0x1 and 0x2 are lookups and hold nothing; a BY_DRIVER open is 0x10, and an SNP on the handle would make stormnic-mlx5 decline.) A driver that declines gives the NIC back; if the firmware's driver does not take it again the line says this NIC has no driver, and the boot goes on over any other.

For e1000e (#70) that is an Intel 82574L or chipset GbE (the 67 8086: IDs stormnic-e1000e binds), taken from Intel's UNDI driver or a NIC's iPXE ROM. The driver has run only on QEMU's 82574L and the PCH parts in simulation; if it does not bind a real part, the firmware's driver gets the NIC back.

The rustnic media builds its own drivers and takes no nic-drivers golden. Each ISO's stormboot.conf names the variant, and the console prints it under the banner:

media       : fw
media       : rustnic ixgbe@02fcc5d mlx4@398a1a4 virtio@2dcafd3 e1000e@bd67192 igb@6990341

The fw medium's BUILD says drivers = none, and a --drivers given to build-golden.sh stormbootx is ignored. Its stormboot.conf names forge (192.168.31.202) and dns = 192.168.31.252; which image a machine boots is its boothost on the engine, so one golden boots every machine. stormbootx is a stormcentral component of kind media, and so is stormbootx-rustnic: stormcentral component build <name> builds each into a drive golden whose bytes are its boot/<name>.iso. nic-drivers is a component of kind tree. The two -disk goldens are not registered yet (stormcentral#190).

Firmware requirements

  • A UEFI driver for the NIC (EFI_SIMPLE_NETWORK), the firmware's or one on the media (#26), and nothing above it. The firmware's MNP/IP4/DHCP4/TCP4 are not used (#56). A firmware driver is usually loaded only when the NIC is in UEFI/PXE mode in setup (Dell: Integrated NIC Enabled with PXE; GlobalSlotDriverDisable off, or add-in cards have no UEFI driver). The Supermicro X9 blades' NICs carry legacy option ROMs only, so they boot stormbootx-rustnic, which brings its own.
  • Any OVMF will do for emulation, Fedora's included (tests/net-ovmf.sh).

In the code, not active

The older sbregistry /v1/clones/claim path is no longer here (#17): it was compiled out from the day the boothost claim replaced it, and it spoke plain HTTP with no credential to a node registry that is moving to TLS with auth (stormcos#355). It is in the history before #17.

  • config::render / config::write_file and the stamp key: the first self-update plan (#2), superseded by #83, which writes through its own selfupdate.rs.
  • The FEC self-heal (automatic write on "all ports down") was switched off in 0.3.6. It was triggered by a single link sample. The reasoning is in its commit. The FEC pin that followed (#186, #203) was removed by #202: FEC is the NIC driver's.

Status

v0.41.0 (#198: a stormdist release medium on a CD or USB stick installs offline, with its read rate; the rustnic media carry stormnic-igb and stormnic-mlx4 v0.2.8). v0.40.0 (#70: the rustnic media carry stormnic-e1000e, prefer_media_drivers = e1000e). v0.39.2 (#192: the rustnic media carry stormnic-ixgbe 4b4813d, jumbo frames). v0.39.1 (#189: a clean local with a disk that boots does not wait at press c). v0.39.0 (#186: fec_pin = rs, the FEC pin at boot; v0.38.1 the ConnectX scan per root bridge; v0.38.0 #185 -usb.img sticks; v0.37.0 #131 prefer_media_drivers = mlx5; v0.36.0 #183 the engine's boot port; v0.35.x #172/#180 HTTP boot, arm64 stick, one-medium startup.nsh; v0.33–0.34 #167 the API and maint; v0.29.0 #151: stormbootx-stick; v0.28.x: the boot panel #144 and reproducible media #130; v0.27.0 #148 the self-update's lease wait; v0.26.0 #145 the fall-through starts the local disk; v0.25.0 #138's FEC-step budget). The release freeze of 2026-10-14..17 is over. Running on hardware since 2026-09-05. A Dell PowerEdge R230 (C2NR0Q2) booted the ISO over iDRAC virtual media, claimed boothost/C2NR0Q2 and attached a 32 GiB 4K clone from forge over 25 GbE. The console of that first attach, verbatim (the build before chain-loading, ea26be1):

service tag : C2NR0Q2
tcp4        : available
claim       : boothost/C2NR0Q2 at 192.168.31.202:9090
    interface 0 answered (MTU 1500, link up)
  claimed a clone of this machine's image
  portal    : 192.168.31.202:4420  nsid 7
  namespace : 8388608 blocks x 4096 bytes  (32 GiB)
  transfer  : 128 KiB per command  (controller MDTS 5; path MTU 1500)
blockio     : published on handle 0x8301ae98
RESULT: remote image is a local disk. Firmware can boot it.

The same day, with chain-loading (dc44c71), it started stormuefi, which booted stormcos. Today's build ends a good boot with RESULT: image attached; starting its bootloader.

On 2026-09-28 a Supermicro X9 blade (server1), whose firmware has no UEFI driver for its NICs, loaded ipxe-intelx.efi from the media, got tcp4 : available and reached the engine. Booted again from the golden (golden-stormbootx-74a242a6f0e89f75, a709f9f), it named itself server1 from the PTR, claimed boothost/server1 and attached its clone over NVMe/TCP (#26, closed). The release disk's BOOTX64.EFI then read as NOT_FOUND on that firmware, a 4096-byte-block namespace (#33). Volumes stay 4K (owner, 2026-09-29), so stormbootx now reads the ESP itself when the firmware can't (#37). On 2026-09-30 server1 booted release 11.56 from a 4096-byte namespace (v0.7.0 media) once that release's ESP was FAT16 (stormcos#188), which closed #33. On the X9 blades the firmware's own FAT loads BOOTX64.EFI from that FAT16 4K ESP (the master's reading of server1's SOL log on #37: every boot boot : starting \EFI\BOOT\BOOTX64.EFI from the attached image, none read by stormbootx), so esp.rs is the fallback there, not the path. The kernel console on the X9's SOL (COM2, ttyS1) is not stormbootx's: the release names console=ttyS1,115200 too (stormcos#220), and stormuefi ≥ 0.9.0 puts the port ACPI SPCR names last (stormuefi#23). The same night server3, also an X9, booted on smoltcp (v0.9.0): tcp4 : smoltcp over SNP, rng : rdrand, a lease, the claim, the attach, stormuefi and a running stormcos (#56, #68).

Open issues:

Issue What
#92, #89 self-update on metal: stormcentral serves stormbootx-rustnic serial 1 = v0.14.0, which every newer medium declines as older (#96); the promotion of v0.29.0's rustnic golden (canary server1 first) is the master's, 2026-10-17 (#92); nothing is promoted for stormbootx
#93 install-config sizes on real firmware variable stores
#100, #99 boot time: one NVMe/TCP command in flight (~46 MiB/s on metal); #99 (mlxfec's cap9 wait) is moot since #202 removed mlxfec
#71–#74 more Rust NIC drivers, paused by the owner at P3 (each behind its repo's first issue); stormnic-e1000e is on the rustnic media since v0.40.0 (#70), stormnic-igb since #69; stormnic-mlx5 is on the rustnic media since #202 and in the default driver set since #73, passed on C2NR0Q2 and forge1
#84 arm64 media: built (stormbootx-arm64); booting it under AAVMF and registering the golden wait on stormcentral#604
#138 the R230's NIC FEC step: v0.25.0's real-time budget is released, the metal boot is not seen
#154 stormbootx-stick writing a signed raw disk image when a stick has no room for a file update
#143 the fall-through shows as BdsDxe: failed to start Boot0002 … Aborted
#110 the claim over HTTPS against a fleet CA (stormcos#35 is closed as a design; #110 is the work)

A slide deck of the above is in docs/presentation.md (Marp: npx @marp-team/marp-cli docs/presentation.md).

Related: stormuefi (stage two) and stormnetboot (the earlier network-boot project, whose PXE chain this USB/NVMe-TCP path retired).

About

A UEFI NVMe/TCP boot extension: boot a machine from a remote image with no kernel, no initramfs and no PXE

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages