Files
privatae-bastion/client-go/reads.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

324 lines
11 KiB
Go

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)
}