package main // activity.go — what happened on this machine, newest first, in one list: // the console's Activity page and `privatae-bastion activity [--since 7d] // [--json]` (plan Addendum 4, step 3). // // It adds no record of its own. It reads the ones this bastion already keeps, // each the authority for its own kind of fact: // // events.json stops, by the owner or a Host, and stops refused // machine-factor/ the owner's approvals and the stops they carried // audit.jsonl out or refused // /audit.jsonl what each Host did here (files, stores, op runs) // mgmt_audit.jsonl each Host's management ops and the verdict // pairing_audit.jsonl Hosts paired, granted, revoked // sanctions programs marked as yours, and when // local-deny.json capabilities switched off for a Host, and when // // Each file is read from its tail (activityTailMax), so a long-lived machine // shows its recent past without reading years of log on every load. import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "time" ) const ( activityTailMax = 256 << 10 activityLimit = 300 ) type activityItem struct { At time.Time `json:"at"` Kind string `json:"kind"` // stop, approval, host, change Who string `json:"who,omitempty"` What string `json:"what"` Refused bool `json:"refused,omitempty"` } // askedBy is ", asked by " for a row about an agent's ask, else "". func askedBy(row map[string]any) string { if who := actStr(row, "who"); who != "" { return ", asked by " + who } return "" } func collectActivity(since time.Time) []activityItem { var out []activityItem add := func(it activityItem) { if !it.At.Before(since) && it.What != "" { out = append(out, it) } } // Stops, as the page and the Host were told them. if p, err := localEventPath(); err == nil { for _, e := range loadLocalEvents(p) { add(activityItem{At: e.At, Kind: "stop", What: e.Text, Refused: strings.Contains(e.Text, "refused") || strings.Contains(e.Text, "It refused")}) } } // The owner's approvals, and stops that did not happen (one that did is // already an event above). if dir := existingMachineFactorDir(); dir != "" { for _, row := range activityRows(filepath.Join(dir, "audit.jsonl")) { op, decision := actStr(row, "op"), actStr(row, "decision") switch { case op == localStopKind && strings.HasPrefix(decision, "not stopped"): target := actStr(row, "path") // " ", as localStopOnce writes it if pid, sig, ok := strings.Cut(target, " "); ok { target = sig + " (pid " + pid + ")" } add(activityItem{At: rowTime(row), Kind: "stop", Who: "You", What: "approved stopping " + target + ", but it was not stopped: " + strings.TrimPrefix(decision, "not stopped: "), Refused: true}) case op == "local_baseline": add(activityItem{At: rowTime(row), Kind: "change", Who: "You", What: "recorded a new baseline"}) case op == "stepup_approve": add(activityItem{At: rowTime(row), Kind: "approval", Who: "You", What: "approved " + purposeWords(strings.TrimPrefix(decision, "approved: ")) + askedBy(row)}) case op == "stepup_deny": add(activityItem{At: rowTime(row), Kind: "approval", Who: "You", Refused: true, What: "denied " + purposeWords(strings.TrimPrefix(decision, "denied: ")) + askedBy(row)}) case op == localStopAskOp: // An agent asked (agentask.go). Who is the asker in the words // the owner read on the request. add(activityItem{At: rowTime(row), Kind: "approval", Who: actStr(row, "who"), What: decision}) case op == askHoldOp: who, what := "This machine", "held asks from "+actStr(row, "path")+" ("+decision+")" if decision == "lifted by the owner" { who, what = "You", "lifted the hold on asks from "+actStr(row, "path") } add(activityItem{At: rowTime(row), Kind: "approval", Who: who, What: what, Refused: who != "You"}) } } } // What each Host did here. if cfgs, err := loadPairings(); err == nil { for _, c := range cfgs { if !c.active() { continue // nothing is created for a pending pairing (run.go) } for _, row := range activityRows(filepath.Join(c.dcnDir(), "audit.jsonl")) { decision := actStr(row, "decision") what := opWords(actStr(row, "op")) if p := actStr(row, "path"); p != "" { what += " " + p } if decision != "" { what += " (" + decision + ")" } add(activityItem{At: rowTime(row), Kind: "host", Who: "Host " + c.HostName, What: what, Refused: strings.HasPrefix(decision, "refused") || strings.HasPrefix(decision, "denied")}) } } } if p, err := mgmtAuditPath(); err == nil { for _, row := range activityRows(p) { verdict := actStr(row, "verdict") what := "ran " + actStr(row, "op") if verdict != "" { what += " (" + verdict if why := actStr(row, "why"); why != "" && verdict != "ok" { what += ": " + why } what += ")" } add(activityItem{At: rowTime(row), Kind: "host", Who: "Host " + actStr(row, "self"), What: what, Refused: verdict != "" && verdict != "ok" && verdict != "allowed"}) } } if d, err := configDir(); err == nil { for _, row := range activityRows(filepath.Join(d, auditFilePairing)) { add(activityItem{At: rowTime(row), Kind: "change", Who: "This machine", What: opWords(actStr(row, "op")) + " " + actStr(row, "subject") + ": " + actStr(row, "decision"), Refused: strings.HasPrefix(actStr(row, "decision"), "refused")}) } } // The owner's own changes. if recs, err := sanctionRecords(); err == nil { for _, r := range recs { add(activityItem{At: r.At, Kind: "change", Who: "You", What: "marked " + r.describeMark() + " as yours"}) } } localDenyMu.Lock() f, err := localDenyLoad() localDenyMu.Unlock() if err == nil { for key, at := range f.At { host, fam, _ := strings.Cut(key, "/") if t, err := time.Parse(time.RFC3339, at); err == nil { add(activityItem{At: t, Kind: "change", Who: "You", What: "switched " + fam + " off for " + host}) } } } sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) }) if len(out) > activityLimit { out = out[:activityLimit] } return out } // activityRows is the JSON rows in the tail of a JSONL file. func activityRows(path string) []map[string]any { var rows []map[string]any for _, line := range tailLines(path, activityTailMax) { var m map[string]any if json.Unmarshal(line, &m) == nil { rows = append(rows, m) } } return rows } func actStr(m map[string]any, k string) string { s, _ := m[k].(string); return cleanText(s, 300) } func rowTime(m map[string]any) time.Time { t, _ := time.Parse(time.RFC3339, actStr(m, "ts")) return t } // opWords names the ops a person meets most in words; anything else keeps its // name, which is what the audit file and the Host both call it. func opWords(op string) string { switch op { case "dcn_read", "read_range": return "read" case "dcn_list", "list": return "listed" case "dcn_stat", "stat": return "looked at" case "pair", "pairing_add": return "Host paired" case "pairing_grant", "pairing_grant_sync": return "Host granted" case "pairing_revoke", "unpair": return "Host removed" case "quarantine_clear": return "Host released from quarantine" case "claim_owner": return "owner name set to" case "stepup_approve": return "approved" } return op } // purposeWords says an approval's purpose the way the request said it. func purposeWords(purpose string) string { if pid, sig, ok := parseLocalStopPurpose(purpose); ok { return fmt.Sprintf("stopping %s (pid %d)", sig, pid) } if kind, _, _ := strings.Cut(purpose, "|"); kind != "" { return "a request (" + kind + ")" } return "a request" } // ── the command ───────────────────────────────────────────────────────────── const activityUsage = "usage: privatae-bastion activity [--since 7d] [--json]" func cmdActivity(args []string) error { sinceFlag, asJSON := "", false for i := 0; i < len(args); i++ { switch { case args[i] == "--json": asJSON = true case args[i] == "--since" && i+1 < len(args): if _, err := parseSince(args[i+1]); err != nil { return err } sinceFlag = args[i+1] i++ default: return errors.New(activityUsage) } } v, err := readMachine("activity", readOpts{Since: sinceFlag}, readActivity) if err != nil { return laneRefusalError("activity", err) } if asJSON { return printJSON(os.Stdout, v) } printActivity(os.Stdout, v.Activity) return nil } // activityRead is `activity`: what happened here, newest first. type activityRead struct { Activity []activityItem `json:"activity"` } func readActivity(o readOpts) (activityRead, error) { since, err := o.since(7 * 24 * time.Hour) if err != nil { return activityRead{}, err } items := collectActivity(time.Now().Add(-since)) if items == nil { items = []activityItem{} } return activityRead{Activity: items}, nil } // parseSince takes Go durations and whole days: 90m, 12h, 7d. func parseSince(s string) (time.Duration, error) { if d, ok := strings.CutSuffix(s, "d"); ok { var n int if _, err := fmt.Sscanf(d, "%d", &n); err == nil && n > 0 && n <= 3650 { return time.Duration(n) * 24 * time.Hour, nil } } if d, err := time.ParseDuration(s); err == nil && d > 0 { return d, nil } return 0, fmt.Errorf("--since %q: give a duration like 12h or 7d", s) } func printActivity(w io.Writer, items []activityItem) { if len(items) == 0 { fmt.Fprintln(w, "Nothing has happened on this machine in that time.") return } for _, it := range items { who := it.Who if who != "" { who += " " } mark := "" if it.Refused { mark = " [refused]" } fmt.Fprintf(w, "%s %s%s%s\n", it.At.Local().Format("Jan 2 15:04"), who, it.What, mark) } }