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.
152 lines
5.3 KiB
Go
152 lines
5.3 KiB
Go
// 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
|
|
// management plane is recon-valuable). Admins authenticate with a client cert
|
|
// issued by the fleet CA; the cert CN is recorded as who approved each enrolment.
|
|
// The fingerprint shown here is what the admin cross-checks against the device's
|
|
// Plymouth splash before approving (the human OOB gate).
|
|
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (b *broker) serveAdmin() {
|
|
mux := http.NewServeMux()
|
|
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)
|
|
|
|
srv := &http.Server{
|
|
Addr: b.cfg.adminAddr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 40 * time.Second, // approve blocks on step-ca
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
if b.cfg.adminInsecure {
|
|
log.Printf("WARNING: BROKER_ADMIN_INSECURE=true — admin UI is PLAINTEXT with NO client-cert auth. DEV ONLY. Bind to loopback and never expose it.")
|
|
log.Fatalf("admin listener: %v", srv.ListenAndServe())
|
|
return
|
|
}
|
|
|
|
if b.cfg.adminCert == "" || b.cfg.adminKey == "" || b.cfg.adminClientCA == "" {
|
|
log.Fatalf("admin mTLS requires BROKER_ADMIN_CERT, BROKER_ADMIN_KEY and BROKER_ADMIN_CLIENT_CA (or set BROKER_ADMIN_INSECURE=true for dev)")
|
|
}
|
|
cert, err := tls.LoadX509KeyPair(b.cfg.adminCert, b.cfg.adminKey)
|
|
if err != nil {
|
|
log.Fatalf("admin server cert: %v", err)
|
|
}
|
|
caPEM, err := os.ReadFile(b.cfg.adminClientCA)
|
|
if err != nil {
|
|
log.Fatalf("admin client CA: %v", err)
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(caPEM) {
|
|
log.Fatalf("admin client CA %s contains no certificates", b.cfg.adminClientCA)
|
|
}
|
|
srv.TLSConfig = &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
|
ClientCAs: pool,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
log.Fatalf("admin listener: %v", srv.ListenAndServeTLS("", ""))
|
|
}
|
|
|
|
func adminCN(r *http.Request) string {
|
|
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
|
|
if cn := r.TLS.PeerCertificates[0].Subject.CommonName; cn != "" {
|
|
return cn
|
|
}
|
|
return "unknown-admin"
|
|
}
|
|
return "dev-insecure"
|
|
}
|
|
|
|
func (b *broker) handleAdminPending(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, b.store.Pending())
|
|
}
|
|
|
|
func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
admin := adminCN(r)
|
|
csrPEM, ok := b.store.csrFor(id)
|
|
if !ok {
|
|
http.Error(w, "enrol-id not pending (expired/decided/unknown)", http.StatusConflict)
|
|
return
|
|
}
|
|
csr, err := parseCSR(csrPEM)
|
|
if err != nil {
|
|
http.Error(w, "stored CSR unparsable: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ctx, cancel := ctxWithTimeout(35 * time.Second)
|
|
defer cancel()
|
|
certPEM, serial, err := b.signer.Sign(ctx, csrPEM, csr.Subject.CommonName)
|
|
if err != nil {
|
|
log.Printf("audit approve-FAILED id=%s admin=%q err=%v", id, admin, err)
|
|
http.Error(w, "signing failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
e, err := b.store.Approve(id, certPEM, serial, admin)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin)
|
|
respondDecision(w, r, e)
|
|
}
|
|
|
|
func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
admin := adminCN(r)
|
|
e, err := b.store.Deny(id, admin)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin)
|
|
respondDecision(w, r, e)
|
|
}
|
|
|
|
// 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) {
|
|
if wantsJSON(r) {
|
|
writeJSON(w, http.StatusOK, e)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func wantsJSON(r *http.Request) bool {
|
|
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
|
}
|
|
|
|
// 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"))
|
|
}
|