carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Adopt-pass advisories: warnings about registry rows whose claims
2// disagree with what systemd is actually running. Advisory only — the
3// pass never mutates a route to "fix" it; the operator decides (see
4// platform issue #14 for the kass incident that motivated this).
5package host
6
7import (
8 "log"
9 "os/exec"
10 "os/user"
11 "strconv"
12 "strings"
13
14 "github.com/carlosframework/platform/internal/caps"
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// warnBackingMismatch reports hosts whose route claims exec backing
20// ("") while a carlos-app@<host> unit is actively running — the state
21// where release adoption still works but config sync silently skips the
22// route. unitsActive answers for ALL candidates in one probe (one
23// systemctl invocation, not one per route). Returns the warned hosts;
24// the caller logs them.
25func warnBackingMismatch(routes []registry.Route, unitsActive func(hosts []string) map[string]bool) []string {
26 var candidates []string
27 for _, rt := range routes {
28 if rt.Kind != "instance" || rt.Platform || rt.Backing == registry.BackingUnit {
29 continue
30 }
31 candidates = append(candidates, rt.Host)
32 }
33 if len(candidates) == 0 {
34 return nil
35 }
36 active := unitsActive(candidates)
37 var warned []string
38 for _, h := range candidates {
39 if active[h] {
40 warned = append(warned, h)
41 }
42 }
43 return warned
44}
45
46// unitsAreActive: the real batched systemd probe. `systemctl is-active`
47// prints one state line per named unit, positionally, and exits non-zero
48// when ANY unit is not active — the exit code is meaningless for a batch,
49// so only stdout is read.
50func unitsAreActive(hosts []string) map[string]bool {
51 args := []string{"is-active"}
52 for _, h := range hosts {
53 args = append(args, "carlos-app@"+h+".service")
54 }
55 out, _ := exec.Command("systemctl", args...).Output()
56 return parseIsActiveBatch(out, hosts)
57}
58
59// parseIsActiveBatch maps `systemctl is-active` stdout onto hosts
60// positionally (named distinctly from probes.go's single-unit parseIsActive,
61// whose tri-state *bool shape this is not). A mismatched line count cannot
62// be attributed positionally — a short batch would misattribute "active" to
63// the wrong host, and the resulting warning tells an operator to delete the
64// wrong host's decrypted secrets file. No warnings beats wrong warnings, so
65// a length mismatch returns an empty map.
66func parseIsActiveBatch(out []byte, hosts []string) map[string]bool {
67 lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n")
68 if len(lines) != len(hosts) {
69 return map[string]bool{}
70 }
71 active := make(map[string]bool, len(hosts))
72 for i, h := range hosts {
73 switch strings.TrimSpace(lines[i]) {
74 case "active", "reloading":
75 active[h] = true
76 }
77 }
78 return active
79}
80
81// adviseBackingMismatches runs the advisory against live systemd and
82// logs each finding — called from RunAdopt, root-side, where systemctl
83// is reachable and the hourly log is read.
84func adviseBackingMismatches(routes []registry.Route) {
85 for _, h := range warnBackingMismatch(routes, unitsAreActive) {
86 log.Printf("adopt: route %s claims exec backing but carlos-app@%s is ACTIVE — stamp backing='unit' (see platform issue #14), then remove the stray /data/%s.config.env the exec loop materialized — the unit reads /etc/carlos/apps instead", h, h, h)
87 }
88}
89
90// imdsAddr is the link-local metadata address every cloud instance role is
91// reached through, and the thing the box's nftables gate is keyed on. A rule
92// that does not name it is not part of that gate (a box can have plenty of
93// other skuid rules), so it is the scope of the parse below as well as a
94// string in the printed command.
95const imdsAddr = "169.254.169.254"
96
97// adviseHostRoleGateMissing: the app holds the host_role grant, so it runs as
98// the static user carlos-<app> — but that uid is not in this box's IMDS
99// nftables gate, and the observed failure mode of that gap is SILENCE, not an
100// error (keymail's rehearsal finding F1: the restore came up empty and
101// served). So it is named every pass for as long as the gap exists.
102//
103// The platform NEVER writes the firewall rule. The firewall is operator
104// territory; this prints the exact command and stops there (spec §4b, §5a).
105//
106// Two seams, two distinct claims, deliberately not folded together:
107//
108// - uidOf answers whether the static user EXISTS on this box at all. It is
109// also the quiet gate on the whole advisory: converge creates that user
110// when it first materializes host_role, so a user that does not resolve
111// means nothing is running under it yet and there is nothing for the gate
112// to be missing. Advising then would be an hourly line about a uid that
113// does not exist.
114// - gateUIDs answers (uids, ok), and three different things can be true.
115// ok == false is "I could not read a gate". A readable ruleset with NO
116// IMDS gate in it at all is a third claim again — the box is ungated, and
117// the fix is not "add a uid" but "harden this box first" (tito has no
118// gate today). Each gets its own line, because acting on the wrong one of
119// the three sends an operator to edit a firewall that is fine, or to add
120// a uid to a gate that does not exist.
121//
122// A route whose grants column does not parse is skipped in silence here:
123// buildPlans already says so once, naming the row and the fix, and repeating
124// it in a second voice would double every line of an already-broken row.
125func adviseHostRoleGateMissing(routes []registry.Route, uidOf func(user string) (int, bool), gateUIDs func() (map[int]bool, bool)) {
126 var cands []registry.Route
127 for _, rt := range routes {
128 // Only a route that can actually materialize host_role: the same
129 // predicate the converge passes use. A grant on a route that cannot
130 // materialize is `carlos route -grant`'s problem (grantRail refuses
131 // it), not the firewall's.
132 if !release.CapabilityRoute(rt) {
133 continue
134 }
135 g, err := caps.ParseGrants(rt.Caps)
136 if err != nil || !g.HostRole {
137 continue
138 }
139 cands = append(cands, rt)
140 }
141 if len(cands) == 0 {
142 return // no candidates, no `nft` invocation at all
143 }
144 uids, ok := gateUIDs() // ONE read of the ruleset for the whole pass
145 for _, rt := range cands {
146 u := caps.AppUser(rt.App)
147 uid, exists := uidOf(u)
148 if !exists {
149 continue
150 }
151 if !ok {
152 log.Printf("adopt: %s holds the host_role grant on %s but this box's nft ruleset could not be read (nft absent, or this pass is not root) — confirm by hand that uid %d is allowed to reach %s (see the app-capabilities design §4b)", u, rt.Host, uid, imdsAddr)
153 continue
154 }
155 if len(uids) == 0 {
156 log.Printf("adopt: %s (uid %d) holds the host_role grant on %s but no IMDS uid gate found in the nft ruleset — this box does not gate %s at all, so every process on it can reach the instance role; run platform-infrastructure scripts/harden-host.sh on this box, then add uid %d", u, uid, rt.Host, imdsAddr, uid)
157 continue
158 }
159 if !uids[uid] {
160 log.Printf("adopt: %s (uid %d) holds the host_role grant on %s but is not in this box's IMDS gate — its AWS clients will be starved SILENTLY; add it: nft add rule inet filter output ip daddr %s skuid %d accept (and add the uid to harden-host.sh so a re-run does not strip it)", u, uid, rt.Host, imdsAddr, uid)
161 }
162 }
163}
164
165// lookupUID resolves a system user to its numeric uid — the same account the
166// converge pass creates for a host_role app, looked up the same way
167// caps.Ops.ensureUser proves its own useradd resolved.
168func lookupUID(name string) (int, bool) {
169 u, err := user.Lookup(name)
170 if err != nil {
171 return 0, false
172 }
173 uid, err := strconv.Atoi(u.Uid)
174 if err != nil {
175 return 0, false
176 }
177 return uid, true
178}
179
180// nftGateUIDs is the real read of the box's IMDS gate: the OS glue half of
181// the parseNftSkuids split, exactly as unitsAreActive is to parseIsActiveBatch.
182//
183// `-n` is load-bearing, not tidiness: it makes nft print uids numerically, so
184// the parse never has to resolve a printed account NAME back to a number and
185// can never mistake one for the other.
186//
187// ok is false ONLY when the ruleset could not be read at all (absent binary,
188// not root, nft error). A ruleset that reads fine and carries no IMDS gate is
189// (empty, true) — a different fact, with a different fix, and one this
190// advisory must not go quiet about: a host_role app on a box with no IMDS
191// gate is not safe, it is ungated, which is F1's exposure with nothing in
192// front of it.
193func nftGateUIDs() (map[int]bool, bool) {
194 out, err := exec.Command("nft", "-n", "list", "ruleset").Output()
195 if err != nil {
196 return nil, false
197 }
198 return parseNftSkuids(out), true
199}
200
201// parseNftSkuids reads the uids ALLOWED to reach the metadata address out of
202// `nft -n list ruleset` output.
203//
204// The production gate is inline and numeric — `ip daddr 169.254.169.254 meta
205// skuid <n> accept`, one rule per allowed uid, written by the infra repo's
206// scripts/harden-host.sh, with no named sets today — so that is the shape
207// this reads, plus nft's `skuid { a, b }` set spelling for free.
208//
209// Two rails, both in the fail-CLOSED direction, because every mistake this
210// function can make ends in an advisory that stays quiet about a silent
211// starvation:
212//
213// - scoped to lines naming imdsAddr: a box has other skuid rules (SSH, a
214// backup agent) and counting those would report a gate allowing uids it
215// does not.
216// - scoped to lines whose VERDICT accepts. `... meta skuid 998 drop` is the
217// opposite of an allowlist entry, and reading it as one would report the
218// gate as open for exactly the uid it blocks. A jump/goto/return is
219// counted as accepting: it hands the packet to another chain rather than
220// dropping it here, and over-reporting a uid as gated is the direction
221// that costs one dismissed advisory line rather than a silent outage.
222//
223// Negated and range matches (`skuid != 0`, `skuid 100-200`) are deliberately
224// not interpreted: neither is an allowlist of one uid, and guessing would be
225// the fail-open direction again.
226func parseNftSkuids(out []byte) map[int]bool {
227 uids := map[int]bool{}
228 for _, line := range strings.Split(string(out), "\n") {
229 if !strings.Contains(line, imdsAddr) {
230 continue
231 }
232 fields := strings.Fields(line)
233 if !acceptingVerdict(fields) {
234 continue
235 }
236 for i := 0; i < len(fields); i++ {
237 if fields[i] != "skuid" || i+1 >= len(fields) {
238 continue
239 }
240 if fields[i+1] == "{" {
241 j := i + 2
242 for ; j < len(fields) && fields[j] != "}"; j++ {
243 addUID(uids, fields[j])
244 }
245 i = j
246 continue
247 }
248 addUID(uids, fields[i+1])
249 i++
250 }
251 }
252 return uids
253}
254
255// acceptingVerdict reports whether a rule's verdict lets the packet through.
256// A rule that drops or rejects is never read for allowed uids, whatever else
257// it says — and a rule with no verdict at all (a counter, a comment) allows
258// nothing either.
259func acceptingVerdict(fields []string) bool {
260 accepting := false
261 for _, f := range fields {
262 switch strings.Trim(f, `",;`) {
263 case "drop", "reject":
264 return false
265 case "accept", "return", "goto", "jump":
266 accepting = true
267 }
268 }
269 return accepting
270}
271
272// addUID records one uid token, tolerating the punctuation nft's set syntax
273// carries ("998," inside braces) and ignoring anything that is not a plain
274// non-negative number.
275func addUID(uids map[int]bool, tok string) {
276 tok = strings.Trim(tok, `",;{}`)
277 n, err := strconv.Atoi(tok)
278 if err != nil || n < 0 {
279 return
280 }
281 uids[n] = true
282}
283