ssh-hostcert + enrol-broker: fix defects surfaced by the F-02 bench

F-02/P-17 SSH host-cert flow was proven end-to-end on comet-beacon-54
(2026-07-11); these fix the correctness/scoping/cleanup defects the bench
surfaced:

- ssh-hostcert: derive the FQDN principal <hostname>.<domain> from the DHCP
  search domain at runtime (resolv.conf), alongside the short hostname, so an
  admin `@cert-authority *.<domain>` line accepts by-FQDN logins instead of
  "name is not a listed principal". Never baked (invariant #1). + tests.
- ssh-ca-bootstrap.sh: export the SSH host CA public key in OpenSSH wire format
  (ssh-keygen -i -m PKCS8), not the PEM/PKCS8 that `step crypto keypair` writes
  -- a PEM @cert-authority anchor is unparseable and silently falls back to TOFU.
- ssh-ca-bootstrap.sh: fail loudly (and verify enableSSHCA=true) if the
  provisioner SSH-enable step fails, instead of swallowing it and shipping a CA
  that 502s every runtime sign.
- dot1x-bench: point the co-located broker at https://localhost:8443, not the
  fleet dnsmasq name ca.fleet.lab (unresolvable from the broker's host netns ->
  "no such host" at sign time). localhost is in the CA cert SAN.
- serving: remove the mock-signer enrol-broker copy -- two host-networked
  brokers collide on 9090/9443 and the mock issued untrusted certs. The real
  broker lives with step-ca (dot1x-bench); nginx /enrol/ still proxies to it on
  loopback.
- enrol-broker: retire the HTML admin console; the fleet-manager passkey SPA now
  drives the mTLS JSON API (/pending, /approve|deny). Removes a weaker,
  client-cert-only parallel human-approve path.

Design-doc bench note deferred: docs/ssh-host-certificate-design.md lives on the
secure-ntp branch (fbb8100), not here -- to be added there / on reconcile.
This commit is contained in:
Audiobuzz
2026-07-11 09:44:33 +00:00
parent 8325818c1b
commit 28d31e436c
9 changed files with 207 additions and 177 deletions
+21 -84
View File
@@ -1,4 +1,4 @@
// admin.go — the mTLS admin plane: the pending-queue web UI + approve/deny.
// admin.go — the mTLS admin plane: the pending-queue + approve/deny JSON API.
//
// This listener is the AUTHENTICATED half of the broker and must live on the
// management network / loopback only — NEVER the boot LAN (docs invariant: the
@@ -11,7 +11,6 @@ package main
import (
"crypto/tls"
"crypto/x509"
"html/template"
"log"
"net/http"
"os"
@@ -21,7 +20,7 @@ import (
func (b *broker) serveAdmin() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", b.handleAdminIndex)
mux.HandleFunc("GET /", b.handleAdminRoot)
mux.HandleFunc("GET /pending", b.handleAdminPending)
mux.HandleFunc("POST /approve/{id}", b.handleApprove)
mux.HandleFunc("POST /deny/{id}", b.handleDeny)
@@ -121,94 +120,32 @@ func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
respondDecision(w, r, e)
}
// respondDecision replies to an approve/deny. A browser form gets redirected back
// to the queue; a caller that asks for JSON (Accept: application/json — e.g. the
// fleet-manager driving the broker as its authorise plane) gets the decided
// Enrolment (cert_serial, hostnqn, context, fingerprint — NOT the cert PEM, which
// is unexported and only reaches the requesting host); any other API caller keeps
// the original 204 (so verify-serving.sh and existing scripts are unaffected).
// respondDecision replies to an approve/deny. A caller that asks for JSON
// (Accept: application/json — the fleet-manager driving the broker as its
// authorise plane) gets the decided Enrolment (cert_serial, hostnqn, context,
// fingerprint — NOT the cert PEM, which is unexported and only reaches the
// requesting host); any other API caller keeps the original 204 (so
// verify-serving.sh and existing scripts are unaffected).
func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) {
switch {
case isBrowser(r):
http.Redirect(w, r, "/", http.StatusSeeOther)
case wantsJSON(r):
if wantsJSON(r) {
writeJSON(w, http.StatusOK, e)
default:
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusNoContent)
}
func wantsJSON(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "application/json")
}
func isBrowser(r *http.Request) bool {
a := r.Header.Get("Accept")
return len(a) >= 9 && a[:9] == "text/html" || // explicit
r.Header.Get("Sec-Fetch-Mode") == "navigate"
// handleAdminRoot: the human approval UI has moved to the fleet-manager SPA
// (WebAuthn-passkey gated), which drives this plane's JSON API over mTLS. This
// listener is now API-only — the old embedded HTML console was retired to avoid a
// second, weaker (client-cert-only) human approve path parallel to the passkey
// UI. Kept as a plaintext pointer so a bookmark to the old console isn't a 404.
func (b *broker) handleAdminRoot(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("enrol-broker admin plane — API only.\n" +
" GET /pending\n POST /approve/{id}\n POST /deny/{id}\n" +
"The human approval UI is the fleet-manager SPA (passkey-gated).\n"))
}
func (b *broker) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
data := struct {
Admin string
Pending []*Enrolment
Now time.Time
}{
Admin: adminCN(r),
Pending: b.store.Pending(),
Now: time.Now(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := adminTmpl.Execute(w, data); err != nil {
log.Printf("admin template: %v", err)
}
}
var adminTmpl = template.Must(template.New("admin").Funcs(template.FuncMap{
"age": func(t time.Time) string { return time.Since(t).Round(time.Second).String() },
}).Parse(adminHTML))
const adminHTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>fleet enrolment — pending</title>
<style>
:root{color-scheme:dark}
body{background:#0b0f14;color:#cfe3ee;font:14px/1.5 ui-monospace,Menlo,Consolas,monospace;margin:0;padding:1.5rem}
h1{font-size:1.1rem;color:#7fd1ff;margin:0 0 .25rem}
.sub{color:#6b8294;margin:0 0 1.25rem}
.empty{color:#6b8294;padding:2rem 0}
.card{background:#111923;border:1px solid #1f2d3a;border-radius:8px;padding:1rem 1.1rem;margin:0 0 1rem}
.fp{font-size:1.25rem;color:#aef9b0;letter-spacing:.5px;word-break:break-word}
.meta{display:grid;grid-template-columns:max-content 1fr;gap:.1rem .8rem;margin:.6rem 0;color:#9fb6c4}
.meta b{color:#6b8294;font-weight:400}
.actions{margin-top:.7rem}
button{font:inherit;border:0;border-radius:6px;padding:.45rem .9rem;cursor:pointer}
.approve{background:#15602f;color:#d6ffe0}
.deny{background:#5e1b1b;color:#ffd9d9;margin-left:.5rem}
form{display:inline}
code{color:#cfe3ee}
</style></head><body>
<h1>fleet enrolment — pending approvals</h1>
<p class="sub">admin: <code>{{.Admin}}</code> · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving</p>
{{if not .Pending}}<div class="empty">no pending enrolments.</div>{{end}}
{{range .Pending}}
<div class="card">
<div class="fp">{{.Fingerprint}}</div>
<div class="meta">
<b>enrol-id</b><code>{{.ID}}</code>
<b>CN</b><code>{{.CN}}</code>
{{if .Context.MAC}}<b>MAC</b><code>{{.Context.MAC}}</code>{{end}}
{{if .Context.DMISerial}}<b>DMI serial</b><code>{{.Context.DMISerial}}</code>{{end}}
{{if .Context.DMIUUID}}<b>DMI uuid</b><code>{{.Context.DMIUUID}}</code>{{end}}
{{if .Context.EKHash}}<b>TPM EK</b><code>{{.Context.EKHash}}</code>{{end}}
<b>pubkey</b><code>{{.PubKeyHash}}</code>
<b>age</b><code>{{age .CreatedAt}}</code>
</div>
<div class="actions">
<form method="post" action="/approve/{{.ID}}"><button class="approve" type="submit">approve</button></form>
<form method="post" action="/deny/{{.ID}}"><button class="deny" type="submit">deny</button></form>
</div>
</div>
{{end}}
</body></html>`