console: fleet page — owner's boxes, raw metrics, rotate/detach
GET /a/{sqid}/fleets/{fleet}: member-readable, owner-gated rotate/detach
forms. Boxes join the registry's fleet_boxes rows to their own heartbeat by
label (Task 6's channel-identity ruling) through
release.NewPrefixStore(releases, "fleets/<fleet>/") + status.LoadBoxes; a
stale or never-joined heartbeat renders "no heartbeat yet / unreachable (age
unknown)", never another row's numbers. Placed apps are routes whose Region
is this fleet's placement target, grouped by app. Rotate mints a fresh token
the same way the CLI API does and renders it directly in the response
body — never a redirect, never a flash cookie — exactly once, beside the
same ROTATION IS NOT REVOCATION warning printFleetToken (fleets.go, the
CLI) prints. Fleet names are a flat, cross-account namespace, so an unknown
fleet and one owned by a different account resolve the same byte-identical
404 apiFleet already gives the CLI API.
registry.Fleet and the new fleetPageBox/fleetPagePlacedApp view types never
carry TokenHash — the page-side twin of fleetView/fleetBoxView's existing
discipline.
4 files changed,
+683
−0
internal/console/fleets.go+314 −0internal/console/fleets_test.go+270 −0internal/console/server.go+22 −0internal/console/templates/fleetdetail.html+77 −0
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index 65c90593e..af307a4dc 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -24,16 +24,21 @@ package console |
| import ( |
| "context" |
| "crypto/rand" |
| + "crypto/subtle" |
| "encoding/hex" |
| "errors" |
| "log" |
| "net/http" |
| + "net/url" |
| "regexp" |
| + "sort" |
| "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| // fleetBoxView and fleetView are the wire shapes for GET .../fleets and the |
| @@ -583,3 +588,312 @@ func (s *Server) handleAPIPlace(w http.ResponseWriter, r *http.Request) { |
| s.audit(acct, app, addr, "place", detail) |
| writeJSON(w, http.StatusOK, map[string]any{}) |
| } |
| + |
| +// ---- the console fleet page (Task 9) -------------------------------------- |
| +// |
| +// GET /a/{sqid}/fleets/{fleet} is member-readable (any role sees the boxes |
| +// and placed apps), and its two mutation forms — rotate, detach — are |
| +// owner-gated, CSRF-checked POSTs onto the same registry writes the CLI API |
| +// doors above already make (SetFleetBoxToken, SetFleetBoxStatus). Nothing |
| +// here is a new capability; it is the same fleet, the same rows, on a |
| +// session instead of a bearer token. |
| + |
| +// fleetPageBox is one box's row on the owner's fleet page: the registry's |
| +// own record (Label, Status) joined to its heartbeat BY LABEL — the |
| +// "Box.Label equals fleet_boxes.label" identity ruling from Task 6, because |
| +// the channel label IS the identity, not a machine pseudonym. |
| +// |
| +// Reachable covers both halves of "this box has nothing current to show": |
| +// no heartbeat has ever joined this row at all (Task 6's KNOWN |
| +// DEGRADATION — a box with invalid fleet identity config heartbeats under a |
| +// hashed pseudonym label instead, which never joins here), and a heartbeat |
| +// joined once but has since gone stale (status.BoxStale against |
| +// status.SnapshotPeriod, the same threshold HostRows/view.go uses for the |
| +// platform's own operator page). Both cases render as "no numbers", never a |
| +// guess — Age distinguishes them for display (see the doc comment on it) |
| +// but neither ever carries Metrics. |
| +type fleetPageBox struct { |
| + Label string |
| + Status string // "active" | "detached" — registry.FleetBox.Status, never TokenHash |
| + // Reachable is true only for a heartbeat that both joined and is fresh. |
| + Reachable bool |
| + // Age is how long ago the last heartbeat was seen, pre-rendered |
| + // ("1h2m") by humanAge (view.go) — set only when a heartbeat joined but |
| + // has gone stale. Empty AND Reachable false together mean no heartbeat |
| + // has ever joined this row: "no heartbeat yet", not "0m ago". |
| + Age string |
| + // Tunnel and Metrics are set only when Reachable — an unreachable or |
| + // never-joined box shows no numbers at all, ever (mirrors HostRows). |
| + Tunnel string |
| + Metrics *status.BoxMetrics |
| +} |
| + |
| +// fleetPagePlacedApp is one app placed on this fleet: its name and the |
| +// routes (by host) that carry the placement — registry.Route rows whose |
| +// Region is registry.PlacementTarget(fleet), grouped by App. |
| +type fleetPagePlacedApp struct { |
| + App string |
| + Hosts []string |
| +} |
| + |
| +// resolveOwnedFleet is the browser routes' fleet-ownership check — the |
| +// page-side twin of apiFleet above, and for the identical reason: fleet |
| +// names are one flat, cross-account namespace (this file's package doc |
| +// comment), so an unknown fleet and one owned by a DIFFERENT account must |
| +// answer with the exact same http.NotFound an unrouted URL gets, never a |
| +// distinguishable "no such fleet" message that would turn this into a |
| +// cross-account oracle. Every browser door onto a {sqid}/{fleet} URL calls |
| +// this AFTER its own membership gate (account or owner), never before. |
| +func (s *Server) resolveOwnedFleet(w http.ResponseWriter, r *http.Request, acct *Account) (*registry.Fleet, *registry.Registry, bool) { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: fleet page: open registry: %v", err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not reach the fleet registry.") |
| + return nil, nil, false |
| + } |
| + name := r.PathValue("fleet") |
| + f, err := reg.GetFleet(name) |
| + if err != nil { |
| + log.Printf("console: fleet page %s/%s: %v", acct.Sqid, name, err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read the fleet.") |
| + return nil, nil, false |
| + } |
| + if f == nil || f.Account != acct.Sqid { |
| + http.NotFound(w, r) |
| + return nil, nil, false |
| + } |
| + return f, reg, true |
| +} |
| + |
| +// accountFleet gates GET .../fleets/{fleet}: session membership, any role — |
| +// a member reads the same page an owner does, just without its forms (the |
| +// template's own {{if .IsOwner}} checks, not a second gate here). |
| +func (s *Server) accountFleet(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, fleet *registry.Fleet, reg *registry.Registry, ok bool) { |
| + addr, raw, acct, role, ok = s.account(w, r) |
| + if !ok { |
| + return "", "", nil, "", nil, nil, false |
| + } |
| + fleet, reg, ok = s.resolveOwnedFleet(w, r, acct) |
| + if !ok { |
| + return "", "", nil, "", nil, nil, false |
| + } |
| + return addr, raw, acct, role, fleet, reg, true |
| +} |
| + |
| +// ownerFleet gates the two mutation doors (rotate, detach): owner only, |
| +// same byte-identical-404 fleet resolution accountFleet uses. |
| +func (s *Server) ownerFleet(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, fleet *registry.Fleet, reg *registry.Registry, ok bool) { |
| + addr, raw, acct, ok = s.owner(w, r) |
| + if !ok { |
| + return "", "", nil, nil, nil, false |
| + } |
| + fleet, reg, ok = s.resolveOwnedFleet(w, r, acct) |
| + if !ok { |
| + return "", "", nil, nil, nil, false |
| + } |
| + return addr, raw, acct, fleet, reg, true |
| +} |
| + |
| +// loadFleetPage reads everything the fleet page's body needs beyond the |
| +// fleet record itself: every registered box, each joined to its own |
| +// heartbeat (read through release.NewPrefixStore(s.cfg.Releases, |
| +// "fleets/<fleet>/"), the same rebasing a real fleet box's agent writes |
| +// through — release/store.go's doc comment on prefixStore names this page |
| +// as its second caller), and every route this account owns whose Region |
| +// places it on this fleet, grouped by app. |
| +// |
| +// Every failure here is soft: a box list, heartbeat read, or route list |
| +// that errors is logged and degrades to "nothing to show" for that section |
| +// (warn carries the first one, for a flash banner) — this page must never |
| +// 500 because one of three independent reads had a bad moment, the same |
| +// posture HostRows (view.go) and handleFleet already take. |
| +func (s *Server) loadFleetPage(ctx context.Context, reg *registry.Registry, acct *Account, fleet *registry.Fleet) (boxes []fleetPageBox, apps []fleetPagePlacedApp, warn string) { |
| + regBoxes, err := reg.ListFleetBoxes(fleet.Name) |
| + if err != nil { |
| + log.Printf("console: fleet page %s/%s: list boxes: %v", acct.Sqid, fleet.Name, err) |
| + warn = "Could not list this fleet's boxes." |
| + } |
| + |
| + // A console with no deployment bucket configured has no heartbeats to |
| + // read at all — every box renders as "no heartbeat yet", not an error. |
| + heartbeats := map[string]status.Box{} |
| + if s.cfg.Releases != nil { |
| + hbs, err := status.LoadBoxes(ctx, release.NewPrefixStore(s.cfg.Releases, "fleets/"+fleet.Name+"/")) |
| + if err != nil { |
| + log.Printf("console: fleet page %s/%s: load heartbeats: %v", acct.Sqid, fleet.Name, err) |
| + } else { |
| + for _, b := range hbs { |
| + heartbeats[b.Label] = b |
| + } |
| + } |
| + } |
| + |
| + now := time.Now() |
| + boxes = make([]fleetPageBox, 0, len(regBoxes)) |
| + for _, b := range regBoxes { |
| + row := fleetPageBox{Label: b.Label, Status: b.Status} |
| + // Join key is the label, per the Task 6 identity ruling in this |
| + // function's doc comment — an orphan heartbeat under some OTHER |
| + // label (a box heartbeating under a hashed pseudonym) simply never |
| + // matches here, which is exactly the degrade-safely behaviour this |
| + // row is built to have: it falls through to the zero-value |
| + // Reachable=false, Age="" case below, "no heartbeat yet". |
| + if hb, joined := heartbeats[b.Label]; joined { |
| + if status.BoxStale(hb, now, status.SnapshotPeriod) { |
| + row.Age = humanAge(now.Sub(hb.GeneratedAt)) |
| + } else { |
| + row.Reachable = true |
| + if hb.Metrics != nil { |
| + row.Tunnel = hb.Metrics.Tunnel |
| + row.Metrics = hb.Metrics |
| + } |
| + } |
| + } |
| + boxes = append(boxes, row) |
| + } |
| + |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: fleet page %s/%s: list routes: %v", acct.Sqid, fleet.Name, err) |
| + if warn == "" { |
| + warn = "Could not list this fleet's placed apps." |
| + } |
| + return boxes, nil, warn |
| + } |
| + target := registry.PlacementTarget(fleet.Name) |
| + byApp := map[string][]string{} |
| + for _, rt := range routes { |
| + if rt.Account != acct.Sqid || rt.Region != target { |
| + continue |
| + } |
| + byApp[rt.App] = append(byApp[rt.App], rt.Host) |
| + } |
| + names := make([]string, 0, len(byApp)) |
| + for app := range byApp { |
| + names = append(names, app) |
| + } |
| + sort.Strings(names) |
| + apps = make([]fleetPagePlacedApp, 0, len(names)) |
| + for _, app := range names { |
| + hosts := byApp[app] |
| + sort.Strings(hosts) |
| + apps = append(apps, fleetPagePlacedApp{App: app, Hosts: hosts}) |
| + } |
| + return boxes, apps, warn |
| +} |
| + |
| +// handleFleetPage serves GET /a/{sqid}/fleets/{fleet}. |
| +func (s *Server) handleFleetPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, fleet, reg, ok := s.accountFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = fleet.Name |
| + p.FleetDetail = fleet |
| + boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet) |
| + p.FleetBoxes = boxes |
| + p.FleetApps = apps |
| + if warn != "" && p.FlashErr == "" { |
| + p.FlashErr = warn |
| + } |
| + s.render(w, http.StatusOK, "fleetdetail", p) |
| +} |
| + |
| +// fleetPageURL is where the two mutation POSTs below redirect back to on an |
| +// ordinary (non-token) outcome. |
| +func fleetPageURL(sqid, fleet string) string { |
| + return "/a/" + url.PathEscape(sqid) + "/fleets/" + url.PathEscape(fleet) |
| +} |
| + |
| +// handleFleetRotateBoxPage answers the owner's "Rotate token" form: the |
| +// same SetFleetBoxToken write handleAPIFleetRotateBox makes, with the fresh |
| +// plaintext shown directly in this response — never redirected, never put |
| +// in a flash cookie — so it is on the page exactly once, the way |
| +// printFleetToken (fleets.go, the CLI) prints it exactly once to stdout. |
| +// ROTATION IS NOT REVOCATION, same warning, same reasoning: see |
| +// handleAPIFleetRotateBox's doc comment. |
| +func (s *Server) handleFleetRotateBoxPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) |
| + } |
| + label := strings.TrimSpace(r.FormValue("label")) |
| + box, err := findFleetBox(reg, fleet.Name, label) |
| + if err != nil { |
| + log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + back("err", "Could not read the box.") |
| + return |
| + } |
| + if box == nil { |
| + back("err", "No such box.") |
| + return |
| + } |
| + plaintext, hash, err := mintFleetToken() |
| + if err != nil { |
| + log.Printf("console: fleet rotate %s/%s/%s: mint token: %v", acct.Sqid, fleet.Name, label, err) |
| + back("err", "Could not mint a token — try again.") |
| + return |
| + } |
| + if err := reg.SetFleetBoxToken(fleet.Name, label, hash); err != nil { |
| + log.Printf("console: fleet rotate %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + back("err", "Could not rotate the token — try again.") |
| + return |
| + } |
| + s.audit(acct, fleet.Name, addr, "fleet-rotate-box", "rotated the token for "+fleet.Name+"/"+label+" (console)") |
| + |
| + p := s.accountPage(w, r, addr, raw, acct, RoleOwner) |
| + p.Title = fleet.Name |
| + p.FleetDetail = fleet |
| + boxes, apps, warn := s.loadFleetPage(r.Context(), reg, acct, fleet) |
| + p.FleetBoxes = boxes |
| + p.FleetApps = apps |
| + p.FlashErr = warn |
| + p.RotatedLabel = label |
| + p.RotatedToken = plaintext |
| + s.render(w, http.StatusOK, "fleetdetail", p) |
| +} |
| + |
| +// handleFleetDetachBoxPage answers the owner's "Detach" form: the same |
| +// SetFleetBoxStatus write handleAPIFleetDetachBox makes. No secret to show, |
| +// so this redirects back to the fleet page with an ordinary flash notice. |
| +func (s *Server) handleFleetDetachBoxPage(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, fleet, reg, ok := s.ownerFleet(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, fleetPageURL(acct.Sqid, fleet.Name), http.StatusSeeOther) |
| + } |
| + label := strings.TrimSpace(r.FormValue("label")) |
| + box, err := findFleetBox(reg, fleet.Name, label) |
| + if err != nil { |
| + log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + back("err", "Could not read the box.") |
| + return |
| + } |
| + if box == nil { |
| + back("err", "No such box.") |
| + return |
| + } |
| + if err := reg.SetFleetBoxStatus(fleet.Name, label, "detached"); err != nil { |
| + log.Printf("console: fleet detach %s/%s/%s: %v", acct.Sqid, fleet.Name, label, err) |
| + back("err", "Could not detach the box — try again.") |
| + return |
| + } |
| + s.audit(acct, fleet.Name, addr, "fleet-detach-box", "detached "+fleet.Name+"/"+label+" (console)") |
| + back("ok", "Detached "+fleet.Name+"/"+label+".") |
| +} |
diff --git a/internal/console/fleets_test.go b/internal/console/fleets_test.go| index 969a34090..4c32b7dd2 100644 |
| --- a/internal/console/fleets_test.go |
| +++ b/internal/console/fleets_test.go |
| @@ -16,11 +16,15 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "net/http" |
| + "net/url" |
| + "strings" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| // fleetFixture is a console with paul (the fixture's default ops-account |
| @@ -462,3 +466,269 @@ func TestPlaceInvalidTargetIs400(t *testing.T) { |
| t.Fatalf("place with a bare region code = %d %v, want 400", status, body) |
| } |
| } |
| + |
| +// ---- Task 9: the console fleet page (owner view) ------------------------- |
| +// |
| +// The properties that matter here, same ordering rule as the API section |
| +// above: a non-member gets the byte-identical 404 an unrouted URL gets, a |
| +// member who isn't the owner sees the same data with no mutation forms, a |
| +// stale heartbeat renders "unreachable" and never its last happy numbers, |
| +// an unjoined registry row (Task 6's KNOWN DEGRADATION carry) renders safely |
| +// rather than crashing or borrowing another row's numbers, and a rotated |
| +// token appears in the response body exactly once, beside the |
| +// detach-not-rotate warning. |
| + |
| +// writeFleetHeartbeat lands one box snapshot at |
| +// fleets/<fleet>/status/boxes/<label>.json — the exact path a real fleet |
| +// box's agent writes to (release.NewPrefixStore, host/agent.go) — so the |
| +// page's loader (fleets.go, via status.LoadBoxes over the same prefix |
| +// store) reads it the same way it would in production. |
| +func writeFleetHeartbeat(t *testing.T, fx *consoleFixture, fleet string, b status.Box) { |
| + t.Helper() |
| + prefixed := release.NewPrefixStore(fx.rel, "fleets/"+fleet+"/") |
| + if err := status.WriteBox(context.Background(), prefixed, b); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestFleetPageOwnerControlsMemberReadOnly: both a member and the owner see |
| +// the fleet's boxes — including a fresh box's raw metrics, which this page |
| +// (unlike the public fleet status page) is allowed to show — but only the |
| +// owner's page carries the rotate/detach forms. A member's page must not |
| +// even offer them. |
| +func TestFleetPageOwnerControlsMemberReadOnly(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-owner") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + writeFleetHeartbeat(t, fx, "acme", status.Box{ |
| + GeneratedAt: time.Now(), Label: "pi-1", |
| + Metrics: &status.BoxMetrics{ |
| + MemTotalBytes: pi64(2 << 30), MemAvailBytes: pi64(1 << 30), |
| + Load1: p(0.42), DiskFreeBytes: pi64(5 << 30), |
| + Release: "abc1234", Tunnel: "connected", |
| + }, |
| + }) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The owner's view: metrics, and both mutation forms. |
| + owner := newBrowser(t) |
| + signUp(t, fx, owner, "code-fleetpage-owner-browser") |
| + ownerStatus, _, ownerBody := get(t, owner, fx.u("/fleets/acme")) |
| + if ownerStatus != http.StatusOK { |
| + t.Fatalf("owner GET /fleets/acme = %d, want 200:\n%s", ownerStatus, ownerBody) |
| + } |
| + for _, want := range []string{"pi-1", "connected", "abc1234", "1.0 GB", "2.0 GB", "0.42", "5.0 GB", "Rotate token</button>", "Detach</button>"} { |
| + if !strings.Contains(ownerBody, want) { |
| + t.Fatalf("owner fleet page missing %q in:\n%s", want, ownerBody) |
| + } |
| + } |
| + |
| + // The member's view: same data, no forms. |
| + fx.setUser("sam@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "code-fleetpage-member-browser") |
| + memberStatus, _, memberBody := get(t, member, fx.u("/fleets/acme")) |
| + if memberStatus != http.StatusOK { |
| + t.Fatalf("member GET /fleets/acme = %d, want 200:\n%s", memberStatus, memberBody) |
| + } |
| + for _, want := range []string{"pi-1", "connected", "abc1234", "1.0 GB", "2.0 GB", "0.42", "5.0 GB"} { |
| + if !strings.Contains(memberBody, want) { |
| + t.Fatalf("member fleet page missing %q in:\n%s", want, memberBody) |
| + } |
| + } |
| + for _, forbidden := range []string{"/fleets/acme/rotate", "/fleets/acme/detach", "Rotate token</button>", "Detach</button>"} { |
| + if strings.Contains(memberBody, forbidden) { |
| + t.Fatalf("member fleet page renders a mutation form (%q) it must not have:\n%s", forbidden, memberBody) |
| + } |
| + } |
| +} |
| + |
| +// TestFleetPageNonMemberGetsIdenticalNotFound: the browser-side twin of |
| +// TestFleetCrossAccountAccess404 — a signed-in caller with no membership in |
| +// the account gets the byte-identical 404 a genuinely unrouted path gets, |
| +// same assertion shape as TestNonMemberGetsIdenticalNotFound. |
| +func TestFleetPageNonMemberGetsIdenticalNotFound(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-nonmember") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + fx.setUser("bob@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-bob") |
| + |
| + nonMemberStatus, _, nonMemberBody := get(t, c, fx.u("/fleets/acme")) |
| + ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page") |
| + if nonMemberStatus != http.StatusNotFound || nonMemberStatus != ghostStatus { |
| + t.Fatalf("non-member fleet page = %d, unrouted page = %d; want both 404", nonMemberStatus, ghostStatus) |
| + } |
| + if nonMemberBody != ghostBody { |
| + t.Fatalf("non-member fleet page body differs from an unrouted page's:\n%q\n%q", nonMemberBody, ghostBody) |
| + } |
| +} |
| + |
| +// TestFleetPageStaleHeartbeatRendersUnreachable: a heartbeat older than |
| +// 3×status.SnapshotPeriod must never show its last happy numbers — only |
| +// that the box is unreachable and how long it's been dark. Same rule |
| +// TestFleetHostsDetailSuppressesStaleMetrics enforces on the operator page, |
| +// applied here to the customer's own. |
| +func TestFleetPageStaleHeartbeatRendersUnreachable(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-stale") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + writeFleetHeartbeat(t, fx, "acme", status.Box{ |
| + GeneratedAt: time.Now().Add(-time.Hour), Label: "pi-1", |
| + Metrics: &status.BoxMetrics{ |
| + MemTotalBytes: pi64(9 << 30), MemAvailBytes: pi64(7 << 30), |
| + Load1: p(8.25), DiskFreeBytes: pi64(3 << 30), |
| + Release: "beefcaf", Tunnel: "connected", |
| + }, |
| + }) |
| + |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-stale-browser") |
| + st, _, body := get(t, c, fx.u("/fleets/acme")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body) |
| + } |
| + if !strings.Contains(body, "unreachable") { |
| + t.Fatalf("stale box did not render as unreachable:\n%s", body) |
| + } |
| + for _, leaked := range []string{"beefcaf", "8.25", "7.0 GB", "9.0 GB", "3.0 GB"} { |
| + if strings.Contains(body, leaked) { |
| + t.Fatalf("stale box's last reading %q leaked into the page:\n%s", leaked, body) |
| + } |
| + } |
| +} |
| + |
| +// TestFleetPageUnjoinedRegistryRowRendersSafely is the Task 6 KNOWN |
| +// DEGRADATION carry: a registry box row with no heartbeat that ever joined |
| +// it (pi-2 here — never written at all, standing in for a box heartbeating |
| +// under a hashed pseudonym per the identity ruling) must render as "no |
| +// heartbeat yet", never crash, and never show pi-1's numbers under pi-2's |
| +// label. |
| +func TestFleetPageUnjoinedRegistryRowRendersSafely(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-unjoined") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + for _, label := range []string{"pi-1", "pi-2"} { |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": label}, token); st != http.StatusOK { |
| + t.Fatalf("add-box %s = %d %v", label, st, body) |
| + } |
| + } |
| + // Only pi-1 ever heartbeats. |
| + writeFleetHeartbeat(t, fx, "acme", status.Box{ |
| + GeneratedAt: time.Now(), Label: "pi-1", |
| + Metrics: &status.BoxMetrics{Load1: p(1.11), Tunnel: "connected"}, |
| + }) |
| + |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-unjoined-browser") |
| + st, _, body := get(t, c, fx.u("/fleets/acme")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleets/acme = %d, want 200:\n%s", st, body) |
| + } |
| + if !strings.Contains(body, "pi-1") || !strings.Contains(body, "1.11") { |
| + t.Fatalf("joined box pi-1's own reading missing:\n%s", body) |
| + } |
| + if !strings.Contains(body, "pi-2") { |
| + t.Fatalf("unjoined box pi-2 missing from the page entirely:\n%s", body) |
| + } |
| + if !strings.Contains(body, "no heartbeat") { |
| + t.Fatalf("unjoined box pi-2 did not render as no-heartbeat-yet:\n%s", body) |
| + } |
| +} |
| + |
| +// TestFleetPageRotateWithoutCSRFForbidden: the rotate form is CSRF-gated |
| +// like every other mutation on this console. |
| +func TestFleetPageRotateWithoutCSRFForbidden(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-csrf") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-csrf-browser") |
| + st, _, _ := postForm(t, c, fx.u("/fleets/acme/rotate"), url.Values{"csrf": {"wrong"}, "label": {"pi-1"}}) |
| + if st != http.StatusForbidden { |
| + t.Fatalf("rotate with a bad csrf = %d, want 403", st) |
| + } |
| +} |
| + |
| +// TestFleetPageRotateShowsTokenOnceWithWarning: the rotate form's response |
| +// carries the fresh plaintext exactly once, alongside the same |
| +// detach-not-rotate warning printFleetToken (fleets.go, the CLI) gives — and |
| +// the registry's stored hash actually moves to match it, the same tripwire |
| +// TestFleetRotateBoxChangesHash checks at the API layer. |
| +func TestFleetPageRotateShowsTokenOnceWithWarning(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-fleetpage-rotate") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-fleetpage-rotate-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + st, _, body := postForm(t, c, fx.u("/fleets/acme/rotate"), url.Values{"csrf": {csrf}, "label": {"pi-1"}}) |
| + if st != http.StatusOK { |
| + t.Fatalf("rotate = %d, want 200 (rendered directly):\n%s", st, body) |
| + } |
| + if !strings.Contains(body, "shown once") { |
| + t.Fatalf("rotate response missing the shown-once notice:\n%s", body) |
| + } |
| + if !strings.Contains(strings.ToUpper(body), "NOT REVOCATION") { |
| + t.Fatalf("rotate response missing the detach-not-rotate warning:\n%s", body) |
| + } |
| + |
| + reg := openTestReg(t, fx) |
| + boxes, err := reg.ListFleetBoxes("acme") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(boxes) != 1 { |
| + t.Fatalf("boxes = %+v, want exactly pi-1", boxes) |
| + } |
| + // The rendered token, extracted crudely (it's inside a <code> tag), must |
| + // hash to what's actually stored — proving the page shows the SAME token |
| + // it wrote, not a decoy. |
| + found := false |
| + for _, hexTok := range strings.Fields(strings.ReplaceAll(strings.ReplaceAll(body, "<code>", " "), "</code>", " ")) { |
| + if len(hexTok) != 64 { |
| + continue |
| + } |
| + sum := sha256.Sum256([]byte(hexTok)) |
| + if hex.EncodeToString(sum[:]) == boxes[0].TokenHash { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("no 64-hex token in the rotate response hashes to the stored TokenHash %s:\n%s", boxes[0].TokenHash, body) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 485f024c3..ff5891e01 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -23,6 +23,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -255,6 +256,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| + // Customer fleets (Task 9): the owner's own remote boxes and what's |
| + // placed on them — member-readable, owner-gated for rotate/detach |
| + // (fleets.go). |
| + s.mux.HandleFunc("GET /a/{sqid}/fleets/{fleet}", s.handleFleetPage) |
| + s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/rotate", s.handleFleetRotateBoxPage) |
| + s.mux.HandleFunc("POST /a/{sqid}/fleets/{fleet}/detach", s.handleFleetDetachBoxPage) |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite) |
| // Owner-only account administration, every one of them behind passkey |
| @@ -366,6 +373,21 @@ type page struct { |
| // 12), one row per route host. Nil renders no section at all — metrics |
| // are decoration on this page and must never be the reason it fails. |
| AppMetrics []InstanceMetrics |
| + |
| + // /a/{sqid}/fleets/{fleet} (Task 9): the owner's own remote boxes, |
| + // joined to their heartbeats, and the account's apps placed on this |
| + // fleet. FleetDetail carries only Name/Account/Bucket — registry.Fleet |
| + // has no TokenHash on it at all (that lives on FleetBox, never echoed |
| + // here — see fleets.go's package doc comment), so putting it straight |
| + // on the page is safe. |
| + FleetDetail *registry.Fleet |
| + FleetBoxes []fleetPageBox |
| + FleetApps []fleetPagePlacedApp |
| + // RotatedLabel/RotatedToken are set only on the response to a rotate |
| + // POST, to show the fresh bearer token exactly once — never stored, |
| + // never re-derivable from a later view of this same page. |
| + RotatedLabel string |
| + RotatedToken string |
| } |
| // IsOwner is the template's one permission question. |
diff --git a/internal/console/templates/fleetdetail.html b/internal/console/templates/fleetdetail.html| new file mode 100644 |
| index 000000000..833ef65cc |
| --- /dev/null |
| +++ b/internal/console/templates/fleetdetail.html |
| @@ -0,0 +1,77 @@ |
| +{{define "fleetdetail"}}{{template "top" .}} |
| +<h1>{{.FleetDetail.Name}}</h1> |
| +<p class="small">Data bucket <code>{{.FleetDetail.Bucket}}</code></p> |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| + |
| +{{if .RotatedToken}} |
| +<div class="banner"> |
| + <p><strong>New token for {{.FleetDetail.Name}}/{{.RotatedLabel}}:</strong> <code>{{.RotatedToken}}</code></p> |
| + <p class="small">(shown once — the console does not store or re-show the plaintext)</p> |
| + <p class="small">ROTATION IS NOT REVOCATION: this refuses the box's NEXT dial-in with the OLD |
| + token, but does NOT evict a channel it already holds — a COMPROMISED box needs Detach, not |
| + another Rotate.</p> |
| +</div> |
| +{{end}} |
| + |
| +<h2>Boxes</h2> |
| +{{if .FleetBoxes}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr> |
| + <th>Label</th><th>Status</th><th>Tunnel</th> |
| + <th>Memory free</th><th>Load</th><th>Disk free</th><th>Release</th> |
| + {{if .IsOwner}}<th></th>{{end}} |
| + </tr> |
| + {{range .FleetBoxes}} |
| + <tr> |
| + <td>{{.Label}}</td> |
| + <td>{{.Status}}</td> |
| + {{if .Reachable}} |
| + <td>{{emptyOrDash .Tunnel}}</td> |
| + <td>{{if .Metrics}}{{bytesOrDash .Metrics.MemAvailBytes}} / {{bytesOrDash .Metrics.MemTotalBytes}}{{else}}—{{end}}</td> |
| + <td>{{if .Metrics}}{{loadOrDash .Metrics.Load1}}{{else}}—{{end}}</td> |
| + <td>{{if .Metrics}}{{bytesOrDash .Metrics.DiskFreeBytes}}{{else}}—{{end}}</td> |
| + <td>{{if .Metrics}}{{emptyOrDash .Metrics.Release}}{{else}}—{{end}}</td> |
| + {{else if .Age}} |
| + <td colspan="5"><span class="chip chip-down">unreachable</span> last heartbeat {{.Age}} ago</td> |
| + {{else}} |
| + <td colspan="5" class="small">no heartbeat yet — unreachable (age unknown)</td> |
| + {{end}} |
| + {{if $.IsOwner}} |
| + <td> |
| + <form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/rotate"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="label" value="{{.Label}}"> |
| + <button type="submit" class="linkish">Rotate token</button> |
| + </form> |
| + <form method="post" action="{{$.Base}}/fleets/{{$.FleetDetail.Name}}/detach"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="label" value="{{.Label}}"> |
| + <button type="submit" class="linkish">Detach</button> |
| + </form> |
| + </td> |
| + {{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Rotating a token refuses that box's NEXT dial-in with the old credential but does |
| +not evict a channel it already holds; a compromised box needs Detach instead.</p> |
| +{{else}}<p class="small">No boxes registered to this fleet yet — add one with <code>carlos fleets add-box</code>.</p>{{end}} |
| + |
| +<h2>Placed apps</h2> |
| +{{if .FleetApps}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>App</th><th>Hosts</th></tr> |
| + {{range .FleetApps}} |
| + <tr> |
| + <td><a href="{{$.Base}}/apps/{{.App}}">{{.App}}</a></td> |
| + <td>{{range .Hosts}}<code>{{.}}</code> {{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No apps placed on this fleet yet — <code>carlos apps place</code> sends one here.</p>{{end}} |
| +{{template "bottom" .}}{{end}} |