draupnir's own BMC — a Rust Redfish server fronting KVM
Feature
redfish-server(OFF by default) ·src/redfish_server.rs· shared vocabulary insrc/redfish/wire.rs· anchored bytests/redfish_server_conformance.rsagainsttests/fixtures/dmtf/· live loop intests/redfish_kvm_loop_proof.rsDeclared 2026-08-15.
What it is
A pure-Rust Redfish service that answers the endpoints draupnir's Redfish client drives, and translates them onto a QEMU/KVM guest through draupnir's own Boot/Lifecycle seam. It turns any Linux box with /dev/kvm into a Redfish-controllable machine.
src/redfish.rs was already the client end — it drives a real iDRAC / iLO / OpenBMC. This is the other end of the same protocol, in the same crate, sharing the same wire vocabulary.
Why it exists
Two reasons, and the second is the durable one.
- A Redfish burn needs a BMC, and a developer box has none. The
iso-metalmatrix row can never go green here, because a human with a USB stick is not a matrix row. This gives the wire half of that proof a service to run against — recorded asiso-redfish-sim, on its own row. - It is a real capability. A lab, a CI fleet or a nested-virt pool becomes addressable with the same out-of-band vocabulary as the metal it stands in for. That is squarely draupnir's charter: kvm and container, low level.
It is Rust rather than OpenStack's Python sushy-emulator because putting the metal-path proof on a Python service would put it outside the pure-Rust charter — and because draupnir already owned both ends, so the server is the hinge between them rather than a new dependency.
THE TRAP, and what is done about it
draupnir's client talking to draupnir's server is a MIRROR. Both ends can be wrong in the same way and every test still passes. That is the identity-value failure: a test sitting on 1.0 cannot tell a working transform from a dead one. A loopback green proves self-consistency, not Redfish.
Two answers, neither optional:
- The shapes are anchored OUTSIDE this repo.
tests/redfish_server_conformance.rsreads DMTF's published JSON Schema, theBase.1.19.0message registry and thepublic-rackmount1mockup — vendored undertests/fixtures/dmtf/, provenance and digests inPROVENANCE.md, re-hashed at run time so a fixture edited to make a test pass is itself a red. See "What the anchor checks" below. - The form is
iso-redfish-sim, neveriso-metal. An emulator proves the wire, never the hardware.iso-metalstays Absent, visibly, on its own row.
The translation — no new machinery
| Redfish | draupnir |
VirtualMedia.InsertMedia |
BootSpec::medium |
Boot.BootSourceOverrideTarget |
BootOrder, via BootOrder::from_redfish_target |
ComputerSystem.Reset {On} |
Boot::boot → KvmBoot → QEMU/OVMF/KVM |
ComputerSystem.Reset {ForceOff} |
Lifecycle::power_off |
ComputerSystem.PowerState |
Lifecycle::status |
BootOrder::from_redfish_target is the inverse of the client's BootOrder::redfish_target, and it lives next to it in src/lib.rs — two mappings in two modules would be two mappings, and the one that mattered would be the one nobody tested.
Each order then selects the constructor that already expresses it:
Medium→BootSpec::iso_boot— the inserted media is the whole payload.Disk→BootSpec::kvm_boot_installed_disk, which by construction carries no medium at all (validate()refusesDisk+ a medium). That is what makes anHddoverride applied rather than merely acknowledged.Auto→ the node's own order: the medium if a tray is loaded, else the local disk.
A target this node cannot honour (Pxe, BiosSetup) is refused by name, never coerced into one it can.
REUSE, not a twin — src/redfish/wire.rs
The paths, property names, action names, enum tokens, the Basic-auth encoder/decoder and the action-body builders/readers are written down once, in redfish::wire, and both ends read them:
- the client composes
host + wire::system_path(id); - the server's routing table is built from the same
wire::*calls; - the client's
insert_media_bodyand the server'sread_insert_media_bodysit in the same file, with a round-trip test between them, so a field renamed on one side fails to compile-or-test rather than silently disagreeing at runtime.
The conformance suite then checks those paths against the @odata.id strings in DMTF's own mockup — so "the two ends agree" and "the two ends are right" are separate, separately proven claims.
TLS — why the server needed a certificate at all
draupnir's client pins the BMC certificate (RedfishBoot::pin_cert_pem; verification stays ON with that cert as the sole trusted root). A pinned client needs a real cert to pin, so the server mints a genuine self-signed X.509 with SANs per run and holds the key in memory only — nothing is written to disk, so there is no .key/.pem/.der that could be committed. cert_pem() hands out the public half.
The crate is rcgen 0.13 with default-features = false, features = ["pem","ring"] — the same crate, features and call shape as Skidbladnir's src/pki.rs, which is the constellation's TLS-identity story and is already proven to load into a real rustls::ServerConfig. korp-installer's SigningIdentity (RSA + x509-cert) was read and deliberately not reused: it mints an Authenticode code-signing identity, not a TLS server identity with SANs. Skidbladnir's code cannot simply be called from here either — Skidbladnir path-deps this crate (Skidbladnir/Cargo.toml:87), so a dep back onto its pki module would be a cycle. Same crate, same pattern: one cert story, not two.
Threading
The accept loop runs on a gatling::background::Job — the constellation's sanctioned home for a raw thread (ROOT-LAW #0), the same seam src/exe.rs:246 already uses. No bare std::thread::spawn, no rayon, no async runtime.
The endpoints
| Method | Path | Answer |
GET |
/redfish |
{"v1": "/redfish/v1/"} — unauthenticated (DSP0266) |
GET |
/redfish/v1/ (and /redfish/v1) |
ServiceRoot — unauthenticated |
GET |
/redfish/v1/SessionService/Sessions |
empty SessionCollection |
GET |
/redfish/v1/Systems |
ComputerSystemCollection |
GET |
/redfish/v1/Systems/{id} |
ComputerSystem (live PowerState) |
PATCH |
/redfish/v1/Systems/{id} |
the one-time Boot override → 204 |
GET |
…/VirtualMedia and …/VirtualMedia/{slot} |
the collection / the slot |
POST |
…/VirtualMedia/{slot}/Actions/VirtualMedia.InsertMedia |
mount → 204 |
POST |
…/VirtualMedia/{slot}/Actions/VirtualMedia.EjectMedia |
unmount → 204 |
POST |
…/Systems/{id}/Actions/ComputerSystem.Reset |
power → 204 |
Everything below the service root needs HTTP Basic auth (401 + WWW-Authenticate otherwise). A wrong method answers 405 with Allow.
What the anchor checks
- No invented property — every key emitted must be one DMTF's schema declares, or a well-formed Redfish annotation (the
patternPropertiesgrammar is matched by hand, soResetType@Redfish.AllowedValuesstill dies). - Nothing required missing — every name in the schema's
requiredarray. - The
@odata.typeis real —#Ns.vX_Y_Z.Typemust be a definition the DMTF schema file for that exact version declares. - Advertised values are DMTF's —
ResetType@Redfish.AllowableValues⊆Resource.json#/definitions/ResetType;BootSourceOverrideTarget…⊆ComputerSystem.json#/definitions/BootSource. - The mockup's URIs are our paths — DMTF's own
@odata.idstrings and the Reset action'starget, compared towire::*. - The errors are the registry's — every message compared field-for-field with the
Base.1.19.0entry of the sameId, and each liveerrorpayload againstredfish-error.v1_0_2+Message.v1_1_2.
The RED controls
| control | where | what it refuses |
bad ResetType |
conformance + loop proof | 400 Base.1.19.0.ActionParameterValueNotInList, quoting the value; nothing boots |
InsertMedia at a ghost image |
conformance + loop proof | 400 Base.1.19.0.ResourceMissingAtURI, naming the URI; nothing mounts |
| override applied, not just acknowledged | conformance (spec level) + loop proof (machine level) | the same ISO left in the tray, only Cd→Hdd: the spec loses its medium AND the guest prints no DVD-ROM line and no appliance banner |
| unhonourable boot target | conformance | Pxe/BiosSetup/Usb → 400 PropertyValueNotInList, never coerced to Cd |
| missing / wrong credential | conformance | 401 + WWW-Authenticate |
| wrong-cert pinning | conformance | the TLS handshake fails — proving pinning is on, not off |
| tampered DMTF fixture | conformance | digest mismatch against PROVENANCE.md |
Named limitations — read before trusting interop
- No real BMC was ever contacted. Conformance to DMTF's published shapes makes interop with an iDRAC/iLO plausible. It is not measured.
iso-metalstays Absent. - This BMC does not fetch remote media. A real BMC pulls the image over http(s)/NFS/CIFS; this one resolves a
file://URI or an absolute path and refuses a remote scheme by name. The client side is unchanged — it still sends whatever URI real metal would be given — but the server side is an emulator difference, and it is loud rather than silent. - Session authentication is not implemented. HTTP Basic only. The required
ServiceRoot.Links.Sessionspointer resolves to an empty, read-onlySessionCollection, andPOSTthere answers405withAllow: GETrather than handing out a plausible-looking token. - The
$metadata/odataservice documents are not served. Nothing draupnir's client asks for; a generic Redfish explorer would notice. - HTTP/1.1 with
Content-Lengthbodies only — no chunked request bodies. Redfish clients do not chunk action bodies, and a service that guessed would be guessing. - One request per connection,
Connection: close, no keep-alive. Keep-alive was tried first and produced a measured flake: 3ECONNRESETfailures in 28 full conformance runs (2026-08-15), the classic idle-close race against the client's connection pool on a deliberately serial accept loop. 0 in 40 after the change. A TLS handshake per request is the price; a proof that lies one run in ten is not. - The accept loop is serial. One request at a time. A BMC control plane makes single-digit numbers of calls per boot; concurrency here would buy nothing and cost a lock story.