| new file mode 100644 |
| index 000000000..f6a00c05a |
| --- /dev/null |
| +++ b/internal/caps/caps.go |
| @@ -0,0 +1,336 @@ |
| +// Package caps is the closed capability vocabulary: what an app may ask the |
| +// platform for, and what each name maps to. A manifest never names a path, a |
| +// user, a uid, a resource number, or a systemd directive — the directives are |
| +// Go constants in this package. There is no passthrough field and no |
| +// "extra" escape hatch; the escape hatch is 90-operator.conf, which this |
| +// package never writes and never reads. |
| +// |
| +// See docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2 (the |
| +// model: self-service vs operator-consented), §3 (wire shape and which way |
| +// each failure falls), §7 (security posture — why each rail exists), and §8 |
| +// (the testing shape this file's tests follow). |
| +package caps |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "regexp" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// Version is the capability block version THIS binary understands. A block |
| +// declaring a higher "v" refuses the version on this box (spec §3b) — fail |
| +// closed in the direction that changes nothing: the box keeps serving what |
| +// it has rather than materialize a unit missing something the app needs. |
| +const Version = 1 |
| + |
| +// Marker is the first line of every drop-in this package generates — the |
| +// same marker `carlos ops litestream` uses. A file at a generated path |
| +// WITHOUT it is never overwritten and never deleted (spec §3c). |
| +const Marker = "# GENERATED by carlos adopt — do not hand-edit" |
| + |
| +// DataReplicatePlatform and DataReplicateApp are the only two values |
| +// Data.Replicate accepts (spec §2, §3a): the box-wide carlos-litestream |
| +// unit replicates the named dbs, or the app streams its own WAL and the |
| +// platform provisions the directory and replicates nothing. Two |
| +// replicators on one WAL fight over checkpointing and corrupt the replica, |
| +// so the two are mutually exclusive — see the "dbs with replicate:\"app\"" |
| +// rule in ValidCapabilities. |
| +const ( |
| + DataReplicatePlatform = "platform" |
| + DataReplicateApp = "app" |
| +) |
| + |
| +// tokenRe is the charset shared by command, runtime_dir, and ports[].name |
| +// (spec §7): one lowercase token, so "../etc", "carlos/apps", absolute |
| +// paths, flags, and spaces never parse. |
| +var tokenRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + |
| +// dbNameRe is data.dbs's charset: a bare filename, used only as |
| +// filepath.Join(stateDir, name) and never a path (spec §7). "." and ".." |
| +// pass this character class and are rejected separately below. |
| +var dbNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) |
| + |
| +// reservedRuntimeDir names a directory the platform or the OS already owns. |
| +// "carlos" is the load-bearing entry: a tenant owning /run/carlos would own |
| +// every tenant socket the edge dials — the single most valuable directory |
| +// on the box (spec §7). The "systemd-" prefix is checked separately. |
| +var reservedRuntimeDir = map[string]bool{ |
| + "carlos": true, |
| + "systemd": true, |
| + "lock": true, |
| + "log": true, |
| + "user": true, |
| + "dbus": true, |
| + "sshd": true, |
| + "udev": true, |
| +} |
| + |
| +// reservedPort is bound absolutely and is never grantable, typo or not |
| +// (spec §7): the edge's own ports and the box's SSH. |
| +var reservedPort = map[int]bool{22: true, 80: true, 443: true} |
| + |
| +// knownCapability is the closed vocabulary's seven names. A manifest key |
| +// under "capabilities" that isn't one of these (or "v") names a capability |
| +// this binary does not know, and UnmarshalJSON refuses the whole block. |
| +var knownCapability = map[string]bool{ |
| + "command": true, |
| + "daemon": true, |
| + "children": true, |
| + "runtime_dir": true, |
| + "ports": true, |
| + "data": true, |
| + "host_role": true, |
| +} |
| + |
| +// Set is a manifest's whole "capabilities" object. It is decoded only |
| +// through UnmarshalJSON below — the strict door that tells an unknown |
| +// capability NAME (refuse) apart from an unknown FIELD inside a known one |
| +// (ignore and note). |
| +type Set struct { |
| + V int `json:"v"` |
| + Command string `json:"command,omitempty"` |
| + Daemon bool `json:"daemon,omitempty"` |
| + Children bool `json:"children,omitempty"` |
| + RuntimeDir string `json:"runtime_dir,omitempty"` |
| + Ports []Port `json:"ports,omitempty"` |
| + Data *Data `json:"data,omitempty"` |
| + HostRole bool `json:"host_role,omitempty"` |
| + |
| + notes []string // unknown-field-inside-a-known-capability lines; see Notes |
| +} |
| + |
| +// Port is one privileged-port ask. Name is an operator/log label and |
| +// carries no behaviour (spec §3a, §4c) — it is never a systemd |
| +// FileDescriptorName. |
| +type Port struct { |
| + Port int `json:"port"` |
| + Name string `json:"name,omitempty"` // an operator/log label; carries NO behaviour |
| +} |
| + |
| +// Data is the persistent-data-dir ask: which files under the state dir are |
| +// databases, and who replicates them. The path itself is never in the |
| +// manifest — it is always StateDirectory=carlos-app/%i (spec §2). |
| +type Data struct { |
| + Replicate string `json:"replicate"` // DataReplicatePlatform | DataReplicateApp |
| + DBs []string `json:"dbs,omitempty"` |
| +} |
| + |
| +// UnmarshalJSON is the strict door (spec §3b), and the three outcomes are |
| +// deliberately different: |
| +// - an unknown CAPABILITY NAME, or a "v" greater than Version: error, |
| +// naming the capability and "update the platform binary". This |
| +// propagates through json.Unmarshal -> release.GetManifest -> |
| +// ensureStaged, which is exactly how the version comes to be refused on |
| +// this box. |
| +// - an unknown FIELD INSIDE a known capability: accepted, recorded in |
| +// notes. |
| +// - anything absent: the zero value, which means "not declared". |
| +// |
| +// It decodes into a map[string]json.RawMessage first so it can check every |
| +// key against the seven known names plus "v" before decoding anything |
| +// typed, then re-decodes each object-valued capability with a second |
| +// map[string]json.RawMessage pass to collect notes before decoding into the |
| +// typed value. json.Decoder.DisallowUnknownFields is deliberately NOT used |
| +// on the whole block — it cannot distinguish an unknown name (refuse) from |
| +// an unknown field (ignore), and that distinction is the entire |
| +// forward-compatibility contract (spec §3b). |
| +func (s *Set) UnmarshalJSON(b []byte) error { |
| + var raw map[string]json.RawMessage |
| + if err := json.Unmarshal(b, &raw); err != nil { |
| + return err |
| + } |
| + |
| + // "v" absent decodes as the zero value, which is never Version (today |
| + // Version is 1) — an omitted "v" on a present block is refused exactly |
| + // like an explicit mismatch, per the validation table. |
| + var v int |
| + if vRaw, ok := raw["v"]; ok { |
| + if err := json.Unmarshal(vRaw, &v); err != nil { |
| + return fmt.Errorf("capabilities: v: %w", err) |
| + } |
| + } |
| + if v != Version { |
| + return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v%d) — update the platform binary", v, Version) |
| + } |
| + |
| + for name := range raw { |
| + if name == "v" { |
| + continue |
| + } |
| + if !knownCapability[name] { |
| + return fmt.Errorf("capabilities: unknown capability %q — update the platform binary", name) |
| + } |
| + } |
| + |
| + var notes []string |
| + |
| + if err := decodeIfPresent(raw, "command", &s.Command); err != nil { |
| + return fmt.Errorf("capabilities: command: %w", err) |
| + } |
| + if err := decodeIfPresent(raw, "daemon", &s.Daemon); err != nil { |
| + return fmt.Errorf("capabilities: daemon: %w", err) |
| + } |
| + if err := decodeIfPresent(raw, "children", &s.Children); err != nil { |
| + return fmt.Errorf("capabilities: children: %w", err) |
| + } |
| + if err := decodeIfPresent(raw, "runtime_dir", &s.RuntimeDir); err != nil { |
| + return fmt.Errorf("capabilities: runtime_dir: %w", err) |
| + } |
| + if err := decodeIfPresent(raw, "host_role", &s.HostRole); err != nil { |
| + return fmt.Errorf("capabilities: host_role: %w", err) |
| + } |
| + |
| + if portsRaw, ok := raw["ports"]; ok { |
| + var items []json.RawMessage |
| + if err := json.Unmarshal(portsRaw, &items); err != nil { |
| + return fmt.Errorf("capabilities: ports: %w", err) |
| + } |
| + ports := make([]Port, len(items)) |
| + for i, item := range items { |
| + var fields map[string]json.RawMessage |
| + if err := json.Unmarshal(item, &fields); err != nil { |
| + return fmt.Errorf("capabilities: ports[%d]: %w", i, err) |
| + } |
| + for f := range fields { |
| + if f != "port" && f != "name" { |
| + notes = append(notes, fmt.Sprintf("capabilities: ports[%d]: unknown field %q ignored", i, f)) |
| + } |
| + } |
| + if err := json.Unmarshal(item, &ports[i]); err != nil { |
| + return fmt.Errorf("capabilities: ports[%d]: %w", i, err) |
| + } |
| + } |
| + s.Ports = ports |
| + } |
| + |
| + if dataRaw, ok := raw["data"]; ok { |
| + var fields map[string]json.RawMessage |
| + if err := json.Unmarshal(dataRaw, &fields); err != nil { |
| + return fmt.Errorf("capabilities: data: %w", err) |
| + } |
| + for f := range fields { |
| + if f != "replicate" && f != "dbs" { |
| + notes = append(notes, fmt.Sprintf("capabilities: data: unknown field %q ignored", f)) |
| + } |
| + } |
| + var d Data |
| + if err := json.Unmarshal(dataRaw, &d); err != nil { |
| + return fmt.Errorf("capabilities: data: %w", err) |
| + } |
| + s.Data = &d |
| + } |
| + |
| + sort.Strings(notes) |
| + s.V = v |
| + s.notes = notes |
| + return nil |
| +} |
| + |
| +// decodeIfPresent unmarshals raw[key] into out when key is present, and |
| +// leaves out untouched (the zero value, meaning "not declared") otherwise. |
| +func decodeIfPresent(raw map[string]json.RawMessage, key string, out any) error { |
| + if v, ok := raw[key]; ok { |
| + return json.Unmarshal(v, out) |
| + } |
| + return nil |
| +} |
| + |
| +// Notes are the ignore-with-a-log lines the decoder collected, sorted. |
| +// Callers print them once per pass, never per host. |
| +func (s *Set) Notes() []string { |
| + return s.notes |
| +} |
| + |
| +// ValidCapabilities is the semantic gate, shared by SHIP (a human is |
| +// present to read the refusal) and ADOPT (the box refuses the version), |
| +// exactly as appconfig.ValidExecDelivery is shared by set-time and |
| +// sync-time. A nil Set is valid and means no capabilities. app is needed |
| +// for the host_role user-name length check and nothing else. |
| +func ValidCapabilities(app string, s *Set) error { |
| + if s == nil { |
| + return nil |
| + } |
| + if s.V != Version { |
| + return fmt.Errorf("capabilities: v %d is not supported by this binary (this binary speaks v%d) — update the platform binary", s.V, Version) |
| + } |
| + if s.Command != "" && !tokenRe.MatchString(s.Command) { |
| + 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) |
| + } |
| + if s.RuntimeDir != "" { |
| + if !tokenRe.MatchString(s.RuntimeDir) { |
| + return fmt.Errorf("capabilities: runtime_dir %q must match ^[a-z][a-z0-9-]{0,31}$", s.RuntimeDir) |
| + } |
| + if reservedRuntimeDir[s.RuntimeDir] || strings.HasPrefix(s.RuntimeDir, "systemd-") { |
| + return fmt.Errorf("capabilities: runtime_dir %q is a reserved name", s.RuntimeDir) |
| + } |
| + } |
| + if err := validPorts(s.Ports); err != nil { |
| + return err |
| + } |
| + if s.Data != nil { |
| + if err := validData(s.Data); err != nil { |
| + return err |
| + } |
| + } |
| + if s.HostRole { |
| + if u := AppUser(app); len(u) > 32 { |
| + 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) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// validPorts encodes the ports rules: only privileged ports are a |
| +// capability, 22/80/443 are reserved absolutely, no duplicates, and a bound |
| +// of 4 — a bound, not a feature. |
| +func validPorts(ports []Port) error { |
| + if len(ports) > 4 { |
| + return fmt.Errorf("capabilities: ports: %d declared, want at most 4", len(ports)) |
| + } |
| + seen := make(map[int]bool, len(ports)) |
| + for _, p := range ports { |
| + if p.Port < 1 || p.Port > 1023 { |
| + 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) |
| + } |
| + if reservedPort[p.Port] { |
| + return fmt.Errorf("capabilities: ports: port %d is reserved and not grantable", p.Port) |
| + } |
| + if seen[p.Port] { |
| + return fmt.Errorf("capabilities: ports: port %d declared more than once", p.Port) |
| + } |
| + seen[p.Port] = true |
| + if p.Name != "" && !tokenRe.MatchString(p.Name) { |
| + return fmt.Errorf("capabilities: ports: name %q must match ^[a-z][a-z0-9-]{0,31}$ — it lands in logs", p.Name) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// validData encodes the data rules: replicate must be one of the two |
| +// constants, dbs is a bound list of bare filenames, and dbs is meaningless |
| +// (and refused) when the app replicates its own WAL. |
| +func validData(d *Data) error { |
| + if d.Replicate != DataReplicatePlatform && d.Replicate != DataReplicateApp { |
| + return fmt.Errorf("capabilities: data.replicate %q must be %q or %q", d.Replicate, DataReplicatePlatform, DataReplicateApp) |
| + } |
| + if len(d.DBs) > 8 { |
| + return fmt.Errorf("capabilities: data.dbs: %d entries, want at most 8", len(d.DBs)) |
| + } |
| + if d.Replicate == DataReplicateApp && len(d.DBs) > 0 { |
| + 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) |
| + } |
| + for _, name := range d.DBs { |
| + if name == "." || name == ".." || !dbNameRe.MatchString(name) { |
| + return fmt.Errorf("capabilities: data.dbs: %q must be a bare filename matching ^[A-Za-z0-9._-]{1,64}$", name) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// AppUser is the static system user a host_role app runs as, DERIVED by the |
| +// platform and never taken from a manifest. |
| +func AppUser(app string) string { |
| + return "carlos-" + app |
| +} |