Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

197 lines
8.0 KiB
Go

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
}