diff --git a/docs/tpm-enrolment-design.md b/docs/tpm-enrolment-design.md index c156087..ed87ede 100644 --- a/docs/tpm-enrolment-design.md +++ b/docs/tpm-enrolment-design.md @@ -348,8 +348,9 @@ exists. Enrolment is where it comes into being. `tpm-selftest.sh` capability check; step-ca + EAP-TLS bench. 2. **Broker — BUILT (2026-06-28): [`serving/enrol-broker/`](../serving/enrol-broker/).** Go, stdlib-only. Public unauth listener (`POST /request`, `GET /status/{id}`) - behind the serving nginx `/enrol/`; admin **mTLS web UI** (`/`, `/pending`, - `/approve|deny/{id}`) on the mgmt network; wordlist fingerprint = `H(CSR-pubkey ‖ + behind the serving nginx `/enrol/`; admin **mTLS JSON API** (`/pending`, + `/approve|deny/{id}`) on the mgmt network — driven by the fleet-manager passkey + SPA (the broker's own HTML console was retired 2026-07-11); wordlist fingerprint = `H(CSR-pubkey ‖ server-nonce)`; time-boxed in-memory store + per-IP rate limit; signs via `step ca token`+`step ca sign` (broker holds the provisioner password) with a dev `mock` signer. **Fingerprint-only assurance.** Admin client CA is a diff --git a/files/ssh-hostcert/main.go b/files/ssh-hostcert/main.go index 6c946e5..26d1314 100644 --- a/files/ssh-hostcert/main.go +++ b/files/ssh-hostcert/main.go @@ -77,9 +77,15 @@ func main() { if err != nil { log.Fatalf("read host key %s: %v", *hostKey, err) } - princ := splitPrincipals(*principals) + // Principals are the names admins connect by. Default to this host's + // (DHCP-assigned) hostname AND its FQDN . derived from the + // DHCP search domain at runtime (never baked — invariant #1); any explicit + // -principals are added on top. Without the FQDN a `@cert-authority *.` + // line rejects the cert with "name is not a listed principal" on FQDN logins + // (bench-confirmed on comet-beacon-54, 2026-07-11). + princ := appendUnique(defaultPrincipals(), splitPrincipals(*principals)...) if len(princ) == 0 { - princ = defaultPrincipals() + princ = []string{"localhost"} } keyID := "" if id.cert.Leaf != nil { @@ -175,12 +181,58 @@ func splitPrincipals(s string) []string { return out } +// defaultPrincipals returns the names admins connect to this host by: the +// (DHCP-assigned) hostname and, when a DHCP search domain is known, the FQDN +// .. The FQDN is derived at runtime (never baked — invariant +// #1), mirroring how the NTS dispatcher derives ntp.: an admin's +// `@cert-authority *.` line only matches the host if the FQDN is a cert +// principal, so a short-name-only cert fails FQDN logins. func defaultPrincipals() []string { h, err := os.Hostname() if err != nil || h == "" { - return []string{"localhost"} + return nil } - return []string{h} + out := []string{h} + if !strings.Contains(h, ".") { + if dom := dhcpDomain(); dom != "" { + out = append(out, h+"."+dom) + } + } + return out +} + +// resolvConf is the file dhcpDomain reads (a var so tests can point elsewhere). +var resolvConf = "/etc/resolv.conf" + +// dhcpDomain returns the first DHCP-provided search/domain from resolv.conf +// (NetworkManager writes it from DHCP option 15/119). Empty if none is set. +func dhcpDomain() string { + b, err := os.ReadFile(resolvConf) + if err != nil { + return "" + } + for ln := range strings.SplitSeq(string(b), "\n") { + f := strings.Fields(ln) + if len(f) >= 2 && (f[0] == "search" || f[0] == "domain") { + return f[1] + } + } + return "" +} + +// appendUnique appends to base each of extra not already present (order-preserving). +func appendUnique(base []string, extra ...string) []string { + seen := make(map[string]bool, len(base)) + for _, s := range base { + seen[s] = true + } + for _, s := range extra { + if s != "" && !seen[s] { + seen[s] = true + base = append(base, s) + } + } + return base } func envOr(k, def string) string { diff --git a/files/ssh-hostcert/main_test.go b/files/ssh-hostcert/main_test.go index 3e467a5..787a9ee 100644 --- a/files/ssh-hostcert/main_test.go +++ b/files/ssh-hostcert/main_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "os" "strings" "testing" ) @@ -40,6 +41,64 @@ func TestSplitPrincipals(t *testing.T) { } } +func TestDhcpDomain(t *testing.T) { + dir := t.TempDir() + rc := dir + "/resolv.conf" + old := resolvConf + resolvConf = rc + defer func() { resolvConf = old }() + + if err := os.WriteFile(rc, []byte("domain corp.example\n"), 0o644); err != nil { + t.Fatal(err) + } + if d := dhcpDomain(); d != "corp.example" { + t.Errorf("domain line: got %q", d) + } + if err := os.WriteFile(rc, []byte("nameserver 1.1.1.1\nsearch a.lab b.lab\n"), 0o644); err != nil { + t.Fatal(err) + } + if d := dhcpDomain(); d != "a.lab" { + t.Errorf("search line: got %q", d) + } + os.Remove(rc) + if d := dhcpDomain(); d != "" { + t.Errorf("missing file: got %q", d) + } +} + +func TestDefaultPrincipalsFQDN(t *testing.T) { + h, _ := os.Hostname() + if h == "" || strings.Contains(h, ".") { + t.Skip("host name unsuitable for this test") + } + dir := t.TempDir() + rc := dir + "/resolv.conf" + if err := os.WriteFile(rc, []byte("nameserver 10.0.0.1\nsearch fleet.lab example.org\n"), 0o644); err != nil { + t.Fatal(err) + } + old := resolvConf + resolvConf = rc + defer func() { resolvConf = old }() + + got := defaultPrincipals() + if len(got) != 2 || got[0] != h || got[1] != h+".fleet.lab" { + t.Errorf("defaultPrincipals = %v, want [%s %s.fleet.lab]", got, h, h) + } +} + +func TestAppendUnique(t *testing.T) { + got := appendUnique([]string{"a", "b"}, "b", "c", "", "a", "d") + want := []string{"a", "b", "c", "d"} + if len(got) != len(want) { + t.Fatalf("got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v want %v", got, want) + } + } +} + func TestParseHandle(t *testing.T) { h, err := parseHandle("0x81000100") if err != nil || uint32(h) != 0x81000100 { diff --git a/serving/defaults.env b/serving/defaults.env index 6fdcb6d..6fc8ff4 100644 --- a/serving/defaults.env +++ b/serving/defaults.env @@ -104,38 +104,13 @@ SUBNQN=nqn.2026-06.org.example.fleet:rootfs TARGET_PORT=4420 SPDK_CPUMASK=0x1 # one polling core is plenty for a read-only boot fs -# --- TPM enrolment broker (TEMPORARY in this stack — see docs/TODO.md #11) --- -# The broker is the one unauthenticated pre-auth endpoint (nginx proxies /enrol/ -# to it). Defaults run the zero-dependency LAB path: a `mock` signer (ephemeral -# CA; certs NOT trusted by RADIUS/NVMe) + plaintext loopback admin — enough to -# exercise the request/approve/poll flow while the host side is built. -# -# For REAL enrolment: set ENROL_SIGNER=step, point ENROL_STEP_CA_URL at a -# reachable step-ca (e.g. the one in serving/dot1x-bench/), drop its root + -# provisioner password into ./enrol-pki/, generate the admin mTLS PKI -# (./enrol-broker/admin-pki.sh ./enrol-pki/broker ), and set -# ENROL_ADMIN_INSECURE=false. Then it belongs in its own CA-co-located stack. -ENROL_SIGNER=mock # mock (lab, no CA) | step (real, needs a CA) -ENROL_ADMIN_INSECURE=true # true = plaintext loopback admin (LAB ONLY); false = mTLS -ENROL_PENDING_TTL=10m # time-box on unapproved requests -ENROL_WORDS=8 # fingerprint word count -ENROL_HOSTNQN_BASE=nqn.2026-06.org.example.fleet:host- -# step signer (only when ENROL_SIGNER=step) — paths are INSIDE the container -# (./enrol-pki is mounted at /pki): -ENROL_STEP_CA_URL= # e.g. https://ca.fleet.lab:8443 -ENROL_STEP_CA_ROOT=/pki/root_ca.crt -ENROL_STEP_PROVISIONER=enrol -ENROL_STEP_PASSWORD_FILE=/pki/password -# Issued-cert lifetime. NOTE: the step-ca provisioner must ALLOW this — a stock -# step-ca caps certs at 24h, so 720h is rejected ("requested duration ... more -# than the authorized maximum certificate duration of 24h"). The bench -# step-ca/bootstrap.sh now raises authority.claims.maxTLSCertDuration; a -# different CA needs the same. Verified end-to-end against a real step-ca. -ENROL_CERT_NOT_AFTER=720h -# admin mTLS (only when ENROL_ADMIN_INSECURE=false): -ENROL_ADMIN_CERT=/pki/broker/server.crt -ENROL_ADMIN_KEY=/pki/broker/server.key -ENROL_ADMIN_CLIENT_CA=/pki/broker/admin-ca.crt +# --- TPM enrolment broker — configured in the CA-co-located stack ----------- +# The broker no longer runs in this stack (a mock-signer copy here collided with +# the real one on 9090/9443 and issued untrusted certs — removed 2026-07-11). It +# lives in serving/dot1x-bench/ alongside step-ca (real signing needs the CA), and +# the `http` nginx here still proxies /enrol/ -> 127.0.0.1:9090 to reach it on the +# same host. Configure the broker (signer, provisioner, admin mTLS) in +# serving/dot1x-bench/.env, not here. # --- SPDK version loader / reaper (declarative rolling updates) ------------- # The spdk-loader service stages + adds ${IMAGE_VERSION} to the long-lived target diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml index 9e052ac..68b3369 100644 --- a/serving/docker-compose.yml +++ b/serving/docker-compose.yml @@ -193,50 +193,15 @@ services: # For HTTPS boot (production), terminate TLS here with the boot-infra CA's # cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab. - # --- TPM enrolment broker (TEMPORARY home — see docs/TODO.md #11) ----------- - # The `http` nginx already reverse-proxies /enrol/ -> 127.0.0.1:9090 (this - # broker's public listener), so an empty-NV host can POST its CSR and poll for - # the signed cert via the normal serving host. For now the broker runs HERE so - # a single `compose up` in serving/ brings the enrolment endpoint up. - # - # ⚠ PROPER HOME is its own stack co-located with step-ca (the fleet CA), i.e. - # serving/dot1x-bench/, since REAL signing needs that CA. SPLIT THIS BACK OUT - # once the CA lives beside it. Do NOT run this AND dot1x-bench's enrol-broker on - # the same host — they collide on 9090/9443. - # - # Defaults below are the zero-dependency LAB path: `mock` signer (ephemeral - # in-process CA — certs are NOT trusted by RADIUS/NVMe) + plaintext loopback - # admin. For REAL enrolment, in .env set ENROL_SIGNER=step + point - # ENROL_STEP_CA_* at a reachable step-ca, and set ENROL_ADMIN_INSECURE=false - # after generating the admin mTLS PKI (a DEDICATED admin CA, not the fleet root): - # ./enrol-broker/admin-pki.sh ./enrol-pki/broker - # then import enrol-pki/broker/clients/.p12 and open https://127.0.0.1:9443/ - enrol-broker: - build: ./enrol-broker - network_mode: host # public on 127.0.0.1:9090 (nginx proxies /enrol/) - restart: unless-stopped - environment: - BROKER_PUBLIC_ADDR: "127.0.0.1:9090" # nginx /enrol/ proxies here - BROKER_ADMIN_ADDR: "127.0.0.1:9443" # mgmt/loopback ONLY — never the boot LAN - BROKER_SIGNER: ${ENROL_SIGNER:-mock} - BROKER_PENDING_TTL: ${ENROL_PENDING_TTL:-10m} - BROKER_WORDS: ${ENROL_WORDS:-8} - HOSTNQN_BASE: ${ENROL_HOSTNQN_BASE:-nqn.2026-06.org.example.fleet:host-} - # --- step signer (only consulted when ENROL_SIGNER=step) --- - STEP_CA_URL: ${ENROL_STEP_CA_URL:-} - STEP_CA_ROOT: ${ENROL_STEP_CA_ROOT:-/pki/root_ca.crt} - STEP_PROVISIONER: ${ENROL_STEP_PROVISIONER:-enrol} - STEP_PROVISIONER_PASSWORD_FILE: ${ENROL_STEP_PASSWORD_FILE:-/pki/password} - STEP_CERT_NOT_AFTER: ${ENROL_CERT_NOT_AFTER:-720h} - # --- admin mTLS (set ENROL_ADMIN_INSECURE=false for real mTLS) --- - BROKER_ADMIN_INSECURE: ${ENROL_ADMIN_INSECURE:-true} - BROKER_ADMIN_CERT: ${ENROL_ADMIN_CERT:-/pki/broker/server.crt} - BROKER_ADMIN_KEY: ${ENROL_ADMIN_KEY:-/pki/broker/server.key} - BROKER_ADMIN_CLIENT_CA: ${ENROL_ADMIN_CLIENT_CA:-/pki/broker/admin-ca.crt} - volumes: - # CA root + provisioner password (step signer) and admin mTLS certs live - # here; unused/empty for the default mock+insecure path. Gitignored. - - ./enrol-pki:/pki:ro + # --- TPM enrolment broker — NOT defined here (lives in the CA stack) -------- + # The broker's home is co-located with step-ca (the fleet CA), i.e. + # serving/dot1x-bench/, since real signing needs that CA. A `mock`-signer + # convenience copy used to run here too, but two host-networked brokers collide + # on 9090/9443 and the mock copy issued certs RADIUS/NVMe don't trust — so it was + # removed (bench 2026-07-11; see docs/ssh-host-certificate-design.md). The `http` + # nginx still reverse-proxies /enrol/ -> 127.0.0.1:9090; bring up + # serving/dot1x-bench on the same host (its broker binds that loopback port) to + # serve enrolment. Do NOT re-add a second broker here. # --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------ # Svelte + Tailwind app built into a static bundle served by its own nginx. diff --git a/serving/dot1x-bench/docker-compose.yml b/serving/dot1x-bench/docker-compose.yml index fc18e02..a445b70 100644 --- a/serving/dot1x-bench/docker-compose.yml +++ b/serving/dot1x-bench/docker-compose.yml @@ -70,7 +70,12 @@ services: BROKER_PUBLIC_ADDR: "127.0.0.1:9090" BROKER_ADMIN_ADDR: "127.0.0.1:9443" BROKER_SIGNER: "step" - STEP_CA_URL: "https://${CA_DNS:-ca.fleet.lab}:${CA_PORT:-8443}" + # Co-located with step-ca (both network_mode: host), so reach it over + # loopback — NOT the fleet DNS name. ca.fleet.lab is a dnsmasq name for + # boot-LAN clients; the broker's host resolver doesn't know it, so signing + # died with "lookup ca.fleet.lab: no such host" (bench 2026-07-11). localhost + # is always in the CA cert SAN (DOCKER_STEPCA_INIT_DNS_NAMES) so TLS verifies. + STEP_CA_URL: "https://localhost:${CA_PORT:-8443}" STEP_CA_ROOT: "/pki/root_ca.crt" STEP_PROVISIONER: "enrol" STEP_PROVISIONER_PASSWORD_FILE: "/pki/stepca/secrets/password" diff --git a/serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh b/serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh index e7e1c04..2ece5a7 100755 --- a/serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh +++ b/serving/dot1x-bench/step-ca/ssh-ca-bootstrap.sh @@ -82,10 +82,33 @@ fi # The broker authenticates to step-ca with this JWK provisioner (signer.go). It # must be allowed to issue SSH HOST certs. BENCH-VERIFY the exact subcommand/flag # on your step version (some use `provisioner update --ssh`). -echo "==> enabling SSH on provisioner '${PROVISIONER}' (BENCH-VERIFY the flag)" -incca step ca provisioner update "${PROVISIONER}" --ssh 2>/dev/null \ - && restart_ca \ - || echo " NOTE: could not auto-enable SSH on '${PROVISIONER}' — enable it by hand (step ca provisioner update ${PROVISIONER} --ssh) and restart step-ca." +echo "==> enabling SSH host-cert issuance on provisioner '${PROVISIONER}'" +# FAIL LOUDLY: a swallowed failure here leaves enableSSHCA=false, and every +# `step ssh certificate` at runtime dies with a 502 whose cause the operator +# can't see (bench 2026-07-11). If your `step` uses a different flag, fix it and +# re-run rather than shipping a half-configured CA. +if ! incca step ca provisioner update "${PROVISIONER}" --ssh; then + echo "ERROR: 'step ca provisioner update ${PROVISIONER} --ssh' failed. Your step" >&2 + echo " version may use a different flag — check: step ca provisioner update --help" >&2 + echo " Aborting so SSH host-cert signing isn't silently left disabled." >&2 + exit 1 +fi +restart_ca +# Verify it actually took — the CA only issues SSH host certs if enableSSHCA=true. +if ! python3 - "$CA_JSON" "$PROVISIONER" <<'PY' +import json,sys +c=json.load(open(sys.argv[1])); name=sys.argv[2] +for p in c["authority"]["provisioners"]: + if p.get("name")==name and p.get("claims",{}).get("enableSSHCA") is True: + sys.exit(0) +sys.exit(1) +PY +then + echo "ERROR: provisioner '${PROVISIONER}' still not SSH-enabled (enableSSHCA != true)" >&2 + echo " after the update + restart. SSH host-cert signing would fail at runtime; aborting." >&2 + exit 1 +fi +echo " SSH host-cert issuance enabled on '${PROVISIONER}' (enableSSHCA=true)." # --- 3. broker host-plane server cert + fleet client-CA bundle -------------- # The host plane is mTLS: its SERVER cert must be FLEET-signed (the booted host @@ -104,8 +127,19 @@ cat "$PKI/root_ca.crt" > "$PKI/broker/fleet-client-ca.crt" [ -f "$PKI/intermediate_ca.crt" ] && cat "$PKI/intermediate_ca.crt" >> "$PKI/broker/fleet-client-ca.crt" # --- 4. export the SSH host CA public key (admin trust anchor) --------------- -echo "==> exporting SSH host CA public key -> $PKI/ssh_host_ca.pub" -incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca.pub +# `step crypto keypair` writes the public key in PEM/PKCS8, but a known_hosts +# `@cert-authority` line needs the single-line OpenSSH wire format +# (`ecdsa-sha2-nistp256 AAAA…`). Emitting the PEM verbatim yields an unparseable +# anchor → ssh silently falls back to TOFU (bench 2026-07-11). Convert it. +echo "==> exporting SSH host CA public key (OpenSSH format) -> $PKI/ssh_host_ca.pub" +incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca_key.pem +command -v ssh-keygen >/dev/null 2>&1 || { + echo "ERROR: ssh-keygen not found — cannot convert the CA key to OpenSSH format." >&2 + echo " Install openssh-clients; the raw PKCS8 key is at $PKI/ssh_host_ca_key.pem." >&2 + exit 1 +} +ssh-keygen -i -m PKCS8 -f "$PKI/ssh_host_ca_key.pem" > "$PKI/ssh_host_ca.pub" +rm -f "$PKI/ssh_host_ca_key.pem" echo echo "Admin rollout — add ONE line to each admin's ~/.ssh/known_hosts (or a system" echo "known_hosts), so every fleet host is trusted via the CA instead of TOFU:" diff --git a/serving/enrol-broker/README.md b/serving/enrol-broker/README.md index 5b5cb0f..02c8f27 100644 --- a/serving/enrol-broker/README.md +++ b/serving/enrol-broker/README.md @@ -19,7 +19,7 @@ PUBLIC (plain HTTP, behind the serving nginx /enrol/) UNAUTHENTICATED GET /healthz ADMIN (mTLS, management network / loopback ONLY) admin client cert - GET / the pending-queue web UI (fingerprint + approve/deny) + GET / plaintext pointer (API only; human UI is the fleet-manager SPA) GET /pending JSON queue POST /approve/{id} OOB-approve -> step ca sign -> store cert POST /deny/{id} reject @@ -74,9 +74,11 @@ docker compose up -d step-ca && ./step-ca/bootstrap.sh # CA + root_ca.crt + en docker compose up -d enrol-broker ``` -Then import `pki/broker/clients/alice.p12` (empty password) into your browser and -open `https://127.0.0.1:9443/`. Hosts reach the public side via the serving -stack's nginx: `http://boot.fleet.lab/enrol/`. +Approve/deny is driven by the **fleet-manager SPA** (passkey-gated), which calls +this plane's JSON API over mTLS; the broker no longer serves its own HTML console. +For a scripted/manual approve, use the admin client cert directly against the API, +e.g. `curl --cert/--key … https://127.0.0.1:9443/pending`. Hosts reach the public +side via the serving stack's nginx: `http://boot.fleet.lab/enrol/`. ### Admin mTLS PKI — important diff --git a/serving/enrol-broker/admin.go b/serving/enrol-broker/admin.go index d1280bb..c4375f6 100644 --- a/serving/enrol-broker/admin.go +++ b/serving/enrol-broker/admin.go @@ -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 = ` - - -fleet enrolment — pending - -

fleet enrolment — pending approvals

-

admin: {{.Admin}} · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving

-{{if not .Pending}}
no pending enrolments.
{{end}} -{{range .Pending}} -
-
{{.Fingerprint}}
-
- enrol-id{{.ID}} - CN{{.CN}} - {{if .Context.MAC}}MAC{{.Context.MAC}}{{end}} - {{if .Context.DMISerial}}DMI serial{{.Context.DMISerial}}{{end}} - {{if .Context.DMIUUID}}DMI uuid{{.Context.DMIUUID}}{{end}} - {{if .Context.EKHash}}TPM EK{{.Context.EKHash}}{{end}} - pubkey{{.PubKeyHash}} - age{{age .CreatedAt}} -
-
-
-
-
-
-{{end}} -`