package main import ( "encoding/json" "os" "path/filepath" "time" ) // Killing a process TREE, not a pid and a snapshot of its children. // // The first version took one parent map, then killed the root and every // descendant in it. Two things escaped that: a child forked between the // snapshot and the signal (the agent was still running while its tree was being // read), and anything a victim forked while its own parent was being killed. An // agent that notices it is being stopped has exactly that window. // // So the tree is FROZEN first, then killed. Freezing (SIGSTOP on unix) is the // part that closes the window: a stopped process cannot fork. Each round walks // the tree again, freezes whatever it has not seen, and the walk repeats until a // round finds nothing new — every process that could still create another one // is stopped by then. Only then is anything killed, so no victim is ever // reparented to init while still running (the kill order no longer matters). // // This is the stop-then-kill approach every supervisor without a cgroup // uses; cgroup.kill would be stronger on Linux, but it needs the tree to have // been STARTED inside a cgroup this process owns, and the bastion is stopping // software it did not start. No maintained Go library does a cross-platform // "kill this existing tree until it is gone" (checked 2026-09-23: gopsutil walks // and signals but has no tree kill; the small tree-kill packages have no users), // so it is written here over the process walk this package already has. // // Windows has no SIGSTOP. There the rounds kill as they go, which still // catches a child forked during the kill on the next walk, because Windows keeps // the parent pid of an orphan (processParents validates it against creation // time so a recycled pid is never read as a parent). // // WHAT THIS STILL CANNOT REACH, named rather than implied: a process that left // the tree BEFORE the order arrived (a daemon that double-forked and was // reparented to init) has no parent link to follow. It is not lost, it is a // separate thing: it shows up in the delta as its own proc_new finding, and // containFindings (containfinding.go) can stop it by its fingerprint. // // THE PROCESSES ABOVE THE ROOT ARE NOT IN THE TREE, BUT THE FREEZE CAN STILL // REACH THEM. sudo and su stop THEMSELVES when the child they are waiting on // is stopped (that is how job control keeps working through them), and stay // stopped until something continues them; under a terminal the stop is relayed // a hop further, from sudo's monitor process to sudo itself and on to the // terminal wrapper. Our SIGSTOP on the agent does exactly that to such a chain, // and the kill that follows never wakes it: the agent dies, its parent is still // stopped, so it never collects the exit — the agent lingers as a zombie under // a parent that hangs for ever, and anything asking whether that pid is gone is // told it is not. Measured on an agent started as `sudo -u `: // sudo `T`, agent `Z`, until someone sent sudo SIGCONT by hand. A plain shell // does not relay a stop; it is read and left alone. // // So the root's ancestors are read BEFORE the first freeze, and after the kill // every one that was running then and is stopped now, under the same identity, // is resumed (thawRelayingAncestors). One somebody stopped on purpose, or a // number that has changed hands, is left alone. They ride in the frozen-set // journal too, from the first freeze on, so a bastion that dies between the // freeze and the thaw continues them on its next start. Where a process's state // cannot be read (procidentity // for that platform answers "unknown"), no ancestor is recorded and the thaw // does nothing. // killTreeMaxRounds bounds the walk. A tree that is still growing after this // many frozen rounds is being grown by something outside it; the kill proceeds // with what was found rather than looping for ever. const killTreeMaxRounds = 8 // stopProcess and resumeProcess are the freeze. Side effects, injectable for the // same reason killProcess is (contain.go): the decision must be testable // without signalling anything. TestMain replaces both with no-ops so no test // that stubs only killProcess can stop a real process. var ( stopProcess = osStopProcess resumeProcess = osResumeProcess ) // procIdentity is what makes a pid mean one process: its start time as the // kernel records it, and whether it is already stopped. ok=false where the // platform cannot say (then nothing is compared and the kill proceeds as it // did before identities existed). Injectable like the signals. var procIdentity = osProcIdentity // killTree freezes and then kills root and every descendant of it. // // `spare` PRUNES: a spared process is not signalled AND nothing beneath it is // walked. The tree kill used to test only the root, so stopping a finding // took everything under it — a replaced sshd took every ssh session with it, // accounting entries included, which is exactly what the guards on the root // exist to refuse. Killing a parent does not kill its // children, so a spared subtree simply survives, re-parented. // // A PID IS NOT A PROCESS. Each pid's identity is read before it is stopped and // again after: a different process under the same number (the original exited // and the number was reused) is resumed at once and left alone. It is read // once more before the kill, so a process that died while frozen cannot hand // its number to a stranger that is then killed. A process that was ALREADY // stopped when we found it is killed with the tree but never resumed by us — // someone else stopped it, deliberately. // // The frozen set is written down before the kill (killTreeJournal) and removed // after, so a bastion that dies between freeze and kill thaws what it froze on // its next start (recoverFrozenTrees) instead of leaving it stopped for ever. // // It returns how many processes the kill reached, the root included. func killTree(root int, spare func(int) bool) int { return killTreeExpect(root, spare, "") } // killTreeExpect is killTree for a root whose identity was read when it was // chosen (the finding walk): if the pid no longer carries that identity, the // number has changed hands since and nothing is signalled. That closes the gap // between choosing the root and the first read inside the kill. func killTreeExpect(root int, spare func(int) bool, rootID string) int { if spare(root) { return 0 } if rootID != "" { if id, _, ok := procIdentity(root); ok && id != rootID { return 0 } } type frozenProc struct { id string wasStopped bool } seen := map[int]bool{} order := []int{} frozen := map[int]frozenProc{} journal := []frozenEntry{} killed := 0 // The root's ANCESTORS, read BEFORE anything is frozen: whether each was // already stopped then is what decides, after the kill, whether a stop we // caused is undone (thawRelayingAncestors). Read on the first walk only — // the root's ancestry does not change while the root is alive. They go into // the journal WITH THE FIRST FREEZE, not before it: a relayed stop cannot // begin until our SIGSTOP lands, and a journal holding a running process we // never signalled would let a restart continue a stop the user made // themselves in the meantime. So a bastion that dies between the freeze and // the thaw continues them on its next start like anything else it stopped, // and one that dies before the first freeze has nothing to continue. var ancestors []relayingAncestor ancestorsJournalled := false for round := 0; round < killTreeMaxRounds; round++ { parents := processTree() if round == 0 && freezeSupported { ancestors = readRelayingAncestors(parents, root) } grew := false for _, pid := range append([]int{root}, descendantsPruned(parents, root, spare)...) { if seen[pid] || spare(pid) { continue } seen[pid] = true grew = true id0, stopped0, known := procIdentity(pid) if !freezeSupported { // No freeze on this platform: kill as found, and let the next // walk pick up anything forked in the meantime. if known { if id, _, ok := procIdentity(pid); ok && id != id0 { continue } } if killProcess(pid) == nil { killed++ } continue } if stopProcess(pid) != nil { continue } if known { if id1, _, ok := procIdentity(pid); !ok || id1 != id0 { // Stopped a stranger: the number changed hands between the // read and the signal. Undo at once, and never kill it. _ = resumeProcess(pid) continue } } order = append(order, pid) frozen[pid] = frozenProc{id: id0, wasStopped: stopped0} if !stopped0 { // Journalled AS it is frozen, not after every round: a bastion // that dies mid-freeze still leaves a record of what it stopped. // The ancestors ride in the first such write (see above). if !ancestorsJournalled { for _, a := range ancestors { journal = append(journal, frozenEntry{PID: a.pid, ID: a.id}) } ancestorsJournalled = true } journal = append(journal, frozenEntry{PID: pid, ID: id0}) killTreeJournalWrite(journal) } } if !grew { break } } if !freezeSupported { return killed } for _, pid := range order { f := frozen[pid] if f.id != "" { if id, _, ok := procIdentity(pid); ok && id != f.id { continue // gone while frozen; the number is someone else's now } } if killProcess(pid) == nil { killed++ } else if !f.wasStopped { // A process we stopped and then could not kill must not be left // stopped: that would be a hang this box caused, reported as nothing. _ = resumeProcess(pid) } } thawRelayingAncestors(ancestors) killTreeJournalWrite(nil) return killed } // relayingAncestor is a process above the root that was RUNNING when the kill // began, with the identity it had then. Only those can have been stopped by // our freeze; an ancestor already stopped at the start is someone else's // decision and is not recorded. type relayingAncestor struct { pid int id string } // ancestorThawDepth bounds the walk upward. The stop is relayed one hop at a // time (sudo's monitor to sudo, sudo to the terminal wrapper); a few hops // cover every launch shape seen, and init is never reached. const ancestorThawDepth = 8 // readRelayingAncestors walks from the root's parent upward through the parent // map, nearest first, and records each running ancestor whose identity can be // read. It stops at init, at this bastion, at an unknown pid, at an ancestor // that is already stopped (nothing above it can be relaying OUR stop through a // process that was not running), and at a cycle. func readRelayingAncestors(parents map[int]int, root int) []relayingAncestor { out := []relayingAncestor{} seen := map[int]bool{root: true} pid := parents[root] for hop := 0; hop < ancestorThawDepth; hop++ { if pid <= 1 || pid == os.Getpid() || seen[pid] { return out } seen[pid] = true id, stopped, ok := procIdentity(pid) if !ok || id == "" || stopped { return out } out = append(out, relayingAncestor{pid: pid, id: id}) pid = parents[pid] } return out } // ancestorThawChecks and ancestorThawWait bound how long the kill waits for an // ancestor to show the stop it relays. Each relays on its child's stop // NOTIFICATION, which can land a moment after our signal and a hop later for // each level, so one read straight after the kill can miss it. An ancestor that // never follows (almost every root has a running parent) costs each tree kill // (checks − 1) × wait, a quarter of a second; a containment of several pids // pays it once per pid, on a heartbeat measured in tens of seconds. The wait is // a variable so the tests do not sleep. const ancestorThawChecks = 6 var ancestorThawWait = func() { time.Sleep(50 * time.Millisecond) } // thawRelayingAncestors resumes every recorded ancestor that is stopped now, // under the identity it had before the first freeze (the header names the // mechanism). A number that has changed hands belongs to a stranger and is // dropped. Rounds end as soon as nothing is left to watch. func thawRelayingAncestors(ancestors []relayingAncestor) { pending := append([]relayingAncestor(nil), ancestors...) for i := 0; i < ancestorThawChecks && len(pending) > 0; i++ { if i > 0 { ancestorThawWait() } still := pending[:0] for _, a := range pending { id, stopped, ok := procIdentity(a.pid) if !ok || id != a.id { continue } if stopped { _ = resumeProcess(a.pid) continue } still = append(still, a) } pending = still } } // descendantsPruned is descendantsOf that does not walk below a spared pid. func descendantsPruned(parents map[int]int, root int, spare func(int) bool) []int { children := map[int][]int{} for pid, pp := range parents { if pp > 0 && pid != pp { children[pp] = append(children[pp], pid) } } seen := map[int]bool{root: true} out := []int{} queue := []int{root} for len(queue) > 0 { cur := queue[0] queue = queue[1:] for _, c := range children[cur] { if seen[c] { continue } seen[c] = true if spare(c) { continue // neither it nor anything beneath it } out = append(out, c) queue = append(queue, c) } } return out } // spareFromContain is the set no containment may touch: init, this bastion, and // the local agent it launched itself. func spareFromContain(pid int) bool { return pid <= 1 || pid == os.Getpid() || faIsOwnChild(pid) } // ── the frozen-set journal ─────────────────────────────────────────────────── type frozenEntry struct { PID int `json:"pid"` ID string `json:"id"` } func killTreeJournalPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "killtree-frozen.json"), nil } // killTreeJournalWrite records the processes this box has frozen and not yet // killed; nil removes the record. Best-effort: failing to write it must not // stop a kill the host ordered. var killTreeJournalWrite = func(entries []frozenEntry) { p, err := killTreeJournalPath() if err != nil { return } if len(entries) == 0 { _ = os.Remove(p) return } if b, err := json.Marshal(entries); err == nil { _ = atomicWrite(p, b) // temp + rename: a crash mid-write never leaves half a record } } // recoverFrozenTrees thaws anything a previous run froze and did not get to // kill (it died mid-kill: a crash, an OOM, a self-update restart). Only a pid // whose identity still matches AND that is still stopped is resumed, so a // number reused since is never touched. Called once at daemon start. func recoverFrozenTrees() { p, err := killTreeJournalPath() if err != nil { return } raw, err := os.ReadFile(p) if err != nil { return } var entries []frozenEntry if json.Unmarshal(raw, &entries) == nil { for _, e := range entries { if id, stopped, ok := procIdentity(e.PID); ok && id == e.ID && stopped { _ = resumeProcess(e.PID) } } } _ = os.Remove(p) }