carlos / platform Public

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>
Paul Campbell 8fe7d5e67cf009657dddffa361af3213349b7bfd parent 0b775cd
3 files changed, +145 −0
  • infra/remote-host.md +79 −0
  • internal/host/bootstrap.go +39 −0
  • internal/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