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 converge pass (spec §3c, §4b, §4d, §4e).
2//
3// Converge is the only thing in this repo that writes root-owned systemd
4// unit text, so every rail it carries is here rather than spread across its
5// callers: the three-state rule, the marker check, the box-wide claims, the
6// DynamicUser-flip refusal, and the socket restart ordering. Its only
7// contact with the outside world is through Ops, so its tests touch no
8// systemd, no /etc, no /run and no /var/lib.
9package caps
10
11import (
12 "bytes"
13 "errors"
14 "fmt"
15 "os"
16 "os/exec"
17 "os/user"
18 "path/filepath"
19 "sort"
20 "strconv"
21 "strings"
22)
23
24const (
25 // DefaultEtcDir is where systemd reads unit configuration; DefaultStateRoot
26 // is the parent of the template's StateDirectory=carlos-app/%i; DefaultRunDir
27 // is the parent of RuntimeDirectory=<name>. All three are overridable ONLY
28 // so tests can point them at a t.TempDir() — nothing in a manifest or a
29 // registry row ever reaches them.
30 DefaultEtcDir = "/etc/systemd/system"
31 DefaultStateRoot = "/var/lib/carlos-app"
32 DefaultRunDir = "/run"
33
34 // DropInName is the one generated file per unit. `10-` sorts before an
35 // operator's `90-operator.conf`, which this package never writes and never
36 // reads (spec §3c).
37 DropInName = "10-carlos-capabilities.conf"
38)
39
40// Ops is every piece of the outside world this package touches, injected so
41// the converge tests need no systemd, no root, and no /etc — the same seam
42// shape activator.UnitBackend.Systemctl uses.
43type Ops struct {
44 Authorize func() error
45
46 CheckHost func(string) error
47 EtcDir string // "" = DefaultEtcDir
48 StateRoot string // "" = DefaultStateRoot
49 RunDir string // "" = DefaultRunDir
50 Systemctl func(args ...string) error // nil = the real systemctl binary
51 Query func(args ...string) bool // nil = the real systemctl; exit 0 = yes
52 EnsureUser func(name string) error // nil = the real, idempotent useradd
53 Logf func(format string, a ...any) // nil = fmt.Printf
54}
55
56// Plan is one route's converge input.
57type Plan struct {
58 Host string
59 App string
60 Set *Set // NIL means the manifest did not resolve — LEAVE THE DROP-INS ALONE
61 Grants Grants
62 Hibernate bool
63
64 // DeferRestart says the CALLER will start this route's service itself,
65 // after this converge returns, so nothing here may bring it up.
66 //
67 // It exists for exactly one caller: release.adoptHost's version-change
68 // converge, which runs BEFORE the live symlink moves. Without it a daemon
69 // version change cycles the unit twice, and the first cycle runs the OLD
70 // binary under the NEW unit shape — a boot of a combination nobody
71 // shipped, on a mail server, for the width of a symlink swap. With it the
72 // service stays down across the swap and comes up exactly once, on the
73 // new binary, through Restart.
74 //
75 // It only ever affects a DAEMON plan's service verbs (step 8's start,
76 // step 9's restart, and socketSequence's trailing start). A non-daemon
77 // route's service is not started here with or without it, and the socket
78 // unit's own verbs are never deferred — a listener the caller is not
79 // going to re-bind must be re-bound here.
80 //
81 // The grant-drift pass (host.capabilityPass) leaves this FALSE and must:
82 // restarting the live binary to pick up a new unit shape is the entire
83 // point of that pass, and nothing there follows up with a Restart.
84 DeferRestart bool
85}
86
87// Snapshot is what was on disk before Converge wrote, enough to put it back.
88//
89// A half whose file exists WITHOUT the marker is recorded as "not had": this
90// pass never wrote it, so putting it back is not this pass's business — and
91// Restore is marker-checked too, so a "not had" half can never turn into the
92// deletion of somebody's hand-written drop-in.
93//
94// Daemon travels with the snapshot because putting the BYTES back is only
95// half of putting the box back: a restored ListenStream that systemd never
96// re-binds restores nothing, and re-binding it needs the same stop → restart
97// → start ordering Converge uses, which needs to know whether the service is
98// supposed to be running. See Restore.
99type Snapshot struct {
100 Host string
101 Service, Socket []byte
102 ServiceHad, SocketHad bool
103 Daemon bool
104}
105
106// Result is what Converge did.
107type Result struct {
108 Prev Snapshot
109 Changed bool // any byte written or removed; the caller need not daemon-reload, Converge already did
110}
111
112// ServiceUnit and SocketUnit are the two per-instance units the template
113// family instantiates for a route host.
114func ServiceUnit(host string) string { return "carlos-app@" + host + ".service" }
115func SocketUnit(host string) string { return "carlos-app@" + host + ".socket" }
116
117// DropInPaths returns the two generated file paths for a host. etcDir "" is
118// DefaultEtcDir. The host is NOT validated here — every caller that joins
119// these paths validates it first with validHost; this function is the naming
120// rule alone, so tests and advisories can name a path without re-deriving it.
121func DropInPaths(etcDir, host string) (service, socket string) {
122 if etcDir == "" {
123 etcDir = DefaultEtcDir
124 }
125 return filepath.Join(etcDir, ServiceUnit(host)+".d", DropInName),
126 filepath.Join(etcDir, SocketUnit(host)+".d", DropInName)
127}
128
129// Converge materializes p and returns what it replaced.
130//
131// The three states are NOT interchangeable, and this is the same rule
132// appconfig.Sync uses for exactly the same reason: a bucket hiccup must not
133// strip :25 off a running mail server.
134// - p.Set == nil (absent or unreadable staged manifest): leave every drop-in
135// exactly as it is, log one line, return Changed=false, nil error.
136// - p.Set non-nil but INVALID for this binary (a capability name or a "v"
137// it does not know — reachable via a platform-binary rollback onto an
138// already-staged version, since ensureStaged short-circuits on a staged
139// artifact and never re-reads its manifest): same leave-alone posture,
140// plus an error, so a version-change caller refuses the version instead
141// of pointing live at something it cannot materialize.
142// - p.Set non-nil, renders bytes: write them if they differ.
143// - p.Set non-nil, renders nil: REMOVE the generated file (marker-checked).
144//
145// Two different kinds of error come back, and they are not the same claim:
146//
147// - a REFUSAL (every check above, plus the claims and state-dir guards) is
148// Changed=false with nothing written, having already logged one line
149// naming the operator's fix. Callers that merely survey the box (the
150// hourly grant-drift pass) can log-and-continue; callers on the
151// version-change path let it refuse the version.
152// - a mid-pass IO or systemd FAILURE (a write, a daemon-reload, the socket
153// sequence) rolls the bytes back to Prev so that "the bytes on disk say
154// converged" stays a sound invariant — if systemd never loaded them, they
155// must not be on disk claiming it did, or the next pass would find no
156// diff and never retry. Changed is then false, because the disk is back
157// where it started. It is true only when even the roll-back failed, which
158// is the honest answer: bytes moved and this pass could not undo it.
159//
160// Failures AFTER a successful reload — an `enable`, a `start`, a daemon's
161// `restart` — deliberately do NOT roll the bytes back. systemd has the new
162// configuration and it is correct; the failure is the app's. Rolling back
163// there would flap the unit text between two shapes on every pass, and the
164// version-change caller already owns that case (it rolls back the symlink and
165// calls Restore).
166//
167// That hand-off has a signal, and a caller that ignores it leaves the NEW
168// version's unit shape on disk under the OLD binary forever — the next pass
169// finds no diff, snapshots those bytes as Prev, and "restores" them:
170//
171// Changed == true together with a non-nil error means BYTES ARE STILL MOVED
172// and this function is not going to move them back.
173//
174// A version-change caller must answer that with Restore(o, res.Prev). Changed
175// == false with an error is a refusal (or a roll-back this function already
176// performed) and needs nothing: Prev is then either empty — because the
177// refusal came before the drop-ins were even read — or already on disk. Do
178// not call Restore unconditionally on a refusal; an empty Prev says "this
179// route had no generated drop-ins", and Restore honours that by REMOVING the
180// ones it finds.
181//
182// Concurrency, stated rather than solved: two converges in one process (the
183// hourly RunAdopt and the version-change converge inside Pass) are
184// sequential. `carlos adopt` run by hand while carlos-adopt.timer fires is
185// two processes, and there the atomic rename makes the FILE safe — a reader
186// sees old or new bytes, never a torn file — but NOT the systemctl sequence:
187// two passes could interleave a stop and a start. The repo already accepts
188// exactly this for appconfig.Sync, and the failure mode is a unit that ends
189// the minute running the right unit text, so it is accepted here too.
190func Converge(o Ops, p Plan) (Result, error) {
191 var res Result
192 if o.CheckHost != nil {
193 if err := o.CheckHost(p.Host); err != nil {
194 return res, err
195 }
196 }
197 res.Prev.Host = p.Host
198 if err := o.authorizeMutation(); err != nil {
199 return res, err
200 }
201
202 // The host becomes a filename under /etc/systemd/system, written as
203 // root. Same guard, same reason, same trust level as appconfig.syncHost's
204 // at :293 — a registry row is trusted, but not to place a root-owned file
205 // wherever it likes.
206 if err := validHost(p.Host); err != nil {
207 o.logf(" %q: %v", p.Host, err)
208 return res, err
209 }
210 svcPath, sockPath := DropInPaths(o.etcDir(), p.Host)
211
212 if p.Set == nil {
213 // State two of three. NOT a removal: a capability is only ever
214 // removed because a manifest that ACTUALLY RESOLVED no longer
215 // declares it, or because a grant was revoked.
216 o.logf(" %s: no capability manifest resolved this pass — leaving its drop-ins exactly as they are", p.Host)
217 return res, nil
218 }
219
220 // The staged manifest is re-validated here even though ship and adopt
221 // already gate it: ensureStaged returns early when the artifact is
222 // already on disk, so a ROLLED-BACK platform binary can meet a staged
223 // version whose capability block it does not understand. This is the
224 // backstop, and it falls the same way as an unreadable manifest.
225 if err := ValidCapabilities(p.App, p.Set); err != nil {
226 o.logf(" %s: %v; leaving its drop-ins exactly as they are", p.Host, err)
227 return res, err
228 }
229
230 // A daemon the platform is asked to keep running and to stop when idle is
231 // a contradiction, and hibernation for these apps means an MX that sleeps.
232 if p.Set.Daemon && p.Hibernate {
233 err := fmt.Errorf("capability daemon and hibernation contradict")
234 o.logf(" %s: %v — nothing written and no unit state changed; fix it with: carlos route --host %s --hibernate=false", p.Host, err, p.Host)
235 return res, err
236 }
237
238 hostRole := p.Set.HostRole && p.Grants.HostRole
239 res.Prev.Daemon = p.Set.Daemon
240
241 // Not a refusal — a granted port on a hibernating route is a legitimate
242 // shape (socket activation is the wake-on-connect half of hibernation) —
243 // but it is a wake path that does NOT go through the activator, so the
244 // edge's health gate does not cover it: a broken boot answers a connection
245 // on :25 by parking it in the backlog rather than by returning a clean
246 // 503. One line per pass, for as long as the combination exists, because
247 // the operator who granted the port did not necessarily choose this.
248 if p.Hibernate {
249 for _, tok := range grantedPortTokens(p) {
250 o.logf(" %s: %s is granted on a hibernating route — a connection to that port starts the unit directly, bypassing the activator's health-gated wake; the edge's clean 503 on a broken boot does not cover this path", p.Host, tok)
251 }
252 }
253
254 // Read both halves BEFORE any refusal that needs to know what this route
255 // already claims, and before anything is written. Nothing here changes
256 // state, so an early read costs a refusal nothing.
257 curSvc, svcExists, svcGenerated, err := readDropIn(svcPath)
258 if err != nil {
259 o.logf(" %s: read %s: %v", p.Host, svcPath, err)
260 return res, err
261 }
262 curSock, sockExists, sockGenerated, err := readDropIn(sockPath)
263 if err != nil {
264 o.logf(" %s: read %s: %v", p.Host, sockPath, err)
265 return res, err
266 }
267 if svcExists && svcGenerated {
268 res.Prev.Service, res.Prev.ServiceHad = curSvc, true
269 }
270 if sockExists && sockGenerated {
271 res.Prev.Socket, res.Prev.SocketHad = curSock, true
272 }
273
274 if err := o.checkStateDirShape(p, hostRole, curSvc, svcExists && svcGenerated); err != nil {
275 return res, err
276 }
277 if err := o.checkClaimsOnDisk(p, curSvc, svcExists && svcGenerated); err != nil {
278 return res, err
279 }
280
281 // The user must exist before a User= line naming it does: the unit would
282 // fail to start and Restart=always would spin it.
283 if hostRole {
284 user := AppUser(p.App)
285 if err := o.ensureUser(user); err != nil {
286 o.logf(" %s: create system user %s: %v — refusing the whole plan rather than write a User= line for a user that does not exist", p.Host, user, err)
287 return res, err
288 }
289 }
290
291 svcBody, err := RenderService(p.App, p.Set, p.Grants)
292 if err != nil {
293 o.logf(" %s: render service drop-in: %v; leaving its drop-ins exactly as they are", p.Host, err)
294 return res, err
295 }
296 sockBody, err := RenderSocket(p.Set, p.Grants)
297 if err != nil {
298 o.logf(" %s: render socket drop-in: %v; leaving its drop-ins exactly as they are", p.Host, err)
299 return res, err
300 }
301
302 svcChanged, svcErr := o.materialize(p.Host, svcPath, svcBody, curSvc, svcExists, svcGenerated)
303 var sockChanged bool
304 var sockErr error
305 if svcErr == nil {
306 sockChanged, sockErr = o.materialize(p.Host, sockPath, sockBody, curSock, sockExists, sockGenerated)
307 }
308 res.Changed = svcChanged || sockChanged
309 if err := errors.Join(svcErr, sockErr); err != nil {
310 // A half-written pass is the divergence this whole rollback exists to
311 // prevent: one unit converged, the other not, and the bytes on disk
312 // claiming both are done. Put them back so the next pass sees a diff
313 // and retries the WHOLE change.
314 res.Changed = o.rollbackBytes(res.Prev, res.Changed)
315 return res, err
316 }
317
318 // systemd reads nothing from disk until it is told to. One reload, and
319 // only when something actually changed — this is what makes an unchanged
320 // pass free. It happens BEFORE any restart or start below, without
321 // exception: a restart against configuration systemd has not re-read is a
322 // pass that looks like it worked and changed nothing.
323 if res.Changed {
324 if err := o.systemctl("daemon-reload"); err != nil {
325 o.logf(" %s: daemon-reload: %v", p.Host, err)
326 res.Changed = o.rollbackBytes(res.Prev, res.Changed)
327 return res, err
328 }
329 }
330
331 sockLive := sockBody != nil
332 st := o.applyUnitState(p, sockLive, svcChanged, sockChanged)
333 if st.err != nil {
334 if st.diverged {
335 // The socket sequence is the one place where systemd's running
336 // state can end up disagreeing with bytes that read "correct".
337 // Roll them back so the next pass retries rather than concluding
338 // there is nothing to do.
339 res.Changed = o.rollbackBytes(res.Prev, res.Changed)
340 }
341 return res, st.err
342 }
343 if res.Changed {
344 what := "capability drop-ins converged"
345 if st.restarted {
346 what += " and the unit restarted"
347 }
348 o.logf(" %s: %s", p.Host, what)
349 }
350 return res, nil
351}
352
353// rollbackBytes puts both halves back to what this pass found, WITHOUT
354// touching unit state — the caller is already in a failure path and the point
355// is only to stop the disk from claiming a change systemd never took. It
356// reports whether any byte is still moved afterwards, which is what Converge
357// returns as Changed: false when the disk is back where it started, true when
358// even the roll-back failed.
359func (o Ops) rollbackBytes(prev Snapshot, changed bool) bool {
360 if !changed {
361 return false
362 }
363 svcChanged, sockChanged, errs := o.applyHalves(prev)
364 if len(errs) > 0 {
365 o.logf(" %s: could not roll the capability drop-ins back after a failed pass (%v) — the next pass will find them mid-change", prev.Host, errors.Join(errs...))
366 return true
367 }
368 if svcChanged || sockChanged {
369 if err := o.systemctl("daemon-reload"); err != nil {
370 o.logf(" %s: daemon-reload after roll-back: %v", prev.Host, err)
371 }
372 o.logf(" %s: rolled the capability drop-ins back to what this pass found, so the next pass retries the whole change", prev.Host)
373 }
374 return false
375}
376
377// applyHalves writes a Snapshot's bytes over whatever is on disk now — the
378// shared body of Restore and rollbackBytes. Marker-checked in both
379// directions, exactly as Converge is: a half that was not ours when the
380// snapshot was taken is not ours to write over or delete now either.
381func (o Ops) applyHalves(want Snapshot) (svcChanged, sockChanged bool, errs []error) {
382 svcPath, sockPath := DropInPaths(o.etcDir(), want.Host)
383 halves := []struct {
384 path string
385 body []byte
386 had bool
387 out *bool
388 }{
389 {svcPath, want.Service, want.ServiceHad, &svcChanged},
390 {sockPath, want.Socket, want.SocketHad, &sockChanged},
391 }
392 for _, half := range halves {
393 body := half.body
394 if !half.had {
395 body = nil
396 }
397 cur, exists, generated, err := readDropIn(half.path)
398 if err != nil {
399 o.logf(" %s: read %s: %v", want.Host, half.path, err)
400 errs = append(errs, err)
401 continue
402 }
403 did, err := o.materialize(want.Host, half.path, body, cur, exists, generated)
404 if err != nil {
405 errs = append(errs, err)
406 }
407 *half.out = did
408 }
409 return svcChanged, sockChanged, errs
410}
411
412// grantedPortTokens is the declared-AND-granted port set, as canonical grant
413// tokens, sorted — the ports that actually materialize.
414func grantedPortTokens(p Plan) []string {
415 var toks []string
416 for _, decl := range p.Set.Ports {
417 if p.Grants.Ports[decl.Port] {
418 toks = append(toks, PortToken(decl.Port))
419 }
420 }
421 sort.Strings(toks)
422 return toks
423}
424
425// unitState is what applyUnitState did and how badly it failed. diverged is
426// the distinction that matters: a failed SOCKET SEQUENCE means systemd's
427// running listener no longer matches the bytes on disk, and only the caller
428// rolling those bytes back makes the next pass retry.
429type unitState struct {
430 restarted bool
431 diverged bool
432 err error
433}
434
435// repaired announces a drift repair — a verb this pass issued because
436// systemd's RUNNING state disagreed with what the route declares, not because
437// any byte changed.
438//
439// It is deliberately not folded into Converge's trailing "converged" line,
440// which is gated on res.Changed: the repairs below fire on passes where
441// nothing was written at all, and reporting them there made the most
442// surprising thing this pass does — starting a unit an operator stopped by
443// hand ten minutes ago to debug it — completely silent. Whoever stopped that
444// unit needs the hourly log to say which verb ran and why.
445//
446// Only a repair that SUCCEEDED is announced; a failed one is already reported
447// by applyUnitState's error line, and "started it" is not a claim to make
448// about a start that did not.
449func (o Ops) announceRepair(host string, err error, format string, a ...any) {
450 if err != nil {
451 return
452 }
453 o.logf(" %s: %s", host, fmt.Sprintf(format, a...))
454}
455
456// applyUnitState is steps 7-9: the socket restart ordering, the enable/start
457// a daemon route's drift repair needs, and the service restart. Every verb
458// here runs AFTER Converge's daemon-reload, never before it.
459//
460// p.DeferRestart suppresses every verb that would BRING A DAEMON UP — step
461// 8's start, step 9's restart, and socketSequence's trailing start — and
462// nothing else. The socket unit's own verbs, the enables, and the service
463// STOP still happen: a listener has to be re-bound here because the caller
464// will not do it, and leaving the service down across the caller's symlink
465// swap is the entire point. See Plan.DeferRestart.
466func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) unitState {
467 svcUnit, sockUnit := ServiceUnit(p.Host), SocketUnit(p.Host)
468 var st unitState
469 note := func(err error) {
470 if err != nil && st.err == nil {
471 st.err = err
472 }
473 }
474
475 restartedService := false
476
477 // Step 7. A running service holds the bound fd, so restarting the socket
478 // unit under it races into EADDRINUSE and the MX comes back unbound.
479 //
480 // This runs when the socket drop-in was REMOVED as well as when it was
481 // rewritten: systemd is still holding the listener the removed file asked
482 // for, and "a grant is not a promise" (spec §7) means revoking port:25
483 // has to actually drop :25 — a rewrite nobody reloads into a running
484 // socket unit is a revoke that did nothing.
485 if sockChanged {
486 err := o.socketSequence(p.Host, p.Set.Daemon, p.DeferRestart, "restart")
487 note(err)
488 if err != nil {
489 st.diverged = true
490 }
491 restartedService = true
492 }
493
494 // Step 8. `systemctl enable` writes the boot-time .wants symlink and
495 // bootstrap does not enable tenant socket units — without this the
496 // ListenStream binds nothing after a reboot. Both verbs are gated on a
497 // query rather than run unconditionally, so a converged box's hourly pass
498 // changes no state at all (which is what makes "an unchanged pass does
499 // nothing" true for a daemon route, not just for a quiet one).
500 if sockLive {
501 if !o.query("is-enabled", sockUnit) {
502 err := o.systemctl("enable", sockUnit)
503 note(err)
504 o.announceRepair(p.Host, err, "%s was not enabled — enabled it, or its ListenStream binds nothing after a reboot", sockUnit)
505 }
506 if !o.query("is-active", sockUnit) && !restartedService {
507 // Bringing an inactive socket up goes through the same ordering:
508 // the service may be holding the port itself today (keymail binds
509 // :25 until its LISTEN_FDS change lands), and a bare socket start
510 // under it is the EADDRINUSE race again.
511 err := o.socketSequence(p.Host, p.Set.Daemon, p.DeferRestart, "start")
512 note(err)
513 o.announceRepair(p.Host, err, "%s was not active — started it through the stop → socket → start ordering, so nothing races the service for the port", sockUnit)
514 restartedService = true
515 }
516 }
517 if p.Set.Daemon {
518 if !o.query("is-enabled", svcUnit) {
519 err := o.systemctl("enable", svcUnit)
520 note(err)
521 o.announceRepair(p.Host, err, "%s was not enabled — enabled it, so it comes back after a reboot", svcUnit)
522 }
523 if !o.query("is-active", svcUnit) && !p.DeferRestart {
524 // enable+start, not try-restart: a daemon that crashed into a
525 // stopped state comes back rather than being left down by a
526 // try-restart no-op (spec §4d).
527 //
528 // Deferred, this is the start that would run the OLD binary: the
529 // service is inactive here precisely because step 7 just stopped
530 // it, and the caller's Restart is one symlink swap away.
531 err := o.systemctl("start", svcUnit)
532 note(err)
533 o.announceRepair(p.Host, err, "the service was not running and this route declares daemon — started it")
534 restartedService = true
535 }
536 }
537
538 // Step 9. A non-daemon route's restart is not this function's business:
539 // the caller's existing try-restart owns it, byte-identically to today.
540 // Nor is a DEFERRED daemon's: restarting it here would cycle the old
541 // binary under the new unit text, and the caller restarts it once, after
542 // the symlink moves.
543 if svcChanged && p.Set.Daemon && !restartedService && !p.DeferRestart {
544 note(o.systemctl("restart", svcUnit))
545 restartedService = true
546 }
547 st.restarted = restartedService
548 if st.err != nil {
549 o.logf(" %s: unit state: %v", p.Host, st.err)
550 }
551 return st
552}
553
554// socketSequence is the pinned ordering: stop the service, restart (or
555// start) the socket, start the service.
556//
557// What the ordering buys, stated precisely so nobody reads more into it than
558// it gives: while the SERVICE is down and the socket unit is up, inbound
559// connections queue in the kernel backlog and are answered when the service
560// comes back — that is the phase spec §4c's "an MX restart never refuses a
561// connection" is about, and it is strictly better than today, where a relay
562// restart drops :25 entirely. During the socket unit's OWN restart the
563// listener does not exist, and connections in that window are refused, not
564// queued. The window is one systemctl verb wide and only opens when the
565// rendered ListenStream set actually changed.
566//
567// The service start is ATTEMPTED even when the socket verb failed. Returning
568// early there would leave the service stopped — trading a socket that did not
569// rebind for an app that is not running at all — so the error is remembered
570// and returned, and the start happens anyway.
571//
572// Accepted, and theoretical until keymail's LISTEN_FDS change lands: between
573// the stop and the socket verb the socket unit may still be up, so a
574// connection can socket-ACTIVATE the service back before this function starts
575// it — after which the start is systemd's no-op. That is a correct end state
576// (the service is running against the reloaded configuration), just not one
577// this function drove; it is not worth an isolate/mask dance to prevent.
578//
579// A route that is neither a daemon nor currently running is neither stopped
580// nor started: a hibernating tenant's drop-ins are unit text, not a wake.
581//
582// deferStart drops the trailing service start for a DAEMON whose caller is
583// about to start it itself (Plan.DeferRestart) — the service is then stopped
584// here and stays stopped until the caller's Restart, which is what keeps the
585// old binary from ever running under the new unit shape. It is deliberately
586// ignored for a non-daemon route: there the caller's follow-up is a
587// try-restart, which is a no-op on a stopped unit, so skipping the start
588// would leave the app down.
589func (o Ops) socketSequence(host string, daemon, deferStart bool, verb string) error {
590 svcUnit, sockUnit := ServiceUnit(host), SocketUnit(host)
591 shouldRun := daemon || o.query("is-active", svcUnit)
592
593 var errs []error
594 if shouldRun {
595 if err := o.systemctl("stop", svcUnit); err != nil {
596 errs = append(errs, err)
597 }
598 }
599 if err := o.systemctl(verb, sockUnit); err != nil {
600 errs = append(errs, err)
601 }
602 if shouldRun && !(daemon && deferStart) {
603 if err := o.systemctl("start", svcUnit); err != nil {
604 errs = append(errs, err)
605 }
606 }
607 return errors.Join(errs...)
608}
609
610// materialize writes body (or removes the file when body is nil) and reports
611// whether any byte moved. A file at a generated path WITHOUT the marker on
612// its first line is never overwritten and never deleted: it is reported once
613// per pass and this half is skipped entirely, while the other half still
614// converges.
615func (o Ops) materialize(host, path string, body, cur []byte, exists, generated bool) (bool, error) {
616 if exists && !generated {
617 o.logf(" %s: %s exists but does not carry the %q marker — hand-edited or written by something else; never overwritten, never removed, skipping this unit's drop-in", host, path, Marker)
618 return false, nil
619 }
620 if body == nil {
621 if !exists {
622 return false, nil
623 }
624 if err := os.Remove(path); err != nil {
625 o.logf(" %s: remove %s: %v", host, path, err)
626 return false, err
627 }
628 o.logf(" %s: no capability materializes here any more — removed %s", host, path)
629 return true, nil
630 }
631 if exists && bytes.Equal(cur, body) {
632 return false, nil // up to date: no write, no reload, no restart
633 }
634 if err := writeFileAtomic(path, body); err != nil {
635 o.logf(" %s: write %s: %v", host, path, err)
636 return false, err
637 }
638 return true, nil
639}
640
641// writeFileAtomic creates the .d directory and renames a temp file into
642// place, so a concurrent reader (systemd, or a second adopt process) sees
643// either the old bytes or the new ones and never a torn file. Root-owned
644// 0644, set explicitly rather than left to the process umask, because these
645// bytes are read by systemd and their mode is part of the contract.
646func writeFileAtomic(path string, body []byte) error {
647 dir := filepath.Dir(path)
648 if err := os.MkdirAll(dir, 0o755); err != nil {
649 return err
650 }
651 tmp := path + ".tmp"
652 if err := os.WriteFile(tmp, body, 0o644); err != nil {
653 return err
654 }
655 if err := os.Chmod(tmp, 0o644); err != nil {
656 os.Remove(tmp)
657 return err
658 }
659 if err := os.Rename(tmp, path); err != nil {
660 os.Remove(tmp)
661 return err
662 }
663 return nil
664}
665
666// readDropIn returns a generated file's bytes, whether it exists at all, and
667// whether it carries the marker.
668func readDropIn(path string) (b []byte, exists, generated bool, err error) {
669 b, err = os.ReadFile(path)
670 if err != nil {
671 if os.IsNotExist(err) {
672 return nil, false, false, nil
673 }
674 return nil, false, false, err
675 }
676 return b, true, bytes.HasPrefix(b, []byte(Marker+"\n")), nil
677}
678
679// checkStateDirShape is spec §4b's pinned sharp edge. With DynamicUser=yes
680// systemd materialises StateDirectory under /var/lib/private/ with a SYMLINK
681// at /var/lib/carlos-app/<host>; with a static User= it expects a real
682// directory. Flipping between the two under an existing state dir is refused
683// in place — the operator does the stop → move → start, because moving a
684// live app's data is not a thing an hourly timer should do unattended.
685//
686// The OFF direction is deliberately narrow: it refuses only when THIS
687// package's own current drop-in is what put the unit on a static user. A
688// pre-existing real directory that no drop-in of ours created (a legacy
689// hand-provisioned console, for example) is not a flip and must not freeze an
690// unrelated route's capabilities forever.
691func (o Ops) checkStateDirShape(p Plan, hostRole bool, curSvc []byte, generated bool) error {
692 dir := filepath.Join(o.stateRoot(), p.Host)
693 fi, err := os.Lstat(dir)
694 if err != nil {
695 return nil // absent is fine in both directions
696 }
697 wasStatic := generated && dropInHas(curSvc, "DynamicUser=no")
698 switch {
699 case hostRole && fi.Mode()&os.ModeSymlink != 0:
700 e := fmt.Errorf("host_role wants a static user but %s is a symlink (the DynamicUser shape, real data under /var/lib/private)", dir)
701 o.logf(" %s: %v — refused in place, nothing written; the operator's move is: systemctl stop %s && mv /var/lib/private/carlos-app/%s %s && rm the symlink && chown -R %s %s && systemctl start %s",
702 p.Host, e, ServiceUnit(p.Host), p.Host, dir, AppUser(p.App), dir, ServiceUnit(p.Host))
703 return e
704 case !hostRole && wasStatic && fi.IsDir():
705 e := fmt.Errorf("dropping host_role returns the unit to DynamicUser, which cannot use the existing real directory %s", dir)
706 o.logf(" %s: %v — refused in place, nothing written; the operator's move is: systemctl stop %s, move the data under /var/lib/private/carlos-app/%s, then start it",
707 p.Host, e, ServiceUnit(p.Host), p.Host)
708 return e
709 }
710 return nil
711}
712
713// checkClaimsOnDisk resolves the two box-wide exclusivities against what is
714// ALREADY WRITTEN on this box, before anything is written here. CheckClaims
715// covers a whole pass's plans at once; this covers the single-route
716// version-change path, which never sees the other routes — and it is the only
717// one of the two that notices a drop-in left behind by a route that has since
718// been removed from the registry.
719//
720// The /run existence check is the second guard on runtime_dir. The static
721// reservedRuntimeDir list is necessarily finite — a box-specific
722// /run/tailscale is not on it and never will be — so a name that already
723// exists under /run and is not this route's own prior claim is refused,
724// whatever the list says.
725func (o Ops) checkClaimsOnDisk(p Plan, curSvc []byte, generated bool) error {
726 ports, dirs := scanClaims(o.etcDir(), p.Host)
727
728 for _, decl := range p.Set.Ports {
729 if !p.Grants.Ports[decl.Port] {
730 continue // an ungranted port materializes nothing and claims nothing
731 }
732 if other, taken := ports[decl.Port]; taken {
733 e := fmt.Errorf("port %d is already claimed by %s's drop-in on this box", decl.Port, other)
734 o.logf(" %s: %v — refusing before anything is written; :%d can be held by exactly one process on one machine", p.Host, e, decl.Port)
735 return e
736 }
737 }
738
739 if p.Set.RuntimeDir == "" {
740 return nil
741 }
742 if other, taken := dirs[p.Set.RuntimeDir]; taken {
743 e := fmt.Errorf("runtime_dir %q is already claimed by %s's drop-in on this box", p.Set.RuntimeDir, other)
744 o.logf(" %s: %v — refusing before anything is written; a shared /run directory is another app's sockets", p.Host, e)
745 return e
746 }
747 ownClaim := generated && dropInHas(curSvc, "RuntimeDirectory="+p.Set.RuntimeDir)
748 if ownClaim {
749 return nil
750 }
751 runPath := filepath.Join(o.runDir(), p.Set.RuntimeDir)
752 if _, err := os.Lstat(runPath); err == nil {
753 e := fmt.Errorf("runtime_dir %q: %s already exists and is not this route's own claim", p.Set.RuntimeDir, runPath)
754 o.logf(" %s: %v — refusing before anything is written; systemd would hand this app a directory something else on the box already owns", p.Host, e)
755 return e
756 }
757 return nil
758}
759
760// scanClaims reads every OTHER host's drop-in at the generated path on this
761// box and returns which privileged ports and which runtime directories they
762// claim, mapped to the host that claims them.
763//
764// A file at that path WITHOUT the marker counts too, and deliberately: it is
765// not ours to rewrite, so systemd will apply it, so the claim it makes is
766// real. Refusing is the fail-closed direction — the alternative is two units
767// racing for :25, which is the exact failure this check exists to prevent.
768// (Only the 10- path is read; an operator's 90-operator.conf is theirs, and
769// this package neither reads nor reasons about it.)
770func scanClaims(etcDir, exceptHost string) (map[int]string, map[string]string) {
771 ports := map[int]string{}
772 dirs := map[string]string{}
773 ents, err := os.ReadDir(etcDir)
774 if err != nil {
775 return ports, dirs
776 }
777 for _, e := range ents {
778 if !e.IsDir() {
779 continue
780 }
781 name := e.Name()
782 host, kind, ok := parseUnitDir(name)
783 if !ok || host == exceptHost {
784 continue
785 }
786 b, exists, _, err := readDropIn(filepath.Join(etcDir, name, DropInName))
787 if err != nil || !exists {
788 continue
789 }
790 for _, line := range strings.Split(string(b), "\n") {
791 switch kind {
792 case "socket":
793 if v, ok := strings.CutPrefix(line, "ListenStream="); ok {
794 if n, ok := listenStreamPort(v); ok {
795 ports[n] = host
796 }
797 }
798 case "service":
799 if v, ok := strings.CutPrefix(line, "RuntimeDirectory="); ok && v != "" {
800 dirs[v] = host
801 }
802 }
803 }
804 }
805 return ports, dirs
806}
807
808// listenStreamPort reads the port out of a ListenStream= value. This package
809// only ever WRITES the bare-number form, but the claim scan also reads files
810// this package did not write, and systemd accepts several spellings of the
811// same bind: "25", "0.0.0.0:25", "[::]:25", "1.2.3.4:25". Taking the segment
812// after the last colon covers all of them, so an address form cannot dodge
813// the box-wide port claim — the check would be worth little if it could.
814//
815// A value containing a path separator is a unix socket (the template's own
816// ListenStream=/run/carlos/apps/%i.sock) and claims no TCP port at all.
817func listenStreamPort(v string) (int, bool) {
818 v = strings.TrimSpace(v)
819 if v == "" || strings.Contains(v, "/") {
820 return 0, false
821 }
822 if i := strings.LastIndex(v, ":"); i >= 0 {
823 v = v[i+1:]
824 }
825 n, err := strconv.Atoi(v)
826 if err != nil || n < 1 || n > 65535 {
827 return 0, false
828 }
829 return n, true
830}
831
832// parseUnitDir splits "carlos-app@<host>.service.d" into its host and kind.
833func parseUnitDir(name string) (host, kind string, ok bool) {
834 rest, ok := strings.CutPrefix(name, "carlos-app@")
835 if !ok {
836 return "", "", false
837 }
838 for _, k := range []string{"service", "socket"} {
839 if h, ok := strings.CutSuffix(rest, "."+k+".d"); ok && h != "" {
840 return h, k, true
841 }
842 }
843 return "", "", false
844}
845
846// dropInHas reports whether body carries the exact directive line.
847func dropInHas(body []byte, line string) bool {
848 for _, l := range strings.Split(string(body), "\n") {
849 if l == line {
850 return true
851 }
852 }
853 return false
854}
855
856// CheckClaims resolves box-wide exclusivity BEFORE anything is written,
857// across every plan on this box at once: a privileged port claimed by two
858// routes, and a runtime_dir claimed by two routes. Returns host -> refusal; a
859// host absent from the map cleared.
860//
861// Deterministic on ties: the route whose Host sorts first keeps the claim and
862// the other is refused, so a conflict does not flap between passes — two
863// routes taking turns owning :25 would restart a mail server every hour
864// forever.
865//
866// A plan whose Set is nil claims nothing: it is the leave-alone state, and
867// leaving a drop-in alone is not a new claim. An ungranted port likewise
868// claims nothing, because nothing materializes for it.
869//
870// Known limit, named rather than papered over: this tie-break is decided
871// against the PLANS, and Converge's own check is decided against what is
872// already on DISK. When the lexicographically-LATER host is the one already
873// materialized, the two disagree — CheckClaims refuses the later host, and
874// Converge then refuses the earlier host too, because the later host's
875// drop-in really is holding the port. The result is a stable both-refused
876// stall, not a flap: neither route materializes and both log every pass until
877// an operator revokes one grant. That is the fail-closed direction (nobody
878// silently takes :25 off a running mail server), and the alternative —
879// letting the winner evict the incumbent's drop-in — is a pass that stops
880// somebody else's listener to satisfy a sort order.
881func CheckClaims(plans []Plan) map[string]error {
882 sorted := make([]Plan, len(plans))
883 copy(sorted, plans)
884 sort.Slice(sorted, func(i, j int) bool { return sorted[i].Host < sorted[j].Host })
885
886 refused := map[string]error{}
887 ports := map[int]string{}
888 dirs := map[string]string{}
889 for _, p := range sorted {
890 if p.Set == nil || refused[p.Host] != nil {
891 continue
892 }
893 for _, decl := range p.Set.Ports {
894 if !p.Grants.Ports[decl.Port] {
895 continue
896 }
897 if other, taken := ports[decl.Port]; taken {
898 refused[p.Host] = fmt.Errorf("port %d is already claimed on this box by %s — :%d can be held by exactly one process on one machine; revoke it from one of the two (carlos route --host %s --revoke %s)",
899 decl.Port, other, decl.Port, p.Host, PortToken(decl.Port))
900 break
901 }
902 ports[decl.Port] = p.Host
903 }
904 if refused[p.Host] != nil {
905 continue
906 }
907 if p.Set.RuntimeDir == "" {
908 continue
909 }
910 if other, taken := dirs[p.Set.RuntimeDir]; taken {
911 refused[p.Host] = fmt.Errorf("runtime_dir %q is already claimed on this box by %s — a shared /run directory is another app's sockets; one of the two must declare a different name",
912 p.Set.RuntimeDir, other)
913 continue
914 }
915 dirs[p.Set.RuntimeDir] = p.Host
916 }
917 return refused
918}
919
920// Restore puts prev back — the drop-in half of adoptHost's restart-failure
921// rollback (the symlink half is release.PointLive's).
922//
923// The SERVICE half's restart is the caller's: it rolled the symlink back and
924// it owns bringing the app up on the old version. The SOCKET half's is NOT.
925// Bytes are not a binding: a restored ListenStream that systemd never re-binds
926// restores nothing — the MX stays off :25 after a rollback — and a rolled-back
927// ADDED port stays publicly bound to a binary that never asked for it. So when
928// the socket half's bytes move, Restore runs the same stop → restart → start
929// sequence Converge does, which is why Snapshot carries Daemon.
930//
931// Marker-checked in both directions, exactly as Converge is: a half that was
932// not ours when the snapshot was taken is not ours to write over or delete
933// now either.
934func Restore(o Ops, prev Snapshot) error {
935 if err := o.authorizeMutation(); err != nil {
936 return err
937
938 }
939 if o.CheckHost != nil {
940 if err := o.CheckHost(prev.Host); err != nil {
941 return err
942 }
943 }
944 if err := validHost(prev.Host); err != nil {
945 o.logf(" %q: %v", prev.Host, err)
946 return err
947 }
948 svcChanged, sockChanged, errs := o.applyHalves(prev)
949 if svcChanged || sockChanged {
950 // Before any unit verb, without exception.
951 if err := o.systemctl("daemon-reload"); err != nil {
952 o.logf(" %s: daemon-reload: %v", prev.Host, err)
953 errs = append(errs, err)
954 }
955 o.logf(" %s: capability drop-ins rolled back to the previous version's", prev.Host)
956 }
957 if sockChanged {
958 // Never deferred: a restore is the end of the line, and its caller
959 // has already rolled the symlink back to the binary this shape
960 // belongs to.
961 if err := o.socketSequence(prev.Host, prev.Daemon, false, "restart"); err != nil {
962 o.logf(" %s: rebinding the previous version's listeners: %v", prev.Host, err)
963 errs = append(errs, err)
964 }
965 }
966 return errors.Join(errs...)
967}
968
969// Restart is the daemon route's restart: `systemctl restart`, and its error
970// is REAL — it is what makes adoptHost's rollback fire. Non-daemon routes are
971// not this function's business: rootRestartRoute's try-restart keeps owning them,
972// byte-identically.
973func Restart(o Ops, p Plan) error {
974 if err := o.authorizeMutation(); err != nil {
975 return err
976
977 }
978 if o.CheckHost != nil {
979 if err := o.CheckHost(p.Host); err != nil {
980 return err
981 }
982 }
983 if err := validHost(p.Host); err != nil {
984 o.logf(" %q: %v", p.Host, err)
985 return err
986 }
987 return o.systemctl("restart", ServiceUnit(p.Host))
988}
989
990// validHost refuses anything that is not a bare hostname before it is joined
991// into a root-written /etc/systemd/system path — the same guard, in the same
992// direction, as appconfig.syncHost's.
993func validHost(host string) error {
994 if host == "" || host == "." || host == ".." || host != filepath.Base(host) {
995 return fmt.Errorf("not a bare hostname, refusing to derive a /etc/systemd/system path from it")
996 }
997 return nil
998}
999
1000func (o Ops) etcDir() string {
1001 if o.EtcDir == "" {
1002 return DefaultEtcDir
1003 }
1004 return o.EtcDir
1005}
1006
1007func (o Ops) stateRoot() string {
1008 if o.StateRoot == "" {
1009 return DefaultStateRoot
1010 }
1011 return o.StateRoot
1012}
1013
1014func (o Ops) runDir() string {
1015 if o.RunDir == "" {
1016 return DefaultRunDir
1017 }
1018 return o.RunDir
1019}
1020
1021func (o Ops) logf(format string, a ...any) {
1022 if o.Logf != nil {
1023 o.Logf(format, a...)
1024 return
1025 }
1026 fmt.Printf(format+"\n", a...)
1027}
1028
1029// systemctl runs one state-CHANGING verb. Every call here is something a
1030// converged box does not need: an unchanged pass makes none of them, which is
1031// why the tests can assert "zero calls" and mean it.
1032func (o Ops) systemctl(args ...string) error {
1033 if o.Systemctl != nil {
1034 return o.Systemctl(args...)
1035 }
1036 out, err := exec.Command("systemctl", args...).CombinedOutput()
1037 if err != nil {
1038 return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
1039 }
1040 return nil
1041}
1042
1043// query runs a systemctl QUERY verb (`is-active`, `is-enabled`) and reports
1044// whether it answered yes. It is a separate seam from Systemctl on purpose: a
1045// query changes nothing, and the properties this package promises — an
1046// unchanged pass writes no bytes, reloads nothing and restarts nothing — are
1047// about state CHANGES. Folding the two together would force a choice between
1048// repairing drift (an unenabled socket unit, a daemon that is not running,
1049// both named in spec §4d as what this pass fixes) and being quiet when there
1050// is no drift; with the seams split, both hold.
1051//
1052// A systemctl that cannot run at all answers "no", which errs toward
1053// attempting an idempotent enable/start rather than silently skipping the
1054// repair.
1055func (o Ops) query(args ...string) bool {
1056 if o.Query != nil {
1057 return o.Query(args...)
1058 }
1059 return exec.Command("systemctl", args...).Run() == nil
1060}
1061
1062// ensureUser creates the locked, home-less, nologin system user a host_role
1063// app runs as, idempotently — exactly as host.ensureServiceUser creates
1064// `carlos`, and with the name DERIVED by AppUser, never taken from a manifest.
1065func (o Ops) ensureUser(name string) error {
1066 if o.EnsureUser != nil {
1067 return o.EnsureUser(name)
1068 }
1069 if _, err := user.Lookup(name); err == nil {
1070 return nil
1071 }
1072 out, err := exec.Command("useradd", "--system", "--user-group",
1073 "--no-create-home", "--shell", "/usr/sbin/nologin", name).CombinedOutput()
1074 if err != nil {
1075 return fmt.Errorf("create user %s: %v: %s", name, err, strings.TrimSpace(string(out)))
1076 }
1077 // useradd is not the only way this can fail: a NSS backend that accepted
1078 // the create but does not yet resolve the name would leave a User= line
1079 // pointing at nothing. Prove the account is resolvable before saying yes.
1080 if _, err := user.Lookup(name); err != nil {
1081 return fmt.Errorf("created user %s but it does not resolve: %v", name, err)
1082 }
1083 return nil
1084}
1085