| 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. |
| 10 | package caps |
| 11 | |
| 12 | import ( |
| 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). |
| 23 | const 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.) |
| 39 | const ( |
| 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. |
| 88 | var 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. |
| 148 | func 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. |
| 286 | func 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. |
| 327 | func 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 | |