carlos / platform Public

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.
Paul Campbell 8532a8bf3f3e7ae997bc3f6320f0a71a9aa8700c parent 68e2de7
4 files changed, +683 −0
  • internal/console/fleets.go +314 −0
  • internal/console/fleets_test.go +270 −0
  • internal/console/server.go +22 −0
  • internal/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}}