package main import ( "encoding/json" "os" "path/filepath" "sync" "time" ) // events.go — what HAPPENED to this machine, kept where the person sitting at // it can read it. // // THE DEFECT. contain.go's entire output was a line on stdout — // `contain: terminated agent pid %d and %d child process(es) (host-directed)`, // still printed at contain.go:106 — and it persisted nothing. A host had just // killed the user's running programs and the box kept no trace of it, so a // machine whose agents were contained thirty seconds ago renders exactly like // one nothing has ever happened to. On Windows it relaunches with DETACHED_PROCESS // after a self-update (relaunch_windows.go:47), so that line goes to a console // that does not exist: the same defect the whole local UI exists to fix, // landing on the one directive that takes something away from the user // without asking them first. // // WHY THIS IS NOT directive_refusal.go, whose shape it otherwise copies. // That file holds ONE slot, last-one-wins, cleared when the same directive // later succeeds (directive_refusal.go:50). Correct there: a refusal is a // STATE, it rides the enforcing report on every beat, and the gate that // produced it re-asserts it on the next one. A containment is an EVENT — it // cannot be resolved by a later success, the second one does not replace the // first, and nothing regenerates it if it is lost. Same shape (package-level, // one mutex, a clock seam for tests), different lifetime, so a list instead of // a slot. // // IT SURVIVES A RESTART, and that is the deliberate half of the design. // The cheaper choice — memory only, like directive_refusal.go, with no file, no // cap and no corruption path — ties the record to the PROCESS, and this process // exits on purpose on every self-update (update.go:127, `os.Exit(0)` right // after relaunchAfterUpdate). That is the most common restart this binary has // and it is the exact event that ate the console in the first place. A record // of "your host killed the programs you were running" that a restart erases is // not a record of it. Set against that, the file holds a host name, a pid and a // signature NAME (agents.go:180 — the table entry that matched, not the command // line), in the directory that already holds this machine's pairing // credentials and TOTP seeds at 0700. // // THE DAEMON OPENS IT; the recorder never resolves the path by itself. A // recordLocalEvent that called configDir() on demand would write into the real // config dir of whoever runs `go test`: agents_kind_test.go:342 drives // maybeContain with stubbed kills and no config isolation, so two forged "your // host stopped an agent" events would appear in a developer's own live UI. That // is not hypothetical here — freeagent_managed_test.go:61-68 records the same // class of accident, measured on a machine with an active bastion. So the // path is claimed once by the daemon (openLocalEventLog, called from // startUIServer), and anything recorded before that instant is merged in when // it is, which closes the window between the first heartbeat and the UI coming // up rather than pretending it does not exist. // localEvent is one thing that happened to this machine, already in the words // the page will show. Text is composed by the CALLER, because the caller is the // only place that still holds the host, the pid and the signature; this file // never formats a sentence it would have to keep in step with contain.go. type localEvent struct { // Kind is the family, so the page can style without parsing Text. Kind string `json:"kind"` At time.Time `json:"at"` Text string `json:"text"` } // eventContain is the only kind today. Named rather than inlined because the // page styles on it and a typo in a string literal is a silent miss. const eventContain = "contain" // localEventCap bounds the file. Every record rewrites the whole blob under the // mutex, and the page renders all of it, so an uncapped log would grow for the // life of the install and be re-serialised on each append. Twenty is roughly // 2 KB and is far more containment than any healthy machine will ever see — if // a box passes twenty, the oldest are not what its owner needs to read. const localEventCap = 20 var ( localEventMu sync.Mutex // localEventList is oldest-first, at most localEventCap. localEventList []localEvent // localEventFile is "" until the daemon claims it. See the header. localEventFile string // localEventNow is the clock seam: the test pins the timestamp, exactly as // directive_refusal.go:35 does. localEventNow = time.Now ) // localEventPath is the record's home — beside ui.addr and the pairing state, // in the config dir configDir() creates at 0700 (config.go:137). func localEventPath() (string, error) { d, err := configDir() if err != nil { return "", err } return filepath.Join(d, "events.json"), nil } // openLocalEventLog binds the record to disk. Called once, by the daemon. // // It MERGES rather than replaces: events recorded before this call (the window // between a pairing's first beat and the UI starting, run.go:120-131) are newer // than anything on disk by construction, so they go after the loaded ones and // the whole list is saved. A second call is a no-op, so the merge can never // duplicate what it just wrote. func openLocalEventLog() { p, err := localEventPath() if err != nil { return } localEventMu.Lock() defer localEventMu.Unlock() if localEventFile != "" { return } localEventFile = p pending := localEventList localEventList = append(loadLocalEvents(p), pending...) trimLocalEventsLocked() if len(pending) > 0 { saveLocalEventsLocked() } } // loadLocalEvents reads the file, or returns nothing. A missing, unreadable or // corrupt file is NOT an error worth surfacing: this is an advisory record, and // a bastion that refuses to run because its event log will not parse would turn // a cosmetic problem into an outage. Starting empty loses history; refusing to // start loses the machine. func loadLocalEvents(path string) []localEvent { b, err := os.ReadFile(path) if err != nil { return nil } var out []localEvent if json.Unmarshal(b, &out) != nil { return nil } return out } // recordLocalEvent appends one event. Best-effort by design: a write failure // must never change what the caller did, because by the time this is called the // processes are already dead. func recordLocalEvent(kind, text string) { localEventMu.Lock() defer localEventMu.Unlock() localEventList = append(localEventList, localEvent{ Kind: kind, At: localEventNow().UTC(), Text: text, }) trimLocalEventsLocked() saveLocalEventsLocked() } func trimLocalEventsLocked() { if len(localEventList) > localEventCap { localEventList = append([]localEvent(nil), localEventList[len(localEventList)-localEventCap:]...) } } // saveLocalEventsLocked writes the whole list through atomicWrite (ctx.go:133 — // temp file, 0600, rename), so a reader never sees a half-written blob and a // crash mid-append leaves the previous list intact rather than an empty one. func saveLocalEventsLocked() { if localEventFile == "" { return } if b, err := json.MarshalIndent(localEventList, "", " "); err == nil { _ = atomicWrite(localEventFile, b) } } // localEventsNewestFirst is what the page renders: a copy, newest first, // because a person reads "what just happened" before "what happened last // month". // // Newest by the order things were RECORDED, not by their timestamps. The two // agree whenever the clock moves forward, and when it does not — an NTP // correction, a VM resuming from a snapshot — the recording order is the one // that is true about what happened in what sequence, while sorting by At would // reorder the page to agree with a clock that was wrong. // // A copy rather than the slice itself: the caller renders outside the mutex and // the next containment appends in place. func localEventsNewestFirst() []localEvent { localEventMu.Lock() defer localEventMu.Unlock() out := make([]localEvent, 0, len(localEventList)) for i := len(localEventList) - 1; i >= 0; i-- { out = append(out, localEventList[i]) } return out }