carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# No-inbound box runbook (private-subnet posture)
2
3> **STATUS: written for Paul's applies. Nothing in this document has been
4> applied, no tofu in it has been written into a `.tf` file, and no live box
5> was touched by the branch that added it.** It documents a network shape
6> for `carlos sidecars` design §8's `CARLOS_INBOUND=none` boxes; the module
7> that would produce it does not exist in this repo yet — authoring it is
8> Step 1 below, not a precondition already met.
9
10A `CARLOS_INBOUND=none` box accepts sidecar work only: no routed instance
11traffic, no certs, no tunnel-served upstream, no public status entry. This
12runbook covers the **hardest** version of that box — one that sits in a
13private subnet with no route to the internet at all, egress only through a
14NAT gateway, a security group with zero inbound rules, and no SSM agent, so
15the box has no **inbound** surface at all, from any direction, by any
16channel — no SSH, no SSM, nothing dialing in. It still has the platform's
17own outbound surface: the fleet tunnel and the sidecar's own agent-socket
18protocol keep working exactly as they do on any other fleet box (see "The
19box env contract" below), and those two are what every verification in
20this document ends up reading, because they are the only channels that
21exist.
22
23## Read this first: do you actually need this?
24
25**Probably not yet.** Fleet boxes in this estate are *already*
26outbound-only dial — `infra/eleven-fleet.md` and `infra/remote-host.md`
27describe the shape every existing fleet box runs today: a security group
28with no inbound rules beyond what a normal host needs, admin reachability
29through SSM (or none, off-cloud), and all platform traffic — heartbeats,
30release pulls, the fleet tunnel — dialed **out**. Eleven's boxes in
31`titogo` are that shape right now. Design §8 is explicit about this: the
32private-subnet posture below is *available when wanted*, not a
33prerequisite for running a sidecar. Eleven's first sidecar box needs
34**no new network shape at all** — enrol it on the fleet box it already has,
35per `infra/eleven-fleet.md`, and stop reading here.
36
37Reach for this runbook only when outbound-only-at-the-security-group-layer
38isn't enough — when the requirement is "this workload must be provably
39unreachable even if every SG rule and every credential on the box were
40compromised," because there is, at the network layer, no path in to reach
41it with. That is a stronger claim than "nothing dials it today," and it
42costs a NAT gateway (an hourly + per-GB charge) and the loss of SSM
43break-glass access — weigh that before applying anything below.
44
45## Preconditions (must be true before Step 1)
46
47- [ ] **Sidecar members merged and deployed.** The `CARLOS_INBOUND` env
48 read (`internal/host/agent.go`), the placement half
49 (`internal/provision/sidecar_reconcile.go`,
50 `provision.PlacementNoInbound`), and the mint-time refusal on
51 no-inbound boxes (`internal/provision/reconcile.go`) all need to be
52 live on the box's `carlos` build — built fresh, not a stale
53 checkout (recorded trap, `carlos-cli-releases.md`).
54- [ ] **The workload already exists as a fleet box**, following
55 `infra/eleven-fleet.md`'s Ceremony through Step 3 (fleet created, box
56 attached, `host.env` flipped and verified clean) — this runbook adds
57 network hardening around a box that already dials the right console
58 with the right credentials, it does not replace that ceremony.
59- [ ] **A decision on which existing VPC/subnet plan hosts this.** The
60 sketch below assumes an existing VPC (e.g. `titogo`'s) with spare
61 CIDR room for one new private subnet and either a shared or new NAT
62 gateway. A from-scratch VPC is a bigger tofu change than this
63 runbook covers.
64- [ ] **The sidecar's own record targets `placement: "no-inbound"`** (or
65 its app's manifest does) — `carlos sidecars enable -app <app>
66 -placement no-inbound` (or `carlos sidecars create ... ` under a
67 manifest already carrying it). Without this the record mints on any
68 box in the pool, including ones with inbound service, and the box
69 built below sits idle.
70- [ ] **Nobody is depending on SSM Session Manager reaching this box.**
71 There is no admin channel to it once this ships except the fleet
72 tunnel and the sidecar's own agent-socket protocol (design §7.1) —
73 confirm that is acceptable for this workload before Step 3.
74
75## The tofu shape
76
77Four pieces, applied in order because each one's Verify depends on the
78last existing. **No module in this repo produces this shape yet** — the
79HCL sketches below name the resources it needs, in this repo's existing
80naming conventions (`infra/modules/region-host/main.tf`,
81`infra/modules/region-remote-host/main.tf`), for whoever authors the
82module; they are not a file to copy-paste and apply as-is.
83
84**1. NAT gateway (skip if one already serves this VPC)**
85
86A NAT gateway must sit in an existing **public** subnet — one that already
87has a route to an internet gateway — even though nothing routes to it from
88outside. It is the private subnet's only way out.
89
90 resource "aws_eip" "nat" {
91 domain = "vpc"
92 }
93
94 resource "aws_nat_gateway" "sidecar" {
95 allocation_id = aws_eip.nat.id
96 subnet_id = <existing public subnet id>
97 }
98
99*Verify:* `aws ec2 describe-nat-gateways` (or the tofu plan/apply output)
100shows state `available`. This step alone costs money and touches no box —
101safe to apply and sit on.
102*Abort:* `tofu destroy -target aws_nat_gateway.sidecar -target aws_eip.nat`
103before anything below references it. Nothing yet depends on it.
104
105**2. Private subnet + route table (no IGW route)**
106
107The defining property: this subnet's route table has **no** `0.0.0.0/0 ->
108igw-...` route. Its only default route is to the NAT gateway from Step 1.
109
110 resource "aws_subnet" "sidecar_private" {
111 vpc_id = <vpc id>
112 cidr_block = <a free /28 or larger in this VPC's CIDR>
113 availability_zone = <az the NAT gateway's subnet is in, for cost — cross-AZ NAT traffic bills extra>
114 }
115
116 resource "aws_route_table" "sidecar_private" {
117 vpc_id = <vpc id>
118 route {
119 cidr_block = "0.0.0.0/0"
120 nat_gateway_id = aws_nat_gateway.sidecar.id
121 }
122 # No route to an aws_internet_gateway, ever — that absence IS the
123 # posture. Do not add one "temporarily" to debug something; that
124 # defeats this runbook's entire point for as long as it's there.
125 }
126
127 resource "aws_route_table_association" "sidecar_private" {
128 subnet_id = aws_subnet.sidecar_private.id
129 route_table_id = aws_route_table.sidecar_private.id
130 }
131
132*Verify:* `aws ec2 describe-route-tables --route-table-ids
133<id>` — confirm the route list contains exactly one `0.0.0.0/0` entry and
134its target is the NAT gateway, not `igw-...`. This is the single fact this
135whole runbook exists to guarantee; check it by reading the table, not by
136trusting the plan.
137*Abort:* the subnet and route table have nothing in them yet (Step 3
138hasn't placed an instance) — `tofu destroy -target` both, no downstream
139cleanup.
140
141**3. Security group — zero inbound**
142
143Stricter than `region-host`'s SG (which still opens 80/443): this box
144serves no certs and no tunnel-terminated traffic, so it has nothing to
145listen on at all.
146
147 resource "aws_security_group" "sidecar_no_inbound" {
148 name = "<box name>-no-inbound"
149 description = "sidecar-only box: no ingress by design"
150 vpc_id = <vpc id>
151 # No ingress block. Not "ingress restricted" — no ingress rule
152 # exists to restrict. Do not add var.extra_ingress-style exceptions
153 # to this SG; a box that needs an inbound rule is not this class of
154 # box.
155 egress {
156 from_port = 0
157 to_port = 0
158 protocol = "-1"
159 cidr_blocks = ["0.0.0.0/0"]
160 }
161 }
162
163*Verify:* `aws ec2 describe-security-groups --group-ids <id>` shows an
164empty `IpPermissions` list (ingress) and the single egress rule.
165*Abort:* unattached SG, `tofu destroy -target` it directly.
166
167**4. The instance — no SSM**
168
169Same AMI/instance-type shape as `infra/modules/region-host`, placed in the
170Step 2 subnet with the Step 3 SG, **no public IP**, and — the deliberate
171omission — no `aws_iam_role_policy_attachment` for
172`AmazonSSMManagedInstanceCore`. Without a route to the internet or a VPC
173interface endpoint for SSM (which this runbook does not add — an endpoint
174is itself a reachable surface), the SSM agent could not dial its service
175even if installed, so leaving the IAM grant off is not extra caution, it
176is removing a credential that would do nothing but sit there. **This means
177there is no break-glass path onto this box, ever, by anyone, other than
178what the sidecar and fleet-tunnel protocols carry.** That is the point,
179not an oversight — confirm the last Precondition checkbox again before
180applying this step.
181
182 resource "aws_instance" "sidecar" {
183 ami = <same arm64 Debian AMI region-host resolves>
184 instance_type = <same class region-host's boxes use>
185 subnet_id = aws_subnet.sidecar_private.id
186 vpc_security_group_ids = [aws_security_group.sidecar_no_inbound.id]
187 associate_public_ip_address = false
188 iam_instance_profile = <a role scoped like region-remote-host's,
189 WITHOUT ssm:* and WITHOUT
190 AmazonSSMManagedInstanceCore>
191 user_data = <same bootstrap script shape as region-host>
192 }
193
194*Verify:* the instance reaches `running` in `describe-instances`, and — the
195important negative check — `aws ssm describe-instance-information` does
196**not** list it, ever, even minutes later. If it appears there, something
197above is wrong (a stray endpoint, a stray IGW route, or the SSM agent
198running with a grant it shouldn't have) — treat that as a failed apply,
199not a bonus.
200*Abort:* terminate the instance (`tofu destroy -target
201aws_instance.sidecar`); nothing else in Steps 1-3 depended on it existing.
202
203## The box env contract
204
205`/etc/carlos/host.env` on this box carries the same fleet contract
206`infra/eleven-fleet.md` Step 3 stages, **plus** the one new variable that
207makes this a no-inbound box:
208
209 AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials
210 CARLOS_HIBERNATE_BUCKET=<the fleet's data bucket>
211 CARLOS_FLEET=<fleet name>
212 CARLOS_FLEET_BOX=<this box's label>
213 CARLOS_REGION=fleet/<fleet name>
214 CARLOS_STATUS_PREFIX=fleets/<fleet name>/
215 CARLOS_RELEASE_PUBKEY=<the console's release public key>
216 CARLOS_TUNNEL_URL=https://<the console's reserved tunnel hostname>
217 CARLOS_TUNNEL_NAME=<fleet name>/<this box's label>
218 CARLOS_TUNNEL_TOKEN=<from the fleet's add-box call — shown once>
219 CARLOS_INBOUND=none
220
221`CARLOS_INBOUND=none` is read wherever `CARLOS_REGION`/`CARLOS_POOLS` are
222(`internal/host/agent.go`), folded case/whitespace-insensitively
223(`provision.NormalizeInbound`), and — deliberately — any value that is
224neither empty, `any`, nor `none` is treated as `none`: an unrecognised
225setting is a typo, and the restrictive reading is the safe one. Getting
226this var wrong fails closed (a box that should serve nothing accidentally
227stays inert) rather than open.
228
229The NAT gateway makes `CARLOS_TUNNEL_URL`'s dial-out (and the release/
230heartbeat traffic to `CARLOS_HIBERNATE_BUCKET`) work exactly the way it
231does on any other fleet box — this box's whole platform surface is still
232outbound, it just has no *possible* inbound path underneath it, where an
233ordinary fleet box merely has no *configured* one.
234
235## Verify the box came up and adopted its sidecars
236
237This box has no shell, no SSM, and — deliberately — no path into the
238platform's own log stream either: `internal/activator/logsink.go`'s
239`Event` calls are the only thing that ever writes to
240`CARLOS_STATUS_PREFIX`'s log sink, and nothing in `internal/host/agent.go`,
241`internal/tunnelclient` (the source of the "tunnel connected" line), or
242`internal/provision` (the source of a mint-time log line) calls it. So "go
243read a log line" is not an available verb for this box, on-box or
244off — every verification below uses a surface the platform already exposes
245with no box access of any kind:
246
2471. **`carlos sidecars ls -app <app> -long`** — the strongest single check.
248 `HOST` names a route, `PLACEMENT` reads `no-inbound`, and once the
249 child has called `/v1/ready` at least once, `VERSION` reads the running
250 build instead of `unknown` (design §9.2's honesty rule: an absent
251 version beats an overclaimed one). A reported version proves the whole
252 chain end to end in one command — the box booted, dialed out, adopted
253 the record, spawned the child, and the child called back over the
254 agent socket. This is the check that matters most; do it first.
2552. **`carlos fleets ls -account <acct>`** proves the box is
256 **registered**, nothing more. Its `STATUS` column is
257 `registry.FleetBox.Status` — an `active`/`detached` membership flag set
258 at `add-box` time (`internal/console/fleets.go`), not a heartbeat.
259 `infra/eleven-fleet.md`'s own Step 2 shows this plainly: it reads
260 `active` immediately after `add-box`, before the box has ever booted.
261 Reading `active` here tells you the token exists and hasn't been
262 detached; it tells you nothing about whether the box has ever dialed
263 in. Do not stop at this step and conclude the box came up.
2643. **The console fleet page**
265 (`/a/<account-sqid>/fleets/<fleet name>`) is the one surface that
266 actually renders heartbeat freshness — `internal/console/fleets.go`
267 joins each box's registry row to its own heartbeat by label and shows
268 it Reachable (a fresh heartbeat joined) or gives its Age (a heartbeat
269 joined but has gone stale) or neither (no heartbeat has ever joined at
270 all). This is a browser-only check — no CLI verb surfaces it as of this
271 branch — but it needs no box access, only a signed-in session, so it
272 still fits this runbook's "no box commands" constraint.
2734. **`aws ec2 get-console-output --instance-id <id>`** if 1 and 3 both
274 stay stale past a reasonable wait and you need to see the box actually
275 trying to boot. This is the one channel that needs no reachable surface
276 on the box at all — it reads whatever the instance wrote to its serial
277 console (cloud-init/systemd boot log) from the AWS API side, not the
278 box's. It is the sole operator-side breadcrumb for a box with neither
279 SSH nor SSM, and the reason to leave the AMI's normal verbose boot
280 logging on rather than quieting it for this box class.
281
282There is no honest check for "did the provisioning pass log a mint-time
283refusal or success" from off the box — if 1 and 3 both look right but the
284record still isn't where you expect, the *Abort* below is the next step,
285not a log line to go find.
286
287*Abort (this section):* if the sidecar never mints here, check placement
288before network: `carlos sidecars ls -app <app> -long` showing
289`PLACEMENT any` means the record itself never asked for `no-inbound` — fix
290the manifest/record, not the box. If placement is right and it still
291doesn't mint, there is no way to inspect the running process's actual
292environment on this box (no shell, no SSM) — treat `CARLOS_INBOUND` as
293unfalsifiable in place. The fix is the same one Rollback summary's last
294entry describes for this box class: there is no in-place restage, so
295terminate the instance and re-apply Step 4 with the value double-checked
296in its user-data, then re-run checks 1 and 3 above against the replacement.
297
298## Flipping an EXISTING box to `CARLOS_INBOUND=none`
299
300**This does not evacuate the instances the box already serves.** Setting
301`CARLOS_INBOUND=none` on a box that currently runs routed instances (or
302was reachable and had inbound work placed on it) only refuses **new**
303routed mints from then on — it is a mint-time check
304(`internal/provision/reconcile.go`'s `case !ok:` branch), not a converge-
305time or removal-time one. Every route already `Provisioned` on that box
306keeps running, keeps converging, keeps being served, exactly as before.
307
308This is deliberate, not an oversight: making the flag evacuate the box
309would mean a single typo in `host.env` — a fat-fingered `CARLOS_INBOUND=none`
310meant for a different box, or a stray copy-paste — could take down every
311app the box was serving, with no confirmation step and no way to see it
312coming. A box that stops *accepting new work* loudly logs every refusal;
313a box that *stops serving existing work* on the same typo is a production
314incident with the blast radius of whatever happened to be running there.
315
316**So: drain the box before flipping it, not after.** Move its existing
317routed instances off (`carlos apps place` to another target, or whatever
318this box's own placement mechanism is) and confirm they've actually
319stopped being served here — a `curl -sI --resolve` against each affected
320host showing it answered from elsewhere, not just that placement changed
321— before staging the new `host.env`. Only once the box carries no routed
322work should `CARLOS_INBOUND=none` land on it; at that point it is a
323statement of fact about the box, not an instruction to make it one.
324
325## Rollback summary
326
327Every step above has its own Abort; in order of how far you got:
328
329- Only Steps 1-3 applied, no instance yet: `tofu destroy -target` each
330 resource in reverse order (SG, route table + association, subnet, NAT
331 gateway + EIP) — nothing else references them.
332- Instance applied, box never adopted anything: `tofu destroy -target
333 aws_instance.sidecar`, then the network resources as above.
334- Box adopted a sidecar, now going away: detach it from the fleet first
335 (`carlos fleets detach-box -account <acct> <fleet> <box label>` — the
336 hard cut, per `infra/eleven-fleet.md`'s own Abort for that step) so the
337 console stops expecting heartbeats from it, then terminate the instance
338 and tear down the network resources.
339- Flipped an existing box and want to revert to accepting inbound work
340 again: restore the prior `host.env` (drop `CARLOS_INBOUND=none`, or set
341 it back to `any`), restart the agent unit — this is a converge-time
342 read, so the very next pass allows new mints again. **This step assumes
343 the ordinary shell/SSM access an existing fleet box normally has.** It
344 does NOT apply to a box built via this runbook's own Step 4, which has
345 neither: there is no in-place way to edit that box's `host.env` at all.
346 Reverting one of those means replacing it — terminate the instance and
347 re-apply Step 4 with the desired `host.env` baked into its user-data —
348 not restaging a file on a running box. It does not retro-
349 actively place anything that was moved off during the drain; that's a
350 separate `carlos apps place` back, a deliberate decision, not a side
351 effect of the env flip.
352