console: BYO fleet instructions page (D8)
GET /a/{sqid}/fleet/byo, member-readable (same gate as the Fleet tab),
ActiveNav "fleet". Makes the Fleet tab's "How to add and harden a box"
button real: what you need, create-the-fleet commands with the
shown-once token warning (fleetdetail.html's exact rotation-is-not-
revocation phrasing), the box env contract transcribed verbatim from
infra/dev/region-pi.md with <name>/<label> placeholders, a Hardening
checklist pointing at internal/host/units/ for the systemd reference
shapes, and what happens next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+98
−0
internal/console/accountfleet.go+16 −0internal/console/accountfleet_test.go+20 −0internal/console/server.go+1 −0internal/console/templates/fleet_byo.html+61 −0
diff --git a/internal/console/accountfleet.go b/internal/console/accountfleet.go| index b4f6fd98c..ab2dd6327 100644 |
| --- a/internal/console/accountfleet.go |
| +++ b/internal/console/accountfleet.go |
| @@ -205,3 +205,19 @@ func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) { |
| } |
| s.render(w, http.StatusOK, "account_fleet", p) |
| } |
| + |
| +// handleFleetBYO serves GET /a/{sqid}/fleet/byo: the "how to add and harden |
| +// a box" page the Fleet tab's button links to. It is static, per-deployment |
| +// content — the account only personalizes the eyebrow and header, same as |
| +// every other account page — so the handler is handleAccountFleetTab minus |
| +// the shared-fleet/own-fleets assembly: gate, page scaffold, render. |
| +func (s *Server) handleFleetBYO(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = "Bring your own fleet" |
| + p.ActiveNav = "fleet" |
| + s.render(w, http.StatusOK, "fleet_byo", p) |
| +} |
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go| index 3975af76a..1cd589a81 100644 |
| --- a/internal/console/accountfleet_test.go |
| +++ b/internal/console/accountfleet_test.go |
| @@ -341,6 +341,26 @@ func TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned(t *testing.T) { |
| } |
| } |
| +// TestFleetBYOPageMemberReadable: the BYO instructions page is member- |
| +// readable, the same gate as the Fleet tab itself — a member deciding |
| +// whether to bring a box needs to read the recipe as much as the owner |
| +// does. It must carry the exact commands and env var an operator will |
| +// actually type/set, and the Hardening section the checklist lives under. |
| +func TestFleetBYOPageMemberReadable(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + st, _, body := get(t, c, fx.u("/fleet/byo")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet/byo = %d, want 200:\n%s", st, body) |
| + } |
| + for _, want := range []string{"carlos fleets create", "CARLOS_TUNNEL_NAME", "Hardening"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("BYO page missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| // TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost |
| // the shared card, never the page — the fleets index is registry-side and |
| // keeps rendering, with a flash that says which half is unknown. |
diff --git a/internal/console/server.go b/internal/console/server.go| index 8835cce5a..9f89f7c6b 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -316,6 +316,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // account-by-session, so a link a teammate opens lands where you meant. |
| s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome) |
| s.mux.HandleFunc("GET /a/{sqid}/fleet", s.handleAccountFleetTab) |
| + s.mux.HandleFunc("GET /a/{sqid}/fleet/byo", s.handleFleetBYO) |
| s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings) |
| // Go 1.22's mux prefers this literal over apps/{app} below — exactly why |
diff --git a/internal/console/templates/fleet_byo.html b/internal/console/templates/fleet_byo.html| new file mode 100644 |
| index 000000000..5fd76c0f2 |
| --- /dev/null |
| +++ b/internal/console/templates/fleet_byo.html |
| @@ -0,0 +1,61 @@ |
| +{{define "fleet_byo"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +<div class="pagehead"> |
| + <p class="eyebrow">Fleet · {{.Account.Name}}</p> |
| + <h1>Bring your own fleet</h1> |
| + <p class="lede">Run your apps on hardware you own. Your boxes dial out to the platform over an |
| + outbound tunnel; nothing ever connects in.</p> |
| +</div> |
| + |
| +<h2>What you need</h2> |
| +<ul> |
| + <li>A Linux box, arm64 or amd64, that can reach the internet outbound.</li> |
| + <li>An object-storage bucket you own for the fleet's data.</li> |
| + <li>Nothing listens inbound, ever — no port to open, no firewall rule to punch a hole in.</li> |
| +</ul> |
| + |
| +<h2>Create the fleet</h2> |
| +<p>From a terminal signed in to this console:</p> |
| +<pre><code>carlos fleets create -bucket <your-bucket> <name> |
| +carlos fleets add-box <name> <label></code></pre> |
| +<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> |
| + |
| +<h2>Configure the box</h2> |
| +<p>On the box, in a root-owned env file the agent's unit loads (see Hardening below):</p> |
| +<pre><code>AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials |
| +CARLOS_HIBERNATE_BUCKET=<your-bucket> |
| +CARLOS_FLEET=<name> |
| +CARLOS_FLEET_BOX=<label> |
| +CARLOS_REGION=fleet/<name> |
| +CARLOS_STATUS_PREFIX=fleets/<name>/ |
| +CARLOS_TUNNEL_URL=<this deployment's tunnel address> |
| +CARLOS_TUNNEL_TOKEN=<from carlos fleets add-box <name> <label> — shown once> |
| +CARLOS_TUNNEL_NAME=<name>/<label></code></pre> |
| +<p class="small"><code>CARLOS_FLEET_BOX</code> is optional and defaults from |
| +<code>CARLOS_TUNNEL_NAME</code>'s label half. <code>AWS_SHARED_CREDENTIALS_FILE</code> points at |
| +an access key for the bucket above, written root-owned mode 600 alongside the env file.</p> |
| + |
| +<h2>Harden it</h2> |
| +<ul> |
| + <li>Run the agent as a dedicated non-root user, not root.</li> |
| + <li>Make the env file root-owned, mode 600 — it holds the tunnel token.</li> |
| + <li>Default-deny inbound on the box's firewall; it only ever dials out.</li> |
| + <li>Turn on unattended security upgrades.</li> |
| + <li>Keep the clock sane — chrony or systemd-timesyncd; leases compare timestamps.</li> |
| + <li>Run the agent under a systemd unit with <code>ProtectSystem=strict</code> and |
| + <code>NoNewPrivileges=yes</code> — see <code>internal/host/units/</code> in the platform repo |
| + for the reference shapes.</li> |
| + <li>Rotate the token if it may have leaked. If the box itself may be compromised, |
| + <strong>Detach</strong> it instead — rotating only refuses its next dial-in, it does not evict |
| + a channel the box already holds.</li> |
| +</ul> |
| + |
| +<h2>Then</h2> |
| +<p>The box appears on your fleet page within minutes — it self-reports on a heartbeat cadence, not |
| +instantly. From there, place an app on it with <code>carlos apps place</code> or from the app's |
| +Instances tab.</p> |
| +{{template "bottom" .}}{{end}} |