carlos / platform Public

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>
Paul Campbell cba3fc3afaf0db347846ab3a241aff9e8ffaaed6 parent 89c89d3
4 files changed, +98 −0
  • internal/console/accountfleet.go +16 −0
  • internal/console/accountfleet_test.go +20 −0
  • internal/console/server.go +1 −0
  • internal/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 &lt;your-bucket&gt; &lt;name&gt;
+carlos fleets add-box &lt;name&gt; &lt;label&gt;</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=&lt;your-bucket&gt;
+CARLOS_FLEET=&lt;name&gt;
+CARLOS_FLEET_BOX=&lt;label&gt;
+CARLOS_REGION=fleet/&lt;name&gt;
+CARLOS_STATUS_PREFIX=fleets/&lt;name&gt;/
+CARLOS_TUNNEL_URL=&lt;this deployment's tunnel address&gt;
+CARLOS_TUNNEL_TOKEN=&lt;from carlos fleets add-box &lt;name&gt; &lt;label&gt; — shown once&gt;
+CARLOS_TUNNEL_NAME=&lt;name&gt;/&lt;label&gt;</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}}