carlos / platform Public

docs: no-inbound box runbook, and the legacy/native sidecar split

Paul Campbell pushed by paul@keymail.dev de20cfe333f79ea52a9849828f0416a773f60bdd parent 95c037b
4 files changed, +350 −8
  • CLAUDE.md +11 −0
  • infra/no-inbound-box.md +306 −0
  • internal/activator/activator.go +20 −8
  • internal/activator/backend_exec.go +13 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index d2f8faccb..b4e4439ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -135,6 +135,17 @@ read as a single piece rather than commit by commit.
`.carlos/config`. The fallback is the operator's real login and default
console; a logged-in laptop running `go test ./...` minted 40+ real accounts
on the production console before this was isolated.
+- **A sidecar is a member that nothing can dial.** `Kind == "sidecar"` rows
+ carry no socket and no addr, and are refused by the edge ON KIND — not
+ merely unmatched by it — before dispatch ever looks at shape. That refusal
+ has to be explicit: a fleet-placed sidecar row reaches the edge's fleet
+ arm on shape alone (a `Tunnel`/`Region` it never should have earned), so
+ `publishResidentFleetBindings` also skips it — a sidecar is never
+ advertised as served in a fleet binding. Separately, each reconcile pass's
+ removal authority is scoped to its OWN kind: without that scoping the
+ instance pass and the sidecar pass delete each other's rows on every
+ tick, forever, with no error anywhere. Use `registry.Route.IsSidecar()` in
+ any code that needs the check — never the bare `"sidecar"` literal.
## Deploy reality (2026-08-02; convergence rewritten 2026-08-08)
diff --git a/infra/no-inbound-box.md b/infra/no-inbound-box.md
new file mode 100644
index 000000000..e16ae23ae
--- /dev/null
+++ b/infra/no-inbound-box.md
@@ -0,0 +1,306 @@
+# No-inbound box runbook (private-subnet posture)
+
+> **STATUS: written for Paul's applies. Nothing in this document has been
+> applied, no tofu in it has been written into a `.tf` file, and no live box
+> was touched by the branch that added it.** It documents a network shape
+> for `carlos sidecars` design §8's `CARLOS_INBOUND=none` boxes; the module
+> that would produce it does not exist in this repo yet — authoring it is
+> Step 1 below, not a precondition already met.
+
+A `CARLOS_INBOUND=none` box accepts sidecar work only: no routed instance
+traffic, no certs, no tunnel-served upstream, no public status entry. This
+runbook covers the **hardest** version of that box — one that sits in a
+private subnet with no route to the internet at all, egress only through a
+NAT gateway, a security group with zero inbound rules, and no SSM agent, so
+the box has no reachable surface whatsoever, from any direction, by any
+channel.
+
+## Read this first: do you actually need this?
+
+**Probably not yet.** Fleet boxes in this estate are *already*
+outbound-only dial — `infra/eleven-fleet.md` and `infra/remote-host.md`
+describe the shape every existing fleet box runs today: a security group
+with no inbound rules beyond what a normal host needs, admin reachability
+through SSM (or none, off-cloud), and all platform traffic — heartbeats,
+release pulls, the fleet tunnel — dialed **out**. Eleven's boxes in
+`titogo` are that shape right now. Design §8 is explicit about this: the
+private-subnet posture below is *available when wanted*, not a
+prerequisite for running a sidecar. Eleven's first sidecar box needs
+**no new network shape at all** — enrol it on the fleet box it already has,
+per `infra/eleven-fleet.md`, and stop reading here.
+
+Reach for this runbook only when outbound-only-at-the-security-group-layer
+isn't enough — when the requirement is "this workload must be provably
+unreachable even if every SG rule and every credential on the box were
+compromised," because there is, at the network layer, no path in to reach
+it with. That is a stronger claim than "nothing dials it today," and it
+costs a NAT gateway (an hourly + per-GB charge) and the loss of SSM
+break-glass access — weigh that before applying anything below.
+
+## Preconditions (must be true before Step 1)
+
+- [ ] **Sidecar members merged and deployed.** The `CARLOS_INBOUND` env
+ read (`internal/host/agent.go`), the placement half
+ (`internal/provision/sidecar_reconcile.go`,
+ `provision.PlacementNoInbound`), and the mint-time refusal on
+ no-inbound boxes (`internal/provision/reconcile.go`) all need to be
+ live on the box's `carlos` build — built fresh, not a stale
+ checkout (recorded trap, `carlos-cli-releases.md`).
+- [ ] **The workload already exists as a fleet box**, following
+ `infra/eleven-fleet.md`'s Ceremony through Step 3 (fleet created, box
+ attached, `host.env` flipped and verified clean) — this runbook adds
+ network hardening around a box that already dials the right console
+ with the right credentials, it does not replace that ceremony.
+- [ ] **A decision on which existing VPC/subnet plan hosts this.** The
+ sketch below assumes an existing VPC (e.g. `titogo`'s) with spare
+ CIDR room for one new private subnet and either a shared or new NAT
+ gateway. A from-scratch VPC is a bigger tofu change than this
+ runbook covers.
+- [ ] **The sidecar's own record targets `placement: "no-inbound"`** (or
+ its app's manifest does) — `carlos sidecars enable -app <app>
+ -placement no-inbound` (or `carlos sidecars create ... ` under a
+ manifest already carrying it). Without this the record mints on any
+ box in the pool, including ones with inbound service, and the box
+ built below sits idle.
+- [ ] **Nobody is depending on SSM Session Manager reaching this box.**
+ There is no admin channel to it once this ships except the fleet
+ tunnel and the sidecar's own agent-socket protocol (design §7.1) —
+ confirm that is acceptable for this workload before Step 3.
+
+## The tofu shape
+
+Four pieces, applied in order because each one's Verify depends on the
+last existing. **No module in this repo produces this shape yet** — the
+HCL sketches below name the resources it needs, in this repo's existing
+naming conventions (`infra/modules/region-host/main.tf`,
+`infra/modules/region-remote-host/main.tf`), for whoever authors the
+module; they are not a file to copy-paste and apply as-is.
+
+**1. NAT gateway (skip if one already serves this VPC)**
+
+A NAT gateway must sit in an existing **public** subnet — one that already
+has a route to an internet gateway — even though nothing routes to it from
+outside. It is the private subnet's only way out.
+
+ resource "aws_eip" "nat" {
+ domain = "vpc"
+ }
+
+ resource "aws_nat_gateway" "sidecar" {
+ allocation_id = aws_eip.nat.id
+ subnet_id = <existing public subnet id>
+ }
+
+*Verify:* `aws ec2 describe-nat-gateways` (or the tofu plan/apply output)
+shows state `available`. This step alone costs money and touches no box —
+safe to apply and sit on.
+*Abort:* `tofu destroy -target aws_nat_gateway.sidecar -target aws_eip.nat`
+before anything below references it. Nothing yet depends on it.
+
+**2. Private subnet + route table (no IGW route)**
+
+The defining property: this subnet's route table has **no** `0.0.0.0/0 ->
+igw-...` route. Its only default route is to the NAT gateway from Step 1.
+
+ resource "aws_subnet" "sidecar_private" {
+ vpc_id = <vpc id>
+ cidr_block = <a free /28 or larger in this VPC's CIDR>
+ availability_zone = <az the NAT gateway's subnet is in, for cost — cross-AZ NAT traffic bills extra>
+ }
+
+ resource "aws_route_table" "sidecar_private" {
+ vpc_id = <vpc id>
+ route {
+ cidr_block = "0.0.0.0/0"
+ nat_gateway_id = aws_nat_gateway.sidecar.id
+ }
+ # No route to an aws_internet_gateway, ever — that absence IS the
+ # posture. Do not add one "temporarily" to debug something; that
+ # defeats this runbook's entire point for as long as it's there.
+ }
+
+ resource "aws_route_table_association" "sidecar_private" {
+ subnet_id = aws_subnet.sidecar_private.id
+ route_table_id = aws_route_table.sidecar_private.id
+ }
+
+*Verify:* `aws ec2 describe-route-tables --route-table-ids
+<id>` — confirm the route list contains exactly one `0.0.0.0/0` entry and
+its target is the NAT gateway, not `igw-...`. This is the single fact this
+whole runbook exists to guarantee; check it by reading the table, not by
+trusting the plan.
+*Abort:* the subnet and route table have nothing in them yet (Step 3
+hasn't placed an instance) — `tofu destroy -target` both, no downstream
+cleanup.
+
+**3. Security group — zero inbound**
+
+Stricter than `region-host`'s SG (which still opens 80/443): this box
+serves no certs and no tunnel-terminated traffic, so it has nothing to
+listen on at all.
+
+ resource "aws_security_group" "sidecar_no_inbound" {
+ name = "<box name>-no-inbound"
+ description = "sidecar-only box: no ingress by design"
+ vpc_id = <vpc id>
+ # No ingress block. Not "ingress restricted" — no ingress rule
+ # exists to restrict. Do not add var.extra_ingress-style exceptions
+ # to this SG; a box that needs an inbound rule is not this class of
+ # box.
+ egress {
+ from_port = 0
+ to_port = 0
+ protocol = "-1"
+ cidr_blocks = ["0.0.0.0/0"]
+ }
+ }
+
+*Verify:* `aws ec2 describe-security-groups --group-ids <id>` shows an
+empty `IpPermissions` list (ingress) and the single egress rule.
+*Abort:* unattached SG, `tofu destroy -target` it directly.
+
+**4. The instance — no SSM**
+
+Same AMI/instance-type shape as `infra/modules/region-host`, placed in the
+Step 2 subnet with the Step 3 SG, **no public IP**, and — the deliberate
+omission — no `aws_iam_role_policy_attachment` for
+`AmazonSSMManagedInstanceCore`. Without a route to the internet or a VPC
+interface endpoint for SSM (which this runbook does not add — an endpoint
+is itself a reachable surface), the SSM agent could not dial its service
+even if installed, so leaving the IAM grant off is not extra caution, it
+is removing a credential that would do nothing but sit there. **This means
+there is no break-glass path onto this box, ever, by anyone, other than
+what the sidecar and fleet-tunnel protocols carry.** That is the point,
+not an oversight — confirm the last Precondition checkbox again before
+applying this step.
+
+ resource "aws_instance" "sidecar" {
+ ami = <same arm64 Debian AMI region-host resolves>
+ instance_type = <same class region-host's boxes use>
+ subnet_id = aws_subnet.sidecar_private.id
+ vpc_security_group_ids = [aws_security_group.sidecar_no_inbound.id]
+ associate_public_ip_address = false
+ iam_instance_profile = <a role scoped like region-remote-host's,
+ WITHOUT ssm:* and WITHOUT
+ AmazonSSMManagedInstanceCore>
+ user_data = <same bootstrap script shape as region-host>
+ }
+
+*Verify:* the instance reaches `running` in `describe-instances`, and — the
+important negative check — `aws ssm describe-instance-information` does
+**not** list it, ever, even minutes later. If it appears there, something
+above is wrong (a stray endpoint, a stray IGW route, or the SSM agent
+running with a grant it shouldn't have) — treat that as a failed apply,
+not a bonus.
+*Abort:* terminate the instance (`tofu destroy -target
+aws_instance.sidecar`); nothing else in Steps 1-3 depended on it existing.
+
+## The box env contract
+
+`/etc/carlos/host.env` on this box carries the same fleet contract
+`infra/eleven-fleet.md` Step 3 stages, **plus** the one new variable that
+makes this a no-inbound box:
+
+ AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
+ CARLOS_HIBERNATE_BUCKET=<the fleet's data bucket>
+ CARLOS_FLEET=<fleet name>
+ CARLOS_FLEET_BOX=<this box's label>
+ CARLOS_REGION=fleet/<fleet name>
+ CARLOS_STATUS_PREFIX=fleets/<fleet name>/
+ CARLOS_RELEASE_PUBKEY=<the console's release public key>
+ CARLOS_TUNNEL_URL=https://<the console's reserved tunnel hostname>
+ CARLOS_TUNNEL_NAME=<fleet name>/<this box's label>
+ CARLOS_TUNNEL_TOKEN=<from the fleet's add-box call — shown once>
+ CARLOS_INBOUND=none
+
+`CARLOS_INBOUND=none` is read wherever `CARLOS_REGION`/`CARLOS_POOLS` are
+(`internal/host/agent.go`), folded case/whitespace-insensitively
+(`provision.NormalizeInbound`), and — deliberately — any value that is
+neither empty, `any`, nor `none` is treated as `none`: an unrecognised
+setting is a typo, and the restrictive reading is the safe one. Getting
+this var wrong fails closed (a box that should serve nothing accidentally
+stays inert) rather than open.
+
+The NAT gateway makes `CARLOS_TUNNEL_URL`'s dial-out (and the release/
+heartbeat traffic to `CARLOS_HIBERNATE_BUCKET`) work exactly the way it
+does on any other fleet box — this box's whole platform surface is still
+outbound, it just has no *possible* inbound path underneath it, where an
+ordinary fleet box merely has no *configured* one.
+
+## Verify the box came up and adopted its sidecars
+
+1. `journalctl -u carlos-edge` on the box (if you still have a channel to
+ read it — see the SSM note above; this may mean reading it once before
+ Step 4 removes the grant, or reading it from the box's own logs once
+ they reach the fleet's data bucket) shows "tunnel connected".
+2. The fleet page (`/a/<account-sqid>/fleets/<fleet name>`) on the
+ console shows this box with a fresh heartbeat.
+3. `carlos sidecars ls -app <app> -long` shows the sidecar's `HOST` column
+ naming a route, `PLACEMENT` reading `no-inbound`, and — once it has
+ called `/v1/ready` at least once — `VERSION` reading the running build
+ rather than `unknown` (design §9.2's honesty rule: an absent version
+ beats an overclaimed one, so `unknown` here is not a bug, it just means
+ the sidecar hasn't reported in yet).
+4. The box's own log line confirms placement worked, not just that a
+ record exists: `provision: minted sidecar route ... on box declaring
+ CARLOS_INBOUND=none` (or equivalent — check the actual log text at
+ apply time) in the box's provisioning-pass log output, once the fleet's
+ serial has moved and the box has polled it.
+
+*Abort (this section):* if the sidecar never mints here, check placement
+before network: `carlos sidecars ls -app <app> -long` showing
+`PLACEMENT any` means the record itself never asked for `no-inbound` — fix
+the manifest/record, not the box. If placement is right and it still
+doesn't mint, confirm `CARLOS_INBOUND=none` actually reached the running
+process (`ps`/`/proc/<pid>/environ` from whatever channel you still have,
+or a fresh `host.env` re-stage plus agent restart if the value was typoed
+on write).
+
+## Flipping an EXISTING box to `CARLOS_INBOUND=none`
+
+**This does not evacuate the instances the box already serves.** Setting
+`CARLOS_INBOUND=none` on a box that currently runs routed instances (or
+was reachable and had inbound work placed on it) only refuses **new**
+routed mints from then on — it is a mint-time check
+(`internal/provision/reconcile.go`'s `case !ok:` branch), not a converge-
+time or removal-time one. Every route already `Provisioned` on that box
+keeps running, keeps converging, keeps being served, exactly as before.
+
+This is deliberate, not an oversight: making the flag evacuate the box
+would mean a single typo in `host.env` — a fat-fingered `CARLOS_INBOUND=none`
+meant for a different box, or a stray copy-paste — could take down every
+app the box was serving, with no confirmation step and no way to see it
+coming. A box that stops *accepting new work* loudly logs every refusal;
+a box that *stops serving existing work* on the same typo is a production
+incident with the blast radius of whatever happened to be running there.
+
+**So: drain the box before flipping it, not after.** Move its existing
+routed instances off (`carlos apps place` to another target, or whatever
+this box's own placement mechanism is) and confirm they've actually
+stopped being served here — a `curl -sI --resolve` against each affected
+host showing it answered from elsewhere, not just that placement changed
+— before staging the new `host.env`. Only once the box carries no routed
+work should `CARLOS_INBOUND=none` land on it; at that point it is a
+statement of fact about the box, not an instruction to make it one.
+
+## Rollback summary
+
+Every step above has its own Abort; in order of how far you got:
+
+- Only Steps 1-3 applied, no instance yet: `tofu destroy -target` each
+ resource in reverse order (SG, route table + association, subnet, NAT
+ gateway + EIP) — nothing else references them.
+- Instance applied, box never adopted anything: `tofu destroy -target
+ aws_instance.sidecar`, then the network resources as above.
+- Box adopted a sidecar, now going away: detach it from the fleet first
+ (`carlos fleets detach-box -account <acct> <fleet> <box label>` — the
+ hard cut, per `infra/eleven-fleet.md`'s own Abort for that step) so the
+ console stops expecting heartbeats from it, then terminate the instance
+ and tear down the network resources.
+- Flipped an existing box and want to revert to accepting inbound work
+ again: restore the prior `host.env` (drop `CARLOS_INBOUND=none`, or set
+ it back to `any`), restart the agent unit — this is a converge-time
+ read, so the very next pass allows new mints again. It does not retro-
+ actively place anything that was moved off during the drain; that's a
+ separate `carlos apps place` back, a deliberate decision, not a side
+ effect of the env flip.
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 4ff0c1f2f..fd3bdc753 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -14,14 +14,26 @@
// back in. Membership of one: hash picks self, leases are uncontested except
// against a previous incarnation of ourselves.
//
-// Sidecar co-spawn: a host with a sidecar staged (/data/<host>-sidecar.env)
-// gets its `sidecar run` child spawned right after the instance turns
-// healthy, and SIGTERMed first at sleep — the sidecar exists exactly while
-// its instance is awake, so its lifecycle solves itself. Its own connections
-// flow back through the edge (the box pins its hosted names to loopback), so
-// the sidecar marks them (SidecarHeader) and the activator excludes them from
-// the idle clock — otherwise the pair could never sleep, and an orphaned
-// sidecar's reconnect loop would wake the instance right back (see ServeHTTP).
+// Sidecar co-spawn (LEGACY, but LIVE — Eleven runs on it today): a host with
+// a sidecar staged (/data/<host>-sidecar.env) gets its `sidecar run` child
+// spawned right after the instance turns healthy, and SIGTERMed first at
+// sleep — the sidecar exists exactly while its instance is awake, so its
+// lifecycle solves itself. Its own connections flow back through the edge
+// (the box pins its hosted names to loopback), so the sidecar marks them
+// (SidecarHeader) and the activator excludes them from the idle clock —
+// otherwise the pair could never sleep, and an orphaned sidecar's reconnect
+// loop would wake the instance right back (see ServeHTTP).
+//
+// Standalone sidecar members (NATIVE): a route with registry.Route.IsSidecar
+// true is its own record, its own registry row, with no parent instance to
+// hang its lifecycle from — WakeWorker is its request-less entry point, and
+// ExecBackend.StartWorker (not StartSidecar) is what spawns it. This is the
+// package's other sidecar mechanism, not a replacement for the co-spawn
+// above: a host may carry one or the other but never both (the provisioning
+// reconciler's mint-time check in internal/provision/sidecar_reconcile.go
+// refuses to mint a native row for a host that already has a co-spawn env
+// file staged). Retiring the co-spawn path is a follow-up, gated on Eleven's
+// own migration onto native sidecar records.
//
// Opt-in is PER HOST (routes.hibernate): with the bucket env set, only
// flagged instances are activator-managed; the rest stay on their systemd
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index fd76a385e..4338af469 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -732,6 +732,16 @@ func (b *ExecBackend) sidecarEnvPath(host string) string {
// HasSidecar: a host has a sidecar iff its sidecar EnvironmentFile exists.
// Derived from the filesystem on purpose, the way systemd units would do it:
// no registry column to drift from what's actually staged.
+//
+// This is the LEGACY file-staged co-spawn — Eleven runs on it today, so it
+// is not going anywhere yet. The NATIVE path is a route with
+// registry.Route.IsSidecar true: its own registry row, spawned by
+// StartWorker instead of StartSidecar, with no parent instance and no file
+// on disk. A host may carry one mechanism or the other but never both — the
+// provisioning reconciler's mint-time check (sidecar_reconcile.go) refuses
+// to mint a native row for a host this method already reports true for.
+// Retiring this file path is a follow-up gated on Eleven's own migration to
+// native sidecar records.
func (b *ExecBackend) HasSidecar(rt registry.Route) bool {
_, err := os.Stat(b.sidecarEnvPath(rt.Host))
return err == nil
@@ -743,6 +753,9 @@ func (b *ExecBackend) HasSidecar(rt registry.Route) bool {
// probe; its own connect/backoff loop absorbs a slow instance. The live
// symlink exists by now: the instance spawn checked it. The ctx is unused (an
// exec doesn't block); it's here for the backend seam's symmetry.
+//
+// LEGACY co-spawn spawn path (see HasSidecar) — StartWorker is the native
+// spawn path for a standalone registry.Route.IsSidecar row.
func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, error) {
fileEnv, err := readEnvFile(b.sidecarEnvPath(rt.Host))
if err != nil {