// Adopt-pass advisories: warnings about registry rows whose claims // disagree with what systemd is actually running. Advisory only — the // pass never mutates a route to "fix" it; the operator decides (see // platform issue #14 for the kass incident that motivated this). package host import ( "log" "os/exec" "os/user" "strconv" "strings" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // warnBackingMismatch reports hosts whose route claims exec backing // ("") while a carlos-app@ unit is actively running — the state // where release adoption still works but config sync silently skips the // route. unitsActive answers for ALL candidates in one probe (one // systemctl invocation, not one per route). Returns the warned hosts; // the caller logs them. func warnBackingMismatch(routes []registry.Route, unitsActive func(hosts []string) map[string]bool) []string { var candidates []string for _, rt := range routes { if rt.Kind != "instance" || rt.Platform || rt.Backing == registry.BackingUnit { continue } candidates = append(candidates, rt.Host) } if len(candidates) == 0 { return nil } active := unitsActive(candidates) var warned []string for _, h := range candidates { if active[h] { warned = append(warned, h) } } return warned } // unitsAreActive: the real batched systemd probe. `systemctl is-active` // prints one state line per named unit, positionally, and exits non-zero // when ANY unit is not active — the exit code is meaningless for a batch, // so only stdout is read. func unitsAreActive(hosts []string) map[string]bool { args := []string{"is-active"} for _, h := range hosts { args = append(args, "carlos-app@"+h+".service") } out, _ := exec.Command("systemctl", args...).Output() return parseIsActiveBatch(out, hosts) } // parseIsActiveBatch maps `systemctl is-active` stdout onto hosts // positionally (named distinctly from probes.go's single-unit parseIsActive, // whose tri-state *bool shape this is not). A mismatched line count cannot // be attributed positionally — a short batch would misattribute "active" to // the wrong host, and the resulting warning tells an operator to delete the // wrong host's decrypted secrets file. No warnings beats wrong warnings, so // a length mismatch returns an empty map. func parseIsActiveBatch(out []byte, hosts []string) map[string]bool { lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") if len(lines) != len(hosts) { return map[string]bool{} } active := make(map[string]bool, len(hosts)) for i, h := range hosts { switch strings.TrimSpace(lines[i]) { case "active", "reloading": active[h] = true } } return active } // adviseBackingMismatches runs the advisory against live systemd and // logs each finding — called from RunAdopt, root-side, where systemctl // is reachable and the hourly log is read. func adviseBackingMismatches(routes []registry.Route) { for _, h := range warnBackingMismatch(routes, unitsAreActive) { 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) } } // imdsAddr is the link-local metadata address every cloud instance role is // reached through, and the thing the box's nftables gate is keyed on. A rule // that does not name it is not part of that gate (a box can have plenty of // other skuid rules), so it is the scope of the parse below as well as a // string in the printed command. const imdsAddr = "169.254.169.254" // adviseHostRoleGateMissing: the app holds the host_role grant, so it runs as // the static user carlos- — but that uid is not in this box's IMDS // nftables gate, and the observed failure mode of that gap is SILENCE, not an // error (keymail's rehearsal finding F1: the restore came up empty and // served). So it is named every pass for as long as the gap exists. // // The platform NEVER writes the firewall rule. The firewall is operator // territory; this prints the exact command and stops there (spec §4b, §5a). // // Two seams, two distinct claims, deliberately not folded together: // // - uidOf answers whether the static user EXISTS on this box at all. It is // also the quiet gate on the whole advisory: converge creates that user // when it first materializes host_role, so a user that does not resolve // means nothing is running under it yet and there is nothing for the gate // to be missing. Advising then would be an hourly line about a uid that // does not exist. // - gateUIDs answers (uids, ok), and three different things can be true. // ok == false is "I could not read a gate". A readable ruleset with NO // IMDS gate in it at all is a third claim again — the box is ungated, and // the fix is not "add a uid" but "harden this box first" (tito has no // gate today). Each gets its own line, because acting on the wrong one of // the three sends an operator to edit a firewall that is fine, or to add // a uid to a gate that does not exist. // // A route whose grants column does not parse is skipped in silence here: // buildPlans already says so once, naming the row and the fix, and repeating // it in a second voice would double every line of an already-broken row. func adviseHostRoleGateMissing(routes []registry.Route, uidOf func(user string) (int, bool), gateUIDs func() (map[int]bool, bool)) { var cands []registry.Route for _, rt := range routes { // Only a route that can actually materialize host_role: the same // predicate the converge passes use. A grant on a route that cannot // materialize is `carlos route -grant`'s problem (grantRail refuses // it), not the firewall's. if !release.CapabilityRoute(rt) { continue } g, err := caps.ParseGrants(rt.Caps) if err != nil || !g.HostRole { continue } cands = append(cands, rt) } if len(cands) == 0 { return // no candidates, no `nft` invocation at all } uids, ok := gateUIDs() // ONE read of the ruleset for the whole pass for _, rt := range cands { u := caps.AppUser(rt.App) uid, exists := uidOf(u) if !exists { continue } if !ok { 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) continue } if len(uids) == 0 { 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) continue } if !uids[uid] { 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) } } } // lookupUID resolves a system user to its numeric uid — the same account the // converge pass creates for a host_role app, looked up the same way // caps.Ops.ensureUser proves its own useradd resolved. func lookupUID(name string) (int, bool) { u, err := user.Lookup(name) if err != nil { return 0, false } uid, err := strconv.Atoi(u.Uid) if err != nil { return 0, false } return uid, true } // nftGateUIDs is the real read of the box's IMDS gate: the OS glue half of // the parseNftSkuids split, exactly as unitsAreActive is to parseIsActiveBatch. // // `-n` is load-bearing, not tidiness: it makes nft print uids numerically, so // the parse never has to resolve a printed account NAME back to a number and // can never mistake one for the other. // // ok is false ONLY when the ruleset could not be read at all (absent binary, // not root, nft error). A ruleset that reads fine and carries no IMDS gate is // (empty, true) — a different fact, with a different fix, and one this // advisory must not go quiet about: a host_role app on a box with no IMDS // gate is not safe, it is ungated, which is F1's exposure with nothing in // front of it. func nftGateUIDs() (map[int]bool, bool) { out, err := exec.Command("nft", "-n", "list", "ruleset").Output() if err != nil { return nil, false } return parseNftSkuids(out), true } // parseNftSkuids reads the uids ALLOWED to reach the metadata address out of // `nft -n list ruleset` output. // // The production gate is inline and numeric — `ip daddr 169.254.169.254 meta // skuid accept`, one rule per allowed uid, written by the infra repo's // scripts/harden-host.sh, with no named sets today — so that is the shape // this reads, plus nft's `skuid { a, b }` set spelling for free. // // Two rails, both in the fail-CLOSED direction, because every mistake this // function can make ends in an advisory that stays quiet about a silent // starvation: // // - scoped to lines naming imdsAddr: a box has other skuid rules (SSH, a // backup agent) and counting those would report a gate allowing uids it // does not. // - scoped to lines whose VERDICT accepts. `... meta skuid 998 drop` is the // opposite of an allowlist entry, and reading it as one would report the // gate as open for exactly the uid it blocks. A jump/goto/return is // counted as accepting: it hands the packet to another chain rather than // dropping it here, and over-reporting a uid as gated is the direction // that costs one dismissed advisory line rather than a silent outage. // // Negated and range matches (`skuid != 0`, `skuid 100-200`) are deliberately // not interpreted: neither is an allowlist of one uid, and guessing would be // the fail-open direction again. func parseNftSkuids(out []byte) map[int]bool { uids := map[int]bool{} for _, line := range strings.Split(string(out), "\n") { if !strings.Contains(line, imdsAddr) { continue } fields := strings.Fields(line) if !acceptingVerdict(fields) { continue } for i := 0; i < len(fields); i++ { if fields[i] != "skuid" || i+1 >= len(fields) { continue } if fields[i+1] == "{" { j := i + 2 for ; j < len(fields) && fields[j] != "}"; j++ { addUID(uids, fields[j]) } i = j continue } addUID(uids, fields[i+1]) i++ } } return uids } // acceptingVerdict reports whether a rule's verdict lets the packet through. // A rule that drops or rejects is never read for allowed uids, whatever else // it says — and a rule with no verdict at all (a counter, a comment) allows // nothing either. func acceptingVerdict(fields []string) bool { accepting := false for _, f := range fields { switch strings.Trim(f, `",;`) { case "drop", "reject": return false case "accept", "return", "goto", "jump": accepting = true } } return accepting } // addUID records one uid token, tolerating the punctuation nft's set syntax // carries ("998," inside braces) and ignoring anything that is not a plain // non-negative number. func addUID(uids map[int]bool, tok string) { tok = strings.Trim(tok, `",;{}`) n, err := strconv.Atoi(tok) if err != nil || n < 0 { return } uids[n] = true }