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>takesfileas the path relative to the card thatGET /sdcard/filesreturns, 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 withlocalor a URL. Until now the listing offered paths that nothing could take back.localis unchanged (an absolute path, not confined, astpi flash --localuses it); it is now documented as such, next tosdcard, in the OpenAPI document, which also describes the flash parameters for the first time. The firmware upgrade shares the same code and acceptssdcardtoo. A file on the card cannot be named underos_upgrade/, which an upgrade empties before it stages.sha256is now checked forsdcardandlocalflashes, as it is for uploads and URLs; before, it was accepted and ignored.local=0no longer counts aslocal, and a card file namedhttpd.imgis no longer taken for a URL.
Fixed
skip_crc=falseskipped the CRC check. The flag was read by whether the key was present, not by its value, soskip_crc=falseandskip_crc=0turned verification off. The web interface has sentskip_crc=falseon every flash since BMC-UI 3.25.0, so every node flash started from the web interface skipped the check. Nowskip_crcskips only when it is present with an empty value or1,true,yes,on(any case), which keepstpi's key-onlyskip_crcworking;0,false,no,offand an absent key verify; any other value is refused with 400 "Invalidskip_crcvalue". 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.htmlwith 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. Nowindex.htmlis 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-controlstill 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
validateand byPUT, as "did not match any variant of untagged enum Proposal". The same document withnames: {}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 theu16the 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
Proposaland through the flattenedPUTbody; 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_remoteread the updater's stdout and ignored how the process ended.tpi-selfupdate --listprinted{"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, andcurl: (6) Could not resolve hoston 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 br0failed at every boot. Theuphook 2.38.0 wrote into/etc/network/interfacescarried a literal#-- the# br0tag 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/shrefused the unterminated quote, andifupexited 1 forbr0. 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 regularresolv.confinto 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 andifupexits 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.confgets 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.PUTanswers 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 andlast_revertsays 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 andS00dsamigrates, so the boot path needs nothing new.The bridge is never brought down.
ifdown br0would take the compute modules' ports out of the bridge with it; the address is changed withipon a bridge that stays up, and udhcpc is stopped (it releases and flushes on SIGTERM, which is what-Ris for) or started the wayifupstarts it. The resolvers for a static address go into/etc/resolv.confat apply, and as anuphook 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.orgnever resolves).GETreportsrunning,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=ntpsays what chrony thinks of each source.sources: one entry per source fromchronyc -c sources--selected,combined,excluded,unreachable,falsetickerortoo_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/bmcanswers 403 to everything except/authenticate,/accessand/access/password, with aapplication/problem+jsonbody 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 wordturing, of/etc/shadowitself, 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, runchpasswd, 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-boardtpiworking on a board that has not been set up; it is the only thing left that relies on the bypass at all.tpipointed 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:
/metricsis a separate plain-HTTP listener andS99postupdatedoes not pass through/api/bmcat 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/accessgainsfactory_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/confirmanswers 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
tpion 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
namesmap 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.
Trunknames its own two VLANsmanagementandnodes, because those are the numbers the operator chose and has to match on the router.FlatandSplitname 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:
GETandPUT /api/bmc/network/switch, andPOST .../confirmand.../revert.A change is applied on approval and kept on proof.
PUTanswers 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
safemodeon the kernel command line, whichpreinitalready 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/presetsandPOST /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 vlancalls 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
Splititself would eventually disagree with the board about whatSplitmeans, 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 ishaarlem.internal. The certificate its own authority issued namesbmc-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.lanproduces, 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,PUTandDELETEon/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=setcall 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.DELETEremoves 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_infogains asourcelabel,self-signedorinstalled, 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
-
/metricsnow reports the USB multiplexer, including which module is armed for USB boot. Two families:bmcd_node_usb_boot_armed, one sample per module, andbmcd_usb_config, which names the node, mode, route and bus type of the persisted configuration.tpi flashandtpi advanced msdleave the configuration atFlashing(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 statuscould not separate the two either, because it prints the same route forUsbAandFlashing. 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"} 1would 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). Thetype=usbresponse 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 aLegacyResponserendered byResponder, which has always honoured the carried status;api::accessis the first to return one as anErrdirectly 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
passwdon 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/accessnow 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/passwordchanges a local password, andPUT/DELETE /api/bmc/access/client-caset and remove the trust anchor.Not through the legacy dispatcher, and that is the point. Every
opt=setcall is written to the audit log with its whole query string, so a password routed that way would be recorded in clear inbmcd.<date>.logand 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
BanPatrolslows 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 inargvis inpsfor 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_cawas never safe to ship in a defaultconfig.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 — andconfig.yamlstill wins wherever it speaks. Where it pinned a value, these endpoints refuse to touch it and say so.A consequence worth having:
client_canow 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 aDropguard. 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::timeoutaround 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.jsonand 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, alsoGET /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 --localalready 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
.imgsays it is most likely a truncated download, and a.tpusays 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/../../etcand 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.2for a full-speed device and2-1.2for 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 insteadabout 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
-
/metricsreports when the certificate the listener is serving expires, asbmcd_tls_certificate_expiry_timestamp_seconds, alongsidebmcd_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
/metricscannot 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.
Confirmed
Section titled “Confirmed”- 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_groupsextension 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 getshandshake_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_errorcounter.Two tests cover it, and both were confirmed to fail without the change:
the_acceptor_offers_tls13_and_still_falls_back_to_tls12, anda_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_caturns 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, defaultx-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
dateprinted, 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 onlyUTCis accepted: chrono parses%Zas 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:
tpi1.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".UNTYPEDis 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
tpiand the web interface for no gain. Where a shape is odd the oddity is preserved and explained —power,usbandsdcardanswer 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 isuseon the wire, because that is what upstream sent, anduseis a Rust keyword — so the field is renamed, and a test asserts the wire name rather than the Rust one. Andpowerreports 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
userename: 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+jsonexists so a generated client can branch onstatus, 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
schemarsderives 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
-
/metricsmoved to its own listener: port 9110, plain HTTP, no credential (SQU-178). It used to sit on:443beside 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/bmcand power four compute modules off. On a listener that serves nothing but/metricsthere 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
hostas the API, so restricting the daemon to a management address restricts both.metrics_portis a new key indefault_config.yaml, defaulted so that a configuration file written for an older bmcd still parses. A request to/metricson:443now answers 404.
Removed
-
The metrics token, entirely.
GET /api/bmc/metrics-token,POST /api/bmc/metrics-token/rotate, themetrics_tokenoperation, 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
secretstier. The metrics token was the only thing in it, sosecrets,contains_secretsand thesecrets=1parameter all described a credential that no longer exists. An emptysecrets=1would be a control that lies.format_versionis unchanged at 1 on purpose: an export written by an older board still imports, because serde ignores thesecretsandcontains_secretsit carries. A test asserts that. The token inside it is dropped, which is the right fate for a credential nothing takes.
Side effect worth recording
Section titled “Side effect worth recording”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 --openapiprints 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 schemasand 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
schemarsandserdeagree — and they part company over exactly the attributes this daemon uses:skip_serializing_if,rename_all, and serdedefaults. Where they part, the document describes a shape the board never sends, which is whattpi'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")]besidePortKind'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_historyis omitted entirely rather than sent as null; the same board mid-update with every field present; a switch port whosekindis a renamed enum; a port the driver never probed, with every optional absent at once; and a fan both held and governed.jsonschemais a dev-dependency, so none of this reaches the board.
Fixed
- Nothing was wrong:
schemarshandlesskip_serializing_ifandrename_allcorrectly 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/sourcesand/firmware/checkcarry 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 —
tpishipped 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 inUNTYPED, their200says so in words, and a test requires every read operation to be either described or on that list. Proven by removing/hostnamefrom 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
$refin the document must resolve to a component that exists. A dangling$refrenders as an empty box in every viewer and generates a client that will not compile.
Changed
-
schemarsis 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 offfrom the web interface, fromtpion 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 anaudittarget 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/bmcskips authentication entirely for requests from the board itself; the line for one saysloopback (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
/tmpand/var/logare 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/logasauthpriv.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_firstreturned 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_pathused 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@1withnode1@1throughnode4@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=coolingtakes a new optionalmode:manualpauses the zone's governor and then writes the step,autohands 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_speedwrotecur_stateinto a zone whose policy isstep_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_wisepoll 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=coolingreportszoneandoverridden, so a client can tell whether a step it writes will hold instead of leaving a person to watch a slider and guess.zoneis read from the zone's owncdevNsymlinks rather than assumed: the kernel records the binding on the zone's side only, andthermal_zone0drivingcooling_device0is 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
activetrip, checked every 15 seconds. This board declares nocriticaltrip — the hottest thing in its device tree ishotat 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 tobmcd_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_listeneropens/dev/input/event0, which exists only on the board, and returned an errormainpropagated -- 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/thermalis 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
statvfscounts, which areu64on x86-64 andu32on 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
crossjob runscargo checkagainstarmv7-unknown-linux-gnueabihfwith 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 withexpected u32, found u64at the comparison. That asymmetry is the whole point.The board is actually built soft-float (
BR2_ARM_EABI=y), so its triple isarmv7-unknown-linux-gnueabi-- Debian'sarmel, 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 intarget_abiand 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
# HELPtext ofbmcd_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_historyatNone, 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 writeREPLACES=into the staged note,S99postupdatecopies it to/mnt/overlay/rollback-versionat the instant it promotes, andfirmware_slotsreports 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
refreshingflag can no longer stick. It was cleared by a statement afterfan_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_ERRORcarries 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 acurlper 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.
promotedis 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 nocurl, 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_slotscarriespromotion_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'stpi, this fork'stpi, 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 withapplication/problem+json(RFC 9457) carrying the same message the legacy form puts inresult; mutations arePOSTand 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/sourcesandPOST /configaccept 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 areu64on x86-64 andu32on this board's 32-bit ARM, soblocks_available() * fragment_size()built on a workstation and failed to cross-compile — and would have overflowed at 4 GB if it had. Cast tou64before multiplying, which is whatselect_staging_dirtwo 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 u64noru64::fromworks 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=thermalreports each zone's trip points (SQU-135). The governor isstep_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_min20 °C,fan_low45 °C,fan_mid60 °C,fan_high70 °C, allactive, plushotat 95 °C; the board reads 50.6 °C, which is abovefan_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=configreturns everything a person has configured as one JSON document — hostname, time servers, firmware sources, node names — andopt=set&type=configapplies 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
htoprcand a zero-bytecrond.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=1includes the metrics token; without it the key is absent from the document entirely rather than present and empty.contains_secretsis 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 aKEY=VALUEfile 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_timeis 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 — whichensure()would have done.
2.13.0 — 9 September 2026
Added
-
The time sources are a setting (SQU-167).
opt=get&type=ntpreturns the configured servers, whether the running image can accept any, and the clock's state;opt=set&type=ntp&servers=a,breplaces 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
sourcedirfile rather than by rewriting/etc/chrony.conf: that config is in the read-only image, andchronyc reload sourcespicks up the change without restarting chronyd or losing the discipline it has built up. The first server is written with chrony'sprefer, 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> iburstin another daemon's config file, so a value carrying a newline would append directives of its own.configurablereports false on an image whosechrony.confpredates thesourcedirline, 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=hostnamereturns the live name and the one that takes effect at the next boot — they differ when someone has runhostnameby hand — andopt=set&type=hostname&name=…changes both and restartsmdnsdso 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
mdnsdwould then advertisea.b.local, which is not what anyone meant.Renaming changes the
instancelabel 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=1writes the image to/mnt/sdcard/firmware/and stops: nothing is staged, nothing is armed, and the catalogue'slocalsource 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_updatestages into a scratch directory, runsosupdateon 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/sdcardis 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.partialfile 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 sources15.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_blockingand 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_blockingper source through aJoinSet, answers reordered to the configured order; the lock taken only to store the result; and a caller gets the cached catalogue at once withrefreshingset while the refresh runs behind it.refresh=1starts that refresh instead of waiting for the entry to age out, and returns immediately.
Added
Catalog.refreshingandCatalog.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.refreshingis 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
unknownrelation whatever its name, so av2.8.1-rc1on the SD card while the board ran v2.8.0 showed as?, sorted under "older or unrelated", andtpi firmware checknever 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
trustcolumn says, which already readsunverifiedfor 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.0abovev2.10.0in a list whose ownrelationfields said the opposite. -
prereleaseis set from the tag rather than hard-coded tofalse, 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.0is after2.9.0, which a text compare reverses), and a prerelease comes before its own release (v2.8.1-rc1is afterv2.8.0and beforev2.8.1). Anything not version-shaped compares to nothing at all, because a build stampedVERSION=localonce 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,DummyValidatorandFalseValidatordeleted as unused; anunreachable!()inutilsreplaced byas_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
/metricsauthenticates 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_inforeports 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
/infois no longer served unauthenticated over plain HTTP (SQU-125).- A ban answers with 429 and a
Retry-Afterrather than "wrong password". - Only
http/1.1is offered over ALPN, so h2 framing is unreachable (SQU-126).