package main // reads.go — what the machine's bastion knows, read by an agent the machine's // owner runs (plan Addendum 6, slice 2). // // Installed for the whole machine, the bastion runs as root and keeps its // records in root's state. An agent the person runs works in the person's // account: `status`, `activity` and `report` sent it to sudo or a browser, // and it has neither; `agents`, `schedules` and `tool-servers` showed it only // what that account can read. ask.sock (agentask.go) already knows from the // kernel which account connected. Asked for a read by the account recorded as // the machine's owner (owner_account.go), the daemon builds it with the // machine's own view and answers with it. That is what the owner's console // lane (consolelane.go) already shows that account, with one difference: // posture, which the console has no page for, adds the machine's firewall // checks, read as the administrator. The delegation tag an agent carries, // which the console never shows, is in no read at all (readAgents). // Any other account is refused; where a read means something with its own // permissions (the agents, schedules and tool servers it can see), it gets // that, and is told so. // // Each read is one value, built where the machine's state is and printed by // whoever asked: in words, or with --json as the value itself. The words and // the JSON are never built apart, so they cannot say different things. import ( "encoding/json" "errors" "fmt" "io" "net" "os" "runtime" "strconv" "sync" "time" ) const ( // readMaxBytes bounds one read's answer. A report on a busy machine is // a few hundred KB; this is room for a pathological one. readMaxBytes = 16 << 20 // readEnvelope is room in readMaxBytes for the reply around the view. readEnvelope = 64 << 10 // readDeadline is how long the daemon spends on one read. Comparing // against the baseline walks every process and digests their files. readDeadline = 90 * time.Second // readReuse is how long a heavy read's answer is handed out again // rather than run again: an agent polling it costs one pass, not one // pass per poll. readReuse = 30 * time.Second ) // readOpts are the flags a read takes, the same ones on both sides. type readOpts struct { Since string `json:"since,omitempty"` // activity, report: 12h, 7d Prompts bool `json:"prompts,omitempty"` // agents: each session's last prompt Host string `json:"host,omitempty"` // approvals: a Host's requests Machine bool `json:"machine,omitempty"` // approvals: the machine's own } func (o readOpts) since(def time.Duration) (time.Duration, error) { if o.Since == "" { return def, nil } return parseSince(o.Since) } type machineRead struct { build func(readOpts) (any, error) heavy bool // walks every process or runs the package manager } // machineReads are what the daemon answers over ask.sock, by name. var machineReads = map[string]machineRead{ "status": {build: func(o readOpts) (any, error) { return readStatus(o) }}, "agents": {build: func(o readOpts) (any, error) { return readAgents(o) }}, "schedules": {build: func(o readOpts) (any, error) { return readSchedules(o) }}, "tool-servers": {build: func(o readOpts) (any, error) { return readToolServers(o) }}, "posture": {build: func(o readOpts) (any, error) { return readPosture(o) }, heavy: true}, "baseline": {build: func(o readOpts) (any, error) { return readBaseline(o) }}, "baseline-diff": {build: func(o readOpts) (any, error) { return readBaselineDiff(o) }, heavy: true}, "activity": {build: func(o readOpts) (any, error) { return readActivity(o) }}, "report": {build: func(o readOpts) (any, error) { return readReport(o) }, heavy: true}, "approvals": {build: func(o readOpts) (any, error) { return readApprovals(o) }}, } // readsGoThroughLane: the machine's bastion is a system service this account // does not administer, so its state is not this process's to read. var readsGoThroughLane = func() bool { if runtime.GOOS == "windows" || machineAuthorityHeld() { return false // no ask pipe on Windows yet; the administrator reads directly } if runtime.GOOS == "linux" { return linuxSystemUnitPresent() } return systemService.installed } // laneError is the machine's bastion not answering a read for this account: // refused (not the owner's account), or nothing answering at all (down). type laneError struct { why string down bool } func (e *laneError) Error() string { return e.why } // readMachine is the read `name`: built here when this process holds the // machine's state, else asked of the machine's bastion. func readMachine[T any](name string, o readOpts, build func(readOpts) (T, error)) (T, error) { if !readsGoThroughLane() { return build(o) } var v T err := readViaSock(name, o, &v) return v, err } // readOwnOrMachine is readMachine for a read that means something with this // account's own permissions: refused by the machine's bastion, it reads what // this account can see, and says so on stderr. func readOwnOrMachine[T any](name string, o readOpts, build func(readOpts) (T, error)) (T, error) { v, err := readMachine(name, o, build) var le *laneError if !errors.As(err, &le) { return v, err } fmt.Fprintf(os.Stderr, "%s.\nShowing only what this account can read itself.\n", sentenceCase(le.why)) return build(o) } // sentenceCase starts s with a capital, for a reason said as a sentence. func sentenceCase(s string) string { if s == "" || s[0] < 'a' || s[0] > 'z' { return s } return string(s[0]-'a'+'A') + s[1:] } // laneRefusalError is a read that only the machine's bastion can answer, // refused: why, and how the administrator reads it. func laneRefusalError(cmd string, err error) error { var le *laneError if !errors.As(err, &le) { return err } return fmt.Errorf("%s\n As the administrator: sudo -H privatae-bastion %s", le.why, cmd) } // ── the asking side ───────────────────────────────────────────────────────── // readSockPath is where the machine's bastion answers; a variable so a test // can stand one up elsewhere. var readSockPath = askSockPath func readViaSock(name string, o readOpts, v any) error { p := readSockPath() c, err := net.DialTimeout("unix", p, 3*time.Second) if err != nil { return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"} } defer c.Close() _ = c.SetDeadline(time.Now().Add(readDeadline + 10*time.Second)) req := agentAskRequest{Op: "read", Read: name, Since: o.Since, Prompts: o.Prompts, Host: o.Host, Machine: o.Machine} if err := json.NewEncoder(c).Encode(req); err != nil { return &laneError{down: true, why: "the read could not be sent (" + err.Error() + ")"} } var r agentReadReply if err := json.NewDecoder(io.LimitReader(c, readMaxBytes)).Decode(&r); err != nil { return &laneError{down: true, why: "the machine's bastion did not answer the read (" + err.Error() + ")"} } if !r.OK { return &laneError{why: r.Why} } if err := json.Unmarshal(r.View, v); err != nil { return fmt.Errorf("the machine's bastion answered %s in a form this one cannot read (%v); "+ "the two may be different versions", name, err) } return nil } // ── the answering side (the daemon) ───────────────────────────────────────── type agentReadReply struct { OK bool `json:"ok"` Why string `json:"why,omitempty"` User string `json:"user,omitempty"` View json.RawMessage `json:"view,omitempty"` } // readLaneRefusal says why account uid may not read through the lane, or "". // The owner's account only: the agents, folders and prompts of every // account's sessions are the owner's to see, not another account's. func readLaneRefusal(uid uint32, who string) string { owner, ownerName, present, err := ownerAccountLoad() switch { case err != nil: return "this machine's owner account cannot be read (" + err.Error() + ")" case !present: return "no account is recorded as this machine's owner, so its bastion answers reads only for the " + "administrator. To read from your own account, the administrator records it once: " + "sudo -H privatae-bastion approvals-owner --account " + accountForHint(who) case owner != uid: name := ownerName if name == "" { name = "uid " + strconv.FormatUint(uint64(owner), 10) } return "this machine's bastion answers reads only for its owner's account (" + name + "), not " + who } return "" } // heavyReads runs one heavy read at a time and hands its answer out again // for readReuse. var heavyReads struct { sync.Mutex last map[string]heavyAnswer } type heavyAnswer struct { at time.Time view []byte } var readNow = time.Now // readSlots is how many reads the daemon answers at once: fewer than // ask.sock's connections (askSockInFlight), so reads that walk every process // can never leave a stop ask with no connection to arrive on. var readSlots = make(chan struct{}, 4) func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply { r := agentReadReply{User: user} if why := readLaneRefusal(uid, user); why != "" { r.Why = why return r } select { case readSlots <- struct{}{}: defer func() { <-readSlots }() default: r.Why = "this machine's bastion is answering " + strconv.Itoa(cap(readSlots)) + " reads already; ask again in a moment" return r } spec, ok := machineReads[req.Read] if !ok { r.Why = "this bastion has no read called " + strconv.Quote(cleanText(req.Read, 40)) return r } o := readOpts{Since: req.Since, Prompts: req.Prompts, Host: req.Host, Machine: req.Machine} view, err := runMachineRead(req.Read, spec, o) if err != nil { r.Why = err.Error() return r } if len(view) > readMaxBytes-readEnvelope { // Cut off at readMaxBytes, the asker would read a bastion that // does not answer. r.Why = fmt.Sprintf("this read's answer is %d MiB, more than the %d MiB one answer may be; "+ "ask for a shorter period with --since", (len(view)+(1<<20)-1)>>20, readMaxBytes>>20) return r } r.OK, r.View = true, view return r } func runMachineRead(name string, spec machineRead, o readOpts) ([]byte, error) { build := func() ([]byte, error) { v, err := spec.build(o) if err != nil { return nil, err } return json.Marshal(v) } if !spec.heavy { return build() } key, _ := json.Marshal(struct { N string O readOpts }{name, o}) heavyReads.Lock() defer heavyReads.Unlock() if a, ok := heavyReads.last[string(key)]; ok && readNow().Sub(a.at) < readReuse { return a.view, nil } b, err := build() if err != nil { return nil, err } if heavyReads.last == nil { heavyReads.last = map[string]heavyAnswer{} } for k, a := range heavyReads.last { if readNow().Sub(a.at) >= readReuse { delete(heavyReads.last, k) } } heavyReads.last[string(key)] = heavyAnswer{at: readNow(), view: b} return b, nil } // ── flags ─────────────────────────────────────────────────────────────────── // jsonOnly reads a command's flags when --json is its only one. func jsonOnly(usage string, args []string) (bool, error) { asJSON := false for _, a := range args { if a != "--json" { return false, errors.New(usage) } asJSON = true } return asJSON, nil } func printJSON(w io.Writer, v any) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(v) }