Files
privatae-bastion/client-go/activity.go
T
Privatae 4f8285ef18
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s
bastion 0.60.0
2026-11-02 00:10:58 +00:00

304 lines
9.8 KiB
Go

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
// <pairing>/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 <who>" 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") // "<pid> <signature>", 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)
}
}