| 1 | package activator |
| 2 | |
| 3 | // The instance secret. CARLOS_ADMIN_TOKEN is what the agent presents to an |
| 4 | // instance for the things only the platform may ask (GET /api/next-due, and |
| 5 | // now the scheduled-work tick — spec 2026-08-23-scheduled-work-design.md |
| 6 | // §6.4) and what the instance presents back on its control socket, where it |
| 7 | // is the bearer on the schedule verbs. That bearer keeps out other UNIX users |
| 8 | // on the box and any unauthenticated dialer; it does NOT close the same-uid |
| 9 | // sibling gap internal/edge/purge.go documents, because 0600 on this file is |
| 10 | // owner-only and every exec child shares the agent's uid — a sibling can read |
| 11 | // this file and present the token. Read purge.go for why that residual is |
| 12 | // bounded (no privilege a sibling lacked already) and why per-instance uids |
| 13 | // are the actual fix. Until this file nothing on the platform minted it: Eleven's own |
| 14 | // tooling wrote it by hand, console-created instances never had one at all, |
| 15 | // and the next-due probe failed silently for every one of them. Minted here, |
| 16 | // once, by whichever comes first: the first spawn, the first seed, or the |
| 17 | // schedule runner's first tick for the host (internal/host/schedule_wiring.go |
| 18 | // — an instance asleep since before the roll would otherwise wait for a |
| 19 | // visitor to mint the token its own tick needs). Never rotated by the |
| 20 | // platform, because the running instance already holds the value and a |
| 21 | // rotation behind its back would lock the agent out of its own instance. |
| 22 | |
| 23 | import ( |
| 24 | "crypto/rand" |
| 25 | "encoding/hex" |
| 26 | "errors" |
| 27 | "fmt" |
| 28 | "log" |
| 29 | "os" |
| 30 | "path/filepath" |
| 31 | "sync" |
| 32 | "time" |
| 33 | |
| 34 | "github.com/carlosframework/platform/internal/instancefs" |
| 35 | "github.com/carlosframework/platform/internal/instancekey" |
| 36 | ) |
| 37 | |
| 38 | // adminTokenMu serializes minting across the process. Two callers can find |
| 39 | // the token missing at once — the runner's tick and a visitor-triggered |
| 40 | // Start for the same host — and without this both would append a line, |
| 41 | // leaving a file with two tokens of which readers see the last and the |
| 42 | // first spawn's child holds the first. |
| 43 | var adminTokenMu sync.Mutex |
| 44 | |
| 45 | // adminTokenLogged remembers when each (condition, file) pair last had its |
| 46 | // complaint logged, so the two conditions below are diagnoses rather than a |
| 47 | // log flood. Both are STICKY — a file the agent cannot tighten, a token the |
| 48 | // wrong shape — and every one of the callers repeats: the runner ensures the |
| 49 | // token on every tick for the host (every 15s while a timer is due), so an |
| 50 | // un-throttled line would be thousands a day saying the same thing. Read and |
| 51 | // written only under adminTokenMu. |
| 52 | // |
| 53 | // A TIME and not a bool, because the conditions can also RECUR: the chmod is |
| 54 | // retried every tick, so an operator can fix a 0644 file and something can |
| 55 | // re-widen it weeks later — and a once-per-process bool would keep the agent |
| 56 | // silent about a world-readable instance secret until the next binary roll, |
| 57 | // which is weeks away. An hour is quiet enough for the 15s caller and loud |
| 58 | // enough that a condition which comes back is heard again. |
| 59 | var adminTokenLogged = map[string]time.Time{} |
| 60 | |
| 61 | // adminTokenLogMu guards adminTokenLogged: logPerFile is called under |
| 62 | // adminTokenMu by the mints and outside it by the replica gate. |
| 63 | var adminTokenLogMu sync.Mutex |
| 64 | |
| 65 | // adminTokenLogRepeat is how long one (condition, file) complaint stays |
| 66 | // deduplicated before it is said again. |
| 67 | const adminTokenLogRepeat = time.Hour |
| 68 | |
| 69 | // ensureAdminTokenAt is EnsureAdminToken on p's layout: through instancefs |
| 70 | // for a nested row's tenant-writable directory, by path otherwise. The |
| 71 | // legacy launch bodies each know their layout and call one or the other; |
| 72 | // the claim path, which serves both, calls this. |
| 73 | func ensureAdminTokenAt(p instancePaths, name string) error { |
| 74 | if p.layout == LayoutNested { |
| 75 | return EnsureAdminTokenBeneath(p.dataDir, name) |
| 76 | } |
| 77 | return EnsureAdminToken(filepath.Join(p.dataDir, name)) |
| 78 | } |
| 79 | |
| 80 | // logPerFile emits msg unless this (kind, path) pair has already said it |
| 81 | // within adminTokenLogRepeat. Keyed by kind as well as path so a file that |
| 82 | // is both un-tightenable AND holding a malformed token still reports both — |
| 83 | // one condition must not swallow the other's only line. The replica gate |
| 84 | // keys it by host instead of path, to the same end. |
| 85 | func logPerFile(kind, path, msg string) { |
| 86 | key := kind + "\x00" + path |
| 87 | adminTokenLogMu.Lock() |
| 88 | defer adminTokenLogMu.Unlock() |
| 89 | if last, seen := adminTokenLogged[key]; seen && time.Since(last) < adminTokenLogRepeat { |
| 90 | return |
| 91 | } |
| 92 | adminTokenLogged[key] = time.Now() |
| 93 | log.Print(msg) |
| 94 | } |
| 95 | |
| 96 | // wellFormedAdminToken reports whether tok has the exact shape this file |
| 97 | // mints: 64 lowercase hex characters (32 random bytes, hex-encoded). A |
| 98 | // torn write — an append interrupted mid-line, a file truncated by a full |
| 99 | // disk — can leave a short or garbled value that authenticates nothing, |
| 100 | // and the failure it produces afterwards ("401 on every tick") points at |
| 101 | // the wrong thing entirely. |
| 102 | // |
| 103 | // A token that fails this is not necessarily broken: before the platform |
| 104 | // minted these, Eleven's tooling wrote them by hand, and a hand-written |
| 105 | // value of another shape works perfectly well as long as the instance and |
| 106 | // the file agree. That is exactly why a failure here is a WARNING and never |
| 107 | // a rotation — see EnsureAdminToken. |
| 108 | func wellFormedAdminToken(tok string) bool { |
| 109 | if len(tok) != 64 { |
| 110 | return false |
| 111 | } |
| 112 | for i := 0; i < len(tok); i++ { |
| 113 | c := tok[i] |
| 114 | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { |
| 115 | return false |
| 116 | } |
| 117 | } |
| 118 | return true |
| 119 | } |
| 120 | |
| 121 | // EnsureAdminToken makes sure path (an instance's own <host>.env) carries a |
| 122 | // CARLOS_ADMIN_TOKEN, minting one if it does not. The file is the instance's, |
| 123 | // not the agent's: every other line in it is preserved untouched and an |
| 124 | // existing token is never rewritten. The file ends up 0600 whether it was |
| 125 | // created here, already existed in a looser mode, or already held a token — |
| 126 | // that is the same mode the env file carries everywhere else, and the only |
| 127 | // thing standing between another UNIX user on the box and the instance's |
| 128 | // secret — the one exception being a path that turns out to be a symlink, |
| 129 | // which is reported and never chmodded. An existing token is also |
| 130 | // shape-checked, and a value that isn't the shape this mints is reported and |
| 131 | // left exactly as it is. |
| 132 | func EnsureAdminToken(path string) error { |
| 133 | return ensureAdminToken(filepath.Dir(path), filepath.Base(path), false) |
| 134 | } |
| 135 | |
| 136 | // EnsureAdminTokenBeneath is EnsureAdminToken for a nested row's own env |
| 137 | // file (design §3.2): the file lives on tenant-writable ground, so the read |
| 138 | // and the append both go through instancefs.OpenBeneath instead of a plain |
| 139 | // path-based open, refusing anything a tenant could plant there that is not |
| 140 | // the regular file this contract expects (a FIFO the plain calls would |
| 141 | // otherwise block on forever). |
| 142 | // |
| 143 | // EnsureAdminToken is expressed as a call into the same body (below) rather |
| 144 | // than as a second copy of the mint-and-parse logic, because its own |
| 145 | // os.ReadFile/os.OpenFile calls are byte-identical in behavior to going |
| 146 | // through instancefs for a path no tenant can ever reach: nothing on the |
| 147 | // flat path is ever a symlink or a special file in practice, and |
| 148 | // instancefs adds only refusals, never a different outcome, for an |
| 149 | // ordinary regular file. Splitting path into dir/name here, once, is what |
| 150 | // lets both callers share every line after the read. |
| 151 | func EnsureAdminTokenBeneath(dir, name string) error { |
| 152 | return ensureAdminToken(dir, name, true) |
| 153 | } |
| 154 | |
| 155 | // ensureAdminToken is EnsureAdminToken's and EnsureAdminTokenBeneath's |
| 156 | // shared body. beneath selects which pair of I/O primitives the read and |
| 157 | // the append use; everything else, the parse, the mode check, the shape |
| 158 | // check, the mint, is one piece of logic so the two callers cannot drift. |
| 159 | func ensureAdminToken(dir, name string, beneath bool) error { |
| 160 | path := filepath.Join(dir, name) |
| 161 | adminTokenMu.Lock() |
| 162 | defer adminTokenMu.Unlock() |
| 163 | // Read the raw bytes once: they answer both questions this needs — is a |
| 164 | // token already there, and does the file end on a line boundary. |
| 165 | var raw []byte |
| 166 | var err error |
| 167 | if beneath { |
| 168 | raw, err = instancefs.ReadBeneathLimit(dir, name, 1<<20) |
| 169 | } else { |
| 170 | raw, err = os.ReadFile(path) |
| 171 | } |
| 172 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 173 | return fmt.Errorf("read %s: %w", path, err) |
| 174 | } |
| 175 | if tok := parseEnvBytes(raw)["CARLOS_ADMIN_TOKEN"]; tok != "" { |
| 176 | // The early return is NOT a no-op. Two things still have to be true |
| 177 | // of a file that already holds the secret. |
| 178 | // |
| 179 | // First, the mode. The chmod below used to run only on the minting |
| 180 | // path, so an env file that was already 0644 when the platform found |
| 181 | // a token in it stayed 0644 — holding the very secret the mode is |
| 182 | // there to protect. Failing to tighten it does NOT fail the ensure, |
| 183 | // because the token still works and refusing the wake over a mode |
| 184 | // would trade a hardening for an outage. |
| 185 | // |
| 186 | // Lstat first, and never chmod a symlink: os.Chmod is path-based |
| 187 | // chmod(2), which FOLLOWS one. A same-uid sibling — the residual the |
| 188 | // spec's §7.2 documents and accepts — that replaced <host>.env with a |
| 189 | // link would otherwise aim the agent's chmod at a file of its |
| 190 | // choosing, now once every 15s rather than once per instance |
| 191 | // lifetime. backend_exec.go does exactly this dance before it |
| 192 | // normalizes an instance socket ("a symlink planted at the route's |
| 193 | // path must not become a mode change somewhere else") and the hazard |
| 194 | // is the same one. The Perm check also keeps the common case — a file |
| 195 | // already at 0600 — down to the Lstat the guard costs anyway. |
| 196 | switch fi, statErr := os.Lstat(path); { |
| 197 | case statErr != nil: |
| 198 | logPerFile("chmod", path, fmt.Sprintf("activator: %s holds CARLOS_ADMIN_TOKEN but could not be stat'd to check its mode: %v — another UNIX user on this box may be able to read the instance's secret", path, statErr)) |
| 199 | case fi.Mode()&os.ModeSymlink != 0: |
| 200 | logPerFile("chmod", path, fmt.Sprintf("activator: %s is a SYMLINK, not the instance's own env file — refusing to change its target's mode, and the token in it should be treated as compromised (a same-uid process on this box planted this)", path)) |
| 201 | case fi.Mode().Perm() != 0o600: |
| 202 | if err := os.Chmod(path, 0o600); err != nil { |
| 203 | logPerFile("chmod", path, fmt.Sprintf("activator: %s holds CARLOS_ADMIN_TOKEN at mode %#o and could not be tightened to 0600: %v — another UNIX user on this box may be able to read the instance's secret", path, fi.Mode().Perm(), err)) |
| 204 | } |
| 205 | } |
| 206 | // Second, the shape. A torn write leaves a value that will 401 every |
| 207 | // tick, and the platform must say so rather than let the operator |
| 208 | // discover it as "scheduled work stopped working". |
| 209 | // |
| 210 | // It is LEFT IN PLACE, never rotated (ruling, 2026-08-24). The |
| 211 | // running instance was handed this same value at spawn, so file and |
| 212 | // process agree and ticks authenticate; re-minting behind the |
| 213 | // instance's back would 401 a live instance instead of repairing |
| 214 | // anything. Consistency beats hygiene here, and the operator has the |
| 215 | // one safe repair — stop the instance, delete the line, let the next |
| 216 | // spawn mint it. |
| 217 | if !wellFormedAdminToken(tok) { |
| 218 | logPerFile("shape", path, fmt.Sprintf("activator: CARLOS_ADMIN_TOKEN in %s is not the shape the platform mints (64 hex characters) — left in place, because the running instance holds this same value and rotating it would lock the platform out of a live instance; if it is corrupt, stop the instance, delete the line and restart it to have one minted", path)) |
| 219 | } |
| 220 | return nil |
| 221 | } |
| 222 | secret := make([]byte, 32) |
| 223 | if _, err := rand.Read(secret); err != nil { |
| 224 | return fmt.Errorf("mint admin token for %s: %w", path, err) |
| 225 | } |
| 226 | // Append rather than rewrite: the instance owns this file, and a |
| 227 | // read-modify-write would race anything else editing it. |
| 228 | var f *os.File |
| 229 | if beneath { |
| 230 | f, err = instancefs.OpenBeneath(dir, name, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) |
| 231 | } else { |
| 232 | f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600) |
| 233 | } |
| 234 | if err != nil { |
| 235 | return fmt.Errorf("open %s: %w", path, err) |
| 236 | } |
| 237 | defer f.Close() |
| 238 | // Land on a fresh line: a file whose last line has no trailing newline |
| 239 | // would otherwise swallow our variable into it. |
| 240 | prefix := "" |
| 241 | if len(raw) > 0 && raw[len(raw)-1] != '\n' { |
| 242 | prefix = "\n" |
| 243 | } |
| 244 | if _, err := fmt.Fprintf(f, "%sCARLOS_ADMIN_TOKEN=%s\n", prefix, hex.EncodeToString(secret)); err != nil { |
| 245 | return fmt.Errorf("write %s: %w", path, err) |
| 246 | } |
| 247 | // O_CREATE's mode applies only to a file created here. A pre-existing |
| 248 | // file written 0644 by hand (or by an older tool) now holds a secret, so |
| 249 | // it is tightened after the append, not before: chmod on a file the |
| 250 | // write then failed on would be a change for nothing. |
| 251 | if err := os.Chmod(path, 0o600); err != nil { |
| 252 | return fmt.Errorf("chmod %s: %w", path, err) |
| 253 | } |
| 254 | return nil |
| 255 | } |
| 256 | |
| 257 | // parseEnvBytes parses an EnvironmentFile already in hand: KEY=VALUE lines, |
| 258 | // blank lines and # comments skipped. It is readEnvFile's whole body (that |
| 259 | // one adds the read), split out so EnsureAdminToken can answer "is the token |
| 260 | // set" and "does the file end in a newline" from ONE read of the file — two |
| 261 | // reads could disagree, and the second one decides where the token lands. |
| 262 | func parseEnvBytes(raw []byte) map[string]string { |
| 263 | return instancekey.ParseEnv(raw) |
| 264 | } |
| 265 | |