Skip to content

bmcd

The daemon: the API, the update logic, the metrics.

Newest release 2.40.0, 15 October 2026. 46 in total. Each entry is this repository's own CHANGELOG.md where it has one, and the release note where it does not — fetched by just refresh-changelog, so this page and the repository cannot disagree.

2.40.0 — 15 October 2026

Changed

  • Flashing waits up to 15 s for the module's USB boot device, and names what is in the way when it does not appear. It used to look at the bus once, a second after power-on, and give up with "Compute module's USB interface not found or supported". A module that enumerates slowly, or that fails high-speed enumeration and is retried by the kernel, was refused for that. It now looks every 250 ms. If nothing turns up the error says so for the node (numbered as users see it) and lists the other USB devices seen on the node's lines, root hubs excluded, with a hint that a device in the mini-PCIe slot of nodes 1 and 2 shares the USB pair used for flashing. On a board with a fanout hub a supported device on the wrong port is still reported as such. Found from a user report on Discord, where a Zigbee stick (1a86:7523) in node 2's slot kept an RK1 from enumerating.
  • A cancelled flash stops waiting at once, and a flash that never starts puts the node back. The wait for the boot device honours the cancel token, so a cancel (or a client that gave up) no longer leaves a worker polling for up to 15 s against a restarted flash. When the device does not appear, the node is powered off and USB restored exactly as after any other failed flash, instead of staying powered in flashing mode while the error tells the user to retry; the original error is the one returned.
2.39.0 — 14 October 2026

Added

  • A node can be flashed from an image on the SD card, by the path the card listing gives. opt=set&type=flash&node=<0..3>&sdcard=1&file=<path> takes file as the path relative to the card that GET /sdcard/files returns, and it is confined to the card the way the listing is: an absolute path, a .. that leaves the card, a symlink that points out of it, a missing file, a directory and an unmounted card are each refused with a 400 and a message saying which. It cannot be combined with local or a URL. Until now the listing offered paths that nothing could take back. local is unchanged (an absolute path, not confined, as tpi flash --local uses it); it is now documented as such, next to sdcard, in the OpenAPI document, which also describes the flash parameters for the first time. The firmware upgrade shares the same code and accepts sdcard too. A file on the card cannot be named under os_upgrade/, which an upgrade empties before it stages. sha256 is now checked for sdcard and local flashes, as it is for uploads and URLs; before, it was accepted and ignored. local=0 no longer counts as local, and a card file named httpd.img is no longer taken for a URL.

Fixed

  • skip_crc=false skipped the CRC check. The flag was read by whether the key was present, not by its value, so skip_crc=false and skip_crc=0 turned verification off. The web interface has sent skip_crc=false on every flash since BMC-UI 3.25.0, so every node flash started from the web interface skipped the check. Now skip_crc skips only when it is present with an empty value or 1, true, yes, on (any case), which keeps tpi's key-only skip_crc working; 0, false, no, off and an absent key verify; any other value is refused with 400 "Invalid skip_crc value". Reported by a user on Discord.
2.38.4 — 6 October 2026

Fixed

  • After an upgrade the browser could keep running the old interface, and the page then broke: a password-change page, nodes shown as off, a failed upgrade flow, until the browser cache was cleared. The daemon sent index.html with no cache instruction, so browsers guessed how long to keep it (a fraction of its age, hours or days) and never asked again. The old page then asked for script files the new firmware no longer has, and was answered with the web page itself instead of a script, which the browser refuses to run. Now index.html is always checked again (a cheap "unchanged" answer when it is), the built files under /assets/ are kept for a year because their names change with their content, and a file under /assets/ that does not exist answers 404 rather than the web page. Pages such as /power-control still open the interface on a reload, and the API is untouched. Reported by a reader on the Turing Pi Discord.
2.38.3 — 23 September 2026

Fixed

  • A switch document with a VLAN name was refused by validate and by PUT, as "did not match any variant of untagged enum Proposal". The same document with names: {} passed. Both endpoints accept either a preset or a whole document, and the type that says "either" is #[serde(untagged)], which buffers the body before choosing; in the buffered form a map key "50" is a string that never becomes the u16 the names table is keyed by — while a direct read of the struct converts it, which is why every unit test of the document passed. From the day names existed. Reported as BMC-Firmware#59 by a reader whose interface greyed out Try it and Apply and told him his board could not check a layout; reproduced on a board with the same document twice.

    Names are now read from their wire form — string keys, parsed here — so both paths agree, and a key that is not a VLAN id is refused by name rather than as "no variant matched". A test feeds a named document through Proposal and through the flattened PUT body; on the old code it fails with the reporter's exact message.

2.38.2 — 22 September 2026

Fixed

  • A firmware source that could not be reached reported nothing rather than a failure. list_remote read the updater's stdout and ignored how the process ended. tpi-selfupdate --list printed {"releases":[]} and exited 0 when it could not reach the source, putting the real reason on stderr where nothing read it -- so on a board with no resolver every remote source came back with no candidates and no error, and the firmware page rendered that as "nothing new". Reported from a 2.4 board on 2026-09-22 as "checking github for updated firmware also fails silently", and reproduced on board B: with an empty /etc/resolv.conf, four sources, zero candidates, zero errors, and curl: (6) Could not resolve host on the updater's stderr.

    The exit status is now read before the output. The updater is fixed in firmware v2.36.0 to exit non-zero and print nothing; this is the other half, so the next script that fails cheerfully cannot put the page back to lying. A failure with nothing on stderr now names the exit code instead of saying "no output", which was the only sentence the page had to show.

2.38.1 — 22 September 2026

Fixed

  • A static address lost its resolvers at every reboot, and ifup br0 failed at every boot. The up hook 2.38.0 wrote into /etc/network/interfaces carried a literal # -- the # br0 tag udhcpc uses on resolv.conf lines -- and ifupdown-ng reads # anywhere on a line as the start of a comment. The hook was cut off mid-string, /bin/sh refused the unterminated quote, and ifup exited 1 for br0. The address was already on the bridge by then, so the board came up reachable, with an empty /etc/resolv.conf (a symlink into tmpfs, fresh every boot) and a clock that could not resolve its server. Reported from a 2.4 board on 2026-09-22 by the reader whose report shaped 2.38.0; he had found the workaround of writing a regular resolv.conf into the overlay by hand.

    The hook now spells the hash as \043, which busybox printf turns back into #; the file it writes is byte-for-byte what it was meant to be, and udhcpc's filter still recognises the lines as its own. Measured on board B: the old hook fails with "unterminated quoted string", the new one writes the file and ifup exits 0.

    Boards that 2.38.0 already wrote are repaired at the first start of this daemon: a file this daemon wrote that is not what this version renders is rewritten (same document, corrected hook), and a static address whose resolvers are missing from /etc/resolv.conf gets them written then -- bmcd starts after the network does, so this is exactly the moment the failed hook left empty. A file written by hand is never touched.

2.38.0 — 21 September 2026

Added

  • The board's own address, applied then confirmed at the new address. GET/PUT /api/bmc/network/address, POST .../confirm, .../revert, .../validate, GET .../limits. One document -- {"mode":"dhcp"} or {"mode":"static","address","prefix","gateway","dns","search"} -- the same shape as the switch and for the same reason: the address is how you reach the page you change it from. PUT answers 202; the address goes on the bridge, a window runs (30 s by default, 10-300), and unless a confirmation reaches the daemon at the new address the previous one is put back and last_revert says why. A confirmation from the board itself never used the address and is refused with 403. Only a confirmed document is written to /etc/network/interfaces -- the same v2.1 stanza the image ships and S00dsa migrates, so the boot path needs nothing new.

    The bridge is never brought down. ifdown br0 would take the compute modules' ports out of the bridge with it; the address is changed with ip on a bridge that stays up, and udhcpc is stopped (it releases and flushes on SIGTERM, which is what -R is for) or started the way ifup starts it. The resolvers for a static address go into /etc/resolv.conf at apply, and as an up hook in the stanza at boot -- this image has no resolvconf.

    The board refuses what could never be reached: a gateway off the subnet, the network or broadcast address, a prefix outside /8-/30. It warns, and applies, what is merely unwise: no gateway, no resolver (so pool.ntp.org never resolves). GET reports running, configured (what a reboot comes back to), live (what the bridge actually has), and whether the file was written by this daemon, by hand, or could not be read.

    Asked for from the Discord on 2026-09-21 by a user who had set up the Split layout and then had to change the BMC's address over SSH.

  • GET ?type=ntp says what chrony thinks of each source. sources: one entry per source from chronyc -c sources -- selected, combined, excluded, unreachable, falseticker or too_variable, stratum, how many of the last eight polls answered, the last offset, and whether it is one of the servers configured through this daemon. Every field is chrony's own column. "Not synchronised" alone sent a user to Discord with nothing to act on; chrony always knows why.

v2.37.0 — 20 September 2026

Security

  • A board still on its factory password can do exactly one thing. Every board leaves the factory as root / turing — printed in the quick-start guide, identical on every board ever sold, on a project whose own front page says a board should not face the internet. Upstream has had an issue open about it since 2023.

    Until it is changed, /api/bmc answers 403 to everything except /authenticate, /access and /access/password, with a application/problem+json body saying why. Not a banner: a banner is a thing people close.

    The test is the factory password, not the first login. root's hash is asked whether it still verifies the word turing, of /etc/shadow itself, cached against that file's modification time. Never a flag. A board whose password was changed over SSH before anyone opened the interface is never asked; a board reset to factory defaults is asked again, because it is a factory board again. A change made by anything — this daemon, passwd, an editor — is noticed on the next request, with no restart.

    Loopback is exempt, because refusing it would protect nothing. Anyone with a shell on the board is already root on it — they can read /etc/shadow, run chpasswd, or stop the daemon. The threat this gate exists for is a stranger who can reach port 443 on the management LAN, and that stranger is not on loopback. What the exemption buys is the on-board tpi working on a board that has not been set up; it is the only thing left that relies on the bypass at all. tpi pointed at a factory board from another machine is over the network, is gated, and says so.

    (An earlier draft of this entry justified the exemption by the first-boot promotion check. That was wrong: /metrics is a separate plain-HTTP listener and S99postupdate does not pass through /api/bmc at all. Corrected rather than deleted — a security exemption with a reason that does not hold is worse than one with a thin reason.)

    A wrong password on a factory board is still answered as a wrong password. Saying "this board is on its factory password" to a failed login would confirm the account name to somebody guessing.

    The fleet is gated too: a gateway vouching for a person says nothing about whether the board has been set up.

    GET /api/bmc/access gains factory_password, so the interface can decide what to render before it asks for anything else. An interface that learned its state from the 403s it collected would show a broken page first and the explanation second.

  • A confirmation sent from the board itself is refused. POST /api/bmc/network/switch/confirm answers 403 to a request that arrived over loopback, and says why:

    A confirmation has to arrive over the network you just changed; that is what it proves. This one came from the board itself, which proves nothing. Confirm from the interface, or from tpi on another machine.

    The whole design of apply-then-confirm rests on where the confirmation came from: after an apply the old path no longer exists, so a request that reaches the daemon came through the new configuration. A request over loopback crossed no switch port. It proves nothing, and it made the change permanent anyway — which is to say the one protection against locking yourself out had a hole in it that could be walked through by accident, from a console, by somebody being careful.

    Found on hardware, not by reading. On 2026-09-20 a document reasoned to be equivalent to Flat was applied to bmc-2 and confirmed from the board's own ssh session; the board's owner lost the interface, and the window that exists for exactly that case never ran, because the confirmation had already arrived.

    Apply and revert stay open to loopback. Applying is how a board is recovered from its console, and reverting is the safe direction — it puts back a configuration that was proved once already. Only the step that makes a change permanent has to have come from somewhere.

Added

  • VLANs can be given names. An optional names map on the switch document, VLAN id to a word of up to 32 characters, carried and persisted with the rest of it.

    It reaches the hardware nowhere: two documents differing only in their names plan no commands at all. It exists because a table of numbers is not a layout anybody can read a year later, and because the alternative — a name file beside the document — is a second thing to keep in step with the first, which would drift the first time somebody applied a preset.

    Trunk names its own two VLANs management and nodes, because those are the numbers the operator chose and has to match on the router. Flat and Split name nothing: Split's identifiers never leave the board, so putting them on a page would be showing somebody two numbers they can do nothing with.

    A name is checked for being a name — not blank, not longer than 32 characters, no control characters, and not attached to a reserved VLAN id — and for nothing else. Naming a VLAN nobody is in yet is allowed: people name a layout while they are building it.

Added

  • Applying a switch configuration, and being able to undo it. The rest of the switch work: GET and PUT /api/bmc/network/switch, and POST .../confirm and .../revert.

    A change is applied on approval and kept on proof. PUT answers 202, not 200: the configuration is on the switch but it is not yours to keep yet. Confirm within the window, or the board puts the previous one back by itself and records why, so the interface can say your change at 12:03 was reverted because it was not confirmed.

    The confirmation must arrive on a new connection, and that is the whole proof. After an apply the old path no longer exists, so any authenticated request that reaches the daemon came through the new configuration. Nothing else needs checking.

    The window does not start when the apply returns. It starts when the uplink carrying the BMC's VLAN reports forwarding. Spanning tree holds a port in listening and learning for its own forwarding delay first — 30 seconds by default — so a window counted from the apply would expire before anybody could confirm a correct Trunk change, and would revert every one of them. The default window is 30 seconds and may be set per apply between 10 and 300.

    Only a confirmed document is ever written. A reboot in the middle of an unconfirmed change finds the previous one in /etc/bmcd/switch.json, because the pending one was never persisted.

    Safe mode skips applying, not reading. Holding KEY1 at power-on puts safemode on the kernel command line, which preinit already reads; the daemon reads it the same way and leaves the switch alone. That is the last exit from a configuration that was reachable when it was confirmed and has since stopped being, because a cable moved or the router changed.

    The commands are planned as a list and then run, so the part most likely to be wrong — the order — is a pure function with tests. Filtering is enabled after the VLANs exist and disabled before they are removed, because a bridge filtering with no VLANs forwards nothing.

Added

  • The switch as one document, and the rules the board refuses. The model half of the most-asked feature on the public roadmap. Two read-only endpoints come with it: GET /api/bmc/network/switch/presets and POST /api/bmc/network/switch/validate.

    The configuration is one object naming all seven ports — four modules, the BMC's own port, and both uplinks — rather than a set of calls. Partial application is how a board strands itself: six bridge vlan calls where the fourth fails leave a switch in a state nobody designed. One document is applied as one operation, and rollback is then "apply the previous document".

    Three presets. Flat is one bridge with filtering off, how a board ships and the reset target. Split is two groups that never meet, the BMC out of one uplink and the modules out of the other, with nothing tagged, so the other end needs no VLAN configuration at all. Trunk is one cable carrying both, tagged, with the second uplink redundant under spanning tree by default.

    The board expands its own presets. A client that expanded Split itself would eventually disagree with the board about what Split means, and that disagreement shows up as a board nobody can reach.

    Five refusals, each one a lockout rather than a preference:

    • the BMC's own port in no untagged VLAN
    • the BMC's own port alone in its VLAN
    • a BMC VLAN that no uplink carries, so the board answers only to the modules it is meant to administer
    • a tagged BMC port, which its network stack cannot read
    • two uplinks sharing a VLAN with spanning tree off, which is a loop

    Everything else is a warning with the port it is about, so an interface can say what is odd without refusing it.

    Nothing here applies anything to hardware. The apply, the confirm window and persistence are separate and deliberately cannot be reached from this model.

Fixed

  • A certificate naming the board by its real DNS name is no longer refused. The name check compared the certificate against the board's own idea of what it is called, which on a real board is not what its certificate says.

    Found on bmc-2 while running the install gate. The board knows itself as bmc-2. Its DHCP search domain is haarlem.internal. The certificate its own authority issued names bmc-2.haarlem.lan. Three different answers to "what is this board called", none of them wrong, and only one of them written anywhere the board can read. That certificate was accepted only because it happens to carry an IP address as well.

    A certificate naming just an FQDN — which is what step ca certificate bmc.lan produces, and what the people who asked for this feature will have — would have been refused.

    So the check now also asks the question a browser actually asks. A browser does not compare a certificate against the board's idea of its own name; it compares it against the name the operator typed, having resolved that name to the board. A name that resolves to one of this board's addresses is a name someone can reach it by.

    Bounded at two seconds per name, and failure is simply "no match", so a board whose DNS is down behaves exactly as it did before. Wildcards and address literals are never looked up.

Security

  • The password validator no longer logs a hash of what was typed. It logged crypt(password, hash) at debug level on every attempt. For a correct password that is the stored hash, which is only as secret as /etc/shadow. For a wrong one it is a hash of whatever was typed — and what people type into the wrong login box is usually a password that is correct somewhere else. A log file is far easier to read than /etc/shadow, and the board's logs are collected.

    It was scaffolding for a four-line function that has tests.

Added

  • Install your own certificate and key, from the interface or the API. GET, PUT and DELETE on /api/bmc/tls/certificate. For anyone running their own CA — the request came twice from people running step-ca — who wants a board that a browser trusting that CA opens without a warning, and a serial console that works, since a click-through exception does not cover the console's WebSocket.

    Before this the only way to put such a certificate on a board was to copy two files over SSH.

    Everything is checked before anything is written, because a board given a certificate it cannot serve may be a board nobody can reach to correct it. The key must belong to the certificate; the certificate must be valid now; it must permit server authentication, where it says anything about purpose at all; and it must name this board. A certificate naming some other host is refused with both name lists in the reason, rather than installed for a browser to reject later.

    The key travels in a JSON body on its own path, never a query string. The legacy dispatcher writes every opt=set call to the audit log in full, so a key routed through it would be recorded in clear. What is logged is the action, the actor, the subject and the expiry.

    DELETE removes the pair and has the board issue its own, so it is never left without one.

Changed

  • The certificate is no longer fixed at startup. It lives behind a lock and is chosen per connection through OpenSSL's servername callback, so installing one takes effect on the next connection with no restart — and the session that sent it survives to read the answer.

    The callback is SNI's, and a BMC is normally reached by address, which sends no SNI. OpenSSL runs the callback for those handshakes too, and there is a test that connects with server-name indication switched off and asserts it is served the certificate installed a moment earlier. Without that, every board would keep serving its startup certificate while the API reported the new one.

  • bmcd_tls_certificate_info gains a source label, self-signed or installed, read from the issuer rather than from any record of how the file was written. It decides whether the board renews the certificate on its own, so an expiry alert means something different for each.

2.36.3 — 12 September 2026

Added

  • /metrics now reports the USB multiplexer, including which module is armed for USB boot. Two families: bmcd_node_usb_boot_armed, one sample per module, and bmcd_usb_config, which names the node, mode, route and bus type of the persisted configuration.

    tpi flash and tpi advanced msd leave the configuration at Flashing(NodeN, …). The daemon persists that and re-applies it on every start, which asserts that module's USB-boot pin and stops it booting from its own eMMC. The module keeps running, because it is already booted — so the fault appears at its next reboot, possibly weeks later, and then it comes up in the USB loader instead of its operating system.

    From outside, that module is indistinguishable from dead hardware: nothing at all on the serial console, not even a bootloader banner, because the loader does not use the console; nothing on the network; and the BMC reporting its rail on. tpi usb status could not separate the two either, because it prints the same route for UsbA and Flashing. The only readout that named the mode was a line in the daemon's own log.

    Measured on hive-6, 2026-09-12: twenty minutes spent on a module that looked dead, with the fact that explained it sitting unexported in the daemon's database. bmcd_node_usb_boot_armed{node="node2"} 1 would have answered it in one query, and a rule on fifteen minutes of it pages the operator who armed it while they still remember doing so.

    The armed family is four samples rather than one because a single "which node" gauge cannot express none, and none is the normal state it exists to assert.

Changed

  • UsbConfig::parts() is now the single mapping from a stored configuration to (node, mode, route). The type=usb response and the metrics exposition share it, so a board cannot report one thing through the API and another on a dashboard.
2.36.2 — 12 September 2026 (no release of its own)

Fixed

  • The board refused every attempt to resume a TLS session, fatally. This is the intermittent fleet console failure: a serial console or an API call through the gateway would occasionally fail outright, on both boards, with nothing in the daemon's log and the board perfectly healthy either side of it.

    Envoy keeps one TLS session per upstream cluster and offers it on the next connection it opens. Measured on the gateway, a third of all new upstream connections to the two boards died this way — 8 of 28 to one, 19 of 45 to the other — while every connection taken from the pool was fine, which is what made it look random from a browser. Reproduced deterministically from a pod on the gateway's own node: 30 fresh connections all succeeded, and 30 that offered back a saved session all failed with tlsv1 alert internal error.

    OpenSSL will not resume a session on a server that asks for client certificates unless the context carries a session id context, and the refusal is not a quiet cache miss — it is internal_error, fatal, sent before a byte of HTTP is exchanged. The error, ssl_get_prev_session: session id context uninitialized, is raised on the server, which never logged it; the client sees only an alert it cannot explain.

    So the fault arrived with client certificates in 2.30.0 and was invisible from the daemon's side for six releases.

    The acceptor now sets a session id context, derived from the client CA so that a board whose trust anchor is replaced will not resume a session authenticated under the old one. There is a test that performs two real handshakes against the real acceptor and offers the first session back; it fails on 2.36.1 with the OpenSSL error above.

2.36.1 — 12 September 2026

Fixed

  • Every refusal from the new access endpoints answered 500. Measured on bmc-2 within the hour of flashing v2.28.0: "the current password is wrong" and "the new password is too short" both came back 500 Internal Server Error, which tells a client the board is broken rather than that the request was — and 500 is the canonical retryable status, so a client is being invited to retry an answer that will never change.

    impl ResponseError for LegacyResponse {} was empty, so actix's default status applied to everything. That was invisible until now because handlers reached through the legacy dispatcher come back as a LegacyResponse rendered by Responder, which has always honoured the carried status; api::access is the first to return one as an Err directly to actix.

    It now reports the status it was built with and renders through the same conversion the success path uses, so a refusal has one shape wherever it came from. The guards themselves were correct throughout — the right requests were refused with the right messages, with the wrong number on them.

2.36.0 — 12 September 2026

Added

  • Who may reach this board, from the interface that asks for the password (SQU-209). Both halves of this board's access control used to live only on its filesystem and arrive over SSH: the local password through passwd on a console, the client CA through a script in another repository. Nothing in the web interface could answer "who can get in", and the one interface that demands a password every time could not change it.

    GET /api/bmc/access now answers with the local account, the trust anchor for proxied identity if one is in effect — subject, issuer, expiry and a SHA-256 fingerprint to compare against the proxy's — the header an identity is read from and which of config/override/default chose it, and how the asking request was itself authenticated. A page can now say "you are here as [email protected], vouched for by the gateway" rather than guess.

    POST /api/bmc/access/password changes a local password, and PUT/DELETE /api/bmc/access/client-ca set and remove the trust anchor.

    Not through the legacy dispatcher, and that is the point. Every opt=set call is written to the audit log with its whole query string, so a password routed that way would be recorded in clear in bmcd.<date>.log and in remote syslog when SQU-108 lands. These take a JSON body and log the action and the actor, never the secret.

    The rules each exist because of a specific way this could go wrong:

    • The current password is required, including from an operator a proxy vouched for. A certificate proves the gateway trusts you; it does not prove you hold this board's console, and a change made without the old password is a lockout anyone with a live session can perform.
    • Twelve characters, counted as characters. A passphrase is not shorter because it is written in Cyrillic, and BanPatrol slows an online guess but does nothing for a stolen /etc/shadow.
    • A wrong password and an account that does not exist get the same refusal, so this cannot be used to enumerate accounts.
    • chpasswd, with the pair on stdin. Not a shell and not a command line: this daemon runs as root, and a password in argv is in ps for anyone on the box.
    • A CA bundle is loaded the way the TLS acceptor will load it, before it is stored. A bundle that cannot be loaded makes the daemon fail to start — which is exactly why client_ca was never safe to ship in a default config.yaml — so it must never reach the disk.
    • Removing the CA is refused when the caller is authenticated by it. That request ends its own session, through the proxy it is asking through. Do it from the board's own interface, with a password.

    The daemon does not rewrite config.yaml. That file is the operator's, it carries comments, and the daemon reads it with a crate that has no writer. So the interface owns two small files instead — the bundle itself at a fixed path, and a JSON sidecar for the header name — and config.yaml still wins wherever it speaks. Where it pinned a value, these endpoints refuse to touch it and say so.

    A consequence worth having: client_ca now falls back to that fixed path when the file exists, so a board whose anchor was removed comes back up instead of refusing to start.

2.35.0 — 11 September 2026

Fixed

  • The board could stop checking for firmware, for ever (SQU-201). The Firmware page said checking the sources now and never stopped, with Check now disabled beside it, and no later refresh could start. Only restarting the daemon cleared it. Opening the Firmware tab on a board whose cached listing had aged out was enough to trigger it; bmc-2 sat that way for three and a half hours on 2026-09-11 with refreshing: true, the timestamp frozen, and no fan-out process running at all.

    The claim that says "a refresh is running" was an AtomicBool, taken before the work and released by a Drop guard. That is correct for a panic and wrong for everything else: a task dropped before its first poll constructs no guard, and a blocking join that never returns never drops one.

    It is a deadline now, and a deadline cannot be lost — the claim expires whether or not anything is left to release it. Three things release it and it takes all three: the guard for a panic, a tokio::time::timeout around the fan-out for a blocking join that never returns, and the deadline for the case where neither of the other two exists. The dedup that stops "check now" turning four sources into twelve requests is kept as a compare-exchange.

    The bound is ten minutes. Not a guess at the work: the fan-out was 74–78 s when first measured, and on 2026-09-12 took 75–100 s on bmc-1 and about 230 s on bmc-2. It is how long a lost refresh may block every future one.

Added

  • The firmware listing survives a reboot, at /mnt/overlay/firmware-catalog.json. Asking four sources takes between 75 and 230 seconds and spends GitHub's unauthenticated quota of sixty an hour four at a time, and all of it used to be thrown away by every reboot — so the first person to open the Firmware page after an upgrade, the person most likely to be looking, paid for the lot.

    It sits beside firmware-sources.json and on the overlay for the same reason: both firmware images mount it, so it survives an A/B promotion. The board answers from it at once and refreshes behind that answer. The age it reports comes from the timestamp inside the file rather than the file's mtime, because what a reader needs is when the sources were asked, and an age that parses to something absurd is clamped to a day.

    It is written only when the offering changes. The overlay is NAND and UBI reports five free eraseblocks of 2040 on the reference board; a listing rewritten every half hour would be thousands of writes a year for bytes that change when somebody publishes a release. The comparison is on what the sources offer, not the whole record, because the timestamp moves every time.

    Only a complete answer is stored: a fan-out where one source errored is good enough to show, because the page renders the error, and not good enough to become the listing a board boots with.

2.34.0 — 11 September 2026

Added

  • opt=get&type=sdcard_files, also GET /api/bmc/sdcard/files: what is on the microSD card (SQU-198). Name, size, modified time, and whether each entry could be written to a compute module.

    tpi flash --local already reads an image off this card, and it is the only sane way to write a multi-gigabyte image to a module: the browser is not in the path, the bytes do not cross the network twice, and an interrupted upload does not mean starting over. What was missing was any way to see what is there — so an operator had to know the path and type it, for the most destructive thing this board does.

    A non-candidate is listed, not hidden, with the reason: a 10-byte .img says it is most likely a truncated download, and a .tpu says it is BMC firmware and belongs on the firmware page. Hiding them makes an operator who cannot find the file they just copied conclude the page is broken.

    The verdict is from the name and the size only. Nothing opens a file: deciding by content means reading the head of every entry on every listing, and what actually protects a flash is the checksum the operator compares.

Security

  • Every path is confined to the card, in one function with its own tests. A listing that accepts ../.. is a directory browser rooted at / on a device that can reflash four computers — and the same resolver will serve rename and delete later, so it is worth getting right once.

    The root and the target are both canonicalised and compared, so a/../../etc and a symlink pointing off the card are caught by the same rule rather than by a check written against the text of the request. A leading / is stripped rather than honoured. A path that does not exist answers "not found", never "outside", because saying "outside" for a typo tells a client something about the filesystem it did not earn.

2.33.0 — 11 September 2026

Fixed

  • A node's USB bus number is not part of its identity, and pinning it refused every flash. The port check added to stop a flash of node 2 writing node 1 compared the whole path — bus included — against a bus hardcoded to 1. It is not a constant. This board pairs an OHCI and an EHCI controller as companions for the same physical ports, so the bus a device lands on is decided by its speed: the same hub port is 1-1.2 for a full-speed device and 2-1.2 for a high-speed one.

    A Rockchip in maskrom is high-speed. So every RK1 the daemon was asked to flash or expose as mass storage was refused with

    node 2 requested on 1-1.2; found Rockusb on 2-1.2 instead

    about a module that was entirely healthy. Found on bmc-2 on 2026-09-11 while trying to recover a node that would not boot.

    The comparison is now on the port chain alone, which is the part that says which module this is. Anything downstream that has to match a sysfs path — finding the block device behind a module — is handed the path of the device that actually answered, so it carries the bus the kernel really used rather than a guess.

    Two tests pin it: the same hub port resolves to the same node on either bus, and no two nodes share a port chain once the bus is out of the comparison.

2.32.0 — 11 September 2026

Added

  • /metrics reports when the certificate the listener is serving expires, as bmcd_tls_certificate_expiry_timestamp_seconds, alongside bmcd_tls_certificate_info{key="ecdsa-p384"} naming the key it is built on.

    This exists because of SQU-115: a board served a certificate that had expired more than a year earlier and nothing anywhere said so. A number a scrape can alert on is the difference between that and a calendar reminder somebody stops reading. The key label is the first thing anyone reaches for when a client refuses to negotiate.

    Both are read from the certificate the daemon actually loaded, at start, so /metrics cannot disagree with the listener about which certificate is in use — replacing the file without restarting is exactly when a fresh read would describe something that is not being served.

    A daemon that cannot make sense of the date reports no series at all rather than a zero, because a zero reads as 1970 and would fire every expiry rule ever written against it.

    Nothing here is secret: every byte of it is sent to each client during the handshake.

  • Every key an operator might install is served, over TLS 1.3 and 1.2 alike: RSA, EC P-256, P-384 and P-521, and Ed25519. This was already true and is now a test that performs a real handshake per key type against the real acceptor, so it covers the cipher list and the signature algorithms rather than only PEM parsing. The estate rule is P-384, cert-manager will mint Ed25519 on request, and a certificate from a public CA is usually still RSA — a daemon quietly serving only some of those fails at renewal time, which is the worst moment to find out.
2.31.0 — 11 September 2026

Changed

  • The daemon offers TLS 1.3, with 1.2 as the fallback. The acceptor was built from Mozilla's version 4 intermediate profile, which pins the maximum protocol version to TLS 1.2; the board's OpenSSL is 3.5.7 and was capable of 1.3 all along. The v5 profile is 1.2 and 1.3 together, so no client loses anything.

    This is not a modernisation. A TLS-1.2-only server changes what a client has to send: under 1.2 the client's supported_groups extension constrains the curve of the SERVER's certificate as well as the key exchange (RFC 4492 §5.1). A client whose curve list stops at P-256 — which is Envoy's default — cannot use a P-384 certificate and gets handshake_failure. Every certificate in the estate this daemon runs in is P-384.

    What that looked like: a proxy holding a valid client certificate, failing every handshake, while the page in front of it drew perfectly and every board card said the board did not answer. The only trace was the proxy's own ssl.connection_error counter.

    Two tests cover it, and both were confirmed to fail without the change: the_acceptor_offers_tls13_and_still_falls_back_to_tls12, and a_client_with_envoys_default_curves_can_reach_a_p384_certificate, which also asserts that the same client pinned to TLS 1.2 still cannot get in — so the test cannot quietly stop proving anything.

2.30.0 — 11 September 2026

Added

  • A proxy holding a certificate from a trusted CA can name the human it authenticated. tls.client_ca turns it on; absent, which is what a board ships with, nothing changes and the header below is never read. This is the daemon half of SQU-136 and the reason a fleet interface can be exposed while a board cannot.
  • tls.identity_header, default x-forwarded-email: which header names that human. Believed only on a connection that presented a certificate this daemon verified. Without one it is ordinary attacker-controlled input — anybody on the management LAN can set a header — so the certificate is what makes it mean anything, and the two are useless apart.
  • A certificate authorises nothing by itself. It only makes the header worth reading; a trusted proxy that names nobody falls through to ordinary token authentication, and a blank name is treated as a misconfiguration rather than an anonymous login.
  • Client certificates are requested but not required, so a browser on the management LAN still reaches the login page. That is the break-glass path and it is deliberately untouched.
  • Audited as <user> (mtls), so a fleet-initiated action names the operator rather than the proxy.
2.29.0 — 10 September 2026

Added

  • bmcd_firmware_last_promotion_timestamp_seconds (SQU-141). The counter beside it says how often the health gate reached a verdict; this says when the last one was, which is what an alert on "no promotion since" and a Grafana annotation beside a memory graph both need. The ticket asked for both families and only the counter had shipped.

    It is absent rather than wrong when it cannot be trusted. The gate writes whatever busybox date printed, and that is the board's own clock — a BMC that has just come up may not have reached chrony, so the instant can be well before the real one. The HELP text says so. And only UTC is accepted: chrono parses %Z as a token and cannot apply an offset, so reading any other zone as UTC would publish an instant that is confidently wrong and looks perfectly healthy. A missing gauge is a nuisance; a plausible wrong one is a trap.

    Three tests. One pins the exact format the gate produces, since parsing it is the whole feature. One refuses a non-UTC zone. One keeps garbage absent rather than letting it become a 1970 timestamp that reads as a real event.

Changed

  • The fan-out doc comment said 16 s; measured twice on the board on 2026-09-09 at 74 s and 78 s, and once at 140 s the following day. Both figures are now recorded with which one no longer holds. It is not cosmetic: tpi 1.5.0 was written believing a re-poll was quick.
2.28.0 — 9 September 2026

Added

  • Every read operation now describes what it answers with (SQU-177). Ten handlers built their response with json! from several sources, so there was no type to derive a schema from and the document said only "not described here". UNTYPED is now empty.

    The shapes are reconstructions, not designs: every field is what the handler already sent, under the name it already used. A rename would break tpi and the web interface for no gain. Where a shape is odd the oddity is preserved and explained — power, usb and sdcard answer a one-element array, which is upstream's convention, and the document says so rather than claiming an object a client cannot index.

    Two details worth having in writing. sdcard's used-bytes field is use on the wire, because that is what upstream sent, and use is a Rust keyword — so the field is renamed, and a test asserts the wire name rather than the Rust one. And power reports strings, not booleans, because a rail the daemon cannot read is "Unknown"; a boolean schema would generate a client that cannot represent the third case.

    Four new contract tests, in the style of the six from 2.25.0. Proven by removing the use rename: the fixture fails saying it no longer exercises what it was written for.

Fixed

  • A caller's mistake answers 400, not 500 (was SQU-176). Naming a cooling device that does not exist, or a step above the fan's maximum, is a client error, and the board answered as though it had failed.

    It matters because problem+json exists so a generated client can branch on status, and 500 is the canonical retryable one — so a client told 500 for "that device does not exist" retries forever against an answer that cannot change.

    Decided by downcasting a typed CoolingRequestError, not by matching on the message: the message is for a person and would take the status with it the first time somebody reworded it. The handlers wrap with .context(...), so the check is against the root cause, and there is a test for exactly that.

Changed

  • schemars derives reach eleven more types. The release binary is 13,024,808 bytes, up from 12,938,240 measured at 2.26.0 — about 86 KB across both this release and 2.27.0's removals, which were not measured separately. A quarter of a percent of the rootfs.
2.27.0 — 9 September 2026

Changed

  • /metrics moved to its own listener: port 9110, plain HTTP, no credential (SQU-178). It used to sit on :443 beside the API and the web interface, behind TLS and a token of its own.

    The token existed for one reason — so that a credential in a scrape config could not also reach /api/bmc and power four compute modules off. On a listener that serves nothing but /metrics there is nothing else to reach. The property is kept and the mechanism is a port instead of a secret, which is one fewer thing to mint, store, rotate and leak.

    The TLS was always scraped with verification disabled, because the board's certificate is expired and carries no SAN, so nothing could verify it. Unverified TLS is a handshake per scrape on a Cortex-A7 in exchange for nothing. What protects the endpoint is the network, and the listener binds the same host as the API, so restricting the daemon to a management address restricts both.

    metrics_port is a new key in default_config.yaml, defaulted so that a configuration file written for an older bmcd still parses. A request to /metrics on :443 now answers 404.

Removed

  • The metrics token, entirely. GET /api/bmc/metrics-token, POST /api/bmc/metrics-token/rotate, the metrics_token operation, the bearer check on /metrics, and /mnt/overlay/metrics-token. Removed rather than left dormant: an endpoint that still exists is an endpoint someone will use.

  • The config export's secrets tier. The metrics token was the only thing in it, so secrets, contains_secrets and the secrets=1 parameter all described a credential that no longer exists. An empty secrets=1 would be a control that lies.

    format_version is unchanged at 1 on purpose: an export written by an older board still imports, because serde ignores the secrets and contains_secrets it carries. A test asserts that. The token inside it is dropped, which is the right fate for a credential nothing takes.

The promotion gate no longer depends on the /api/bmc loopback bypass. It used to reach through it to mint itself a token; it now makes one plain request to 9110. The on-board tpi is the only remaining user of the bypass (SQU-165).

2.26.0 — 9 September 2026

Added

  • bmcd --openapi prints the API document and exits (SQU-149, towards step 4). It needs neither a config file nor a board, which is the point: the docs site has to render this spec, and until now the only place it existed was a running BMC. That is a chicken-and-egg problem for anybody deciding whether to install a release.

  • The release publishes openapi.json, emitted by the binary that implements it and checksummed beside the source tarball. Emitted rather than committed, so it cannot describe a version that is not the one being released.

    The step refuses a document with fewer than twenty paths or five schemas. A generator that quietly fails produces a valid-looking file, and a valid-looking file is what would get published. Checked both ways before shipping: the real run reports 28 paths, 24 schemas and exits 0; a stubbed empty document exits 1 saying so.

2.25.0 — 9 September 2026

Added

  • The published schemas are now checked against what the daemon actually sends (SQU-149, step 3). A schema is derived from a type and a response is serialised from the same type, so the two agree only as far as schemars and serde agree — and they part company over exactly the attributes this daemon uses: skip_serializing_if, rename_all, and serde defaults. Where they part, the document describes a shape the board never sends, which is what tpi's three dead formatters were made of.

    Six tests build what a board would send and hold it against what the document promises. Proven by making the two disagree on purpose: adding #[schemars(rename_all = "PascalCase")] beside PortKind's #[serde(rename_all = "lowercase")] fails both switch-port tests, naming the schema. Removing it returns 13 passed.

    The cases chosen are the ones with something to get wrong: a board with no promotion log, where promotion_history is omitted entirely rather than sent as null; the same board mid-update with every field present; a switch port whose kind is a renamed enum; a port the driver never probed, with every optional absent at once; and a fan both held and governed.

    jsonschema is a dev-dependency, so none of this reaches the board.

Fixed

  • Nothing was wrong: schemars handles skip_serializing_if and rename_all correctly today. These tests record that and stop it drifting, which is a different and smaller claim than fixing something.
2.24.0 — 9 September 2026

Added

  • The OpenAPI document now describes what seven operations answer with (SQU-149, step 2). /thermal, /health, /cooling, /network, /firmware/slots, /firmware/sources and /firmware/check carry real response schemas instead of {}.

    The schemas are derived from the very types the handlers serialise, not written out beside them. The failure this exists to stop is a client written against a shape the daemon has never sent — tpi shipped three such formatters, and nobody noticed because a fourth bug stopped any of them running. A hand-written schema is that same failure with an extra step between it and the reader.

  • The operations that describe nothing are listed, not merely absent. Eleven handlers assemble their answer with json! from several sources and have no single type to derive from. They are named in UNTYPED, their 200 says so in words, and a test requires every read operation to be either described or on that list. Proven by removing /hostname from it: the test fails naming exactly that path.

    Two more tests hold the rest together: nothing may be both described and declared undescribed, and every $ref in the document must resolve to a component that exists. A dangling $ref renders as an empty box in every viewer and generates a client that will not compile.

Changed

  • schemars is a new dependency, and it costs 186 KB of release binary, measured rather than assumed: 12,747,768 bytes before, 12,938,240 after. About half a percent of the rootfs, against a CI gate at ninety.

    A first measurement said it was free, which was wrong: with nothing calling the derived impls the linker had stripped them all. The number above is from a build where the document actually references them.

2.23.0 — 9 September 2026

Added

  • One audit line per mutating API call (SQU-108). Until now a power off from the web interface, from tpi on the board and from a token over the network all looked the same in the log, because none of them was recorded with an identity. Each now writes a line on an audit target naming the action, the node, the caller, their address and the outcome.

    Written around the dispatcher rather than in each handler, for the same reason the dispatcher exists: there is one table of operations and two spellings of it, so an endpoint added later cannot quietly miss the line. Reads are not logged — they are most of the traffic and none of the risk.

  • The loopback bypass is named in that line. /api/bmc skips authentication entirely for requests from the board itself; the line for one says loopback (unauthenticated) rather than folding it in with a real credential. It is the entry somebody reading an audit trail would most want to be able to find, and it is the fault this fork already documents.

  • Audit lines go to the system log as well as the rotating file. Both /tmp and /var/log are tmpfs on this board, so a line that reaches only the file is gone at the next boot — which is exactly the event one would be trying to account for. They are sent to /dev/log as authpriv.info; a board with no syslogd keeps the file copy and says so once on startup.

    Verified against the board's own BusyBox syslogd, which renders the datagram as Sep 9 14:57:06 hive-bmc authpriv.info bmcd[4242]: ….

Changed

  • A bearer token now remembers who it was issued to. The token store held only a last-access time, so a request authenticated by a token could not name its user — every audit line for every logged-in operator would have read the same. A unit test requires the username to come back from authorisation rather than merely Ok.
2.22.0 — 9 September 2026

Fixed

  • A flash now targets the module that was asked for (SQU-105). On a v2.5 board all four modules sit behind one GL850 hub, so every module in maskrom enumerates at once — and find_first returned whichever answered first, ignoring the requested node. Its comment still said "only one node can be visible at any given time", which was true of v2.4's single mux. The result was a flash of node 2 that wrote node 1 and reported success, or "Several supported devices found" on a routine two-module bench.

    A device is now accepted only on the hub port that node is wired to. A device on any other port is refused, naming both: "node 3 requested on 1-1.3; found Rockusb on 1-1.1 instead". The whole failure mode was a success message, so the replacement had to be an error that says what it found.

    The same rule applies to the mass-storage side: get_device_path used to demand exactly one Rockchip block device on the entire board and give up otherwise. It now filters by port, so two modules in maskrom stop being an ambiguity to report and become two ports to tell apart.

  • The node-to-port mapping is read from the device tree, not assumed. The v2.5 DTS declares hub@1 with node1@1 through node4@4, and the kernel publishes all of it under /proc/device-tree. Node N is port N on this board; hard-coding that would have been correct and would still have been a guess, and a wrong guess here writes the wrong module — which is the harm being fixed. A unit test builds a tree wired in the opposite order and requires the opposite answer.

    A board that describes no such hub — v2.4, one node visible at a time — yields no topology, and the first-match behaviour is kept unchanged. The device tree is therefore also the board-revision test, which is better than a revision string because it is the fact the kernel itself is acting on.

2.21.0 — 9 September 2026

Added

  • A fan can now be held at a step (SQU-170). opt=set&type=cooling takes a new optional mode: manual pauses the zone's governor and then writes the step, auto hands the fan back. Omitted, the request means exactly what it always did — write the step and leave the governor running — so a client written before this keeps its behaviour.

    Until now set_cooling_speed wrote cur_state into a zone whose policy is step_wise, and the governor returned the fan to the trip the board was above within a poll. The write succeeded and the setting did not survive, which is the defect behind the fan slider that springs back.

    The governor is paused before the step is written. A step_wise poll is short enough to land between two writes done the other way round, which would leave the governor off on a step nobody asked for. If the step then fails to write, the governor is handed back rather than left paused.

  • type=cooling reports zone and overridden, so a client can tell whether a step it writes will hold instead of leaving a person to watch a slider and guess. zone is read from the zone's own cdevN symlinks rather than assumed: the kernel records the binding on the zone's side only, and thermal_zone0 driving cooling_device0 is true of this board, not of the interface.

  • bmcd_cooling_overridden, 1 while a fan is held. A hold that outlives the person who set it is the failure mode worth alerting on.

Changed

  • A held fan is taken back above the zone's hottest active trip, checked every 15 seconds. This board declares no critical trip — the hottest thing in its device tree is hot at 95 °C, which notifies and does not act — so nothing else would intervene if a fan were left on a low step while the board climbed. Somebody who paused the governor and walked away is not expressing a preference about 80 °C.

    The ceiling is the zone's own trip rather than a constant, for the same reason the rest of the daemon reads its trips instead of carrying a table.

  • Every paused governor is resumed at startup. A hold lives in the kernel, not in this process, so one left behind by a daemon that died is still in force with nothing tracking it.

  • A hold is deliberately not persisted, which is the one way it differs from a plain speed. A persisted speed is replayed into a board whose governor overrules it immediately, so the worst it can do is be briefly wrong. A persisted hold would come back after a reboot with nothing regulating the fan and nobody present who remembers asking for it.

2.20.0 — 9 September 2026

Added

  • bmcd_process_threads (SQU-172). The companion to bmcd_process_resident_bytes, and the number that says which kind of growth is happening: a heap leak grows the resident set with the thread count flat, while a leaked task or an unreaped blocking thread grows both, because every thread carries a stack.

    Added because the 2026-09-09 outage could not be attributed. The board's memory series was queried afterwards and settles one thing: available memory fell about 0.95 MB a minute from 01:15 and recovered at each daemon restart (+3.4 MB, then +10.7 MB), so the memory was held by this process and freed when it exited. What it does not settle is whether that was the heap or threads. This metric answers that at a glance next time.

    Read from /proc/self/status, not by counting /proc/self/task, which would be a directory read per scrape on a 116 MB board.

Changed

  • The hardware-less build starts (SQU-117). run_event_listener opens /dev/input/event0, which exists only on the board, and returned an error main propagated -- so --features stubbed, the build whose entire purpose is running without a board, could not start on one. It now warns and carries on under that feature only. The default build, which is what the firmware compiles, still treats a missing input device as fatal.

    This does not make the daemon properly hardware-less. /sys/class/thermal is not stubbed at all: on a workstation it enumerates the host's cooling devices and reports them as the board's. See SQU-117 for the rest.

Added

  • CI compiles for the board (SQU-171). 2.15.0 multiplied two statvfs counts, which are u64 on x86-64 and u32 on this board's 32-bit ARM. Every CI job was green and the Buildroot cross-compile failed two hours later, because that build was the only thing in the estate that compiled this daemon for the target.

    A new cross job runs cargo check against armv7-unknown-linux-gnueabihf with an armhf sysroot, in about twenty seconds. Verified by reintroducing the 2.15.0 expression on a scratch tree: clippy stays green while the new job fails with expected u32, found u64 at the comparison. That asymmetry is the whole point.

    The board is actually built soft-float (BR2_ARM_EABI=y), so its triple is armv7-unknown-linux-gnueabi -- Debian's armel, which Ubuntu does not carry. The hard-float target stands in for it, and the job asserts what makes that safe: rustc's cfg for the two differs in target_abi and nothing else, so type layout and integer widths are identical. The assertion fails if they ever diverge further.

2.19.1 — 9 September 2026

Fixed

  • Two strings had source indentation in the middle of a sentence. A literal wrapped across source lines and joined by rustfmt keeps the leading spaces of the continuation. Nothing complains: it compiles, it renders, and the exposition stays valid Prometheus.

    One was the # HELP text of bmcd_firmware_promotion_total, which is published to every scraper and shown in dashboard tooltips. The other was the error shown when parking an image with no SD card mounted.

  • A test that covered nothing. The guard written for the above passed with the defect still present: both metric fixtures leave promotion_history at None, so neither renders the family the test was written for. It now fills the optional fields, and asserts the family is in the output before checking it -- so the test cannot go quiet again.

2.19.0 — 9 September 2026

Added

  • A rollback can be named (SQU-158). The interface said "Rollback: version not readable" — honest, because that volume is never mounted, and useless to somebody deciding whether to press Reboot.

    Only the stager ever knows: by the time the gate runs it is the new image reading /etc/os-release. So both stagers now write REPLACES= into the staged note, S99postupdate copies it to /mnt/overlay/rollback-version at the instant it promotes, and firmware_slots reports it as the rollback slot's version.

    A board with no such file still renders "not readable". The version is reported when it was recorded and never guessed — the same rule the rest of this endpoint follows.

2.18.0 — 9 September 2026

The board wedged at 02:11 UTC after losing roughly a megabyte a minute under continuous polling (SQU-172). Its kernel still answered ICMP; nothing listened. The four compute modules were untouched. Recovering it needs a person at the rack, and the metrics could only show the board's memory falling — not what was consuming it. These three changes are so the next occurrence is diagnosable, and so one of the plausible causes cannot happen.

Added

  • bmcd_process_resident_bytes, this daemon's own resident set from /proc/self/statm. Board memory says the board is being consumed; only this says by whom. Its absence is why the diagnosis above had to be argued from timing rather than read off a graph.

Fixed

  • The catalogue's refreshing flag can no longer stick. It was cleared by a statement after fan_out().await, so a panic anywhere in the fan-out left it set for ever — and the interface polls every two seconds for as long as it is set. One browser tab left open then becomes a permanent 2-second poll of a board with 116 MB of RAM. It is cleared by a drop guard now, which runs on a panic as well as on success.

Changed

  • FRESH_AFTER_ERROR carries the arithmetic that makes it consequential: one configured source publishes no checksums and therefore errors routinely, so this two-minute value is the interval that actually governs, and each expiry spawns a shell and a curl per source.
2.17.0 — 9 September 2026

Added

  • bmcd_firmware_promotion_total{result} (SQU-141), counted from the gate's own log. Its history lived only in /mnt/overlay/postupdate.log, so "nineteen consecutive clean promotions" was a number counted by hand and written on a website, where it went stale.

    It had also gone wrong: the board's own log says fifteen boots ran the gate and one was refused — fourteen promotions, not nineteen. The hand-counted figure had drifted, which is the entire argument for the metric. The site's number will link to a query instead of a claim.

    promoted is derived as attempts minus rollbacks, because the gate has no single line meaning kept: it can finish through the metrics check, by skipping that check on a board with no curl, or by promoting anyway when there is no volume to fall back to. Deriving covers all three. The one inaccuracy is a board cut off mid-gate, whose unfinished attempt counts as promoted — said plainly in the metric's help text rather than hidden.

  • firmware_slots carries promotion_history, so the same counts are available without a scrape credential.

2.16.0 — 9 September 2026

Added

  • One path per operation (SQU-149, step 1). GET /api/bmc/thermal, POST /api/bmc/hostname, and thirty-three more, each reaching the same arm of the same dispatcher as its ?opt=&type= form — there is no second implementation to drift. The legacy form stays exactly as it is; it is what upstream's tpi, this fork's tpi, the web interface and a decade of scripts speak.

    What differs, and only on the new paths: a success answers with the bare result rather than {"response":[{"result":…}]}; a refusal answers with application/problem+json (RFC 9457) carrying the same message the legacy form puts in result; mutations are POST and take parameters as a form or a JSON body as well as in the query string, with the query string winning on a clash. POST /firmware/sources and POST /config accept the document itself as the JSON body — the shape a person would write — rather than only the escaped-string-in-a-parameter shape the legacy form needs.

  • GET /api/bmc/openapi.json, OpenAPI 3.1, built from the same table that registers the routes, so an operation cannot be documented without existing or exist without being documented. This release describes every operation's path, method, summary, the parameters that are known, and the error shape. Response bodies are still {}: typing them from the serde types the handlers return is the next step, and an untyped-but-honest response is better than a typed one guessed from memory — the day before this was written, a client shipped three formatters against shapes this daemon has never sent.

Changed

  • api_entry's dispatch is a function of its own, with two callers.
2.15.1 — 9 September 2026

Fixed

  • Park mode did not compile for the board. statvfs's block counts are u64 on x86-64 and u32 on this board's 32-bit ARM, so blocks_available() * fragment_size() built on a workstation and failed to cross-compile — and would have overflowed at 4 GB if it had. Cast to u64 before multiplying, which is what select_staging_dir two hundred lines below already does.

    Caught by the firmware build, not by just check. That is the point worth recording: a green workstation check is not evidence the daemon builds for the board. Nothing in this repository's CI cross-compiles; the firmware build is the only thing that does, and it is two hours long.

    The widening is a small generic rather than a cast at the call site, because neither as u64 nor u64::from works on both: both are required for the target and both trip clippy on the host, where the types already match. A generic conversion is correct on both platforms and carries the reason with it, which a lint suppression would not.

2.15.0 — 9 September 2026

Added

  • type=thermal reports each zone's trip points (SQU-135). The governor is step_wise, so the fan's step is a consequence of which trips the board has crossed — and "the fan is on step 4" with no reason attached is the question this fork was asked to investigate. Measured on the board: fan_min 20 °C, fan_low 45 °C, fan_mid 60 °C, fan_high 70 °C, all active, plus hot at 95 °C; the board reads 50.6 °C, which is above fan_low, which is step 4. Nothing mysterious, and now nothing hidden.

    Read from sysfs rather than written into a client, for the same reason the cooling levels are: it is a fact about this board. The trip type is passed through as the kernel spells it rather than mapped, so a client that meets an unfamiliar one shows it instead of swallowing it.

2.14.0 — 9 September 2026

Added

  • Config export and import (SQU-142). opt=get&type=config returns everything a person has configured as one JSON document — hostname, time servers, firmware sources, node names — and opt=set&type=config applies one. Board B is a named milestone, and the first thing anyone will want is "make it like board A"; today that is five settings re-entered by hand plus a metrics token re-pasted into a scrape config.

    An allow-list, never "everything on the overlay." The overlay also carries htoprc and a zero-byte crond.reboot, residue of tools that were removed, and a wholesale copy would clone that junk to a board that never had them.

    The token is what makes an export sensitive. secrets=1 includes the metrics token; without it the key is absent from the document entirely rather than present and empty. contains_secrets is on the document's face, because the difference decides how the file has to be handled and nobody will remember which request produced it. A token being imported is validated as hexadecimal — it is written into a KEY=VALUE file and used as a Basic-auth password, so a newline or a colon in it would forge a field or split a credential.

    Import is deliberately not transactional. A hostname and a set of firmware sources cannot be rolled back together, and reporting a partial apply as a failure would leave an operator unsure which half took. Every field reports its own outcome as applied, skipped or failed.

    power_on_time is never imported. It is when this board last powered a node on; carried to another board it would report an uptime that never happened.

    The network configuration is not included and will not be: it is per-board by definition and already has its own reset path.

  • metrics_token::peek() reads the stored token without minting one. A backup must record what a board has, not create a credential as a side effect of being backed up — which ensure() would have done.

2.13.0 — 9 September 2026

Added

  • The time sources are a setting (SQU-167). opt=get&type=ntp returns the configured servers, whether the running image can accept any, and the clock's state; opt=set&type=ntp&servers=a,b replaces them and reloads chrony live.

    The image ships pool pool.ntp.org iburst, so a board synchronises straight to the public pool with no way to change that short of an SSH session. The RTC covers boot, so this is not about correctness at power-on: it is about the one time somebody is standing at the board — a WAN outage — when chrony loses its only source and the clock quietly stops being disciplined.

    Written as a chrony sourcedir file rather than by rewriting /etc/chrony.conf: that config is in the read-only image, and chronyc reload sources picks up the change without restarting chronyd or losing the discipline it has built up. The first server is written with chrony's prefer, and only the first — chrony treats several preferred sources as equals, which is not what an ordered list means.

    Server names are validated against an allow-list before they are written, and that is not politeness: the lines are server <value> iburst in another daemon's config file, so a value carrying a newline would append directives of its own. configurable reports false on an image whose chrony.conf predates the sourcedir line, because a setting that is saved and silently never read is worse than one that is absent.

  • The hostname is a control (SQU-138). opt=get&type=hostname returns the live name and the one that takes effect at the next boot — they differ when someone has run hostname by hand — and opt=set&type=hostname&name=… changes both and restarts mdnsd so the board stops advertising the name it no longer has.

    Validated as a single DNS label: letters, digits and hyphens, at most 63, no leading or trailing hyphen, and no dots. The file format would accept a qualified name and mdnsd would then advertise a.b.local, which is not what anyone meant.

    Renaming changes the instance label on every metrics series, so a Prometheus history does not follow it. That is a decision, not a side effect, and it belongs to whoever presses the button — the interface says so before it happens.

2.12.0 — 9 September 2026

Added

  • Park an uploaded image instead of installing it (SQU-134). opt=set&type=firmware&park=1 writes the image to /mnt/sdcard/firmware/ and stops: nothing is staged, nothing is armed, and the catalogue's local source lists it on the next read like any other candidate.

    Until now an upload was never a thing you had, only a thing that happened — os_update stages into a scratch directory, runs osupdate on it at once, and deletes the directory. That made the browser's upload control a second way to install, bypassing the catalogue, so an operator could upload one image and install another with the interface never showing which.

    Three refusals, each for a measured reason. The card must be mounted: /mnt/sdcard is an empty directory when no card is in the slot, and writing there fills the root filesystem instead. It must have room. And the write goes to a .partial file that is renamed only once it is complete and synced, because a truncated image in the directory the catalogue reads would be offered for install.

    Parking does not make the already-staged refusal, and that is deliberate: nothing is being armed, so a board with an image already staged can still be given another to choose from later.

2.11.0 — 9 September 2026

Fixed

  • The catalogue no longer blocks the page, or every other request with it (SQU-143, the cause behind SQU-132). Measured on the board first, because the ticket held a hypothesis and not a diagnosis:

    request before
    firmware_available&refresh=1, four sources 15.9 s
    the same, asking for one source 15.7 s
    cached 0.18 s

    Three faults, compounding. The fan-out ran the sources through a single spawn_blocking and a .map(), so it cost their sum rather than the slowest. The cache mutex was held for the whole of it, so a "check now" froze every other reader too — including a page that wanted nothing but the cached list, which is why the freeze looked like it happened on open. And asking for one source refreshed all four, because the refresh was never per source at all.

    Now: one spawn_blocking per source through a JoinSet, answers reordered to the configured order; the lock taken only to store the result; and a caller gets the cached catalogue at once with refreshing set while the refresh runs behind it. refresh=1 starts that refresh instead of waiting for the entry to age out, and returns immediately.

Added

  • Catalog.refreshing and Catalog.age_seconds, so a page can draw a spinner on its own control and say how old the list is without parsing a timestamp and trusting two clocks. refreshing is omitted when false, so a settled catalogue serialises exactly as before.
  • The catalogue is primed at start-up. Otherwise the first caller after a boot pays for the fan-out, and that is the person watching a board come back from a firmware update.
  • A refresh already in flight is joined, not duplicated: four sources would otherwise become twelve requests against a GitHub quota of sixty an hour.
2.10.1 — 9 September 2026

Fixed

  • A parked image is ordered against what is running (SQU-169). A local candidate was reported with an unknown relation whatever its name, so a v2.8.1-rc1 on the SD card while the board ran v2.8.0 showed as ?, sorted under "older or unrelated", and tpi firmware check never mentioned it. Found while flashing the gate test image.

    The old comment argued that a file on a card carries no promise that its name reflects its contents. True — and that is what the trust column says, which already reads unverified for every local file. Declining to order as well told the user the same thing twice and hid a real upgrade. Version-shaped names are now ordered; anything else still declines.

  • Local candidates are listed newest first by that same ordering. A lexical sort put v2.9.0 above v2.10.0 in a list whose own relation fields said the opposite.

  • prerelease is set from the tag rather than hard-coded to false, so a candidate can be labelled as one.

Added

  • app::version, an ordering for release tags, with the two rules this fork has already got wrong somewhere: numbers compare as numbers (2.10.0 is after 2.9.0, which a text compare reverses), and a prerelease comes before its own release (v2.8.1-rc1 is after v2.8.0 and before v2.8.1). Anything not version-shaped compares to nothing at all, because a build stamped VERSION=local once sorted first and made every release on the list look like an upgrade.
2.10.0 — 8 September 2026

Changed

  • Rust 1.98.1 across the workspace, dependencies refreshed, and 23 clippy lints fixed rather than suppressed. NodeType, DummyValidator and FalseValidator deleted as unused; an unreachable!() in utils replaced by as_chunks::<2>(), which makes the case impossible instead of asserting it.
2.9.0 — 8 September 2026

Changed

  • Firmware sources point at excavador-turing/BMC-Firmware. A board that still holds a retired location is migrated on load and the result persisted, so the move needs no action on the board.
2.8.0 — 8 September 2026 (no release of its own)

Added

  • Firmware sources are configurable and catalogued: GitHub releases, an HTTP directory, or the SD card, each candidate reported with how it compares to the running version and how much is known about its integrity.
  • firmware_install, so a chosen version can be staged.
2.7.0 — 7 September 2026 (no release of its own)

Added

  • /metrics authenticates with its own token rather than the root password, so a scrape credential cannot touch /api/bmc.
  • The daemon reports whether a newer firmware release exists.

Changed

  • Every switch metric series carries kind, not only the ones that are present.
2.5.0 — 7 September 2026 (no release of its own)

Added

  • firmware_info reports which image is staged, not merely that one is.
  • The kernel release is reported on the About page.
  • A release is published when a version tag is pushed.
2.4.0 — 6 September 2026 (no release of its own)

Added

  • A Prometheus scrape endpoint, behind authentication.
  • The SoC temperature and the cooling state, the fan's real duty from the device tree, the running firmware slot and what a rollback lands on, and the condition of the BMC itself.
  • A browser can authenticate the serial websocket.

Fixed

  • /info is no longer served unauthenticated over plain HTTP (SQU-125).
  • A ban answers with 429 and a Retry-After rather than "wrong password".
  • Only http/1.1 is offered over ALPN, so h2 framing is unreachable (SQU-126).

Last updated: