fleet-manager: checkpoint 5 — admin WebAuthn/passkey auth (replaces browser mTLS)
The human->UI hop now authenticates with a hardware passkey (YubiKey), not an mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker stays mTLS — service-to-service.) - webauthn.go: credential store (registered admins + passkeys + a one-time-token jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup. - cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed enrolment link over a shared enrol key; the browser completes the WebAuthn create ceremony. No anyone-can-register endpoint; single-use via the ledger. - authapi.go: register/login ceremonies (usernameless discoverable login, userVerification required) + logout + whoami; short-lived in-memory ceremony store between begin/finish. - session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie) + requireSession middleware gating /api/*, with a CSRF header check on mutations. adminName now comes from the session (audit attribution). - main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org. /api/* mounted behind requireSession; /auth/* + /healthz public. Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate; DNS-01 proves control of bitasylum.org via TXT + supports wildcards). External ACME client issues+renews to the cert path; manager reloads. Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony (virtual authenticator / real YubiKey) is a bench item.
This commit is contained in:
@@ -10,18 +10,40 @@ Unlike `fleet-hostapi` / `fleet-enrol` (baked into the reproducible verity image
|
|||||||
stdlib-only), this runs **server-side** as its own container on the management
|
stdlib-only), this runs **server-side** as its own container on the management
|
||||||
network, so external deps are fine (`grpc-go`, `influxdb-client-go`).
|
network, so external deps are fine (`grpc-go`, `influxdb-client-go`).
|
||||||
|
|
||||||
## Two mTLS planes (distinct trust anchors)
|
## Two planes (distinct trust models)
|
||||||
|
|
||||||
| Plane | Transport | Verifies | Who | Purpose |
|
| Plane | Transport | Browser/peer identity | Who | Purpose |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **admin** | HTTPS + mTLS | **dedicated admin CA** (`admin-pki.sh`) | admin browsers | JSON API + SPA (monitor / authorise / lifecycle) |
|
| **admin** | HTTPS (server-auth) | **WebAuthn/passkey** (YubiKey) → session | admin browsers | JSON API + SPA (monitor / authorise / lifecycle) |
|
||||||
| **agent** | gRPC + mTLS | **fleet CA** (host TPM cert) | enrolled hosts | bidi stream: health/metrics up, desired-state down |
|
| **agent** | gRPC + mTLS | **fleet CA** (host TPM cert) | enrolled hosts | bidi stream: health/metrics up, desired-state down |
|
||||||
|
|
||||||
The split is load-bearing: the agent plane uses the host's **reused TPM identity
|
The agent plane uses the host's **reused TPM identity cert** (the same one used
|
||||||
cert** (the same one used for 802.1X EAP-TLS and from which the NVMe `hostnqn`
|
for 802.1X EAP-TLS and from which the NVMe `hostnqn` derives — "one identity,
|
||||||
derives — "one identity, three stages"), while the admin plane uses a **separate**
|
three stages"). The admin plane authenticates the **human** with a hardware
|
||||||
CA so an enrolled host can never reach approve/deny/lifecycle. See the identity
|
**passkey** (phishing-resistant, origin-bound), not a client cert — so there's no
|
||||||
discussion in the enrolment design §5.
|
cert to import and nothing to phish. (The manager→broker hop is separately mTLS;
|
||||||
|
that's service-to-service — see [Authorise](#authorise-pillar).)
|
||||||
|
|
||||||
|
### Admin auth: passkeys/WebAuthn
|
||||||
|
|
||||||
|
- **Login** is usernameless (discoverable passkey) with `userVerification:
|
||||||
|
required` — tap the YubiKey + PIN. A successful assertion mints an opaque,
|
||||||
|
server-side session (`Secure`/`HttpOnly`/`SameSite=Strict` cookie); mutating
|
||||||
|
requests must also echo the session CSRF token in `X-Fleet-CSRF`.
|
||||||
|
- **Bootstrap** is closed: `fleet-manager register-admin <name>` (run on the mgmt
|
||||||
|
host) mints a one-time, HMAC-signed enrolment link; the admin opens it in the
|
||||||
|
browser and registers their passkey. Single-use (jti ledger). Further admins can
|
||||||
|
be enrolled from the UI once one exists.
|
||||||
|
- **RP is origin-bound**: `FLEET_MGR_RP_ID` / `FLEET_MGR_RP_ORIGIN` must be the
|
||||||
|
UI's real hostname — for this deployment **`diskless-server.bitasylum.org`** /
|
||||||
|
`https://diskless-server.bitasylum.org`.
|
||||||
|
- **HTTPS cert** for the UI is a publicly-trusted cert via **Let's Encrypt
|
||||||
|
DNS-01** (the UI is on the mgmt network, not internet-reachable, so HTTP-01 /
|
||||||
|
TLS-ALPN-01 can't validate; DNS-01 proves control of `bitasylum.org` via a TXT
|
||||||
|
record and supports wildcards). An external ACME client (lego/certbot + DNS
|
||||||
|
provider) issues + renews to `FLEET_MGR_ADMIN_CERT/_KEY`; the manager
|
||||||
|
**hot-reloads** the cert on renewal (no restart). A trusted cert also keeps the
|
||||||
|
WebAuthn secure-context clean (no browser warnings).
|
||||||
|
|
||||||
## Monitoring path (no host ever touches InfluxDB)
|
## Monitoring path (no host ever touches InfluxDB)
|
||||||
|
|
||||||
@@ -57,22 +79,29 @@ inspectable, reproducible; a DB swap is a drop-in behind the `Store` interface.
|
|||||||
|
|
||||||
## API (admin plane)
|
## API (admin plane)
|
||||||
|
|
||||||
| Route | Status |
|
| Route | Auth | Status |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `GET /healthz` | ✅ ckpt 1 |
|
| `GET /healthz` | open | ✅ ckpt 1 |
|
||||||
| `GET /api/hosts` | ✅ ckpt 1 — fleet overview |
|
| `POST /auth/register/begin\|finish` | enrol token | ✅ ckpt 5 — passkey registration ceremony |
|
||||||
| `GET /api/hosts/{serial}` | ✅ ckpt 1 — host detail |
|
| `POST /auth/login/begin\|finish` | passkey | ✅ ckpt 5 — usernameless discoverable login → session |
|
||||||
| `PUT /api/hosts/{serial}/desired` | ✅ ckpt 1 — set desired-state (audited to admin CN) |
|
| `POST /auth/logout` | session | ✅ ckpt 5 |
|
||||||
| `GET /api/enrol/pending` | ✅ ckpt 3 — proxies the broker queue |
|
| `GET /api/whoami` | session | ✅ ckpt 5 — resume session + recover CSRF token |
|
||||||
| `POST /api/enrol/{id}/approve` | ✅ ckpt 3 — signs via broker + **births inventory** (identity/binding/enrolled) |
|
| `GET /api/hosts` | session | ✅ ckpt 1 — fleet overview |
|
||||||
| `POST /api/enrol/{id}/deny` | ✅ ckpt 3 |
|
| `GET /api/hosts/{serial}` | session | ✅ ckpt 1 — host detail |
|
||||||
| `GET /api/hosts/{serial}/metrics?field=&range=` | ✅ ckpt 4 — Flux time-series (field whitelist) |
|
| `PUT /api/hosts/{serial}/desired` | session + CSRF | ✅ ckpt 1 — set desired-state (audited to admin) |
|
||||||
|
| `GET /api/enrol/pending` | session | ✅ ckpt 3 — proxies the broker queue |
|
||||||
|
| `POST /api/enrol/{id}/approve` | session + CSRF | ✅ ckpt 3 — signs via broker + **births inventory** |
|
||||||
|
| `POST /api/enrol/{id}/deny` | session + CSRF | ✅ ckpt 3 |
|
||||||
|
| `GET /api/hosts/{serial}/metrics?field=&range=` | session | ✅ ckpt 4 — Flux time-series (field whitelist) |
|
||||||
|
|
||||||
The authorise pillar drives the enrol-broker's admin plane over mTLS (the manager
|
`/api/*` is gated by the passkey session (mutations also need the CSRF header).
|
||||||
is a service client, gated by the dedicated admin CA) asking for JSON, so an
|
|
||||||
approve returns the decided enrolment; the manager records the **human** approver
|
<a name="authorise-pillar"></a>The authorise pillar drives the enrol-broker's
|
||||||
(its own admin mTLS CN) and births the inventory record. The broker stays the
|
admin plane over **mTLS** (the manager is a service client, gated by the dedicated
|
||||||
enrolment state machine + step-ca signer (v1 "alongside" fold-in).
|
admin CA) asking for JSON, so an approve returns the decided enrolment; the manager
|
||||||
|
records the **human** approver (the signed-in passkey admin) and births the
|
||||||
|
inventory record. The broker stays the enrolment state machine + step-ca signer
|
||||||
|
(v1 "alongside" fold-in).
|
||||||
|
|
||||||
**Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2.
|
**Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2.
|
||||||
`Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down.
|
`Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down.
|
||||||
@@ -86,9 +115,14 @@ inventory for offline delivery. Metrics writing is behind a `MetricsSink`
|
|||||||
| Var | Default | Notes |
|
| Var | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FLEET_MGR_ADMIN_ADDR` | `127.0.0.1:9500` | admin listener |
|
| `FLEET_MGR_ADMIN_ADDR` | `127.0.0.1:9500` | admin listener |
|
||||||
| `FLEET_MGR_ADMIN_CERT` / `_KEY` | — | admin server cert/key (mTLS) |
|
| `FLEET_MGR_ADMIN_CERT` / `_KEY` | — | UI server cert/key (server-auth; **LE DNS-01**, hot-reloaded) |
|
||||||
| `FLEET_MGR_ADMIN_CLIENT_CA` | — | **dedicated admin CA** (NOT the fleet CA) |
|
| `FLEET_MGR_RP_ID` | — | WebAuthn RP ID = UI hostname (`diskless-server.bitasylum.org`) |
|
||||||
| `FLEET_MGR_ADMIN_INSECURE` | `false` | plaintext, no client-cert — **dev only** |
|
| `FLEET_MGR_RP_ORIGIN` | — | WebAuthn origin (`https://diskless-server.bitasylum.org`) |
|
||||||
|
| `FLEET_MGR_RP_NAME` | `Fleet Command` | RP display name |
|
||||||
|
| `FLEET_MGR_ENROLL_KEY_FILE` | — | shared HMAC key for enrolment tokens (CLI + service) |
|
||||||
|
| `FLEET_MGR_ADMIN_STORE` | `/var/lib/fleet-manager/admins.json` | registered-passkey store |
|
||||||
|
| `FLEET_MGR_SESSION_TTL_MIN` | `720` | session lifetime (minutes) |
|
||||||
|
| `FLEET_MGR_ADMIN_INSECURE` | `false` | plaintext, **no passkey auth** — dev only |
|
||||||
| `FLEET_MGR_AGENT_ADDR` | `127.0.0.1:9600` | gRPC agent listener |
|
| `FLEET_MGR_AGENT_ADDR` | `127.0.0.1:9600` | gRPC agent listener |
|
||||||
| `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key |
|
| `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key |
|
||||||
| `FLEET_MGR_AGENT_CLIENT_CA` | — | **fleet CA** (verifies host TPM certs). Agent plane starts only when all three are set. |
|
| `FLEET_MGR_AGENT_CLIENT_CA` | — | **fleet CA** (verifies host TPM certs). Agent plane starts only when all three are set. |
|
||||||
@@ -130,4 +164,10 @@ curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name,
|
|||||||
read API (`/api/hosts/{serial}/metrics`) that runs a Flux query with a **field
|
read API (`/api/hosts/{serial}/metrics`) that runs a Flux query with a **field
|
||||||
whitelist** + hex-serial guard (no caller input reaches the query raw). Unit
|
whitelist** + hex-serial guard (no caller input reaches the query raw). Unit
|
||||||
tests for point construction, query building, and unsafe-input rejection.
|
tests for point construction, query building, and unsafe-input rejection.
|
||||||
- Next: themed SPA (ckpt 5) → Docker/compose + PKI + docs (ckpt 6).
|
- **Checkpoint 5 (done):** admin **WebAuthn/passkey** auth replacing browser mTLS —
|
||||||
|
credential store, `register-admin` bootstrap CLI (one-time HMAC enrol token),
|
||||||
|
register/login ceremonies (usernameless + UV required), server-side sessions +
|
||||||
|
CSRF middleware gating `/api/*`, admin TLS switched to server-auth with a
|
||||||
|
hot-reloading cert (for LE DNS-01 renewals). Tests: enrol token, session
|
||||||
|
middleware, cred-store persistence. Live passkey ceremony is a bench item.
|
||||||
|
- Next: themed SPA (ckpt 6) → Docker/compose + PKI/RP + docs (ckpt 7).
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
// admin.go — the admin JSON API (the SPA's backend) served on the mTLS admin
|
// admin.go — the session-gated admin JSON API (the SPA's backend). Mounted under
|
||||||
// plane. Checkpoint 1 covers the MONITOR read surface + LIFECYCLE desired-state;
|
// /api/ behind requireSession; browser identity is the WebAuthn passkey session
|
||||||
// the AUTHORISE routes (enrolment approve/deny) land in checkpoint 3 and the
|
// (session.go), so every mutating action is attributed to the signed-in admin
|
||||||
// InfluxDB-backed metrics routes in checkpoint 4.
|
// (adminName), not a client cert.
|
||||||
//
|
|
||||||
// Every mutating action is attributed to the admin's mTLS client-cert CN
|
|
||||||
// (adminCN), the same audit convention the broker uses.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -16,12 +13,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *server) routes(mux *http.ServeMux) {
|
// apiRoutes registers the session-gated admin API (mounted under /api/ behind
|
||||||
// Liveness (no auth-sensitive data; also reachable in insecure dev mode).
|
// requireSession). The public liveness + WebAuthn ceremonies live in authRoutes.
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
func (s *server) apiRoutes(mux *http.ServeMux) {
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
// Session probe (lets the SPA resume + recover its CSRF token on reload).
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
mux.HandleFunc("GET /api/whoami", s.handleWhoami)
|
||||||
})
|
|
||||||
|
|
||||||
// MONITOR — fleet overview + per-host detail.
|
// MONITOR — fleet overview + per-host detail.
|
||||||
mux.HandleFunc("GET /api/hosts", s.handleListHosts)
|
mux.HandleFunc("GET /api/hosts", s.handleListHosts)
|
||||||
@@ -62,8 +58,8 @@ func (s *server) handleSetDesired(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusBadRequest, "invalid desired-state JSON: "+err.Error())
|
writeErr(w, http.StatusBadRequest, "invalid desired-state JSON: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Provenance: the client-cert CN is authoritative; ignore any body-supplied value.
|
// Provenance: the signed-in admin is authoritative; ignore any body value.
|
||||||
d.UpdatedBy = adminCN(r)
|
d.UpdatedBy = adminName(r)
|
||||||
|
|
||||||
h, ok, err := s.store.SetDesired(serial, d)
|
h, ok, err := s.store.SetDesired(serial, d)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,7 +97,7 @@ func (s *server) handleEnrolPending(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS
|
// handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS
|
||||||
// the inventory record from the returned enrolment + the optional binding the
|
// the inventory record from the returned enrolment + the optional binding the
|
||||||
// human records at approval time. The approver is THIS admin's client-cert CN
|
// human records at approval time. The approver is THIS signed-in admin's identity
|
||||||
// (the human at the SPA), not the broker's view of the manager service.
|
// (the human at the SPA), not the broker's view of the manager service.
|
||||||
func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.broker == nil {
|
if s.broker == nil {
|
||||||
@@ -131,13 +127,13 @@ func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) {
|
|||||||
host := &Host{
|
host := &Host{
|
||||||
Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash},
|
Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash},
|
||||||
Binding: binding,
|
Binding: binding,
|
||||||
Enrolled: Enrolled{Approver: adminCN(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint},
|
Enrolled: Enrolled{Approver: adminName(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint},
|
||||||
}
|
}
|
||||||
if err := s.store.Upsert(host); err != nil {
|
if err := s.store.Upsert(host); err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error())
|
writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminCN(r), id)
|
log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminName(r), id)
|
||||||
|
|
||||||
born, _ := s.store.Get(e.CertSerial)
|
born, _ := s.store.Get(e.CertSerial)
|
||||||
writeJSON(w, http.StatusOK, born)
|
writeJSON(w, http.StatusOK, born)
|
||||||
@@ -190,18 +186,6 @@ func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminCN returns the CN of the admin's mTLS client cert, for audit attribution.
|
|
||||||
// In insecure dev mode there is no peer cert, so it reports a sentinel.
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- JSON response helpers ------------------------------------------------
|
// ---- JSON response helpers ------------------------------------------------
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnrollTokenRoundTrip(t *testing.T) {
|
||||||
|
key := []byte("0123456789abcdef0123456789abcdef")
|
||||||
|
tok, err := mintEnrollToken(key, "alice", 10*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := verifyEnrollToken(key, tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.Name != "alice" || claims.JTI == "" {
|
||||||
|
t.Fatalf("claims = %+v", claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrong key rejected
|
||||||
|
if _, err := verifyEnrollToken([]byte("wrongwrongwrongwrongwrongwrongwr"), tok); err == nil {
|
||||||
|
t.Error("expected wrong-key rejection")
|
||||||
|
}
|
||||||
|
// tampered signature rejected
|
||||||
|
if _, err := verifyEnrollToken(key, tok[:len(tok)-2]+"xx"); err == nil {
|
||||||
|
t.Error("expected tamper rejection")
|
||||||
|
}
|
||||||
|
// expired rejected
|
||||||
|
exp, _ := mintEnrollToken(key, "bob", -time.Minute)
|
||||||
|
if _, err := verifyEnrollToken(key, exp); err == nil {
|
||||||
|
t.Error("expected expiry rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionMiddleware(t *testing.T) {
|
||||||
|
s := &server{cfg: config{}, sessions: newSessionStore(time.Hour)} // adminInsecure=false
|
||||||
|
id, csrf := s.sessions.create("alice")
|
||||||
|
|
||||||
|
var reached string
|
||||||
|
guarded := s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reached = adminName(r)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// no cookie -> 401
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/hosts", nil))
|
||||||
|
if rw.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no cookie: got %d want 401", rw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
withCookie := func(method string, csrfHdr string) *http.Request {
|
||||||
|
r := httptest.NewRequest(method, "/api/x", nil)
|
||||||
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id})
|
||||||
|
if csrfHdr != "" {
|
||||||
|
r.Header.Set(csrfHeader, csrfHdr)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid GET -> reaches handler as alice
|
||||||
|
reached = ""
|
||||||
|
rw = httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rw, withCookie(http.MethodGet, ""))
|
||||||
|
if rw.Code != http.StatusOK || reached != "alice" {
|
||||||
|
t.Fatalf("valid GET: code=%d admin=%q", rw.Code, reached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutating POST without CSRF -> 403
|
||||||
|
rw = httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rw, withCookie(http.MethodPost, ""))
|
||||||
|
if rw.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("POST no CSRF: got %d want 403", rw.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutating POST with correct CSRF -> reaches handler
|
||||||
|
reached = ""
|
||||||
|
rw = httptest.NewRecorder()
|
||||||
|
guarded.ServeHTTP(rw, withCookie(http.MethodPost, csrf))
|
||||||
|
if rw.Code != http.StatusOK || reached != "alice" {
|
||||||
|
t.Fatalf("POST with CSRF: code=%d admin=%q", rw.Code, reached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unknown cookie -> 401
|
||||||
|
rw = httptest.NewRecorder()
|
||||||
|
bad := httptest.NewRequest(http.MethodGet, "/api/x", nil)
|
||||||
|
bad.AddCookie(&http.Cookie{Name: sessionCookie, Value: "nope"})
|
||||||
|
guarded.ServeHTTP(rw, bad)
|
||||||
|
if rw.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad cookie: got %d want 401", rw.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredStorePersistence(t *testing.T) {
|
||||||
|
path := t.TempDir() + "/admins.json"
|
||||||
|
s, err := newCredStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
u := s.getOrCreate("alice")
|
||||||
|
handle := u.IDb
|
||||||
|
if err := s.addCredential("alice", webauthn.Credential{ID: []byte("cred-1"), PublicKey: []byte("pk")}); err != nil {
|
||||||
|
t.Fatalf("add: %v", err)
|
||||||
|
}
|
||||||
|
s.markTokenUsed("jti-1", time.Now().Add(time.Hour).Unix())
|
||||||
|
|
||||||
|
// reload from disk
|
||||||
|
s2, err := newCredStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if s2.count() != 1 {
|
||||||
|
t.Fatalf("admins after reload = %d", s2.count())
|
||||||
|
}
|
||||||
|
got, ok := s2.byHandle(handle)
|
||||||
|
if !ok || got.Name != "alice" || len(got.Creds) != 1 {
|
||||||
|
t.Fatalf("byHandle after reload: ok=%v %+v", ok, got)
|
||||||
|
}
|
||||||
|
if !s2.tokenUsed("jti-1") {
|
||||||
|
t.Error("used-token ledger did not survive reload")
|
||||||
|
}
|
||||||
|
if s2.tokenUsed("jti-unknown") {
|
||||||
|
t.Error("unknown token reported used")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// authapi.go — the WebAuthn ceremony endpoints (unauthenticated: they ARE the
|
||||||
|
// gate) + logout + whoami. register/* is authorised by the one-time enrol token
|
||||||
|
// from the CLI; login/* is usernameless (discoverable passkey). A successful
|
||||||
|
// login mints the server-side session (session.go).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ceremony holds the in-flight WebAuthn SessionData between begin and finish,
|
||||||
|
// plus the enrol claims for a registration. Short-lived, in-memory.
|
||||||
|
type ceremony struct {
|
||||||
|
sess *webauthn.SessionData
|
||||||
|
name string
|
||||||
|
jti string
|
||||||
|
exp int64
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ceremonyStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]ceremony
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} }
|
||||||
|
|
||||||
|
func (c *ceremonyStore) put(sess *webauthn.SessionData, name, jti string, exp int64) string {
|
||||||
|
id := randToken()
|
||||||
|
c.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for k, v := range c.m {
|
||||||
|
if now.After(v.expires) {
|
||||||
|
delete(c.m, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.m[id] = ceremony{sess: sess, name: name, jti: jti, exp: exp, expires: now.Add(5 * time.Minute)}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ceremonyStore) take(id string) (ceremony, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
cer, ok := c.m[id]
|
||||||
|
if ok {
|
||||||
|
delete(c.m, id)
|
||||||
|
}
|
||||||
|
if ok && time.Now().After(cer.expires) {
|
||||||
|
return ceremony{}, false
|
||||||
|
}
|
||||||
|
return cer, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) authRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
|
})
|
||||||
|
if s.wa == nil {
|
||||||
|
return // insecure/dev mode has no passkey ceremonies
|
||||||
|
}
|
||||||
|
mux.HandleFunc("POST /auth/register/begin", s.handleRegisterBegin)
|
||||||
|
mux.HandleFunc("POST /auth/register/finish", s.handleRegisterFinish)
|
||||||
|
mux.HandleFunc("POST /auth/login/begin", s.handleLoginBegin)
|
||||||
|
mux.HandleFunc("POST /auth/login/finish", s.handleLoginFinish)
|
||||||
|
mux.HandleFunc("POST /auth/logout", s.handleLogout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// register/begin — authorised by the one-time enrol token (?token=).
|
||||||
|
func (s *server) handleRegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, err := verifyEnrollToken(s.enrollKey, r.URL.Query().Get("token"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "invalid enrolment token: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.creds.tokenUsed(claims.JTI) {
|
||||||
|
writeErr(w, http.StatusConflict, "enrolment token already used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := s.creds.getOrCreate(claims.Name)
|
||||||
|
sel := protocol.AuthenticatorSelection{
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||||
|
UserVerification: protocol.VerificationRequired,
|
||||||
|
}
|
||||||
|
options, sessionData, err := s.wa.BeginRegistration(user, webauthn.WithAuthenticatorSelection(sel))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "begin registration: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, claims.Name, claims.JTI, claims.Exp), "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cer, ok := s.ceremonies.take(r.URL.Query().Get("cid"))
|
||||||
|
if !ok || cer.sess == nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "unknown or expired ceremony")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.creds.tokenUsed(cer.jti) {
|
||||||
|
writeErr(w, http.StatusConflict, "enrolment token already used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := s.creds.getOrCreate(cer.name)
|
||||||
|
cred, err := s.wa.FinishRegistration(user, *cer.sess, r)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "registration failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.creds.addCredential(cer.name, *cred); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.creds.markTokenUsed(cer.jti, cer.exp)
|
||||||
|
log.Printf("audit ADMIN-ENROL name=%q (passkey registered)", cer.name)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"admin": cer.name, "registered": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// login/begin — usernameless discoverable login.
|
||||||
|
func (s *server) handleLoginBegin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
options, sessionData, err := s.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "begin login: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, "", "", 0), "options": options})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cer, ok := s.ceremonies.take(r.URL.Query().Get("cid"))
|
||||||
|
if !ok || cer.sess == nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "unknown or expired ceremony")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var matched *adminUser
|
||||||
|
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||||
|
u, ok := s.creds.byHandle(userHandle)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown passkey")
|
||||||
|
}
|
||||||
|
matched = u
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
cred, err := s.wa.FinishDiscoverableLogin(handler, *cer.sess, r)
|
||||||
|
if err != nil || matched == nil {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "login failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.creds.updateCredential(matched.IDb, *cred) // persist the signature counter
|
||||||
|
id, csrf := s.sessions.create(matched.Name)
|
||||||
|
setSessionCookie(w, id, s.cfg.sessionTTL)
|
||||||
|
log.Printf("audit ADMIN-LOGIN name=%q", matched.Name)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"admin": matched.Name, "csrf": csrf})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||||
|
s.sessions.delete(c.Value)
|
||||||
|
}
|
||||||
|
clearSessionCookie(w)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWhoami is session-gated (registered under /api): lets the SPA resume an
|
||||||
|
// existing session on reload and recover the CSRF token.
|
||||||
|
func (s *server) handleWhoami(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, _ := sessionFrom(r)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"admin": adminName(r), "csrf": sess.csrf})
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ func TestAuthoriseApproveBirthsInventory(t *testing.T) {
|
|||||||
store, _ := newJSONStore("")
|
store, _ := newJSONStore("")
|
||||||
s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}}
|
s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.routes(mux)
|
s.apiRoutes(mux)
|
||||||
|
|
||||||
// pending proxies through
|
// pending proxies through
|
||||||
pw := httptest.NewRecorder()
|
pw := httptest.NewRecorder()
|
||||||
@@ -93,7 +93,7 @@ func TestAuthoriseDisabledWhenNoBroker(t *testing.T) {
|
|||||||
store, _ := newJSONStore("")
|
store, _ := newJSONStore("")
|
||||||
s := &server{store: store, broker: nil} // authorise not configured
|
s := &server{store: store, broker: nil} // authorise not configured
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
s.routes(mux)
|
s.apiRoutes(mux)
|
||||||
|
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
|
mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil))
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// cli.go — the `register-admin` bootstrap subcommand and the one-time enrolment
|
||||||
|
// token it mints.
|
||||||
|
//
|
||||||
|
// WebAuthn registration is a BROWSER ceremony (the credential is born on the
|
||||||
|
// YubiKey via navigator.credentials.create) — a CLI can't do it directly. So the
|
||||||
|
// CLI's job is to AUTHORISE an enrolment: it mints a short-lived HMAC-signed token
|
||||||
|
// (over a shared enrol key on the mgmt host) and prints an enrolment URL. The
|
||||||
|
// browser completes the ceremony; the register endpoint verifies the token. This
|
||||||
|
// keeps registration closed (no anyone-can-register endpoint) while the actual
|
||||||
|
// key material never leaves the YubiKey. Single-use is enforced by the credStore
|
||||||
|
// jti ledger.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type enrollClaims struct {
|
||||||
|
Name string
|
||||||
|
JTI string
|
||||||
|
Exp int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
|
||||||
|
|
||||||
|
func hmacSum(key []byte, msg string) []byte {
|
||||||
|
m := hmac.New(sha256.New, key)
|
||||||
|
m.Write([]byte(msg))
|
||||||
|
return m.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintEnrollToken -> base64url(name|exp|jti) "." base64url(hmac).
|
||||||
|
func mintEnrollToken(key []byte, name string, ttl time.Duration) (string, error) {
|
||||||
|
jb := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(jb); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
jti := hex.EncodeToString(jb)
|
||||||
|
exp := time.Now().Add(ttl).Unix()
|
||||||
|
msg := name + "|" + strconv.FormatInt(exp, 10) + "|" + jti
|
||||||
|
return b64([]byte(msg)) + "." + b64(hmacSum(key, msg)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyEnrollToken(key []byte, token string) (enrollClaims, error) {
|
||||||
|
parts := strings.SplitN(token, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return enrollClaims{}, fmt.Errorf("malformed token")
|
||||||
|
}
|
||||||
|
msg, err := unb64(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return enrollClaims{}, fmt.Errorf("bad payload")
|
||||||
|
}
|
||||||
|
sig, err := unb64(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return enrollClaims{}, fmt.Errorf("bad signature encoding")
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare(sig, hmacSum(key, string(msg))) != 1 {
|
||||||
|
return enrollClaims{}, fmt.Errorf("bad signature")
|
||||||
|
}
|
||||||
|
f := strings.SplitN(string(msg), "|", 3)
|
||||||
|
if len(f) != 3 {
|
||||||
|
return enrollClaims{}, fmt.Errorf("bad claims")
|
||||||
|
}
|
||||||
|
exp, _ := strconv.ParseInt(f[1], 10, 64)
|
||||||
|
if time.Now().Unix() > exp {
|
||||||
|
return enrollClaims{}, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
return enrollClaims{Name: f[0], JTI: f[2], Exp: exp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEnrollKey reads the shared enrol key (>=32 bytes). Used by the SERVICE — it
|
||||||
|
// must already exist (the same key the CLI mints with).
|
||||||
|
func loadEnrollKey(path string) ([]byte, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("no enrol key file configured (FLEET_MGR_ENROLL_KEY_FILE)")
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) < 32 {
|
||||||
|
return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOrCreateEnrollKey is the CLI's variant: it generates the key on first use.
|
||||||
|
func loadOrCreateEnrollKey(path string) ([]byte, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("set -key or FLEET_MGR_ENROLL_KEY_FILE")
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
|
if len(b) < 32 {
|
||||||
|
return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, key, 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "note: generated a new enrol key at %s (mount the SAME file into the service)\n", path)
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRegisterAdmin implements: fleet-manager register-admin [-key F] [-origin URL] <name>
|
||||||
|
func runRegisterAdmin(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("register-admin", flag.ContinueOnError)
|
||||||
|
keyFile := fs.String("key", env("FLEET_MGR_ENROLL_KEY_FILE", ""), "shared enrol key file")
|
||||||
|
origin := fs.String("origin", env("FLEET_MGR_RP_ORIGIN", ""), "admin UI origin (for the enrolment URL)")
|
||||||
|
ttl := fs.Duration("ttl", 10*time.Minute, "enrolment token validity")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
name := fs.Arg(0)
|
||||||
|
if name == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: fleet-manager register-admin [-key F] [-origin URL] [-ttl D] <name>")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
key, err := loadOrCreateEnrollKey(*keyFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "enrol key: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
tok, err := mintEnrollToken(key, name, *ttl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "mint: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(*origin, "/")
|
||||||
|
if base == "" {
|
||||||
|
base = "https://<admin-ui-origin>"
|
||||||
|
}
|
||||||
|
fmt.Printf("Enrolment link for %q (valid %s) — open on the mgmt network, then tap the YubiKey:\n\n %s/#/enrol?token=%s\n\n", name, ttl.String(), base, tok)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ module fleet-manager
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/influxdata/influxdb-client-go/v2 v2.14.0
|
github.com/influxdata/influxdb-client-go/v2 v2.14.0
|
||||||
google.golang.org/grpc v1.82.0
|
google.golang.org/grpc v1.82.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
@@ -21,11 +22,20 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
|
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
|
||||||
github.com/oapi-codegen/runtime v1.0.0 // indirect
|
github.com/oapi-codegen/runtime v1.0.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
|
golang.org/x/net v0.54.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,14 +7,28 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||||
|
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||||
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4=
|
github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4=
|
||||||
@@ -24,13 +38,19 @@ github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1
|
|||||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||||
github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo=
|
github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo=
|
||||||
github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A=
|
github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
@@ -43,12 +63,16 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||||
|
|||||||
+118
-40
@@ -1,27 +1,27 @@
|
|||||||
// fleet-manager — server-side control plane for the diskless kiosk fleet.
|
// fleet-manager — server-side control plane for the diskless kiosk fleet.
|
||||||
//
|
//
|
||||||
// Two planes, mirroring the enrol-broker's two-listener pattern but with distinct
|
// Two planes with DISTINCT trust models:
|
||||||
// trust anchors (see docs/tpm-enrolment-design.md §5 "identity"):
|
|
||||||
//
|
//
|
||||||
// - ADMIN plane (this checkpoint): HTTPS + mTLS verified by the DEDICATED admin
|
// - ADMIN plane: HTTPS (server-auth TLS) + browser identity via WebAuthn/passkey
|
||||||
// CA (admin-pki.sh) — NOT the fleet CA — so an enrolled host cert can never
|
// (YubiKey) → server-side session. Serves the JSON API + the SPA to admins.
|
||||||
// reach approve/deny/lifecycle. Serves the JSON API and (later) the SPA to
|
// (manager→broker is separately mTLS; that's service-to-service.)
|
||||||
// admin browsers.
|
// - AGENT plane: gRPC + mTLS verified by the FLEET CA — the host's reused TPM
|
||||||
// - AGENT plane (checkpoint 2): gRPC + mTLS verified by the FLEET CA — the
|
// identity cert. Bidi stream: health/metrics up, desired-state down.
|
||||||
// host's reused TPM identity cert. Bidi stream: health/metrics up,
|
|
||||||
// desired-state down.
|
|
||||||
//
|
//
|
||||||
// Checkpoint 1 wires only the admin plane + inventory + a seed host.
|
// A `register-admin` subcommand mints a one-time passkey-enrolment link.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -29,7 +29,6 @@ type config struct {
|
|||||||
adminInsecure bool
|
adminInsecure bool
|
||||||
adminCert string
|
adminCert string
|
||||||
adminKey string
|
adminKey string
|
||||||
adminClientCA string // dedicated admin CA (verifies admin browser certs)
|
|
||||||
|
|
||||||
agentAddr string
|
agentAddr string
|
||||||
agentCert string
|
agentCert string
|
||||||
@@ -45,6 +44,14 @@ type config struct {
|
|||||||
// InfluxDB (the manager is the only reader/writer; token is server-side only).
|
// InfluxDB (the manager is the only reader/writer; token is server-side only).
|
||||||
influx influxConfig
|
influx influxConfig
|
||||||
|
|
||||||
|
// Admin browser auth = WebAuthn/passkey (replaces browser mTLS). Origin-bound.
|
||||||
|
rpID string
|
||||||
|
rpOrigin string
|
||||||
|
rpDisplayName string
|
||||||
|
enrollKeyFile string
|
||||||
|
credStorePath string
|
||||||
|
sessionTTL time.Duration
|
||||||
|
|
||||||
inventoryPath string
|
inventoryPath string
|
||||||
seed bool
|
seed bool
|
||||||
hostnqnBase string
|
hostnqnBase string
|
||||||
@@ -56,7 +63,6 @@ func loadConfig() config {
|
|||||||
adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true",
|
adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true",
|
||||||
adminCert: env("FLEET_MGR_ADMIN_CERT", ""),
|
adminCert: env("FLEET_MGR_ADMIN_CERT", ""),
|
||||||
adminKey: env("FLEET_MGR_ADMIN_KEY", ""),
|
adminKey: env("FLEET_MGR_ADMIN_KEY", ""),
|
||||||
adminClientCA: env("FLEET_MGR_ADMIN_CLIENT_CA", ""),
|
|
||||||
|
|
||||||
agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"),
|
agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"),
|
||||||
agentCert: env("FLEET_MGR_AGENT_CERT", ""),
|
agentCert: env("FLEET_MGR_AGENT_CERT", ""),
|
||||||
@@ -75,6 +81,13 @@ func loadConfig() config {
|
|||||||
bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"),
|
bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
rpID: env("FLEET_MGR_RP_ID", ""),
|
||||||
|
rpOrigin: env("FLEET_MGR_RP_ORIGIN", ""),
|
||||||
|
rpDisplayName: env("FLEET_MGR_RP_NAME", "Fleet Command"),
|
||||||
|
enrollKeyFile: env("FLEET_MGR_ENROLL_KEY_FILE", ""),
|
||||||
|
credStorePath: env("FLEET_MGR_ADMIN_STORE", "/var/lib/fleet-manager/admins.json"),
|
||||||
|
sessionTTL: time.Duration(envInt("FLEET_MGR_SESSION_TTL_MIN", 720)) * time.Minute,
|
||||||
|
|
||||||
inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"),
|
inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"),
|
||||||
seed: env("FLEET_MGR_SEED", "") == "true",
|
seed: env("FLEET_MGR_SEED", "") == "true",
|
||||||
hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"),
|
hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"),
|
||||||
@@ -88,9 +101,22 @@ type server struct {
|
|||||||
hub *agentHub // live agent streams; nil if the agent plane isn't configured
|
hub *agentHub // live agent streams; nil if the agent plane isn't configured
|
||||||
broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured
|
broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured
|
||||||
influx *influxSink // metrics gateway; nil if InfluxDB isn't configured
|
influx *influxSink // metrics gateway; nil if InfluxDB isn't configured
|
||||||
|
|
||||||
|
// Admin passkey auth (nil in insecure dev mode).
|
||||||
|
wa *webauthn.WebAuthn
|
||||||
|
creds *credStore
|
||||||
|
sessions *sessionStore
|
||||||
|
ceremonies *ceremonyStore
|
||||||
|
enrollKey []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Subcommand: `fleet-manager register-admin <name>` mints a passkey-enrolment
|
||||||
|
// link (no server needed) — the bootstrap for the WebAuthn admin plane.
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "register-admin" {
|
||||||
|
os.Exit(runRegisterAdmin(os.Args[2:]))
|
||||||
|
}
|
||||||
|
|
||||||
log.SetFlags(log.LstdFlags | log.LUTC)
|
log.SetFlags(log.LstdFlags | log.LUTC)
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
|
|
||||||
@@ -144,12 +170,42 @@ func main() {
|
|||||||
log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)")
|
log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)")
|
||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
// Admin browser auth = WebAuthn/passkey (secure mode only). Sets up the RP,
|
||||||
s.routes(mux)
|
// the credential store, the shared enrol key, sessions + ceremonies. The admin
|
||||||
|
// TLS below is server-auth only — browser identity comes from the passkey.
|
||||||
|
if !cfg.adminInsecure {
|
||||||
|
wa, err := newWebAuthn(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("webauthn: %v", err)
|
||||||
|
}
|
||||||
|
creds, err := newCredStore(cfg.credStorePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("admin store: %v", err)
|
||||||
|
}
|
||||||
|
key, err := loadEnrollKey(cfg.enrollKeyFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("enrol key: %v", err)
|
||||||
|
}
|
||||||
|
s.wa, s.creds, s.enrollKey = wa, creds, key
|
||||||
|
s.sessions = newSessionStore(cfg.sessionTTL)
|
||||||
|
s.ceremonies = newCeremonyStore()
|
||||||
|
if creds.count() == 0 {
|
||||||
|
log.Printf("NOTE: no admins enrolled yet — run `fleet-manager register-admin <name>` and open the link to add a passkey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP handler: public liveness + WebAuthn ceremonies at the root; the admin
|
||||||
|
// API under /api/ gated by the passkey session (requireSession). The SPA (ckpt
|
||||||
|
// 6) will be served at / from an embedded FS.
|
||||||
|
root := http.NewServeMux()
|
||||||
|
s.authRoutes(root)
|
||||||
|
apiMux := http.NewServeMux()
|
||||||
|
s.apiRoutes(apiMux)
|
||||||
|
root.Handle("/api/", s.requireSession(apiMux))
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.adminAddr,
|
Addr: cfg.adminAddr,
|
||||||
Handler: mux,
|
Handler: root,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
@@ -157,8 +213,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.adminInsecure {
|
if cfg.adminInsecure {
|
||||||
log.Printf("WARNING: admin plane INSECURE (plaintext, no client-cert auth) on %s — dev/lab only", cfg.adminAddr)
|
log.Printf("WARNING: admin plane INSECURE (plaintext, NO passkey auth) on %s — dev/lab only", cfg.adminAddr)
|
||||||
log.Printf("fleet-manager admin (insecure) listening on %s", cfg.adminAddr)
|
|
||||||
log.Fatal(srv.ListenAndServe())
|
log.Fatal(srv.ListenAndServe())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,41 +222,64 @@ func main() {
|
|||||||
log.Fatalf("admin TLS: %v", err)
|
log.Fatalf("admin TLS: %v", err)
|
||||||
}
|
}
|
||||||
srv.TLSConfig = tlsCfg
|
srv.TLSConfig = tlsCfg
|
||||||
log.Printf("fleet-manager admin (mTLS) listening on %s", cfg.adminAddr)
|
log.Printf("fleet-manager admin (HTTPS + passkey) listening on %s (rp=%s)", cfg.adminAddr, cfg.rpID)
|
||||||
log.Fatal(srv.ListenAndServeTLS("", "")) // certs are in TLSConfig
|
log.Fatal(srv.ListenAndServeTLS("", "")) // certs are in TLSConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminTLS builds the mTLS server config: our server cert + REQUIRE a client cert
|
// adminTLS builds the admin plane's SERVER-AUTH TLS. Browser identity is a passkey
|
||||||
// signed by the dedicated admin CA. Reusing the fleet CA here would let any host
|
// (WebAuthn), not a client cert, so there is no ClientCAs/ClientAuth here — just
|
||||||
// cert authenticate to the admin plane — privilege escalation — so it is a hard
|
// our server cert. (manager→broker mTLS is separate; see broker.go.)
|
||||||
// error to omit the admin client CA in secure mode.
|
//
|
||||||
|
// The cert is served via a hot-reloading GetCertificate so an out-of-band renewal
|
||||||
|
// is picked up WITHOUT a restart. The intended deployment is a publicly-trusted
|
||||||
|
// cert obtained by an external ACME client via Let's Encrypt DNS-01 (the UI is on
|
||||||
|
// the mgmt network, not internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate;
|
||||||
|
// DNS-01 proves control via a TXT record and supports wildcards). The ACME client
|
||||||
|
// (lego/certbot + a DNS provider) writes cert+key to FLEET_MGR_ADMIN_CERT/_KEY on
|
||||||
|
// a shared volume; this reloader notices the mtime bump and swaps the cert live.
|
||||||
func adminTLS(cfg config) (*tls.Config, error) {
|
func adminTLS(cfg config) (*tls.Config, error) {
|
||||||
if cfg.adminCert == "" || cfg.adminKey == "" {
|
if cfg.adminCert == "" || cfg.adminKey == "" {
|
||||||
return nil, errString("FLEET_MGR_ADMIN_CERT and FLEET_MGR_ADMIN_KEY are required (or set FLEET_MGR_ADMIN_INSECURE=true for dev)")
|
return nil, errString("FLEET_MGR_ADMIN_CERT and FLEET_MGR_ADMIN_KEY are required (or set FLEET_MGR_ADMIN_INSECURE=true for dev)")
|
||||||
}
|
}
|
||||||
if cfg.adminClientCA == "" {
|
rl := &certReloader{certFile: cfg.adminCert, keyFile: cfg.adminKey}
|
||||||
return nil, errString("FLEET_MGR_ADMIN_CLIENT_CA is required — the dedicated admin CA that verifies admin browser certs (must NOT be the fleet CA)")
|
if _, err := rl.GetCertificate(nil); err != nil { // fail fast on a bad/missing cert
|
||||||
}
|
|
||||||
cert, err := tls.LoadX509KeyPair(cfg.adminCert, cfg.adminKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
caPEM, err := os.ReadFile(cfg.adminClientCA)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pool := x509.NewCertPool()
|
|
||||||
if !pool.AppendCertsFromPEM(caPEM) {
|
|
||||||
return nil, errString("no certificates parsed from FLEET_MGR_ADMIN_CLIENT_CA")
|
|
||||||
}
|
|
||||||
return &tls.Config{
|
return &tls.Config{
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
Certificates: []tls.Certificate{cert},
|
GetCertificate: rl.GetCertificate,
|
||||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
|
||||||
ClientCAs: pool,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// certReloader serves the admin server cert, reloading from disk when the file's
|
||||||
|
// mtime advances (an ACME renewal), so Let's Encrypt DNS-01 rotations apply live.
|
||||||
|
type certReloader struct {
|
||||||
|
certFile, keyFile string
|
||||||
|
mu sync.Mutex
|
||||||
|
cached *tls.Certificate
|
||||||
|
mtime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
fi, statErr := os.Stat(c.certFile)
|
||||||
|
if statErr == nil && (c.cached == nil || fi.ModTime().After(c.mtime)) {
|
||||||
|
cert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile)
|
||||||
|
if err != nil {
|
||||||
|
if c.cached != nil {
|
||||||
|
return c.cached, nil // serve the last good cert through a transient mid-rotation error
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.cached, c.mtime = &cert, fi.ModTime()
|
||||||
|
}
|
||||||
|
if c.cached == nil {
|
||||||
|
return nil, fmt.Errorf("admin cert unavailable: %v", statErr)
|
||||||
|
}
|
||||||
|
return c.cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---- tiny helpers ---------------------------------------------------------
|
// ---- tiny helpers ---------------------------------------------------------
|
||||||
|
|
||||||
func env(k, def string) string {
|
func env(k, def string) string {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// session.go — post-passkey sessions + the middleware that gates the admin API.
|
||||||
|
//
|
||||||
|
// WebAuthn authenticates a login CEREMONY; from there we carry an opaque,
|
||||||
|
// server-side session (revocable, no secrets in the cookie). Cookie is
|
||||||
|
// Secure + HttpOnly + SameSite=Strict; state-changing requests additionally must
|
||||||
|
// echo the session's CSRF token in a header (belt-and-braces beyond same-origin).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "fleet_session"
|
||||||
|
csrfHeader = "X-Fleet-CSRF"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const adminCtxKey ctxKey = 0
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
admin string
|
||||||
|
csrf string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
ttl time.Duration
|
||||||
|
byID map[string]session
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionStore(ttl time.Duration) *sessionStore {
|
||||||
|
return &sessionStore{ttl: ttl, byID: map[string]session{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// create mints a session for a just-authenticated admin, returning the session id
|
||||||
|
// (cookie value) and the CSRF token (returned to the SPA in the login response).
|
||||||
|
func (s *sessionStore) create(admin string) (id, csrf string) {
|
||||||
|
id, csrf = randToken(), randToken()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.byID[id] = session{admin: admin, csrf: csrf, expires: time.Now().Add(s.ttl)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return id, csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sessionStore) get(id string) (session, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.byID[id]
|
||||||
|
if !ok {
|
||||||
|
return session{}, false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.expires) {
|
||||||
|
delete(s.byID, id)
|
||||||
|
return session{}, false
|
||||||
|
}
|
||||||
|
return sess, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sessionStore) delete(id string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.byID, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSession wraps the admin API. It rejects requests without a valid session
|
||||||
|
// cookie, and (for mutating methods) without the matching CSRF header. The
|
||||||
|
// authenticated admin name is injected into the request context for audit.
|
||||||
|
func (s *server) requireSession(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Insecure dev mode has no passkey auth — open, and adminName reports the
|
||||||
|
// dev sentinel. (main refuses insecure unless explicitly enabled.)
|
||||||
|
if s.cfg.adminInsecure {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "not signed in")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess, ok := s.sessions.get(c.Value)
|
||||||
|
if !ok {
|
||||||
|
writeErr(w, http.StatusUnauthorized, "session expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isMutating(r.Method) {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(r.Header.Get(csrfHeader)), []byte(sess.csrf)) != 1 {
|
||||||
|
writeErr(w, http.StatusForbidden, "missing or bad CSRF token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminCtxKey, sess)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionFrom returns the authenticated session injected by requireSession.
|
||||||
|
func sessionFrom(r *http.Request) (session, bool) {
|
||||||
|
sess, ok := r.Context().Value(adminCtxKey).(session)
|
||||||
|
return sess, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMutating(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie writes the session cookie. Secure+HttpOnly+SameSite=Strict.
|
||||||
|
func setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: id,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(ttl.Seconds()),
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminName returns the authenticated admin for audit attribution — the session
|
||||||
|
// identity in secure mode, or the dev sentinel in insecure mode.
|
||||||
|
func adminName(r *http.Request) string {
|
||||||
|
if sess, ok := sessionFrom(r); ok && sess.admin != "" {
|
||||||
|
return sess.admin
|
||||||
|
}
|
||||||
|
return "dev-insecure"
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// webauthn.go — passkey/WebAuthn admin identity: the credential store (which
|
||||||
|
// YubiKeys/passkeys are registered admins) and the webauthn.User implementation.
|
||||||
|
//
|
||||||
|
// The human→UI hop authenticates with a hardware passkey (YubiKey), NOT an mTLS
|
||||||
|
// client cert — phishing-resistant, origin-bound, better UX. (The manager→broker
|
||||||
|
// hop stays mTLS; that's service-to-service.) Registration is bootstrapped by the
|
||||||
|
// `register-admin` CLI, which mints a one-time signed enrolment token; the browser
|
||||||
|
// completes the WebAuthn create ceremony (the credential is born on the YubiKey).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminUser is a registered admin + their passkey credential(s). Implements
|
||||||
|
// webauthn.User. The ID (user handle) is a random opaque blob, NOT the name, so
|
||||||
|
// the name can change without breaking discoverable-login lookups.
|
||||||
|
type adminUser struct {
|
||||||
|
IDb []byte `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Creds []webauthn.Credential `json:"credentials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *adminUser) WebAuthnID() []byte { return u.IDb }
|
||||||
|
func (u *adminUser) WebAuthnName() string { return u.Name }
|
||||||
|
func (u *adminUser) WebAuthnDisplayName() string { return u.Name }
|
||||||
|
func (u *adminUser) WebAuthnCredentials() []webauthn.Credential { return u.Creds }
|
||||||
|
|
||||||
|
// credStore persists registered admins + a used-enrolment-token ledger (so a
|
||||||
|
// one-time enrolment token can't be replayed). JSON file, atomic writes, mutex —
|
||||||
|
// same shape as the inventory store. The SERVICE is the only writer; the CLI only
|
||||||
|
// reads the shared enrol key (see cli.go), never this file.
|
||||||
|
type credStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
data credData
|
||||||
|
byHex map[string]*adminUser // hex(user handle) -> admin (discoverable-login lookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
type credData struct {
|
||||||
|
Admins map[string]*adminUser `json:"admins"` // by name
|
||||||
|
Used map[string]int64 `json:"used_tokens"` // jti -> expiry unix (replay ledger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCredStore(path string) (*credStore, error) {
|
||||||
|
s := &credStore{path: path, data: credData{Admins: map[string]*adminUser{}, Used: map[string]int64{}}, byHex: map[string]*adminUser{}}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read admin store %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if len(b) > 0 {
|
||||||
|
if err := json.Unmarshal(b, &s.data); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin store %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.data.Admins == nil {
|
||||||
|
s.data.Admins = map[string]*adminUser{}
|
||||||
|
}
|
||||||
|
if s.data.Used == nil {
|
||||||
|
s.data.Used = map[string]int64{}
|
||||||
|
}
|
||||||
|
s.reindex()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *credStore) reindex() {
|
||||||
|
s.byHex = map[string]*adminUser{}
|
||||||
|
for _, u := range s.data.Admins {
|
||||||
|
s.byHex[hex.EncodeToString(u.IDb)] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *credStore) save() error {
|
||||||
|
if s.path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if dir := filepath.Dir(s.path); dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(s.data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// count returns the number of registered admins (for the "no admins yet" gate).
|
||||||
|
func (s *credStore) count() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.data.Admins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreate returns the admin by name, creating an unregistered shell (random
|
||||||
|
// handle, no creds yet) on first enrolment.
|
||||||
|
func (s *credStore) getOrCreate(name string) *adminUser {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if u, ok := s.data.Admins[name]; ok {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
id := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(id)
|
||||||
|
u := &adminUser{IDb: id, Name: name, CreatedAt: time.Now().UTC()}
|
||||||
|
s.data.Admins[name] = u
|
||||||
|
s.byHex[hex.EncodeToString(id)] = u
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *credStore) byHandle(handle []byte) (*adminUser, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
u, ok := s.byHex[hex.EncodeToString(handle)]
|
||||||
|
return u, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// addCredential appends a freshly-registered passkey and persists.
|
||||||
|
func (s *credStore) addCredential(name string, c webauthn.Credential) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
u, ok := s.data.Admins[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no such admin %q", name)
|
||||||
|
}
|
||||||
|
u.Creds = append(u.Creds, c)
|
||||||
|
return s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCredential persists an updated credential (e.g. the signature counter)
|
||||||
|
// after a successful login — the anti-clone protection.
|
||||||
|
func (s *credStore) updateCredential(handle []byte, c webauthn.Credential) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
u, ok := s.byHex[hex.EncodeToString(handle)]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range u.Creds {
|
||||||
|
if string(u.Creds[i].ID) == string(c.ID) {
|
||||||
|
u.Creds[i] = c
|
||||||
|
_ = s.save()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenUsed reports whether a one-time enrolment token id was already redeemed,
|
||||||
|
// and reaps expired ledger entries.
|
||||||
|
func (s *credStore) tokenUsed(jti string) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for j, exp := range s.data.Used {
|
||||||
|
if exp < now {
|
||||||
|
delete(s.data.Used, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, used := s.data.Used[jti]
|
||||||
|
return used
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *credStore) markTokenUsed(jti string, exp int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.data.Used[jti] = exp
|
||||||
|
_ = s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebAuthn builds the relying-party instance. WebAuthn is ORIGIN-BOUND, so the
|
||||||
|
// RP ID + origin must match the admin UI's hostname (per-environment config).
|
||||||
|
func newWebAuthn(cfg config) (*webauthn.WebAuthn, error) {
|
||||||
|
if cfg.rpID == "" || cfg.rpOrigin == "" {
|
||||||
|
return nil, fmt.Errorf("WebAuthn needs FLEET_MGR_RP_ID and FLEET_MGR_RP_ORIGIN (the admin UI hostname/origin)")
|
||||||
|
}
|
||||||
|
return webauthn.New(&webauthn.Config{
|
||||||
|
RPID: cfg.rpID,
|
||||||
|
RPDisplayName: cfg.rpDisplayName,
|
||||||
|
RPOrigins: []string{cfg.rpOrigin},
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user