carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package caps is the closed capability vocabulary: what an app may ask the
2// platform for, and what each name maps to. A manifest never names a path, a
3// user, a uid, a resource number, or a systemd directive — the directives are
4// Go constants in this package. There is no passthrough field and no
5// "extra" escape hatch; the escape hatch is 90-operator.conf, which this
6// package never writes and never reads.
7//
8// See docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2 (the
9// model: self-service vs operator-consented), §3 (wire shape and which way
10// each failure falls), §7 (security posture — why each rail exists), and §8
11// (the testing shape this file's tests follow).
12package caps
13
14import (
15 "encoding/json"
16 "fmt"
17 "regexp"
18 "sort"
19 "strings"
20)
21
22// Version is the capability block version THIS binary understands. A block
23// declaring a "v" outside 1..Version refuses the version on this box (spec
24// §3b) — fail closed in the direction that changes nothing: the box keeps
25// serving what it has rather than materialize a unit missing something the
26// app needs. Accepting the whole 1..Version range (not just == Version) is
27// what lets an immutable v1 manifest already sitting in a production bucket
28// keep validating the day Version becomes 2 — a rollback depends on it.
29const Version = 1
30
31// Marker is the first line of every drop-in this package generates — the
32// same SHAPE as the marker `carlos ops litestream` uses (not the same
33// string): a "# GENERATED by carlos ... — do not hand-edit" line, so a
34// hand-edit check can look for that shape without two packages having to
35// agree on one hardcoded literal. A file at a generated path WITHOUT it is
36// never overwritten and never deleted (spec §3c).
37const Marker = "# GENERATED by carlos adopt — do not hand-edit"
38
39// DataReplicatePlatform and DataReplicateApp are the only two values
40// Data.Replicate accepts (spec §2, §3a): the box-wide carlos-litestream
41// unit replicates the named dbs, or the app streams its own WAL and the
42// platform provisions the directory and replicates nothing. Two
43// replicators on one WAL fight over checkpointing and corrupt the replica,
44// so the two are mutually exclusive — see the "dbs with replicate:\"app\""
45// rule in validData.
46const (
47 DataReplicatePlatform = "platform"
48 DataReplicateApp = "app"
49)
50
51// maxPorts and maxDBs are bounds, not features (spec §7) — shared between
52// decode time, where they bound the memory a hostile manifest can force
53// this binary to allocate, and ValidCapabilities, the semantic gate a
54// programmatically-built Set (never having passed through UnmarshalJSON)
55// still has to clear.
56const (
57 maxPorts = 4
58 maxDBs = 8
59)
60
61// tokenRe is the charset shared by command, runtime_dir, and ports[].name
62// (spec §7): one lowercase token, so "../etc", "carlos/apps", absolute
63// paths, flags, and spaces never parse. It is also, byte for byte,
64// release.ValidApp's own app-name charset — see ValidCapabilities's
65// host_role check for why that duplication exists.
66var tokenRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
67
68// dbNameRe is data.dbs's charset: a bare filename, used only as
69// filepath.Join(stateDir, name) and never a path (spec §7). "." and ".."
70// pass this character class and are rejected separately below.
71var dbNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
72
73// reservedRuntimeDir names a directory the platform or the OS already owns.
74// "carlos" is the load-bearing entry: a tenant owning /run/carlos would own
75// every tenant socket the edge dials — the single most valuable directory
76// on the box (spec §7). The rest are /run subdirectories systemd, the
77// kernel, or a common distro tool already owns; the "systemd-" prefix is
78// checked separately, below.
79//
80// static list is the first guard; converge's existence check (Task 6) is
81// the second. This list can never be exhaustive — a box-specific
82// /run/tailscale is not on it and never will be — so the converge pass
83// additionally refuses a runtime_dir grant when /run/<name> already exists
84// on the box and is not this route's own prior claim. See the note
85// appended to the Task 6 section of
86// docs/superpowers/plans/2026-08-03-app-capabilities.md.
87var reservedRuntimeDir = map[string]bool{
88 "carlos": true, "systemd": true, "lock": true, "log": true,
89 "user": true, "dbus": true, "sshd": true, "udev": true,
90 "credentials": true, "sudo": true, "mount": true, "network": true,
91 "initramfs": true, "tmpfiles.d": true, "host": true, "machines": true,
92 "netns": true, "blkid": true, "motd.d": true, "sendsigs.omit.d": true,
93 "console-setup": true, "setrans": true, "shm": true, "dhcpcd": true,
94 "initctl": true,
95}
96
97// reservedPort is bound absolutely and is never grantable, typo or not
98// (spec §7): the edge's own ports and the box's SSH.
99var reservedPort = map[int]bool{22: true, 80: true, 443: true}
100
101// knownCapability is the closed vocabulary's eight names. A manifest key
102// under "capabilities" that isn't one of these (or "v") names a capability
103// this binary does not know, and UnmarshalJSON refuses the whole block.
104var knownCapability = map[string]bool{
105 "command": true,
106 "daemon": true,
107 "children": true,
108 "runtime_dir": true,
109 "ports": true,
110 "data": true,
111 "host_role": true,
112 "doc_engine": true,
113}
114
115// Set is a manifest's whole "capabilities" object. It is decoded only
116// through UnmarshalJSON below — the strict door that tells an unknown
117// capability NAME (refuse) apart from an unknown FIELD inside a known one
118// (ignore and note).
119type Set struct {
120 V int `json:"v"`
121 Command string `json:"command,omitempty"`
122 Daemon bool `json:"daemon,omitempty"`
123 Children bool `json:"children,omitempty"`
124 RuntimeDir string `json:"runtime_dir,omitempty"`
125 Ports []Port `json:"ports,omitempty"`
126 Data *Data `json:"data,omitempty"`
127 HostRole bool `json:"host_role,omitempty"`
128 DocEngine bool `json:"doc_engine,omitempty"`
129
130 notes []string // unknown-field-inside-a-known-capability lines; see Notes
131}
132
133// Port is one privileged-port ask. Name is an operator/log label and
134// carries no behaviour (spec §3a, §4c) — it is never a systemd
135// FileDescriptorName.
136type Port struct {
137 Port int `json:"port"`
138 Name string `json:"name,omitempty"` // an operator/log label; carries NO behaviour
139}
140
141// Data is the persistent-data-dir ask: which files under the state dir are
142// databases, and who replicates them. The path itself is never in the
143// manifest — it is always StateDirectory=carlos-app/%i (spec §2).
144//
145// Asymmetry, on purpose: a manifest with no "data" key at all decodes to a
146// nil *Data on Set — no capability declared, always valid. A manifest that
147// instead writes an explicit "data": null is NOT the same thing:
148// UnmarshalJSON still allocates a non-nil *Data (the zero value, an empty
149// Replicate), which ValidCapabilities then refuses for naming neither
150// accepted replicate value. Fail-closed on purpose: an explicit-but-empty
151// "data" is not silently waved through as "no capability" just because an
152// absent key would have been.
153type Data struct {
154 Replicate string `json:"replicate"` // DataReplicatePlatform | DataReplicateApp
155 DBs []string `json:"dbs,omitempty"`
156}
157
158// UnmarshalJSON is the strict door (spec §3b), and the three outcomes are
159// deliberately different:
160// - an unknown CAPABILITY NAME, or a "v" outside 1..Version: error,
161// naming the capability and "update the platform binary". This
162// propagates through json.Unmarshal -> release.GetManifest ->
163// ensureStaged, which is exactly how the version comes to be refused on
164// this box.
165// - an unknown FIELD INSIDE a known capability: accepted, recorded in
166// notes.
167// - anything absent: the zero value, which means "not declared".
168//
169// It decodes into a map[string]json.RawMessage first so it can check every
170// key against the eight known names plus "v" before decoding anything
171// typed, then re-decodes each object-valued capability with a second
172// map[string]json.RawMessage pass to collect notes AND to decode each known
173// field one at a time by its exact key. That second point matters: a
174// whole-object json.Unmarshal into Port or Data would let Go's decoder
175// case-fold an unrecognised "PORT" or "REPLICATE" onto the typed field via
176// its default case-insensitive tag matching — silently applying exactly the
177// value the notes scan just reported as ignored (config-that-lies). Every
178// field of every object-valued capability is therefore decoded via
179// decodeIfPresent against the fields map's exact string key, never via a
180// whole-object unmarshal.
181//
182// json.Decoder.DisallowUnknownFields is deliberately NOT used on the whole
183// block — it cannot distinguish an unknown name (refuse) from an unknown
184// field (ignore), and that distinction is the entire forward-compatibility
185// contract (spec §3b).
186//
187// Decoding builds into a local Set and assigns to *s only once, at the very
188// end: a mid-decode error can then never leave *s partially populated, by
189// construction rather than by careful statement ordering.
190func (s *Set) UnmarshalJSON(b []byte) error {
191 var raw map[string]json.RawMessage
192 if err := json.Unmarshal(b, &raw); err != nil {
193 return err
194 }
195
196 // "v" must be present as its own key. An absent "v" on a present block
197 // is refused with an honest message naming the missing key — never
198 // treated as "v 0", which would conflate "the block forgot to say" with
199 // "the block explicitly said an invalid version".
200 vRaw, ok := raw["v"]
201 if !ok {
202 return fmt.Errorf(`capabilities: missing "v" (capability block version) — refusing rather than guessing which one was meant`)
203 }
204 var v int
205 if err := json.Unmarshal(vRaw, &v); err != nil {
206 return fmt.Errorf("capabilities: v: %w", err)
207 }
208 if v < 1 || v > Version {
209 return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v1..v%d) — update the platform binary", v, Version)
210 }
211
212 for name := range raw {
213 if name == "v" {
214 continue
215 }
216 if !knownCapability[name] {
217 return fmt.Errorf("capabilities: unknown capability %q — update the platform binary", name)
218 }
219 }
220
221 var out Set
222 out.V = v
223 var notes []string
224
225 if err := decodeIfPresent(raw, "command", &out.Command); err != nil {
226 return fmt.Errorf("capabilities: command: %w", err)
227 }
228 if err := decodeIfPresent(raw, "daemon", &out.Daemon); err != nil {
229 return fmt.Errorf("capabilities: daemon: %w", err)
230 }
231 if err := decodeIfPresent(raw, "children", &out.Children); err != nil {
232 return fmt.Errorf("capabilities: children: %w", err)
233 }
234 if err := decodeIfPresent(raw, "runtime_dir", &out.RuntimeDir); err != nil {
235 return fmt.Errorf("capabilities: runtime_dir: %w", err)
236 }
237 if err := decodeIfPresent(raw, "host_role", &out.HostRole); err != nil {
238 return fmt.Errorf("capabilities: host_role: %w", err)
239 }
240 if err := decodeIfPresent(raw, "doc_engine", &out.DocEngine); err != nil {
241 return fmt.Errorf("capabilities: doc_engine: %w", err)
242 }
243
244 if portsRaw, ok := raw["ports"]; ok {
245 var items []json.RawMessage
246 if err := json.Unmarshal(portsRaw, &items); err != nil {
247 return fmt.Errorf("capabilities: ports: %w", err)
248 }
249 // Bound BEFORE the make below, so the length a hostile manifest
250 // claims bounds the memory this binary allocates for it, not just
251 // the eventual result ValidCapabilities would reject.
252 if len(items) > maxPorts {
253 return fmt.Errorf("capabilities: ports: %d declared, want at most %d", len(items), maxPorts)
254 }
255 ports := make([]Port, len(items))
256 for i, item := range items {
257 var fields map[string]json.RawMessage
258 if err := json.Unmarshal(item, &fields); err != nil {
259 return fmt.Errorf("capabilities: ports[%d]: %w", i, err)
260 }
261 for f := range fields {
262 if f != "port" && f != "name" {
263 notes = append(notes, fmt.Sprintf("capabilities: ports[%d]: unknown field %q ignored", i, f))
264 }
265 }
266 if err := decodeIfPresent(fields, "port", &ports[i].Port); err != nil {
267 return fmt.Errorf("capabilities: ports[%d].port: %w", i, err)
268 }
269 if err := decodeIfPresent(fields, "name", &ports[i].Name); err != nil {
270 return fmt.Errorf("capabilities: ports[%d].name: %w", i, err)
271 }
272 }
273 out.Ports = ports
274 }
275
276 if dataRaw, ok := raw["data"]; ok {
277 var fields map[string]json.RawMessage
278 if err := json.Unmarshal(dataRaw, &fields); err != nil {
279 return fmt.Errorf("capabilities: data: %w", err)
280 }
281 for f := range fields {
282 if f != "replicate" && f != "dbs" {
283 notes = append(notes, fmt.Sprintf("capabilities: data: unknown field %q ignored", f))
284 }
285 }
286 var d Data
287 if err := decodeIfPresent(fields, "replicate", &d.Replicate); err != nil {
288 return fmt.Errorf("capabilities: data.replicate: %w", err)
289 }
290 if dbsRaw, ok := fields["dbs"]; ok {
291 var dbs []string
292 if err := json.Unmarshal(dbsRaw, &dbs); err != nil {
293 return fmt.Errorf("capabilities: data.dbs: %w", err)
294 }
295 // Same bound-before-allocation discipline as ports above.
296 if len(dbs) > maxDBs {
297 return fmt.Errorf("capabilities: data.dbs: %d entries, want at most %d", len(dbs), maxDBs)
298 }
299 d.DBs = dbs
300 }
301 out.Data = &d
302 }
303
304 sort.Strings(notes)
305 out.notes = notes
306 *s = out
307 return nil
308}
309
310// decodeIfPresent unmarshals raw[key] into out when key is present, and
311// leaves out untouched (the zero value, meaning "not declared") otherwise.
312// Because the lookup is an exact Go map-key comparison rather than a
313// whole-object json.Unmarshal, it never falls back to encoding/json's
314// case-insensitive struct-tag matching — a "PORT" or "REPLICATE" the caller
315// has already flagged as an unknown field via the same fields map stays
316// unapplied.
317func decodeIfPresent(raw map[string]json.RawMessage, key string, out any) error {
318 if v, ok := raw[key]; ok {
319 return json.Unmarshal(v, out)
320 }
321 return nil
322}
323
324// Notes are the ignore-with-a-log lines the decoder collected, sorted.
325// Callers print them once per pass, never per host. A nil Set — every
326// production manifest before this feature, and what RunAdopt sees on every
327// route that never declared capabilities — returns nil rather than
328// panicking.
329func (s *Set) Notes() []string {
330 if s == nil {
331 return nil
332 }
333 return s.notes
334}
335
336// ValidCapabilities is the semantic gate, shared by SHIP (a human is
337// present to read the refusal) and ADOPT (the box refuses the version),
338// exactly as appconfig.ValidExecDelivery is shared by set-time and
339// sync-time. A nil Set is valid and means no capabilities. app is needed
340// for the host_role user-name charset and length checks and nothing else.
341func ValidCapabilities(app string, s *Set) error {
342 if s == nil {
343 return nil
344 }
345 if s.V < 1 || s.V > Version {
346 return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v1..v%d) — update the platform binary", s.V, Version)
347 }
348 if s.Command != "" && !tokenRe.MatchString(s.Command) {
349 return fmt.Errorf("capabilities: command %q must be one lowercase token matching ^[a-z][a-z0-9-]{0,31}$ — one token, no flags, no spaces", s.Command)
350 }
351 if s.RuntimeDir != "" {
352 if !tokenRe.MatchString(s.RuntimeDir) {
353 return fmt.Errorf("capabilities: runtime_dir %q must match ^[a-z][a-z0-9-]{0,31}$", s.RuntimeDir)
354 }
355 if reservedRuntimeDir[s.RuntimeDir] || strings.HasPrefix(s.RuntimeDir, "systemd-") {
356 return fmt.Errorf("capabilities: runtime_dir %q is a reserved name", s.RuntimeDir)
357 }
358 }
359 if err := validPorts(s.Ports); err != nil {
360 return err
361 }
362 if s.Data != nil {
363 if err := validData(s.Data); err != nil {
364 return err
365 }
366 }
367 if s.HostRole {
368 // release.ValidApp (internal/release/release.go) already refuses an
369 // app name outside this exact charset at every route-creation entry
370 // point. It is duplicated here — rather than imported, since
371 // release comes to depend on this package in Task 2 and importing
372 // it back would cycle — as defense in depth for the
373 // User=<AppUser(app)> sink: a Set can be validated directly,
374 // without ever having passed through a route.
375 if !tokenRe.MatchString(app) {
376 return fmt.Errorf("capabilities: host_role: app %q fails the app-name charset — see release.ValidApp", app)
377 }
378 if u := AppUser(app); len(u) > 32 {
379 return fmt.Errorf("capabilities: host_role: user %q is longer than the 32-character account-name limit — refused at ship, where a human can rename", u)
380 }
381 }
382 if s.HostRole && s.DocEngine {
383 // Refused as a declaration, before any grant is consulted, because the
384 // two contradict in the rendered bytes: host_role emits
385 // IPAddressAllow=169.254.169.254 to re-open the IMDS the template
386 // fenced, and in systemd's BPF firewall an allow-list match WINS over
387 // a deny-list match (internal/caps/render.go). doc_engine's whole
388 // purpose is to fence the engine's egress against private networks it
389 // can be SSRF'd into — so declaring both asks for a fence with a
390 // hole punched in it by the other half of the same drop-in.
391 //
392 // A document-conversion orchestrator has no business holding the
393 // box's cloud instance role anyway: it converts bytes a caller sent
394 // and needs no cloud credentials to do it. Refusing the pair is the
395 // honest answer to a manifest that asks for both.
396 return fmt.Errorf("capabilities: host_role and doc_engine contradict — doc_engine fences the unit's egress away from private networks, and host_role re-opens the cloud metadata address, which in systemd's BPF firewall an IPAddressAllow wins over any IPAddressDeny. A document-conversion engine needs no cloud instance role; drop host_role")
397 }
398 return nil
399}
400
401// validPorts encodes the ports rules: only privileged ports are a
402// capability, 22/80/443 are reserved absolutely, no duplicates, and a bound
403// of maxPorts — a bound, not a feature.
404func validPorts(ports []Port) error {
405 if len(ports) > maxPorts {
406 return fmt.Errorf("capabilities: ports: %d declared, want at most %d", len(ports), maxPorts)
407 }
408 seen := make(map[int]bool, len(ports))
409 for _, p := range ports {
410 if p.Port < 1 || p.Port > 1023 {
411 return fmt.Errorf("capabilities: ports: port %d is outside 1..1023 — only privileged ports are a capability; a high port needs no help from the platform", p.Port)
412 }
413 if reservedPort[p.Port] {
414 return fmt.Errorf("capabilities: ports: port %d is reserved and not grantable", p.Port)
415 }
416 if seen[p.Port] {
417 return fmt.Errorf("capabilities: ports: port %d declared more than once", p.Port)
418 }
419 seen[p.Port] = true
420 if p.Name != "" && !tokenRe.MatchString(p.Name) {
421 return fmt.Errorf("capabilities: ports: name %q must match ^[a-z][a-z0-9-]{0,31}$ — it lands in logs", p.Name)
422 }
423 }
424 return nil
425}
426
427// validData encodes the data rules: replicate must be one of the two
428// constants, dbs is a bound list of unique bare filenames, and dbs is
429// meaningless (and refused) when the app replicates its own WAL.
430func validData(d *Data) error {
431 if d.Replicate != DataReplicatePlatform && d.Replicate != DataReplicateApp {
432 return fmt.Errorf("capabilities: data.replicate %q must be %q or %q", d.Replicate, DataReplicatePlatform, DataReplicateApp)
433 }
434 if len(d.DBs) > maxDBs {
435 return fmt.Errorf("capabilities: data.dbs: %d entries, want at most %d", len(d.DBs), maxDBs)
436 }
437 if d.Replicate == DataReplicateApp && len(d.DBs) > 0 {
438 return fmt.Errorf("capabilities: data.dbs is meaningless when replicate is %q — the app streams its own WAL, and accepting it would invite the two-replicators hazard", DataReplicateApp)
439 }
440 seen := make(map[string]bool, len(d.DBs))
441 for _, name := range d.DBs {
442 if seen[name] {
443 return fmt.Errorf("capabilities: data.dbs: %q declared more than once — two replicators on one WAL fight over checkpointing", name)
444 }
445 seen[name] = true
446 if name == "." || name == ".." || !dbNameRe.MatchString(name) {
447 return fmt.Errorf("capabilities: data.dbs: %q must be a bare filename matching ^[A-Za-z0-9._-]{1,64}$", name)
448 }
449 }
450 return nil
451}
452
453// AppUser is the static system user a host_role app runs as, DERIVED by the
454// platform and never taken from a manifest.
455func AppUser(app string) string {
456 return "carlos-" + app
457}
458