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: the deterministic drop-in renderer (spec §4a).
2//
3// RenderService and RenderSocket turn a manifest's Set, filtered through an
4// operator's Grants, into the exact bytes of a systemd drop-in — nothing
5// else touches disk here (that is Task 6's converge pass; this file is pure
6// bytes-in-bytes-out). Directive order is FIXED and every list is sorted, so
7// re-rendering an unchanged Set/Grants pair is byte-identical: the converge
8// pass's "write only if it differs" check is what keeps an unchanged pass
9// from triggering a daemon-reload or a restart.
10package caps
11
12import (
13 "fmt"
14 "sort"
15 "strings"
16)
17
18// execStartPath is the literal ExecStart target, byte-identical to
19// internal/host/units/carlos-app@.service's own `ExecStart=%s serve` line.
20// The template is not parameterised by CARLOS_OPT_DIR either — a drop-in
21// that disagreed with the template about the path would be a silent split
22// brain (spec §4a).
23const execStartPath = "/opt/carlos/live/%i"
24
25// docEngineMemoryMax and docEngineMemoryHigh are the resource band a
26// document-conversion orchestrator gets, in place of the template's 512M and
27// the children tier's 1G. Chromium and LibreOffice are not small: one
28// Chromium instance is dozens of processes and hundreds of megabytes before
29// it has rendered anything, and a LibreOffice conversion of a large document
30// is comparable. The default tier OOM-kills an engine mid-render, which is
31// the failure mode that would make this capability useless.
32//
33// High is 90% of Max, the same throttle-and-reclaim-before-the-kill band the
34// children tier uses (1G/920M) and internal/cgroup's highOf applies. (The
35// exec-backed path has no engine tier — internal/host's memtier knows only the
36// default and children bands — and doc_engine is unit-backed only: an exec
37// child that declared it would get these directives from no one. The grant
38// door's unit-backed rail is what makes that unreachable.)
39const (
40 docEngineMemoryMax = "4G"
41 docEngineMemoryHigh = "3680M"
42 docEngineTasksMax = "2048"
43)
44
45// docEngineEgressDeny is the private-network fence that pays for the
46// resource headroom above, and the reason this capability is a trade rather
47// than a gift.
48//
49// A conversion engine's entire job is fetching URLs a CALLER chose — every
50// <img>, <iframe>, CSS @import and JS fetch() in an HTML document, every
51// linked image in an Office file. That makes it an SSRF engine by
52// construction: hand it a document and it will happily dial whatever the
53// document names. The base template fences exactly two addresses
54// (carlos-app@.service's IPAddressDeny of the IMDS pair), so without this
55// list an engine reaches every other tenant's private-subnet upstream, the
56// whole VPC, and cloud internal services in the CGNAT range.
57//
58// What this fence does NOT do, stated plainly because a comment that hid it
59// would hide the real residual risk: LOOPBACK STAYS OPEN (see below), so
60// anything listening on 127.0.0.1 on the engine's own box — a co-tenant
61// console, a co-tenant's Addr-route upstream bound to loopback — remains
62// reachable by an SSRF'd engine. That is why the deployment rule for this
63// capability is a box with no NON-ENGINE co-tenants: several doc-engine
64// routes sharing one sidecar box is the expected shape (they run the same
65// hardened profile and hold nothing sensitive on loopback), but a mixed box
66// is not. The fence is one layer, and co-tenancy policy is the other.
67// Neither alone is the answer.
68//
69// Deliberately NOT fenced: 127.0.0.0/8 and ::1. Loopback is how Gotenberg
70// talks to its own Chromium and LibreOffice, and — the load-bearing half —
71// how it reaches a loopback DNS stub resolver. On AWS the VPC resolver lives
72// at VPC-base+2 (10.0.0.2 in a 10.0.0.0/16), INSIDE the 10.0.0.0/8 this list
73// denies, so an engine that resolved through it could not resolve anything
74// and the product would be dead on arrival. The sidecar box must therefore
75// run a loopback stub resolver (systemd-resolved on 127.0.0.53) and hand it
76// to the tenant. That is a BOX property this drop-in cannot assume or check —
77// it is a documented precondition of granting doc-engine (README's
78// capabilities section), and an adopt-time advisory that could detect its
79// absence is a known follow-up, not built here.
80//
81// The template's two IMDS addresses are repeated here even though
82// 169.254.0.0/16 and fc00::/7 already cover them. That redundancy is the
83// point: the security property is "this credential cannot reach the metadata
84// service", and AGENTS.md's object-storage lesson is that a property enforced
85// only by the absence of one thing cannot see the second door. Naming IMDS
86// explicitly keeps it greppable in the rendered bytes and still denied if a
87// future change narrows a range.
88var docEngineEgressDeny = []string{
89 // IPv4: this-host, RFC1918 (all three), IETF protocol assignments,
90 // link-local (IMDS lives here), CGNAT (where cloud internal services
91 // live), benchmarking (198.18.0.0/15 is also a common NAT/proxy
92 // testing range an SSRF probe would try), multicast, and reserved.
93 // Sorted, so the rendered line is deterministic.
94 "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "169.254.0.0/16",
95 "169.254.169.254/32", "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16",
96 "198.18.0.0/15", "224.0.0.0/4", "240.0.0.0/4",
97 // IPv6: NAT64's well-known prefix (it translates INTO private IPv4,
98 // so an unfenced NAT64 route to 10.x would walk around the IPv4 half
99 // of this list), ULA (fd00:ec2::254 — the IPv6 IMDS — lives here),
100 // link-local, site-local (deprecated but routable on some VPCs), and
101 // multicast. Sorted like the IPv4 half, so the rendered line is
102 // deterministic.
103 "64:ff9b::/96", "fc00::/7", "fd00:ec2::254/128", "fe80::/10",
104 "fec0::/10", "ff00::/8",
105}
106
107// RenderService returns the [Service] drop-in body for the capabilities
108// that are BOTH declared by s and (for the operator-consented ones)
109// granted by g, or (nil, nil) when the effective set writes no [Service]
110// directive at all — that is the "remove the generated file" signal Task 6
111// acts on, not "write an empty stanza": one generated file per unit.
112//
113// A nil s ALSO returns (nil, nil), but for an entirely different reason:
114// there is nothing to render because no manifest resolved. That is NOT a
115// removal signal — the converge pass (Task 6) must branch on p.Set == nil
116// BEFORE calling this function and leave every drop-in exactly as it is,
117// never treating this function's (nil, nil) as license to delete anything.
118// The two states look identical from this function's return value on
119// purpose; only the caller, which still has the original *Set, can tell
120// them apart.
121//
122// A non-nil s is run through ValidCapabilities before anything is
123// rendered, and that error is returned unchanged. This closes the
124// direct-injection path: a *Set built by hand — never having passed
125// through UnmarshalJSON's strict decode — could otherwise carry a Command
126// like "relay\nUser=root", or an empty app that would render
127// "User=carlos-". Ship and adopt already share this gate (spec §3b); the
128// renderer is a third caller of the same one, not a second copy of the
129// rules.
130//
131// Directive order is FIXED: command, then runtime_dir, then the resource
132// band, then host_role. The resource band is ONE of two shapes, never both:
133// a granted doc_engine's band (JIT enabled, raised memory/tasks, the egress
134// fence) or the children band (KillMode plus the 1G tier). A conversion
135// orchestrator declares both capabilities, and the engine band REPLACES the
136// children band's scalars rather than stacking on top of them, so no scalar
137// renders twice and the generated unit text never leans on systemd's
138// last-assignment-wins rule. KillMode renders whenever children is declared,
139// band or no band. `daemon` and `data` are self-service capabilities that
140// emit no [Service] directive at all here — `daemon` is enable/start/restart
141// behaviour (Task 6 owns it), and `data`'s path is already the template's own
142// StateDirectory=carlos-app/%i. Ports are the socket unit's alone; see
143// RenderSocket.
144//
145// host_role and doc_engine cannot both be declared: ValidCapabilities refuses
146// the pair, so the host_role block below never coexists with an engine block
147// in one drop-in.
148func RenderService(app string, s *Set, g Grants) ([]byte, error) {
149 if s == nil {
150 return nil, nil
151 }
152 if err := ValidCapabilities(app, s); err != nil {
153 return nil, err
154 }
155
156 var lines []string
157
158 if s.Command != "" {
159 // The empty ExecStart= reset FIRST — systemd's documented way to
160 // reset a list-valued directive from a drop-in. Without it the
161 // unit has two ExecStart lines and refuses to start.
162 lines = append(lines,
163 "ExecStart=",
164 "ExecStart="+execStartPath+" "+s.Command,
165 )
166 }
167
168 if s.RuntimeDir != "" {
169 // RuntimeDirectory is list-valued exactly like ExecStart, but no
170 // reset line is needed here because the template itself sets no
171 // RuntimeDirectory= directive to override. A future template
172 // revision that adds one would need to revisit this.
173 lines = append(lines,
174 "RuntimeDirectory="+s.RuntimeDir,
175 "RuntimeDirectoryMode=0700",
176 "RuntimeDirectoryPreserve=restart",
177 )
178 }
179
180 if s.Children {
181 // KillMode renders for children regardless of the resource band
182 // below: it is the "stop kills the whole tree" promise (systemd's
183 // default, stated because an orchestrator's orphans are exactly what
184 // a hibernation stop must not leave behind), and it has nothing to
185 // do with which tier the unit gets.
186 lines = append(lines, "KillMode=control-group")
187 }
188
189 if s.DocEngine && g.DocEngine {
190 // The engine band REPLACES the children band rather than overriding
191 // it: every scalar renders exactly once, so the generated unit text
192 // never depends on systemd's last-assignment-wins rule and a reader
193 // sees the effective value directly.
194 //
195 // V8's JIT needs memory that is both writable and executable, so the
196 // template's MemoryDenyWriteExecute=yes has to come off for Chromium
197 // to run at all. (--jitless is the alternative and it keeps the
198 // directive, but it is slow enough and incompatible enough with real
199 // pages that it is not what a conversion service can offer.)
200 //
201 // RestrictNamespaces=yes is deliberately LEFT ON. It is what kills
202 // Chromium's own namespace sandbox, so the engine must run
203 // --no-sandbox — and once Chromium's sandbox is off, relaxing the
204 // namespace restriction buys nothing but a wider attack surface for a
205 // renderer exploit. Keep the restriction, accept the engine's own
206 // sandbox is off, and let the egress fence below be the compensating
207 // control. This is the honest ceiling for running untrusted-document
208 // parsers, not a sandbox that holds.
209 lines = append(lines,
210 "MemoryDenyWriteExecute=no",
211 "MemoryMax="+docEngineMemoryMax,
212 "MemoryHigh="+docEngineMemoryHigh,
213 "TasksMax="+docEngineTasksMax,
214 )
215
216 // The fence that pays for all of the above. Empty assignment first
217 // resets the list — same documented systemd pattern the ExecStart
218 // reset uses — so these bytes are self-contained and do not depend on
219 // what the template or another drop-in already denied.
220 //
221 // Safe to reset despite the operator escape hatch: drop-ins are read
222 // in lexicographic order, and this file is 10-carlos-capabilities.conf,
223 // so an operator's 90-operator.conf is applied AFTER this reset and
224 // its own IPAddressDeny entries survive.
225 lines = append(lines, "IPAddressDeny=")
226 lines = append(lines, "IPAddressDeny="+strings.Join(docEngineEgressDeny, " "))
227 } else if s.Children {
228 // A platform-chosen tier, not a manifest-chosen number: `infinity`
229 // is unsayable and no number comes from a manifest.
230 lines = append(lines,
231 "TasksMax=1024",
232 "MemoryMax=1G",
233 // Throttle-and-reclaim before the kill, like the exec path's
234 // memory.high (internal/cgroup applyLimit): the same declared
235 // capability behaves the same on both backings.
236 "MemoryHigh=920M",
237 )
238 }
239
240 if s.HostRole && g.HostRole {
241 user := AppUser(app)
242 lines = append(lines,
243 "DynamicUser=no",
244 // DynamicUser=yes implies RestrictSUIDSGID=yes; dropping
245 // DynamicUser silently drops that hardening along with it, so
246 // this drop-in restores it explicitly — a host_role app has
247 // no business persisting setuid/setgid files in its own
248 // state or runtime directory (spec §4a, §4b).
249 "RestrictSUIDSGID=yes",
250 "User="+user,
251 "Group="+user,
252 // The template denies IMDS unit-wide (issue #351); a matching
253 // IPAddressAllow re-opens it for exactly the tenant that holds
254 // the grant — in systemd's BPF firewall an allow-list match
255 // wins over the deny list. The nftables skuid gate stays the
256 // operator-owned authority on which uids reach the role
257 // (spec §4b); this only lifts the unit-level fence the
258 // template put in everyone's way.
259 "IPAddressAllow=169.254.169.254/32 fd00:ec2::254/128",
260 )
261 }
262
263 if len(lines) == 0 {
264 return nil, nil
265 }
266 return dropIn("[Service]", lines), nil
267}
268
269// RenderSocket returns the [Socket] drop-in body, or (nil, nil) when no
270// declared port is also granted. keymail with no `port:25` grant serves
271// HTTP and does not bind the MX — it does not refuse to start (spec §2).
272//
273// The same nil-vs-nil-input caveat RenderService documents applies here: a
274// nil s returns (nil, nil) meaning "nothing to render" (leave-alone), and a
275// non-nil s rendering (nil, nil) means "remove the generated file" — the
276// converge pass must branch on p.Set == nil before calling, never after.
277//
278// A non-nil s has its ports validated through validPorts — the same
279// function ValidCapabilities uses — before anything is rendered, closing
280// the same direct-injection path RenderService closes (a hand-built Set
281// naming a non-privileged or reserved port). This intentionally does not
282// call the full ValidCapabilities, which needs an app name for the
283// host_role check that has nothing to do with sockets; validPorts is
284// already the one place that rule lives, and reusing it keeps a single
285// source of truth instead of a second copy of the ports rules.
286func RenderSocket(s *Set, g Grants) ([]byte, error) {
287 if s == nil {
288 return nil, nil
289 }
290 if err := validPorts(s.Ports); err != nil {
291 return nil, err
292 }
293
294 var ports []int
295 for _, p := range s.Ports {
296 if g.Ports[p.Port] {
297 ports = append(ports, p.Port)
298 }
299 }
300 if len(ports) == 0 {
301 return nil, nil
302 }
303 sort.Ints(ports)
304
305 // Belt-and-braces de-dup: validPorts above already refuses a
306 // duplicate declared port, so two adjacent-equal entries can't reach
307 // here from a validated Set — kept anyway so this function does not
308 // quietly assume validation happened somewhere else.
309 out := ports[:1]
310 for _, p := range ports[1:] {
311 if p != out[len(out)-1] {
312 out = append(out, p)
313 }
314 }
315 ports = out
316
317 lines := make([]string, len(ports))
318 for i, p := range ports {
319 lines[i] = fmt.Sprintf("ListenStream=%d", p)
320 }
321 return dropIn("[Socket]", lines), nil
322}
323
324// dropIn assembles the marker line, the section header, and the directive
325// lines, each newline-terminated — so the body ends with exactly one
326// trailing newline and no blank line separates the marker from the header.
327func dropIn(section string, lines []string) []byte {
328 var b strings.Builder
329 b.WriteString(Marker)
330 b.WriteByte('\n')
331 b.WriteString(section)
332 b.WriteByte('\n')
333 for _, l := range lines {
334 b.WriteString(l)
335 b.WriteByte('\n')
336 }
337 return []byte(b.String())
338}
339