bastion 0.60.0
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user