Merge branch 'sidecar-engine-capability' into main
caps: doc-engine capability for document-conversion sidecars (Gotenberg, OCR)
12 files changed,
+619
−29
README.md+44 −8cli.go+8 −4cli_test.go+8 −1internal/caps/caps.go+23 −2internal/caps/caps_test.go+28 −0internal/caps/grants.go+41 −7internal/caps/grants_test.go+82 −1internal/caps/render.go+145 −6internal/caps/render_test.go+209 −0internal/caps/testdata/gotenberg.service.conf+11 −0internal/host/caps_pass.go+8 −0internal/host/caps_pass_test.go+12 −0
CI cancelled — superseded by a newer push — run details
diff --git a/README.md b/README.md| index fb49dafa2..44f103eec 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -522,10 +522,10 @@ renders them yet — they are the history a later trend view will read. |
| A handful of apps need more from the platform than the `carlos-app@<host>` |
| template gives every instance for free — a subcommand other than `serve`, a |
| -privileged port, the box's cloud instance role. Rather than hand-provision a |
| -bespoke unit per app, a release **declares** what it needs and the platform |
| -decides how — and whether — to provide it, from a closed vocabulary of seven |
| -names. See |
| +privileged port, the box's cloud instance role, or the resource headroom a |
| +document-conversion engine needs. Rather than hand-provision a bespoke unit |
| +per app, a release **declares** what it needs and the platform decides how — |
| +and whether — to provide it, from a closed vocabulary of eight names. See |
| the `2026-08-03-app-capabilities-design.md` design spec (private `platform-infrastructure` repo) |
| for the full design; this section is the operator-facing summary. |
| @@ -544,12 +544,26 @@ as "none", today's behaviour byte for byte): |
| "host_role": true |
| } |
| +A document-conversion engine (Gotenberg, an OCR service) declares a different |
| +shape — `host_role` and `doc_engine` are mutually exclusive and a manifest |
| +carrying both is refused at ship, because `host_role` re-opens the cloud |
| +metadata address and `doc_engine` exists to fence the engine's egress away |
| +from private networks: |
| + |
| + "capabilities": { |
| + "v": 1, |
| + "daemon": true, |
| + "children": true, |
| + "doc_engine": true |
| + } |
| + |
| `command`, `daemon`, `children`, `runtime_dir` and `data` are self-service — |
| they shape only the app's own sandbox and materialize from the manifest |
| -alone. `ports` and `host_role` are operator-consented: declaring one asks for |
| -it, but nothing materializes until an operator has granted it **on that |
| -specific box**, because the resource (a privileged port, the box's IAM role) |
| -is box-local, not fleet-wide. |
| +alone. `ports`, `host_role` and `doc_engine` are operator-consented: declaring |
| +one asks for it, but nothing materializes until an operator has granted it |
| +**on that specific box**, because the resource (a privileged port, the box's |
| +IAM role, the private-network egress fence that pays for a conversion |
| +engine's JIT and memory headroom) is box-local, not fleet-wide. |
| **Declaring a capability** rides `carlos ship --capabilities <file.json>`, |
| where the file holds exactly the `capabilities` object above (not a whole |
| @@ -564,6 +578,7 @@ because a flag vocabulary would drift from `internal/caps`'s own. |
| carlos route --host keymail.dev --backing unit # first: grants need it |
| carlos route --host keymail.dev --grant port:25 |
| carlos route --host keymail.dev --grant host-role |
| + carlos route --host gotenberg.docs.example.com --grant doc-engine |
| carlos route --host keymail.dev --revoke port:25 |
| carlos route --host keymail.dev --hibernate=false |
| @@ -590,6 +605,27 @@ line is there too — a token this box has granted that the live release no |
| longer declares materializes nothing, and is named with the revoke command |
| rather than left sitting in the registry looking like live authority. |
| +**`doc-engine` carries two box preconditions** the platform cannot check for |
| +you, which is what makes granting it an operator decision rather than a |
| +checkbox. The grant buys the app JIT memory and a raised resource tier, and |
| +in the same drop-in fences its egress away from every private network range — |
| +RFC1918, link-local, CGNAT, IPv6 ULA and the NAT64 prefix — because a |
| +conversion engine fetches whatever URLs the documents handed to it name, and |
| +an unfenced one is an SSRF proxy into the VPC. Two consequences: |
| + |
| +- **The box must hand the tenant a loopback DNS resolver.** The fence |
| + deliberately leaves 127.0.0.0/8 open — the engine drives its own Chromium |
| + and LibreOffice over loopback — but it closes 10.0.0.0/8, and on AWS the |
| + VPC resolver lives inside that range (VPC base + 2). A box running |
| + systemd-resolved on 127.0.0.53 resolves through loopback and the fence is |
| + transparent; a box pointing tenants straight at the VPC resolver gives them |
| + an engine that cannot resolve a hostname. |
| +- **The box should be dedicated.** Loopback staying open means an SSRF'd |
| + engine can still reach anything listening on 127.0.0.1 on its own box — a |
| + co-tenant console, a co-tenant's loopback-bound upstream. The fence closes |
| + the network half of that risk; not co-locating tenants closes the rest. |
| + Run `doc-engine` routes on a box of their own. |
| + |
| **Deploy order is pinned**, same rule and reason as scoped signing keys: the |
| platform binaries that understand a capability name have to be on the box |
| *before* a release declaring it, never after. An older binary meeting a |
diff --git a/cli.go b/cli.go| index ee9f8e20b..f6e7dceb3 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -451,6 +451,7 @@ func runInstances(args []string) { |
| // |
| // carlos route --host keymail.dev --grant port:25 |
| // carlos route --host platform.carlosframework.com --grant host-role |
| +// carlos route --host gotenberg.docs.example.com --grant doc-engine |
| // carlos route --host keymail.dev --revoke port:25 |
| // carlos route --host keymail.dev --hibernate=false |
| // carlos route --host console.carloku.com --unit console.service |
| @@ -479,7 +480,7 @@ func runRoute(args []string) { |
| channel := channelFlag(fs, "release channel to subscribe the route(s) to") |
| environment := environmentFlag(fs, |
| `named config environment to bind the route(s) to (must already exist in the bucket); "-" clears to default`) |
| - grant := fs.String("grant", "", "capability token to grant this route ON THIS BOX: port:<n> (privileged, never 22/80/443) | host-role") |
| + grant := fs.String("grant", "", "capability token to grant this route ON THIS BOX: port:<n> (privileged, never 22/80/443) | host-role | doc-engine") |
| revoke := fs.String("revoke", "", "capability token to revoke from this route on this box") |
| hibernate := fs.Bool("hibernate", false, "hibernate this route when idle; --hibernate=false stops it hibernating") |
| backing := fs.String("backing", "", `who owns this route's process: "unit" (a carlos-app@<host> systemd unit) | "" (an exec child of the agent)`) |
| @@ -530,7 +531,7 @@ func runRoute(args []string) { |
| const usage = "usage: carlos route --host <host> [--channel <channel>] [--environment <name>] [--tunnel <name>] [--registry <db>] [--allow-version-change] [--dry-run]\n" + |
| " or: carlos route --app <app> [--channel <channel>] [--environment <name>] [--account <sqid|name|all>] [--account-id <sqid>] [--registry <db>] [--allow-version-change] [--dry-run]\n" + |
| " (--account-id names ONE account by sqid and supersedes --account, \"all\" included; --tunnel is --host only)\n" + |
| - " or: carlos route --host <host> (--grant | --revoke) <port:n|host-role> [--registry <db>] [--dry-run]\n" + |
| + " or: carlos route --host <host> (--grant | --revoke) <port:n|host-role|doc-engine> [--registry <db>] [--dry-run]\n" + |
| " or: carlos route --host <host> --hibernate[=false] [--registry <db>] [--dry-run]\n" + |
| " or: carlos route --host <host> --backing <unit|\"\"> [--registry <db>] [--dry-run]\n" + |
| " or: carlos route --host <host> --unit <name.service|-> [--registry <db>] [--dry-run]\n" + |
| @@ -1331,13 +1332,16 @@ func grantRail(rt registry.Route, tok string) error { |
| // |
| // host-role is deliberately not conflict-checked: the box's instance role is |
| // not consumed by being used, and two apps holding it is two independent |
| -// operator decisions, not a collision. |
| +// operator decisions, not a collision. doc-engine is not conflict-checked for |
| +// the same reason and a sharper one: the capability is per-route unit text, |
| +// not a box resource, and a dedicated sidecar box running several engine |
| +// routes is the expected shape. |
| // |
| // A row whose caps column does not parse fails the check CLOSED, naming that |
| // row: "I could not find out whether this port is already claimed" is never a |
| // licence to claim it. |
| func portGrantConflict(routes []registry.Route, host, tok string) error { |
| - if tok == caps.HostRoleToken { |
| + if tok == caps.HostRoleToken || tok == caps.DocEngineToken { |
| return nil |
| } |
| for _, other := range routes { |
diff --git a/cli_test.go b/cli_test.go| index 7271f1c87..a98f75b85 100644 |
| --- a/cli_test.go |
| +++ b/cli_test.go |
| @@ -211,7 +211,7 @@ func TestGrantRailRefusals(t *testing.T) { |
| } |
| }) |
| } |
| - for _, tok := range []string{"port:25", "host-role"} { |
| + for _, tok := range []string{"port:25", "host-role", "doc-engine"} { |
| if err := grantRail(unit, tok); err != nil { |
| t.Fatalf("grantRail(unit-backed instance, %q) = %v, want accepted", tok, err) |
| } |
| @@ -257,6 +257,13 @@ func TestPortGrantConflict(t *testing.T) { |
| if err := portGrantConflict(routes, "other.example.com", "host-role"); err != nil { |
| t.Fatalf("host-role = %v, want no conflict even though another route holds it", err) |
| } |
| + // doc-engine is never conflict-checked either, and for a sharper reason: |
| + // it is per-route unit text, not a box resource, and a dedicated sidecar |
| + // box running several engine routes is the expected shape. |
| + engineRoutes := []registry.Route{{Host: "gotenberg.docs.example.com", Caps: "doc-engine"}} |
| + if err := portGrantConflict(engineRoutes, "ocr.docs.example.com", "doc-engine"); err != nil { |
| + t.Fatalf("doc-engine = %v, want no conflict even though another route holds it", err) |
| + } |
| // A row whose caps column does not parse fails CLOSED, naming that row: |
| // "I could not find out whether this port is claimed" is not a licence. |
| bad := []registry.Route{{Host: "handedited.example.com", Caps: "port:25,gpu"}} |
diff --git a/internal/caps/caps.go b/internal/caps/caps.go| index ccc3a18f2..3ddc7c7ce 100644 |
| --- a/internal/caps/caps.go |
| +++ b/internal/caps/caps.go |
| @@ -98,7 +98,7 @@ var reservedRuntimeDir = map[string]bool{ |
| // (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 |
| +// knownCapability is the closed vocabulary's eight 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{ |
| @@ -109,6 +109,7 @@ var knownCapability = map[string]bool{ |
| "ports": true, |
| "data": true, |
| "host_role": true, |
| + "doc_engine": true, |
| } |
| // Set is a manifest's whole "capabilities" object. It is decoded only |
| @@ -124,6 +125,7 @@ type Set struct { |
| Ports []Port `json:"ports,omitempty"` |
| Data *Data `json:"data,omitempty"` |
| HostRole bool `json:"host_role,omitempty"` |
| + DocEngine bool `json:"doc_engine,omitempty"` |
| notes []string // unknown-field-inside-a-known-capability lines; see Notes |
| } |
| @@ -165,7 +167,7 @@ type Data struct { |
| // - 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 |
| +// key against the eight 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 AND to decode each known |
| // field one at a time by its exact key. That second point matters: a |
| @@ -235,6 +237,9 @@ func (s *Set) UnmarshalJSON(b []byte) error { |
| if err := decodeIfPresent(raw, "host_role", &out.HostRole); err != nil { |
| return fmt.Errorf("capabilities: host_role: %w", err) |
| } |
| + if err := decodeIfPresent(raw, "doc_engine", &out.DocEngine); err != nil { |
| + return fmt.Errorf("capabilities: doc_engine: %w", err) |
| + } |
| if portsRaw, ok := raw["ports"]; ok { |
| var items []json.RawMessage |
| @@ -374,6 +379,22 @@ func ValidCapabilities(app string, s *Set) error { |
| 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) |
| } |
| } |
| + if s.HostRole && s.DocEngine { |
| + // Refused as a declaration, before any grant is consulted, because the |
| + // two contradict in the rendered bytes: host_role emits |
| + // IPAddressAllow=169.254.169.254 to re-open the IMDS the template |
| + // fenced, and in systemd's BPF firewall an allow-list match WINS over |
| + // a deny-list match (internal/caps/render.go). doc_engine's whole |
| + // purpose is to fence the engine's egress against private networks it |
| + // can be SSRF'd into — so declaring both asks for a fence with a |
| + // hole punched in it by the other half of the same drop-in. |
| + // |
| + // A document-conversion orchestrator has no business holding the |
| + // box's cloud instance role anyway: it converts bytes a caller sent |
| + // and needs no cloud credentials to do it. Refusing the pair is the |
| + // honest answer to a manifest that asks for both. |
| + 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") |
| + } |
| return nil |
| } |
diff --git a/internal/caps/caps_test.go b/internal/caps/caps_test.go| index df9a86ed7..0e3245d59 100644 |
| --- a/internal/caps/caps_test.go |
| +++ b/internal/caps/caps_test.go |
| @@ -169,6 +169,34 @@ func TestKeymailSpecBlockRoundTripsAndValidates(t *testing.T) { |
| } |
| } |
| +// TestDocEngineDecodesAsAKnownCapability pins the wire shape of the new |
| +// capability: "doc_engine" is one of the closed vocabulary's names, so it |
| +// decodes to Set.DocEngine and is NOT refused as unknown and NOT recorded as an |
| +// ignored field. An absent doc_engine leaves it false (not declared). |
| +func TestDocEngineDecodesAsAKnownCapability(t *testing.T) { |
| + var s Set |
| + if err := json.Unmarshal([]byte(`{"v":1,"doc_engine":true}`), &s); err != nil { |
| + t.Fatalf("doc_engine refused as unknown: %v", err) |
| + } |
| + if !s.DocEngine { |
| + t.Error("DocEngine = false, want true") |
| + } |
| + if n := s.Notes(); len(n) != 0 { |
| + t.Errorf("doc_engine recorded as an ignored field: %v", n) |
| + } |
| + if err := ValidCapabilities("gotenberg", &s); err != nil { |
| + t.Fatalf("ValidCapabilities(doc_engine) = %v, want nil", err) |
| + } |
| + |
| + var absent Set |
| + if err := json.Unmarshal([]byte(`{"v":1}`), &absent); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if absent.DocEngine { |
| + t.Error("an absent doc_engine decoded to true, want false (not declared)") |
| + } |
| +} |
| + |
| // TestNilSetNotesDoesNotPanic pins review finding I1: every production |
| // manifest today decodes to a nil Set, and the first ranged Notes() call in |
| // Task 6's converge pass must not crash RunAdopt fleet-wide. |
diff --git a/internal/caps/grants.go b/internal/caps/grants.go| index 82a00d136..7add04c84 100644 |
| --- a/internal/caps/grants.go |
| +++ b/internal/caps/grants.go |
| @@ -10,6 +10,9 @@ package caps |
| // |
| // host-role the app's processes may use the box's instance role |
| // port:<n> the app may bind privileged port n (1..1023, never 22/80/443) |
| +// doc-engine the app is a document-conversion orchestrator (Chromium, |
| +// LibreOffice, Tesseract-class): JIT and resource headroom, |
| +// in exchange for a private-network egress fence |
| // |
| // Per route, per box, deliberately (decision 4): `:25` can be held by exactly |
| // one process on one machine, and the IMDS gate is that box's firewall. Two |
| @@ -28,6 +31,20 @@ import ( |
| // in a manifest (spec §7). |
| const HostRoleToken = "host-role" |
| +// DocEngineToken is the grant that says this route runs a document-conversion |
| +// orchestrator — Gotenberg, an OCR service, anything driving Chromium or |
| +// LibreOffice over untrusted documents. It is a TRADE, not a gift: the app |
| +// gets the resource headroom those engines cannot run without, and in the |
| +// same drop-in the platform fences its egress away from every private network |
| +// the engine could otherwise be SSRF'd into. RenderService emits both halves |
| +// together or neither, so an operator cannot grant the headroom and forget |
| +// the fence. |
| +// |
| +// Not spelled "engine": `carlos store scan` already uses that word for the |
| +// antivirus engine throughout its operator-facing prose, and two meanings for |
| +// one token would make every caps column ambiguous to read. |
| +const DocEngineToken = "doc-engine" |
| + |
| // portTokenPrefix is the only other token shape. Kept as a constant so the |
| // parser and the formatter can never drift apart by a character. |
| const portTokenPrefix = "port:" |
| @@ -39,10 +56,12 @@ const portTokenPrefix = "port:" |
| func PortToken(p int) string { return portTokenPrefix + strconv.Itoa(p) } |
| // Grants is a parsed `caps` column: which privileged ports this route may |
| -// bind on this box, and whether it may use the box's instance role. |
| +// bind on this box, whether it may use the box's instance role, and whether |
| +// it may run as a document-conversion engine. |
| type Grants struct { |
| - Ports map[int]bool |
| - HostRole bool |
| + Ports map[int]bool |
| + HostRole bool |
| + DocEngine bool |
| } |
| // Has reports whether g holds tok. The token is not validated — an |
| @@ -52,6 +71,9 @@ func (g Grants) Has(tok string) bool { |
| if tok == HostRoleToken { |
| return g.HostRole |
| } |
| + if tok == DocEngineToken { |
| + return g.DocEngine |
| + } |
| if p, ok := parsePortToken(tok); ok { |
| return g.Ports[p] |
| } |
| @@ -68,6 +90,9 @@ func (g Grants) Has(tok string) bool { |
| // this value needs is that one set has exactly one spelling. |
| func (g Grants) String() string { |
| var toks []string |
| + if g.DocEngine { |
| + toks = append(toks, DocEngineToken) |
| + } |
| if g.HostRole { |
| toks = append(toks, HostRoleToken) |
| } |
| @@ -105,6 +130,10 @@ func ParseGrants(col string) (Grants, error) { |
| g.HostRole = true |
| continue |
| } |
| + if tok == DocEngineToken { |
| + g.DocEngine = true |
| + continue |
| + } |
| p, _ := parsePortToken(tok) // ValidGrantToken proved the shape |
| if g.Ports == nil { |
| g.Ports = map[int]bool{} |
| @@ -117,7 +146,8 @@ func ParseGrants(col string) (Grants, error) { |
| // ValidGrantToken is the rail an operator meets at `carlos route -grant`, |
| // and the same rail ParseGrants applies to the stored column. It refuses: |
| // |
| -// - an unknown token shape (only "host-role" and "port:<n>" exist); |
| +// - an unknown token shape (only "host-role", "doc-engine" and "port:<n>" |
| +// exist); |
| // - a non-canonical port spelling ("port:025", "port:+25", "port: 25") — |
| // one grant has exactly one spelling; |
| // - a port outside 1..1023, because a high port needs no help from the |
| @@ -131,13 +161,13 @@ func ParseGrants(col string) (Grants, error) { |
| // keeps the comma-separated column unambiguous. |
| func ValidGrantToken(tok string) error { |
| if tok == "" { |
| - return fmt.Errorf("grant token is empty — want %q or %q", HostRoleToken, PortToken(25)) |
| + return fmt.Errorf("grant token is empty — want %q, %q or %q", HostRoleToken, DocEngineToken, PortToken(25)) |
| } |
| - if tok == HostRoleToken { |
| + if tok == HostRoleToken || tok == DocEngineToken { |
| return nil |
| } |
| if !strings.HasPrefix(tok, portTokenPrefix) { |
| - return fmt.Errorf("unknown grant token %q — the vocabulary is %q and %q", tok, HostRoleToken, portTokenPrefix+"<n>") |
| + return fmt.Errorf("unknown grant token %q — the vocabulary is %q, %q and %q", tok, HostRoleToken, DocEngineToken, portTokenPrefix+"<n>") |
| } |
| p, ok := parsePortToken(tok) |
| if !ok { |
| @@ -182,6 +212,8 @@ func Grant(col, tok string) (string, error) { |
| } |
| if tok == HostRoleToken { |
| g.HostRole = true |
| + } else if tok == DocEngineToken { |
| + g.DocEngine = true |
| } else { |
| p, _ := parsePortToken(tok) |
| if g.Ports == nil { |
| @@ -207,6 +239,8 @@ func Revoke(col, tok string) (string, error) { |
| } |
| if tok == HostRoleToken { |
| g.HostRole = false |
| + } else if tok == DocEngineToken { |
| + g.DocEngine = false |
| } else if p, ok := parsePortToken(tok); ok { |
| delete(g.Ports, p) |
| } |
diff --git a/internal/caps/grants_test.go b/internal/caps/grants_test.go| index a86c81931..ec4671d13 100644 |
| --- a/internal/caps/grants_test.go |
| +++ b/internal/caps/grants_test.go |
| @@ -14,7 +14,7 @@ import ( |
| // they are refused ABSOLUTELY, so an operator typo can never hand a tenant |
| // the edge's ports or the box's SSH (spec §7). |
| func TestValidGrantTokenRefusals(t *testing.T) { |
| - for _, tok := range []string{HostRoleToken, "port:25", "port:465", "port:1", "port:1023"} { |
| + for _, tok := range []string{HostRoleToken, DocEngineToken, "port:25", "port:465", "port:1", "port:1023"} { |
| if err := ValidGrantToken(tok); err != nil { |
| t.Fatalf("ValidGrantToken(%q) = %v, want accepted", tok, err) |
| } |
| @@ -33,6 +33,11 @@ func TestValidGrantTokenRefusals(t *testing.T) { |
| {"port:-1", "1..1023"}, |
| {"hostrole", "unknown grant token"}, |
| {"host-role:1", "unknown grant token"}, |
| + // "engine" alone is NOT the token — it is the word `carlos store scan` |
| + // already owns for the antivirus engine, so the caps token is spelled |
| + // "doc-engine" to stay unambiguous in a caps column. |
| + {"engine", "unknown grant token"}, |
| + {"docengine", "unknown grant token"}, |
| {"", "empty"}, |
| {"port:", "decimal port number"}, |
| {"port:x", "decimal port number"}, |
| @@ -168,6 +173,82 @@ func TestGrantRevokeIdempotent(t *testing.T) { |
| } |
| } |
| +// TestParseGrantsRoundTripsDocEngine pins the new token through the column: |
| +// parse, Has, String, and the canonical sort order (doc-engine first — "d" |
| +// sorts before "h" and "p", so a re-grant of the same set writes the same |
| +// bytes regardless of the order the operator granted them in). |
| +func TestParseGrantsRoundTripsDocEngine(t *testing.T) { |
| + g, err := ParseGrants("doc-engine") |
| + if err != nil { |
| + t.Fatalf("ParseGrants: %v", err) |
| + } |
| + if !g.DocEngine || g.HostRole || len(g.Ports) != 0 { |
| + t.Fatalf("ParseGrants(doc-engine) = %+v, want only DocEngine set", g) |
| + } |
| + if !g.Has(DocEngineToken) || g.Has(HostRoleToken) || g.Has("port:25") { |
| + t.Fatalf("Has is wrong for %+v", g) |
| + } |
| + // Canonical order: doc-engine sorts before host-role and any port. |
| + g, err = ParseGrants("port:25,host-role,doc-engine") |
| + if err != nil { |
| + t.Fatalf("ParseGrants: %v", err) |
| + } |
| + if got := g.String(); got != "doc-engine,host-role,port:25" { |
| + t.Fatalf("String() = %q, want doc-engine,host-role,port:25", got) |
| + } |
| + // A duplicate doc-engine is refused like any other hand-edited duplicate. |
| + if _, err := ParseGrants("doc-engine,doc-engine"); err == nil { |
| + t.Fatal("ParseGrants accepted a duplicate doc-engine") |
| + } |
| +} |
| + |
| +// TestGrantRevokeDocEngine: the new token grants, is idempotent, and revokes, |
| +// through the same String() canonicalisation the other tokens use. |
| +func TestGrantRevokeDocEngine(t *testing.T) { |
| + col, err := Grant("", DocEngineToken) |
| + if err != nil || col != "doc-engine" { |
| + t.Fatalf(`Grant("", doc-engine) = (%q, %v)`, col, err) |
| + } |
| + again, err := Grant(col, DocEngineToken) |
| + if err != nil || again != col { |
| + t.Fatalf("re-granting doc-engine changed the column: %q -> %q (%v)", col, again, err) |
| + } |
| + col, err = Grant(col, "port:25") |
| + if err != nil || col != "doc-engine,port:25" { |
| + t.Fatalf("Grant(port:25) over doc-engine = (%q, %v), want canonical order", col, err) |
| + } |
| + col, err = Revoke(col, DocEngineToken) |
| + if err != nil || col != "port:25" { |
| + t.Fatalf("Revoke(doc-engine) = (%q, %v), want just port:25 left", col, err) |
| + } |
| + // Revoking an absent doc-engine is a quiet success. |
| + if col, err = Revoke(col, DocEngineToken); err != nil || col != "port:25" { |
| + t.Fatalf("revoking an absent doc-engine = (%q, %v), want a quiet no-op", col, err) |
| + } |
| +} |
| + |
| +// TestValidCapabilitiesRefusesHostRoleWithDocEngine pins the contradiction |
| +// refusal: the two capabilities cannot both be declared, because host_role's |
| +// IPAddressAllow re-opens the IMDS address that doc_engine's fence exists to |
| +// close, and in systemd's BPF firewall an allow wins over a deny. The refusal |
| +// fires on the declaration alone, before any grant is consulted. |
| +func TestValidCapabilitiesRefusesHostRoleWithDocEngine(t *testing.T) { |
| + err := ValidCapabilities("gotenberg", &Set{V: 1, HostRole: true, DocEngine: true}) |
| + if err == nil { |
| + t.Fatal("ValidCapabilities accepted host_role + doc_engine together") |
| + } |
| + if !strings.Contains(err.Error(), "contradict") { |
| + t.Fatalf("refusal %q does not say the two contradict", err) |
| + } |
| + // Each alone is fine. |
| + if err := ValidCapabilities("gotenberg", &Set{V: 1, DocEngine: true}); err != nil { |
| + t.Fatalf("doc_engine alone refused: %v", err) |
| + } |
| + if err := ValidCapabilities("keymail", &Set{V: 1, HostRole: true}); err != nil { |
| + t.Fatalf("host_role alone refused: %v", err) |
| + } |
| +} |
| + |
| // TestPortTokenMatchesTheManifestVocabulary: a declared port and its grant |
| // token must agree, or a grant an operator types could never satisfy the |
| // declaration it was typed for. ValidCapabilities and ValidGrantToken share |
diff --git a/internal/caps/render.go b/internal/caps/render.go| index 65b9bd043..1e2c58b2a 100644 |
| --- a/internal/caps/render.go |
| +++ b/internal/caps/render.go |
| @@ -22,6 +22,88 @@ import ( |
| // brain (spec §4a). |
| const execStartPath = "/opt/carlos/live/%i" |
| +// docEngineMemoryMax and docEngineMemoryHigh are the resource band a |
| +// document-conversion orchestrator gets, in place of the template's 512M and |
| +// the children tier's 1G. Chromium and LibreOffice are not small: one |
| +// Chromium instance is dozens of processes and hundreds of megabytes before |
| +// it has rendered anything, and a LibreOffice conversion of a large document |
| +// is comparable. The default tier OOM-kills an engine mid-render, which is |
| +// the failure mode that would make this capability useless. |
| +// |
| +// High is 90% of Max, the same throttle-and-reclaim-before-the-kill band the |
| +// children tier uses (1G/920M) and internal/cgroup's highOf applies. (The |
| +// exec-backed path has no engine tier — internal/host's memtier knows only the |
| +// default and children bands — and doc_engine is unit-backed only: an exec |
| +// child that declared it would get these directives from no one. The grant |
| +// door's unit-backed rail is what makes that unreachable.) |
| +const ( |
| + docEngineMemoryMax = "4G" |
| + docEngineMemoryHigh = "3680M" |
| + docEngineTasksMax = "2048" |
| +) |
| + |
| +// docEngineEgressDeny is the private-network fence that pays for the |
| +// resource headroom above, and the reason this capability is a trade rather |
| +// than a gift. |
| +// |
| +// A conversion engine's entire job is fetching URLs a CALLER chose — every |
| +// <img>, <iframe>, CSS @import and JS fetch() in an HTML document, every |
| +// linked image in an Office file. That makes it an SSRF engine by |
| +// construction: hand it a document and it will happily dial whatever the |
| +// document names. The base template fences exactly two addresses |
| +// (carlos-app@.service's IPAddressDeny of the IMDS pair), so without this |
| +// list an engine reaches every other tenant's private-subnet upstream, the |
| +// whole VPC, and cloud internal services in the CGNAT range. |
| +// |
| +// What this fence does NOT do, stated plainly because a comment that hid it |
| +// would hide the real residual risk: LOOPBACK STAYS OPEN (see below), so |
| +// anything listening on 127.0.0.1 on the engine's own box — a co-tenant |
| +// console, a co-tenant's Addr-route upstream bound to loopback — remains |
| +// reachable by an SSRF'd engine. That is why the deployment rule for this |
| +// capability is a box with no NON-ENGINE co-tenants: several doc-engine |
| +// routes sharing one sidecar box is the expected shape (they run the same |
| +// hardened profile and hold nothing sensitive on loopback), but a mixed box |
| +// is not. The fence is one layer, and co-tenancy policy is the other. |
| +// Neither alone is the answer. |
| +// |
| +// Deliberately NOT fenced: 127.0.0.0/8 and ::1. Loopback is how Gotenberg |
| +// talks to its own Chromium and LibreOffice, and — the load-bearing half — |
| +// how it reaches a loopback DNS stub resolver. On AWS the VPC resolver lives |
| +// at VPC-base+2 (10.0.0.2 in a 10.0.0.0/16), INSIDE the 10.0.0.0/8 this list |
| +// denies, so an engine that resolved through it could not resolve anything |
| +// and the product would be dead on arrival. The sidecar box must therefore |
| +// run a loopback stub resolver (systemd-resolved on 127.0.0.53) and hand it |
| +// to the tenant. That is a BOX property this drop-in cannot assume or check — |
| +// it is a documented precondition of granting doc-engine (README's |
| +// capabilities section), and an adopt-time advisory that could detect its |
| +// absence is a known follow-up, not built here. |
| +// |
| +// The template's two IMDS addresses are repeated here even though |
| +// 169.254.0.0/16 and fc00::/7 already cover them. That redundancy is the |
| +// point: the security property is "this credential cannot reach the metadata |
| +// service", and AGENTS.md's object-storage lesson is that a property enforced |
| +// only by the absence of one thing cannot see the second door. Naming IMDS |
| +// explicitly keeps it greppable in the rendered bytes and still denied if a |
| +// future change narrows a range. |
| +var docEngineEgressDeny = []string{ |
| + // IPv4: this-host, RFC1918 (all three), IETF protocol assignments, |
| + // link-local (IMDS lives here), CGNAT (where cloud internal services |
| + // live), benchmarking (198.18.0.0/15 is also a common NAT/proxy |
| + // testing range an SSRF probe would try), multicast, and reserved. |
| + // Sorted, so the rendered line is deterministic. |
| + "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "169.254.0.0/16", |
| + "169.254.169.254/32", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", |
| + "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4", |
| + // IPv6: NAT64's well-known prefix (it translates INTO private IPv4, |
| + // so an unfenced NAT64 route to 10.x would walk around the IPv4 half |
| + // of this list), ULA (fd00:ec2::254 — the IPv6 IMDS — lives here), |
| + // link-local, site-local (deprecated but routable on some VPCs), and |
| + // multicast. Sorted like the IPv4 half, so the rendered line is |
| + // deterministic. |
| + "64:ff9b::/96", "fc00::/7", "fd00:ec2::254/128", "fe80::/10", |
| + "fec0::/10", "ff00::/8", |
| +} |
| + |
| // RenderService returns the [Service] drop-in body for the capabilities |
| // that are BOTH declared by s and (for the operator-consented ones) |
| // granted by g, or (nil, nil) when the effective set writes no [Service] |
| @@ -46,12 +128,23 @@ const execStartPath = "/opt/carlos/live/%i" |
| // renderer is a third caller of the same one, not a second copy of the |
| // rules. |
| // |
| -// Directive order is FIXED: command, then runtime_dir, then children, then |
| -// host_role. `daemon` and `data` are self-service capabilities that emit no |
| -// [Service] directive at all here — `daemon` is enable/start/restart |
| -// behaviour (Task 6 owns it), and `data`'s path is already the template's |
| -// own StateDirectory=carlos-app/%i. Ports are the socket unit's alone; see |
| +// Directive order is FIXED: command, then runtime_dir, then the resource |
| +// band, then host_role. The resource band is ONE of two shapes, never both: |
| +// a granted doc_engine's band (JIT enabled, raised memory/tasks, the egress |
| +// fence) or the children band (KillMode plus the 1G tier). A conversion |
| +// orchestrator declares both capabilities, and the engine band REPLACES the |
| +// children band's scalars rather than stacking on top of them, so no scalar |
| +// renders twice and the generated unit text never leans on systemd's |
| +// last-assignment-wins rule. KillMode renders whenever children is declared, |
| +// band or no band. `daemon` and `data` are self-service capabilities that |
| +// emit no [Service] directive at all here — `daemon` is enable/start/restart |
| +// behaviour (Task 6 owns it), and `data`'s path is already the template's own |
| +// StateDirectory=carlos-app/%i. Ports are the socket unit's alone; see |
| // RenderSocket. |
| +// |
| +// host_role and doc_engine cannot both be declared: ValidCapabilities refuses |
| +// the pair, so the host_role block below never coexists with an engine block |
| +// in one drop-in. |
| func RenderService(app string, s *Set, g Grants) ([]byte, error) { |
| if s == nil { |
| return nil, nil |
| @@ -85,10 +178,56 @@ func RenderService(app string, s *Set, g Grants) ([]byte, error) { |
| } |
| if s.Children { |
| + // KillMode renders for children regardless of the resource band |
| + // below: it is the "stop kills the whole tree" promise (systemd's |
| + // default, stated because an orchestrator's orphans are exactly what |
| + // a hibernation stop must not leave behind), and it has nothing to |
| + // do with which tier the unit gets. |
| + lines = append(lines, "KillMode=control-group") |
| + } |
| + |
| + if s.DocEngine && g.DocEngine { |
| + // The engine band REPLACES the children band rather than overriding |
| + // it: every scalar renders exactly once, so the generated unit text |
| + // never depends on systemd's last-assignment-wins rule and a reader |
| + // sees the effective value directly. |
| + // |
| + // V8's JIT needs memory that is both writable and executable, so the |
| + // template's MemoryDenyWriteExecute=yes has to come off for Chromium |
| + // to run at all. (--jitless is the alternative and it keeps the |
| + // directive, but it is slow enough and incompatible enough with real |
| + // pages that it is not what a conversion service can offer.) |
| + // |
| + // RestrictNamespaces=yes is deliberately LEFT ON. It is what kills |
| + // Chromium's own namespace sandbox, so the engine must run |
| + // --no-sandbox — and once Chromium's sandbox is off, relaxing the |
| + // namespace restriction buys nothing but a wider attack surface for a |
| + // renderer exploit. Keep the restriction, accept the engine's own |
| + // sandbox is off, and let the egress fence below be the compensating |
| + // control. This is the honest ceiling for running untrusted-document |
| + // parsers, not a sandbox that holds. |
| + lines = append(lines, |
| + "MemoryDenyWriteExecute=no", |
| + "MemoryMax="+docEngineMemoryMax, |
| + "MemoryHigh="+docEngineMemoryHigh, |
| + "TasksMax="+docEngineTasksMax, |
| + ) |
| + |
| + // The fence that pays for all of the above. Empty assignment first |
| + // resets the list — same documented systemd pattern the ExecStart |
| + // reset uses — so these bytes are self-contained and do not depend on |
| + // what the template or another drop-in already denied. |
| + // |
| + // Safe to reset despite the operator escape hatch: drop-ins are read |
| + // in lexicographic order, and this file is 10-carlos-capabilities.conf, |
| + // so an operator's 90-operator.conf is applied AFTER this reset and |
| + // its own IPAddressDeny entries survive. |
| + lines = append(lines, "IPAddressDeny=") |
| + lines = append(lines, "IPAddressDeny="+strings.Join(docEngineEgressDeny, " ")) |
| + } else if s.Children { |
| // A platform-chosen tier, not a manifest-chosen number: `infinity` |
| // is unsayable and no number comes from a manifest. |
| lines = append(lines, |
| - "KillMode=control-group", |
| "TasksMax=1024", |
| "MemoryMax=1G", |
| // Throttle-and-reclaim before the kill, like the exec path's |
diff --git a/internal/caps/render_test.go b/internal/caps/render_test.go| index e8f67a3e5..2e583c8ec 100644 |
| --- a/internal/caps/render_test.go |
| +++ b/internal/caps/render_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "encoding/json" |
| "os" |
| + "strings" |
| "testing" |
| ) |
| @@ -48,6 +49,29 @@ func consoleGrants() Grants { |
| return Grants{HostRole: true} |
| } |
| +// gotenbergSet is the worked example for the doc_engine capability: a |
| +// document-conversion orchestrator that spawns children (Chromium, |
| +// LibreOffice) and is granted the engine trade. |
| +func gotenbergSet(t *testing.T) *Set { |
| + t.Helper() |
| + var s Set |
| + raw := `{ |
| + "v": 1, |
| + "command": "serve", |
| + "daemon": true, |
| + "children": true, |
| + "doc_engine": true |
| + }` |
| + if err := json.Unmarshal([]byte(raw), &s); err != nil { |
| + t.Fatalf("decode gotenberg set: %v", err) |
| + } |
| + return &s |
| +} |
| + |
| +func gotenbergGrants() Grants { |
| + return Grants{DocEngine: true} |
| +} |
| + |
| func readGolden(t *testing.T, name string) []byte { |
| t.Helper() |
| b, err := os.ReadFile("testdata/" + name) |
| @@ -90,6 +114,191 @@ func TestRenderServiceMatchesConsoleGolden(t *testing.T) { |
| } |
| } |
| +func TestRenderServiceMatchesGotenbergGolden(t *testing.T) { |
| + got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + want := readGolden(t, "gotenberg.service.conf") |
| + if !bytes.Equal(got, want) { |
| + t.Fatalf("RenderService mismatch:\n got: %q\n want: %q", got, want) |
| + } |
| +} |
| + |
| +// TestDocEngineTradeIsAtomic is the load-bearing property of this capability: |
| +// the resource relaxation and the egress fence land together or not at all. A |
| +// granted engine that got JIT and memory WITHOUT the private-network fence |
| +// would be an open SSRF proxy into the VPC with a bigger blast radius than the |
| +// stock tenant — the exact opposite of the trade the operator consented to. So |
| +// every relaxation directive must coexist with the fence in the same bytes. |
| +func TestDocEngineTradeIsAtomic(t *testing.T) { |
| + got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + relaxations := []string{"MemoryDenyWriteExecute=no", "MemoryMax=" + docEngineMemoryMax, "TasksMax=" + docEngineTasksMax} |
| + fence := "IPAddressDeny=0.0.0.0/8" |
| + hasFence := bytes.Contains(got, []byte(fence)) |
| + if !hasFence { |
| + t.Fatalf("engine drop-in has no egress fence:\n%s", got) |
| + } |
| + for _, r := range relaxations { |
| + if !bytes.Contains(got, []byte(r)) { |
| + t.Errorf("engine drop-in is missing relaxation %q (the fence is present, so the trade is half-written):\n%s", r, got) |
| + } |
| + } |
| +} |
| + |
| +// TestDocEngineFenceCoversThePrivateRangesAndNeverLoopback pins the two halves |
| +// of the fence that are each independently load-bearing and each easy to get |
| +// wrong: |
| +// |
| +// - every private/reserved range an SSRF'd engine would be pointed at is |
| +// denied, IMDS named explicitly (not only covered by its supernet); |
| +// - loopback is NEVER denied, because Gotenberg drives its own Chromium and |
| +// LibreOffice over 127.0.0.1 and reaches its DNS stub resolver there too. |
| +// Fencing loopback bricks the service and, on AWS, breaks name resolution |
| +// outright (the VPC resolver is at VPC-base+2, inside the denied RFC1918 |
| +// ranges, so the box must hand the tenant a loopback stub). |
| +func TestDocEngineFenceCoversThePrivateRangesAndNeverLoopback(t *testing.T) { |
| + got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + deny := "" |
| + for _, line := range strings.Split(string(got), "\n") { |
| + if strings.HasPrefix(line, "IPAddressDeny=") && line != "IPAddressDeny=" { |
| + deny = line |
| + } |
| + } |
| + for _, mustDeny := range []string{ |
| + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // RFC1918 |
| + "169.254.169.254/32", "fd00:ec2::254/128", // IMDS, named explicitly |
| + "169.254.0.0/16", "fe80::/10", // link-local |
| + "100.64.0.0/10", // CGNAT (cloud internal services) |
| + "fc00::/7", // IPv6 ULA |
| + "192.0.0.0/24", "240.0.0.0/4", // IETF assignments, reserved |
| + "198.18.0.0/15", // benchmarking / common NAT-proxy range |
| + "64:ff9b::/96", // NAT64 well-known prefix (translates into private IPv4) |
| + "fec0::/10", // IPv6 site-local (deprecated but routable on some VPCs) |
| + } { |
| + if !strings.Contains(deny, mustDeny) { |
| + t.Errorf("egress fence does not deny %q:\n%s", mustDeny, deny) |
| + } |
| + } |
| + for _, mustNotDeny := range []string{"127.0.0.0/8", "127.0.0.1", "::1/128", "localhost"} { |
| + if strings.Contains(deny, mustNotDeny) { |
| + t.Errorf("egress fence denies loopback %q — Gotenberg talks to its own engines and its DNS stub resolver over loopback; fencing it bricks the service:\n%s", mustNotDeny, deny) |
| + } |
| + } |
| +} |
| + |
| +// TestDocEngineBandReplacesTheChildrenBand pins the scalar-render-once |
| +// property: a conversion orchestrator declares children AND doc_engine, and the |
| +// engine band replaces the children band's memory/tasks rather than stacking a |
| +// second assignment on top. The generated unit text must never contain two |
| +// MemoryMax or two TasksMax lines, so it does not lean on systemd's |
| +// last-assignment-wins rule — a reader sees the effective value directly. |
| +// KillMode, which is children's "stop kills the tree" promise and not a |
| +// resource scalar, still renders. |
| +func TestDocEngineBandReplacesTheChildrenBand(t *testing.T) { |
| + got, err := RenderService("gotenberg", gotenbergSet(t), gotenbergGrants()) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + for _, scalar := range []string{"MemoryMax=", "TasksMax=", "MemoryHigh="} { |
| + n := 0 |
| + for _, line := range strings.Split(string(got), "\n") { |
| + if strings.HasPrefix(line, scalar) { |
| + n++ |
| + } |
| + } |
| + if n != 1 { |
| + t.Errorf("engine drop-in renders %q %d times, want exactly once (the children band must be replaced, not stacked):\n%s", scalar, n, got) |
| + } |
| + } |
| + if !bytes.Contains(got, []byte("KillMode=control-group")) { |
| + t.Errorf("engine drop-in lost children's KillMode:\n%s", got) |
| + } |
| + if bytes.Contains(got, []byte("MemoryMax=1G")) { |
| + t.Errorf("engine drop-in still carries the children tier's 1G — the engine band did not replace it:\n%s", got) |
| + } |
| +} |
| + |
| +// TestDocEngineUngrantedRendersNoRelaxationAndNoFence is the asymmetric case: |
| +// a manifest declaring doc_engine on a box that has NOT granted it materializes |
| +// neither the relaxation nor the fence. The engine simply will not start under |
| +// the stock template (MemoryDenyWriteExecute stays on, the default tier |
| +// OOM-kills it) — which is the safe direction, and ungrantedAdvice names the |
| +// fix. It must never render the fence alone, either: a fence with no engine is |
| +// a lie in the unit text. |
| +func TestDocEngineUngrantedRendersNoRelaxationAndNoFence(t *testing.T) { |
| + got, err := RenderService("gotenberg", gotenbergSet(t), Grants{}) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + for _, unwanted := range []string{"MemoryDenyWriteExecute=no", "IPAddressDeny=0.0.0.0/8", "MemoryMax=" + docEngineMemoryMax} { |
| + if bytes.Contains(got, []byte(unwanted)) { |
| + t.Errorf("ungranted doc_engine rendered %q:\n%s", unwanted, got) |
| + } |
| + } |
| + // children is still declared and self-service, so its band renders. |
| + if !bytes.Contains(got, []byte("MemoryMax=1G")) { |
| + t.Errorf("ungranted doc_engine lost the children band it still declares:\n%s", got) |
| + } |
| +} |
| + |
| +// TestDocEngineGrantAloneRendersNothing mirrors the host_role grant-lingers |
| +// case: a grant with no matching declaration materializes nothing. The grant is |
| +// not a declaration. |
| +func TestDocEngineGrantAloneRendersNothing(t *testing.T) { |
| + s := &Set{V: 1, Command: "serve"} |
| + got, err := RenderService("gotenberg", s, Grants{DocEngine: true}) |
| + if err != nil { |
| + t.Fatalf("RenderService: %v", err) |
| + } |
| + if bytes.Contains(got, []byte("IPAddressDeny=0.0.0.0/8")) || bytes.Contains(got, []byte("MemoryDenyWriteExecute=no")) { |
| + t.Errorf("a doc_engine grant with no declaration rendered engine directives:\n%s", got) |
| + } |
| +} |
| + |
| +// TestBothGrantsHeldOneDeclaredRendersOnlyTheDeclaredBlock pins the case the |
| +// host_role+doc_engine refusal at declaration time cannot cover by itself: the |
| +// registry's caps column CAN hold both tokens (ValidGrantToken accepts each, |
| +// and nothing forbids the pair in the column — the refusal lives in |
| +// ValidCapabilities, which sees the manifest). So a box whose column says |
| +// "doc-engine,host-role" against a manifest declaring only one must render that |
| +// one block and NOTHING of the other — in particular, a doc_engine declaration |
| +// must never pick up host_role's IPAddressAllow, which in systemd's BPF |
| +// firewall would win over the fence's IPAddressDeny and re-open IMDS. |
| +func TestBothGrantsHeldOneDeclaredRendersOnlyTheDeclaredBlock(t *testing.T) { |
| + both := Grants{DocEngine: true, HostRole: true} |
| + |
| + engineOnly, err := RenderService("gotenberg", &Set{V: 1, Command: "serve", DocEngine: true}, both) |
| + if err != nil { |
| + t.Fatalf("RenderService(doc_engine only): %v", err) |
| + } |
| + if !bytes.Contains(engineOnly, []byte("IPAddressDeny=0.0.0.0/8")) { |
| + t.Errorf("doc_engine declaration under both grants lost its fence:\n%s", engineOnly) |
| + } |
| + for _, forbidden := range []string{"IPAddressAllow", "DynamicUser=no", "User=carlos-gotenberg"} { |
| + if bytes.Contains(engineOnly, []byte(forbidden)) { |
| + t.Errorf("doc_engine declaration under both grants rendered host_role's %q:\n%s", forbidden, engineOnly) |
| + } |
| + } |
| + |
| + hostOnly, err := RenderService("keymail", &Set{V: 1, Command: "relay", HostRole: true}, both) |
| + if err != nil { |
| + t.Fatalf("RenderService(host_role only): %v", err) |
| + } |
| + if !bytes.Contains(hostOnly, []byte("IPAddressAllow=169.254.169.254/32")) { |
| + t.Errorf("host_role declaration under both grants lost its IPAddressAllow:\n%s", hostOnly) |
| + } |
| + if bytes.Contains(hostOnly, []byte("IPAddressDeny=0.0.0.0/8")) || bytes.Contains(hostOnly, []byte("MemoryDenyWriteExecute=no")) { |
| + t.Errorf("host_role declaration under both grants rendered engine directives:\n%s", hostOnly) |
| + } |
| +} |
| + |
| // TestRenderIsDeterministic renders the same effective set from two |
| // independently-decoded Sets whose "ports" arrays are given in different |
| // orders, and asserts byte-identical output with ports in ascending order |
diff --git a/internal/caps/testdata/gotenberg.service.conf b/internal/caps/testdata/gotenberg.service.conf| new file mode 100644 |
| index 000000000..9f3d48bc6 |
| --- /dev/null |
| +++ b/internal/caps/testdata/gotenberg.service.conf |
| @@ -0,0 +1,11 @@ |
| +# GENERATED by carlos adopt — do not hand-edit |
| +[Service] |
| +ExecStart= |
| +ExecStart=/opt/carlos/live/%i serve |
| +KillMode=control-group |
| +MemoryDenyWriteExecute=no |
| +MemoryMax=4G |
| +MemoryHigh=3680M |
| +TasksMax=2048 |
| +IPAddressDeny= |
| +IPAddressDeny=0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 169.254.169.254/32 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 64:ff9b::/96 fc00::/7 fd00:ec2::254/128 fe80::/10 fec0::/10 ff00::/8 |
diff --git a/internal/host/caps_pass.go b/internal/host/caps_pass.go| index a643abe17..aa3a53af4 100644 |
| --- a/internal/host/caps_pass.go |
| +++ b/internal/host/caps_pass.go |
| @@ -231,6 +231,10 @@ func ungrantedAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []string { |
| out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — the unit keeps DynamicUser and cannot use the box's instance role; grant it with: carlos route --host %s --grant %s", |
| rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken)) |
| } |
| + if s.DocEngine && !g.DocEngine { |
| + out = append(out, fmt.Sprintf("adopt: %s declares capability %s but this box has not granted it — the unit keeps MemoryDenyWriteExecute and the default memory tier, so a Chromium or LibreOffice engine will not start (and would be OOM-killed if it did); grant it with: carlos route --host %s --grant %s", |
| + rt.Host, caps.DocEngineToken, rt.Host, caps.DocEngineToken)) |
| + } |
| return out |
| } |
| @@ -273,6 +277,10 @@ func undeclaredGrantAdvice(rt registry.Route, s *caps.Set, g caps.Grants) []stri |
| out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps DynamicUser and reaches nothing of the box's instance role; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it", |
| rt.Host, caps.HostRoleToken, rt.Host, caps.HostRoleToken)) |
| } |
| + if g.DocEngine && !s.DocEngine { |
| + out = append(out, fmt.Sprintf("adopt: %s is granted %s but the live release does not declare it — the unit keeps the default resource tier and its normal egress; revoke it with `carlos route --host %s --revoke %s`, or ship a release that declares it", |
| + rt.Host, caps.DocEngineToken, rt.Host, caps.DocEngineToken)) |
| + } |
| return out |
| } |
diff --git a/internal/host/caps_pass_test.go b/internal/host/caps_pass_test.go| index e8c579791..f530fc66a 100644 |
| --- a/internal/host/caps_pass_test.go |
| +++ b/internal/host/caps_pass_test.go |
| @@ -439,6 +439,11 @@ func TestBuildPlansAdvisesUngrantedDeclaredCapabilities(t *testing.T) { |
| set: &caps.Set{V: 1, HostRole: true}, |
| want: []string{"declares capability host-role", "carlos route --host keymail.dev --grant host-role"}, |
| }, |
| + { |
| + name: "ungranted doc_engine says the engine will not start, and names the fix", |
| + set: &caps.Set{V: 1, DocEngine: true}, |
| + want: []string{"declares capability doc-engine", "MemoryDenyWriteExecute", "OOM-killed", "carlos route --host keymail.dev --grant doc-engine"}, |
| + }, |
| { |
| name: "one line per capability, in port order", |
| set: &caps.Set{V: 1, HostRole: true, Ports: []caps.Port{{Port: 465}, {Port: 25}}}, |
| @@ -502,6 +507,13 @@ func TestBuildPlansAdvisesGrantedButUndeclaredCapabilities(t *testing.T) { |
| want: []string{"is granted host-role", "keeps DynamicUser", |
| "carlos route --host keymail.dev --revoke host-role"}, |
| }, |
| + { |
| + name: "granted doc-engine the live release never declares", |
| + set: &caps.Set{V: 1, Daemon: true}, |
| + grants: "doc-engine", |
| + want: []string{"is granted doc-engine", "keeps the default resource tier", |
| + "carlos route --host keymail.dev --revoke doc-engine"}, |
| + }, |
| { |
| name: "declared and granted says nothing", |
| set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}, HostRole: true}, |