Gunnar

mikronova/draupnir

.nornir/redfish-server.md

# draupnir's own BMC — a Rust Redfish server fronting KVM

> Feature `redfish-server` (OFF by default) · `src/redfish_server.rs` ·
> shared vocabulary in `src/redfish/wire.rs` ·
> anchored by `tests/redfish_server_conformance.rs` against `tests/fixtures/dmtf/` ·
> live loop in `tests/redfish_kvm_loop_proof.rs`
> Declared 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.

1. **A Redfish burn needs a BMC**, and a developer box has none. The `iso-metal`
   matrix 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 as `iso-redfish-sim`, on its own row.
2. **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.rs`
  reads DMTF's published JSON Schema, the `Base.1.19.0` message registry and the
  `public-rackmount1` mockup — vendored under `tests/fixtures/dmtf/`, provenance and
  digests in `PROVENANCE.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`, never `iso-metal`.** An emulator proves the wire,
  never the hardware. `iso-metal` stays 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()` refuses `Disk` + a medium). That is what makes an
  `Hdd` override *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_body` and the server's `read_insert_media_body` sit 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

1. **No invented property** — every key emitted must be one DMTF's schema declares,
   or a *well-formed* Redfish annotation (the `patternProperties` grammar is matched
   by hand, so `ResetType@Redfish.AllowedValues` still dies).
2. **Nothing required missing** — every name in the schema's `required` array.
3. **The `@odata.type` is real** — `#Ns.vX_Y_Z.Type` must be a definition the DMTF
   schema file *for that exact version* declares.
4. **Advertised values are DMTF's** — `ResetType@Redfish.AllowableValues` ⊆
   `Resource.json#/definitions/ResetType`; `BootSourceOverrideTarget…` ⊆
   `ComputerSystem.json#/definitions/BootSource`.
5. **The mockup's URIs are our paths** — DMTF's own `@odata.id` strings and the
   Reset action's `target`, compared to `wire::*`.
6. **The errors are the registry's** — every message compared field-for-field with
   the `Base.1.19.0` entry of the same `Id`, and each live `error` payload against
   `redfish-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-metal` stays 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.Sessions` pointer resolves to an empty, read-only
  `SessionCollection`, and `POST` there answers `405` with `Allow: GET` rather than
  handing out a plausible-looking token.
* **The `$metadata` / `odata` service documents are not served.** Nothing draupnir's
  client asks for; a generic Redfish explorer would notice.
* **HTTP/1.1 with `Content-Length` bodies 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: 3 `ECONNRESET` failures 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.