host: carlos bootstrap -offcloud + remote host runbook (software half)
Adds bootstrapOffcloud for hosts with no EC2 instance profile (issue #53 point 4): before any bootstrap mutation, requires a staged 0600 credentials file at <root>/etc/carlos/credentials, then runs the normal idempotent bootstrap and (on a live root) chowns the file to the service user. Wires the -offcloud flag into `carlos bootstrap`; unchanged bootstrap behavior when the flag is absent. infra/remote-host.md documents the software-half runbook: credential scope story (deployment bucket + this host's own region bucket only), staging steps, the CARLOS_TUNNEL_* env contract on both host and edge sides, and the -dev (HTTP) edge mode bearer-token-in-clear caveat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+145
−0
infra/remote-host.md+79 −0internal/host/bootstrap.go+39 −0internal/host/host_test.go+27 −0
diff --git a/infra/remote-host.md b/infra/remote-host.md| new file mode 100644 |
| index 000000000..375e67083 |
| --- /dev/null |
| +++ b/infra/remote-host.md |
| @@ -0,0 +1,79 @@ |
| +# Remote host runbook (software half) |
| + |
| +A CARLOS host is anything that can reach the deployment bucket. A remote |
| +host is OUTBOUND-ONLY: its whole network surface is outbound 443 to S3 and |
| +the edge tunnel. No inbound port, no SSH path required by the platform. |
| + |
| +Prereqs: systemd Debian-family OS, 64-bit (the artifact key is |
| +GOOS-GOARCH — linux-arm64 or linux-amd64; a 32-bit OS resolves nothing). |
| + |
| +## Steps |
| + |
| +1. Provision the IAM principal + region data bucket |
| + (`infra/modules/region-remote-host`). Scope: deployment bucket + this |
| + host's own region bucket, and NOTHING else — wrong-region-fails-at-IAM |
| + holds off-cloud too. A scoped access key is acceptable to start; IAM |
| + Roles Anywhere is the long-term, hq-grade alternative (no long-lived |
| + key on the box at all). |
| + |
| +2. Stage `/etc/carlos/credentials` (mode 0600): |
| + |
| + [default] |
| + aws_access_key_id=... |
| + aws_secret_access_key=... |
| + |
| + `carlos bootstrap -offcloud` refuses to proceed if this file is |
| + missing or not exactly 0600 — it will name this path in the error. |
| + |
| +3. Stage `/etc/carlos/host.env` — the usual agent env, plus the |
| + credentials pointer and the three tunnel variables. All three tunnel |
| + variables are required together; if any is missing the dial-out loop |
| + stays inert (no crash, no retry storm — it just never dials): |
| + |
| + AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials |
| + CARLOS_TUNNEL_URL=https://<edge's reserved tunnel hostname> |
| + CARLOS_TUNNEL_TOKEN=<this host's token> |
| + CARLOS_TUNNEL_NAME=<this host's channel name> |
| + |
| +4. Stage the binary at `/opt/carlos/carlos` (matching GOOS-GOARCH). |
| + |
| +5. Run: `carlos bootstrap -offcloud` |
| + |
| + This does everything the normal `carlos bootstrap` does (units, dirs, |
| + service user, timers) and, before any of it, checks the credentials |
| + precondition from step 2 — a failed check leaves the box untouched. On |
| + a live root it also chowns the credentials file to the service user |
| + (still mode 0600). |
| + |
| +6. `systemctl start carlos-edge.service` |
| + |
| +7. Verify: |
| + - `journalctl -u carlos-edge` shows "tunnel connected". |
| + - The box appears in `carlos status -live` within one heartbeat |
| + (5 minutes + jitter). |
| + |
| +## Edge side (once per edge box, not per remote host) |
| + |
| +Set in the edge box's `host.env`: |
| + |
| + CARLOS_TUNNEL_HOST=<reserved hostname> |
| + CARLOS_TUNNEL_CLIENTS=name=token,name2=token2,... |
| + |
| +`CARLOS_TUNNEL_HOST` must be a bare hostname — no port, no scheme. Each |
| +name in `CARLOS_TUNNEL_CLIENTS` must match `^[a-z0-9-]{1,32}$` or the edge |
| +refuses to boot. |
| + |
| +Back routes to a tunneled host with: |
| + |
| + carlos route -host <h> -tunnel <name> |
| + |
| +## Security note |
| + |
| +In `-dev` (HTTP) edge mode the registration bearer token travels in clear |
| +text. Tunnel hosts must only ever dial a real-TLS edge in production — |
| +never point `CARLOS_TUNNEL_URL` at an HTTP edge outside a dev sandbox. |
| + |
| +## Related |
| + |
| +The hq PHYSICAL half (racking, power, network uplink for a region-hq box) |
| +lands separately in `infra/tito/region-hq.md`. |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 024587fd8..1cbe9aff6 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -89,12 +89,51 @@ func systemctlPlan(units []string) [][]string { |
| func RunBootstrap(args []string) { |
| fs := flag.NewFlagSet("bootstrap", flag.ExitOnError) |
| root := fs.String("root", "/", "install root; a staging dir writes files only (no user creation, no systemctl)") |
| + offcloud := fs.Bool("offcloud", false, "off-cloud box: require a staged 0600 credentials file instead of an EC2 instance profile") |
| _ = fs.Parse(args) |
| + if *offcloud { |
| + if err := bootstrapOffcloud(*root); err != nil { |
| + log.Fatalf("bootstrap: %v", err) |
| + } |
| + return |
| + } |
| if err := bootstrap(*root); err != nil { |
| log.Fatalf("bootstrap: %v", err) |
| } |
| } |
| +// bootstrapOffcloud is bootstrap for a box with no EC2 instance profile |
| +// (issue #53 point 4): identical structural work, plus the credential |
| +// preconditions — a staged 0600 credentials file scoped (at IAM) to the |
| +// deployment bucket + this host's own region bucket and nothing else, so |
| +// wrong-region-fails-at-IAM holds off-cloud too. The flag is explicit; no |
| +// IMDS sniffing (the agent's one IMDS probe is already gated for non-EC2). |
| +// |
| +// The credential check runs BEFORE any system mutation: a failed precondition |
| +// leaves the box untouched, same as a failed bootstrap() would. |
| +func bootstrapOffcloud(root string) error { |
| + cred := filepath.Join(root, "etc", "carlos", "credentials") |
| + fi, err := os.Stat(cred) |
| + if err != nil { |
| + return fmt.Errorf("offcloud: stage the scoped credentials file first: %s (0600, see infra/remote-host.md): %w", cred, err) |
| + } |
| + if fi.Mode().Perm() != 0o600 { |
| + return fmt.Errorf("offcloud: %s must be mode 0600, is %o", cred, fi.Mode().Perm()) |
| + } |
| + if err := bootstrap(root); err != nil { |
| + return err |
| + } |
| + // Ownership is a live-box-only step, same as ensureServiceUser/chownToService |
| + // in bootstrap() above — a staging root has no service user to chown to. |
| + if filepath.Clean(root) == "/" { |
| + if err := chownToService(cred); err != nil { // same helper the data dirs use |
| + return err |
| + } |
| + } |
| + fmt.Printf("offcloud bootstrap complete.\n ensure /etc/carlos/host.env sets:\n AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials\n then stage /opt/carlos/carlos and start carlos-edge.service (see infra/remote-host.md)\n") |
| + return nil |
| +} |
| + |
| // bootstrap provisions (or re-converges) a box under root: units into |
| // <root>/etc/systemd/system, the standard dirs, the tmpfiles.d entry, and — |
| // only when root is "/" — the service user, ownership, and the systemctl |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 4aa4995cc..7aea591c6 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -457,6 +457,33 @@ func TestBootstrapStagingRunsNoSystemctl(t *testing.T) { |
| } |
| } |
| +// TestBootstrapOffcloudRequiresCredentialsFile pins the off-cloud precondition |
| +// (issue #53 point 4): a box with no EC2 instance profile must have its scoped |
| +// credentials staged — and staged at 0600 — before bootstrap proceeds. This is |
| +// the staging-root path (no root privileges available in tests), so the |
| +// service-user chown below never runs; that only happens on a live root. |
| +func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) { |
| + root := t.TempDir() |
| + if err := bootstrapOffcloud(root); err == nil { |
| + t.Fatal("want error when <root>/etc/carlos/credentials is absent") |
| + } |
| + credDir := filepath.Join(root, "etc", "carlos") |
| + os.MkdirAll(credDir, 0o755) |
| + credFile := filepath.Join(credDir, "credentials") |
| + os.WriteFile(credFile, []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n"), 0o644) |
| + if err := bootstrapOffcloud(root); err == nil { |
| + t.Fatal("want error on 0644 credentials (must be 0600)") |
| + } |
| + os.Chmod(credFile, 0o600) |
| + if err := bootstrapOffcloud(root); err != nil { |
| + t.Fatalf("offcloud bootstrap with staged 0600 credentials: %v", err) |
| + } |
| + // The normal bootstrap artifacts must exist too — offcloud is a superset. |
| + if _, err := os.Stat(filepath.Join(root, "etc", "systemd", "system", "carlos-edge.service")); err != nil { |
| + t.Fatalf("units not installed: %v", err) |
| + } |
| +} |
| + |
| // TestAdoptTimerStateReading pins the probe's honesty rules at the seam that |
| // needs no systemd to test: systemd's own word for the timer's state maps to |
| // running/not-running, and anything this probe cannot interpret — no systemd |