bastion 0.61.0
ci / personal-data (push) Successful in 8s
ci / test (push) Successful in 7m17s
ci / fuzz (push) Successful in 6m33s

This commit is contained in:
Privatae
2026-11-02 06:20:42 +00:00
parent 4f8285ef18
commit 1176f7fd0b
47 changed files with 3574 additions and 294 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ func cmdAddFolder(host, path, name string) error {
return err
}
if len(cfgs) == 0 {
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
}
// Multi-homed boxes must NAME the host (0.10.0): guessing here is how a folder
// ends up proposed to the wrong entity. One pairing needs no flag.
+12 -1
View File
@@ -39,7 +39,7 @@ const (
)
type agentAskRequest struct {
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go) or "read" (reads.go)
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go), "read" (reads.go) or "act" (owneracts.go)
PID int `json:"pid"`
Request string `json:"request,omitempty"` // "status": the id `stop` printed
// "read": which one, and its flags (readOpts).
@@ -48,6 +48,12 @@ type agentAskRequest struct {
Prompts bool `json:"prompts,omitempty"`
Host string `json:"host,omitempty"`
Machine bool `json:"machine,omitempty"`
// "act": approve, deny or switch (owneracts.go), and what it acts on.
Act string `json:"act,omitempty"`
ID string `json:"id,omitempty"`
Code string `json:"code,omitempty"`
Family string `json:"family,omitempty"`
Off bool `json:"off,omitempty"`
}
type agentAskReply struct {
@@ -145,6 +151,11 @@ func askSockServe(c net.Conn) {
_ = json.NewEncoder(c).Encode(serveRead(uid, user, req))
return
}
if req.Op == "act" {
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
_ = json.NewEncoder(c).Encode(serveAct(uid, user, req))
return
}
_ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c)))
}
+6 -3
View File
@@ -528,10 +528,13 @@ type beatLanes struct {
// noCoders: a tcp pairing, whose coder runs have no control plane to use
// (pair_tcp.go coderTransportRefusal), so it is never offered one.
noCoders bool
// dcnDir: the pairing's DCN state, whose managed agents this beat probes
// and reports (faManagedProbe, faManagedStatus); "" reports none.
dcnDir string
}
func beatLanesFor(cfg *Config) beatLanes {
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport()}
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport(), dcnDir: cfg.dcnDir()}
}
// buildHeartbeatBodyOn is buildHeartbeatBody for a pairing whose lanes are lanes.
@@ -658,7 +661,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
faAdvertised, faOK := freeagentProbe()
// managed instances (freeagent_managed.go) count the same way: any
// serving instance advertises the capability; all must serve for ok
if mAdv, mOK := faManagedProbe(); mAdv {
if mAdv, mOK := faManagedProbe(lanes.dcnDir); mAdv {
if !faAdvertised {
faAdvertised, faOK = true, mOK
} else {
@@ -1067,7 +1070,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
}
if faAdvertised {
body["freeagent_ok"] = faOK
if st := faManagedStatus(); len(st) > 0 {
if st := faManagedStatus(lanes.dcnDir); len(st) > 0 {
body["freeagent_stores"] = st
}
}
+122 -3
View File
@@ -18,11 +18,14 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]"
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]\n" +
" privatae-bastion agents show <pid|session> [--json] [--prompts]"
var familyLabels = map[string]string{
"claude-code": "Claude Code", "aider": "aider", "goose": "goose", "cline": "Cline",
@@ -75,6 +78,13 @@ func readAgents(o readOpts) (agentsRead, error) {
}
func cmdAgents(args []string) error {
show := ""
if len(args) >= 1 && args[0] == "show" {
if len(args) < 2 || strings.HasPrefix(args[1], "--") {
return errors.New(agentsUsage)
}
show, args = args[1], args[2:]
}
asJSON, prompts := false, false
for _, a := range args {
switch a {
@@ -90,6 +100,17 @@ func cmdAgents(args []string) error {
if err != nil {
return err
}
if show != "" {
a, err := pickAgentView(v.Agents, show)
if err != nil {
return err
}
if asJSON {
return printJSON(os.Stdout, a)
}
printAgentDetail(os.Stdout, a, sessionNowFn())
return nil
}
if asJSON {
return printJSON(os.Stdout, v)
}
@@ -97,6 +118,104 @@ func cmdAgents(args []string) error {
return nil
}
// pickAgentView is the agent `agents show` means: a pid, or a session's
// name as `agents` shows it, which must match exactly one (the rule `stop`
// keeps, resolveStopTarget).
func pickAgentView(views []agentView, key string) (agentView, error) {
if pid, err := strconv.Atoi(key); err == nil {
for _, v := range views {
if v.PID == pid {
return v, nil
}
}
return agentView{}, fmt.Errorf("no AI agent here has pid %d; `privatae-bastion agents` lists them", pid)
}
var hits []agentView
for _, v := range views {
if v.Self != nil && v.Self.Name == key {
hits = append(hits, v)
}
}
switch len(hits) {
case 0:
return agentView{}, fmt.Errorf("no running session is named %q; `privatae-bastion agents` lists them", key)
case 1:
return hits[0], nil
}
return agentView{}, fmt.Errorf("%d sessions are named %q; give the pid instead", len(hits), key)
}
// printAgentDetail is one agent with everything a read holds about it: the
// lines `agents` prints, then what it is, how it was recognised, and what it
// says about itself. Its command line is in --json only: arguments can carry
// a key, and words are what gets pasted.
func printAgentDetail(w io.Writer, v agentView, now time.Time) {
if v.Role == "service" {
fmt.Fprintf(w, "background service (pid %d): starts background sessions; not a session itself\n", v.PID)
} else {
l := agentLinesOf(v, now)
fmt.Fprintln(w, l.Head)
if l.State != "" {
fmt.Fprintln(w, " "+l.State)
}
if l.Where != "" {
fmt.Fprintln(w, " "+l.Where)
}
}
row := func(k, val string) {
if val != "" {
fmt.Fprintf(w, " %-15s %s\n", k+":", val)
}
}
row("agent", familyLabel(v.Signature))
row("kind", v.Kind)
row("pid", strconv.Itoa(v.PID))
if v.PPID > 0 {
row("parent pid", strconv.Itoa(v.PPID))
}
// The kernel names the file, but whoever placed it chose its path.
row("program", shownPath(v.Exe))
if v.Exe == "" {
row("program", "unknown (the system did not say which file it runs)")
}
row("account", v.User)
if len(v.RunningNow) > 0 {
row("running now", strings.Join(v.RunningNow, "; "))
}
if len(v.ToolServers) > 0 {
row("tool servers", strings.Join(v.ToolServers, ", "))
}
if s := v.Self; s != nil {
src := s.Source
if s.Verified {
src += " (names this process)"
} else {
src += " (does not name this process)"
}
row("self-reported", src)
row("session id", s.SessionID)
row("last asked", quoteIf(s.LastPrompt))
}
fmt.Fprintln(w, "Names, titles and statuses are what the agent says about itself.")
}
// shownPath is a path the agent chose (its folder) or placed (the file it
// runs), as printed: quoted when it holds control characters, so an escape
// sequence in a name never reaches the terminal or the page.
func shownPath(p string) string {
if strings.ContainsFunc(p, unicode.IsControl) {
return strconv.Quote(p)
}
return p
}
func quoteIf(s string) string {
if s == "" {
return ""
}
return "\"" + s + "\""
}
// printAgents writes the views grouped by agent, sessions waiting for the
// person first, apps last.
func printAgents(w io.Writer, views []agentView, now time.Time) {
@@ -228,7 +347,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
// No session record: name it by what it is and where it works.
l.Head = familyLabel(v.Signature)
if v.Folder != "" {
l.Head += " in " + filepath.Base(v.Folder)
l.Head += " in " + shownPath(filepath.Base(v.Folder))
}
}
if v.Kind == kindApp {
@@ -255,7 +374,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
l.State = strings.Join(state, " · ")
var where []string
if v.Folder != "" {
f := "in " + v.Folder
f := "in " + shownPath(v.Folder)
if v.GitBranch != "" {
f += " (" + v.GitBranch + ")"
}
+1 -1
View File
@@ -380,7 +380,7 @@ func gitBranchOf(dir string) string {
return cleanText(b, 80)
}
if len(h) >= 12 {
return "detached at " + h[:12]
return "detached at " + cleanText(h[:12], 12)
}
return ""
}
+90 -27
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@@ -22,13 +23,13 @@ const backupUsage = `usage:
make this bastion's backup key here and print what to register on your
host (Settings > Backup > keys: kind, label, public key). --replace makes
a new one and keeps the old one, which still opens the backups made with it
privatae-bastion backup key show
privatae-bastion backup key show [--json]
the key this bastion holds, its fingerprint, and any older keys it keeps
privatae-bastion backup reseal --sidecar <file> --to <host key> [--stamp <stamp>]
[--out <file>] [--identity <key file>]
seal one backup's key to a host's own key (age1... or base64), so that
host, and only it, can open that one backup
privatae-bastion backup requests [--host <host>]
privatae-bastion backup requests [--host <host>] [--json]
restore requests your host sent this machine
privatae-bastion backup approve [--host <host>] <id> | deny [--host <host>] <id>
answer one
@@ -39,7 +40,7 @@ const backupUsage = `usage:
let the host paired as <host> list and send the backups in another
host's backup folder here (or any folder) — how a host that replaces a
lost one reaches the copies the lost one kept on this machine
privatae-bastion backup shares --with <host> / unshare --with <host> <name>
privatae-bastion backup shares --with <host> [--json] / unshare --with <host> <name>
the folders shared with a host, and taking one back`
func cmdBackup(args []string) error {
@@ -65,7 +66,11 @@ func cmdBackup(args []string) error {
return cmdBackupKeyInit(replace, flags["--label"])
}
if len(args) >= 2 && args[1] == "show" {
return cmdBackupKeyShow()
asJSON, err := jsonOnly(backupUsage, args[2:])
if err != nil {
return err
}
return cmdBackupKeyShow(asJSON)
}
case "reseal":
flags, rest := backupFlags(args[1:], "--sidecar", "--to", "--stamp", "--out", "--identity")
@@ -75,10 +80,11 @@ func cmdBackup(args []string) error {
return cmdBackupReseal(flags["--sidecar"], flags["--to"], flags["--stamp"], flags["--out"], flags["--identity"])
case "requests":
host, rest := takeHostFlag(args[1:])
if len(rest) != 0 {
return errors.New(backupUsage)
asJSON, err := jsonOnly(backupUsage, rest)
if err != nil {
return err
}
return cmdBackupRequests(host)
return cmdBackupRequests(host, asJSON)
case "approve", "deny":
host, rest := takeHostFlag(args[1:])
if len(rest) != 1 {
@@ -93,10 +99,11 @@ func cmdBackup(args []string) error {
return cmdBackupShare(flags["--with"], flags["--from"], flags["--dir"])
case "shares":
flags, rest := backupFlags(args[1:], "--with")
if len(rest) != 0 || flags["--with"] == "" {
asJSON, err := jsonOnly(backupUsage, rest)
if err != nil || flags["--with"] == "" {
return errors.New(backupUsage)
}
return cmdBackupShares(flags["--with"])
return cmdBackupShares(flags["--with"], asJSON)
case "unshare":
flags, rest := backupFlags(args[1:], "--with")
if len(rest) != 1 || flags["--with"] == "" {
@@ -163,24 +170,53 @@ func cmdBackupKeyInit(replace bool, label string) error {
return nil
}
func cmdBackupKeyShow() error {
// backupKeysRead is every backup key this bastion holds, the current one
// marked. The secret half never leaves its file (backupKeyFile.Identity).
type backupKeysRead struct {
Keys []*backupKeyFile `json:"keys"`
Current string `json:"current,omitempty"` // its fingerprint
}
func readBackupKeys(readOpts) (backupKeysRead, error) {
active, all, err := backupKeysLoad()
if err != nil {
return err
return backupKeysRead{}, err
}
if len(all) == 0 {
fmt.Println("no backup key on this machine — make one with `privatae-bastion backup key init`")
return nil
v := backupKeysRead{Keys: all}
if v.Keys == nil {
v.Keys = []*backupKeyFile{}
}
for _, k := range all {
if active != nil {
v.Current = active.Fingerprint
}
return v, nil
}
func cmdBackupKeyShow(asJSON bool) error {
v, err := readMachine("backup-keys", readOpts{}, readBackupKeys)
if err != nil {
return laneRefusalError("backup key show", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printBackupKeys(os.Stdout, v)
return nil
}
func printBackupKeys(w io.Writer, v backupKeysRead) {
if len(v.Keys) == 0 {
fmt.Fprintln(w, "no backup key on this machine — make one with `privatae-bastion backup key init`")
return
}
for _, k := range v.Keys {
state := "older key (still opens the backups made with it)"
if active != nil && k.Fingerprint == active.Fingerprint {
if k.Fingerprint == v.Current {
state = "current key"
}
fmt.Printf("%s %s\n public key: %s\n age recipient: %s\n made: %s\n key file: %s\n\n",
fmt.Fprintf(w, "%s %s\n public key: %s\n age recipient: %s\n made: %s\n key file: %s\n\n",
k.Fingerprint, state, k.PubB64, k.Recipient, k.CreatedAt, k.Path)
}
return nil
}
func backupIDs(identityFile string) ([]age.Identity, error) {
@@ -339,6 +375,9 @@ func pairingByName(name string) (*Config, error) {
if err != nil {
return nil, err
}
if cfg == nil && len(cfgs) == 0 {
return nil, fmt.Errorf("no host is paired with this bastion, so none is named '%s'", name)
}
if cfg == nil {
return nil, fmt.Errorf("no pairing matches '%s' — paired hosts:\n %s", name, strings.Join(hostNames(cfgs), "\n "))
}
@@ -379,22 +418,46 @@ func cmdBackupShare(with, from, dir string) error {
return nil
}
func cmdBackupShares(with string) error {
reader, err := pairingByName(with)
// backupSharesRead is the folders shared with one pairing.
type backupSharesRead struct {
With string `json:"with"`
Shares []backupShare `json:"shares"`
}
func readBackupShares(o readOpts) (backupSharesRead, error) {
reader, err := pairingByName(o.Host)
if err != nil {
return err
return backupSharesRead{}, err
}
list := sharesFor(reader.HostName)
if len(list) == 0 {
fmt.Println("no folders are shared with", reader.HostName)
return nil
v := backupSharesRead{With: reader.HostName, Shares: sharesFor(reader.HostName)}
if v.Shares == nil {
v.Shares = []backupShare{}
}
for _, s := range list {
fmt.Printf("shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
return v, nil
}
func cmdBackupShares(with string, asJSON bool) error {
v, err := readMachine("backup-shares", readOpts{Host: with}, readBackupShares)
if err != nil {
return laneRefusalError("backup shares --with "+with, err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printBackupShares(os.Stdout, v)
return nil
}
func printBackupShares(w io.Writer, v backupSharesRead) {
if len(v.Shares) == 0 {
fmt.Fprintln(w, "no folders are shared with", v.With)
return
}
for _, s := range v.Shares {
fmt.Fprintf(w, "shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
}
}
func cmdBackupUnshare(with, slug string) error {
reader, err := pairingByName(with)
if err != nil {
+68 -17
View File
@@ -45,6 +45,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"os"
"path/filepath"
@@ -579,28 +580,46 @@ func describeReseal(r *resealRequest) string {
// ── the person's side, on this machine ──────────────────────────────────────
func cmdBackupRequests(host string) error {
cfg, err := stepupPairing(host)
// backupRequestView is one restore request as a read shows it: the armored
// key the host sent, and the one resealed for it, are left out (the digest of
// the one the host sent stays); both are sealed, and a read has no use for
// them.
type backupRequestView struct {
ID string `json:"id"`
State string `json:"state"`
Stamp string `json:"stamp"`
Pairing string `json:"pairing"`
HostNumber int64 `json:"host_number"`
HostLabel string `json:"host_label"`
To string `json:"to"`
ToFP string `json:"to_fp"`
SidecarSHA string `json:"sidecar_sha256"`
At string `json:"at"`
DecidedAt string `json:"decided_at,omitempty"`
Error string `json:"error,omitempty"`
Words string `json:"words"`
}
type backupRequestsRead struct {
Host string `json:"host"`
Requests []backupRequestView `json:"requests"`
// SetAside are request files found unreadable and kept, not read.
SetAside []string `json:"set_aside,omitempty"`
}
func readBackupRequests(o readOpts) (backupRequestsRead, error) {
cfg, err := stepupPairing(o.Host)
if err != nil {
return err
return backupRequestsRead{}, err
}
dir := cfg.dcnDir()
v := backupRequestsRead{Host: cfg.HostName, Requests: []backupRequestView{}}
// Request files set aside as unreadable (resealLoad) are named here, so
// the person learns of them whichever process found them.
if aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*"); len(aside) > 0 {
fmt.Println("set aside as unreadable (kept, not read):")
for _, a := range aside {
fmt.Println(" " + a)
}
fmt.Println()
}
v.SetAside, _ = filepath.Glob(resealPath(dir) + resealAsideSuffix + "*")
m, err := resealRead(dir)
if err != nil {
return err
}
if len(m) == 0 {
fmt.Println("no backup requests from", cfg.HostName)
return nil
return v, err
}
ids := make([]string, 0, len(m))
for id := range m {
@@ -608,12 +627,44 @@ func cmdBackupRequests(host string) error {
}
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
for _, id := range ids {
fmt.Println(describeReseal(m[id]))
fmt.Println()
r := m[id]
v.Requests = append(v.Requests, backupRequestView{ID: r.ID, State: r.State, Stamp: r.Stamp,
Pairing: r.Pairing, HostNumber: r.HostNumber, HostLabel: r.HostLabel, To: r.To, ToFP: r.ToFP,
SidecarSHA: r.SidecarSH, At: r.At, DecidedAt: r.DecidedAt, Error: r.Error, Words: describeReseal(r)})
}
return v, nil
}
func cmdBackupRequests(host string, asJSON bool) error {
v, err := readMachine("backup-requests", readOpts{Host: host}, readBackupRequests)
if err != nil {
return laneRefusalError("backup requests", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printBackupRequests(os.Stdout, v)
return nil
}
func printBackupRequests(w io.Writer, v backupRequestsRead) {
if len(v.SetAside) > 0 {
fmt.Fprintln(w, "set aside as unreadable (kept, not read):")
for _, a := range v.SetAside {
fmt.Fprintln(w, " "+a)
}
fmt.Fprintln(w)
}
if len(v.Requests) == 0 {
fmt.Fprintln(w, "no backup requests from", v.Host)
return
}
for _, r := range v.Requests {
fmt.Fprintln(w, r.Words)
fmt.Fprintln(w)
}
}
func cmdBackupDecide(host, prefix string, approve bool) error {
cfg, err := stepupPairing(host)
if err != nil {
+156 -44
View File
@@ -8,22 +8,32 @@ package main
import (
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"time"
)
// cmdStepupDrop revokes one unused approval this box issued for a pairing.
// From the owner's account beside the machine's bastion it asks that
// bastion (owneracts.go): dropping only takes reach away, so no code.
func cmdStepupDrop(host string, machine bool, prefix string) error {
return printOrAct(agentAskRequest{Act: "approvals-drop", Host: host, Machine: machine, ID: prefix},
stepupCommand("approvals drop", host, machine, prefix),
func() (string, error) { return stepupDropText(host, machine, prefix) })
}
func stepupDropText(host string, machine bool, prefix string) (string, error) {
t, err := resolveStepupTarget(host, machine)
if err != nil {
return err
return "", err
}
dir := t.dir
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return errors.New("which approval? (the id `approve` printed)")
return "", errors.New("which approval? (the id `approve` printed)")
}
stepupMu.Lock()
defer stepupMu.Unlock()
@@ -36,42 +46,45 @@ func cmdStepupDrop(host string, machine bool, prefix string) error {
}
switch len(hits) {
case 0:
return fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
return "", fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
case 1:
default:
return fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
return "", fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
}
a := m[hits[0]]
a.Used = true // burned without ever having run: the same end state a use reaches
m[hits[0]] = a
if err := approvalsSave(dir, m); err != nil {
return err
return "", err
}
fmt.Printf("dropped approval %s (%s) — nothing can run on it now\n", hits[0][:8], a.Purpose)
return nil
return fmt.Sprintf("dropped approval %s (%s) — nothing can run on it now", hits[0][:8], a.Purpose), nil
}
// cmdGrantsDrop removes a held store grant from this box.
// cmdGrantsDrop removes a held store grant from this box (through the
// owner's lane beside the machine's bastion, as cmdStepupDrop).
func cmdGrantsDrop(host, store string) error {
return printOrAct(agentAskRequest{Act: "grants-drop", Host: host, ID: store},
"grants drop "+store+hostFlagWords(host), func() (string, error) { return grantsDropText(host, store) })
}
func grantsDropText(host, store string) (string, error) {
cfg, err := stepupPairing(host)
if err != nil {
return err
return "", err
}
id, err := strconv.ParseInt(strings.TrimSpace(store), 10, 64)
if err != nil {
return errors.New("store id must be a number (the id on the host's store card)")
return "", errors.New("store id must be a number (the id on the host's store card)")
}
dir := cfg.dcnDir()
present, err := dcnDropStore(dir, id, "dropped at this machine's console")
if err != nil {
return err
return "", err
}
if !present {
fmt.Printf("store %d was not held here for %s\n", id, cfg.HostName)
return nil
return fmt.Sprintf("store %d was not held here for %s", id, cfg.HostName), nil
}
fmt.Printf("store %d dropped for %s — the host's reads of it stop now and it learns on the next check-in\n", id, cfg.HostName)
return nil
return fmt.Sprintf("store %d dropped for %s — the host's reads of it stop now and it learns on the next check-in", id, cfg.HostName), nil
}
// cmdQuarantineClear lifts this box's hold on a pairing's write lanes.
@@ -90,65 +103,140 @@ func cmdQuarantineClear(host string) error {
}
// cmdOpsList prints the ops and op grants a pairing holds here (ops.go).
func cmdOpsList(host string) error {
cfg, err := stepupPairing(host)
// opRow is one op a Host holds here, verified against its signature or not.
type opRow struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Privilege string `json:"privilege,omitempty"`
Summary string `json:"summary,omitempty"`
NotVerified string `json:"not_verified,omitempty"`
}
// opGrantRow is one op grant this machine holds and has not dropped.
type opGrantRow struct {
ID string `json:"id"`
Op string `json:"op,omitempty"`
Version string `json:"version,omitempty"`
RunsLeft int `json:"runs_left"`
Until string `json:"until,omitempty"`
Within string `json:"within,omitempty"`
NotVerified string `json:"not_verified,omitempty"`
}
// opsRead is what ops and op grants one pairing holds on this machine.
type opsRead struct {
Host string `json:"host"`
Sandbox bool `json:"sandbox"`
SandboxUnavailable string `json:"sandbox_unavailable,omitempty"`
Ops []opRow `json:"ops"`
Grants []opGrantRow `json:"grants"`
}
func readOps(o readOpts) (opsRead, error) {
cfg, err := stepupPairing(o.Host)
if err != nil {
return err
return opsRead{}, err
}
dir := cfg.dcnDir()
ok, why := opSandboxAvailable()
if ok {
fmt.Println("sandbox: available")
} else {
fmt.Println("sandbox: NOT available —", why)
}
n := 0
v := opsRead{Host: cfg.HostName, Ops: []opRow{}, Grants: []opGrantRow{}}
v.Sandbox, v.SandboxUnavailable = opSandboxAvailable()
if entries, err := os.ReadDir(opsDir(dir)); err == nil {
for _, e := range entries {
if !e.IsDir() || e.Name() == opsRunsDirName || strings.HasSuffix(e.Name(), ".tmp") {
continue
}
n++
r := opRow{ID: e.Name()[:12]}
if m, err := opLoad(dir, e.Name()); err == nil {
fmt.Printf("op %s %s as %s [%s]\n %s\n", m.Name, m.Version, m.Privilege, e.Name()[:12], m.Summary)
r.Name, r.Version, r.Privilege, r.Summary = m.Name, m.Version, m.Privilege, m.Summary
} else {
fmt.Printf("op [%s] NOT VERIFIED: %v\n", e.Name()[:12], err)
r.NotVerified = err.Error()
}
v.Ops = append(v.Ops, r)
}
}
f := opsLoad(dir)
nonces := make([]string, 0, len(f.Grants))
for nonce, rec := range f.Grants {
if rec.Dropped != "" {
continue
if rec.Dropped == "" {
nonces = append(nonces, nonce)
}
t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant)
if err != nil {
fmt.Printf("grant [%s] NOT VERIFIED: %v\n", nonce[:8], err)
continue
}
sort.Strings(nonces)
for _, nonce := range nonces {
rec := f.Grants[nonce]
r := opGrantRow{ID: nonce[:8], RunsLeft: rec.UsesLeft}
if t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant); err != nil {
r.NotVerified = err.Error()
} else {
r.Op, r.Version, r.Until, r.Within = t.Op, t.Version, t.Expires, t.Bounds
}
fmt.Printf("grant [%s] %s %s %d run(s) left, until %s\n within: %s\n", nonce[:8], t.Op, t.Version, rec.UsesLeft, t.Expires, t.Bounds)
v.Grants = append(v.Grants, r)
}
if n == 0 && len(f.Grants) == 0 {
fmt.Println("no ops held for", cfg.HostName)
return v, nil
}
func cmdOpsList(host string, asJSON bool) error {
v, err := readMachine("ops", readOpts{Host: host}, readOps)
if err != nil {
return laneRefusalError("ops", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printOps(os.Stdout, v)
return nil
}
func printOps(w io.Writer, v opsRead) {
if v.Sandbox {
fmt.Fprintln(w, "sandbox: available")
} else {
fmt.Fprintln(w, "sandbox: NOT available —", v.SandboxUnavailable)
}
for _, r := range v.Ops {
if r.NotVerified != "" {
fmt.Fprintf(w, "op [%s] NOT VERIFIED: %s\n", r.ID, r.NotVerified)
continue
}
fmt.Fprintf(w, "op %s %s as %s [%s]\n %s\n", r.Name, r.Version, r.Privilege, r.ID, r.Summary)
}
for _, r := range v.Grants {
if r.NotVerified != "" {
fmt.Fprintf(w, "grant [%s] NOT VERIFIED: %s\n", r.ID, r.NotVerified)
continue
}
fmt.Fprintf(w, "grant [%s] %s %s %d run(s) left, until %s\n within: %s\n", r.ID, r.Op, r.Version, r.RunsLeft, r.Until, r.Within)
}
if len(v.Ops) == 0 && len(v.Grants) == 0 {
fmt.Fprintln(w, "no ops held for", v.Host)
}
}
// cmdOpsDrop takes a held op grant back at the console.
func cmdOpsDrop(host, prefix string) error {
return printOrAct(agentAskRequest{Act: "ops-drop", Host: host, ID: prefix},
"ops drop "+prefix+hostFlagWords(host), func() (string, error) { return opsDropText(host, prefix) })
}
func opsDropText(host, prefix string) (string, error) {
cfg, err := stepupPairing(host)
if err != nil {
return err
return "", err
}
dir := cfg.dcnDir()
prefix = strings.TrimSpace(prefix)
if prefix == "" {
// Every nonce starts with "": with one grant held, an empty id
// would drop it unnamed.
return "", errors.New("which op grant? (the id `privatae-bastion ops` lists, or --all)")
}
if prefix == "--all" {
dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true })
if err != nil {
return err
return "", err
}
fmt.Printf("%d op grant(s) dropped — the host cannot run under any of them again\n", len(dropped))
return nil
return fmt.Sprintf("%d op grant(s) dropped — the host cannot run under any of them again", len(dropped)), nil
}
var hits []string
for nonce, rec := range opsLoad(dir).Grants {
@@ -158,14 +246,38 @@ func cmdOpsDrop(host, prefix string) error {
}
switch len(hits) {
case 0:
return fmt.Errorf("no held op grant starts with %q", prefix)
return "", fmt.Errorf("no held op grant starts with %q", prefix)
case 1:
default:
return fmt.Errorf("%d grants start with %q — give more of the id", len(hits), prefix)
return "", fmt.Errorf("%d grants start with %q — give more of the id", len(hits), prefix)
}
if _, err := opsDropGrant(dir, hits[0], "dropped at this machine's console"); err != nil {
return "", err
}
return fmt.Sprintf("op grant %s dropped — the host cannot run under it again", hits[0][:8]), nil
}
// printOrAct runs a narrowing act: through the owner's lane beside the
// machine's bastion (owneracts.go), else here, printing what it did either way.
func printOrAct(req agentAskRequest, adminCmd string, here func() (string, error)) error {
var done string
var err error
if readsGoThroughLane() {
done, err = actViaSock(req)
if err != nil {
return laneRefusalError(adminCmd, err)
}
} else if done, err = here(); err != nil {
return err
}
fmt.Printf("op grant %s dropped — the host cannot run under it again\n", hits[0][:8])
fmt.Println(done)
return nil
}
// hostFlagWords is " --host <host>" when one was named.
func hostFlagWords(host string) string {
if host == "" {
return ""
}
return " --host " + host
}
+5 -3
View File
@@ -680,7 +680,7 @@ func handleDcnGrantSync(ws *websocket.Conn, dir string, args []string) {
if dcnHasMask(terms, "search") {
faEnsureStore(dir, payload.StoreID, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient)
} else {
faStop(payload.StoreID)
faStop(dir, payload.StoreID)
}
dcnResult(ws, dir, "grant_sync", payload.StoreID, "", map[string]any{
"state": "ok", "root": terms.Root, "mask": terms.Mask,
@@ -708,14 +708,16 @@ func dcnDropStoreLocked(dir string, storeID int64, why string) (bool, error) {
}
key := strconv.FormatInt(storeID, 10)
if _, ok := f.Stores[key]; !ok {
faStop(storeID) // nothing on disk, but never leave an orphan instance running
// Nothing on disk, but never leave an orphan instance running: this
// pairing's own (faKey), never another Host's for the same store id.
faStop(dir, storeID)
return false, nil
}
delete(f.Stores, key)
if err := dcnSaveFile(dir, f); err != nil {
return true, err
}
faStop(storeID)
faStop(dir, storeID)
dcnAudit(dir, "grant_drop", storeID, "", why)
return true, nil
}
+73 -15
View File
@@ -33,6 +33,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@@ -847,31 +848,82 @@ func deviceShownPath() (string, error) {
return filepath.Join(d, "device_shown.json"), nil
}
func cmdDeviceApprovals() error {
// deviceAsk is one ask waiting on this approval device.
type deviceAsk struct {
ID string `json:"id"`
Server string `json:"server"`
At string `json:"at"`
Words string `json:"words"`
}
type deviceApprovalsRead struct {
Asks []deviceAsk `json:"asks"`
}
func readDeviceApprovals(readOpts) (deviceApprovalsRead, error) {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
shown := []string{}
v := deviceApprovalsRead{Asks: []deviceAsk{}}
for _, id := range sortedDeviceRequests(m) {
r := m[id]
if r.State != "pending" {
continue
}
shown = append(shown, id)
fmt.Printf("[%s] on %s, asked %s\n%s\n\n", id[:8], r.Server, r.At, r.Words)
v.Asks = append(v.Asks, deviceAsk{ID: id, Server: r.Server, At: r.At, Words: r.Words})
}
if p, err := deviceShownPath(); err == nil {
raw, _ := json.Marshal(shown)
_ = atomicWrite(p, raw)
return v, nil
}
// cmdDeviceApprovals lists the asks waiting here. Read in words by this
// process, it records them as shown, which is all `device-approve all` will
// sign. --json is for a program, not a person, and records nothing; nor does
// a list read through the lane, which `device-approve` from this account
// would not find.
func cmdDeviceApprovals(args []string) error {
asJSON, err := jsonOnly("usage: privatae-bastion device-approvals [--json]", args)
if err != nil {
return err
}
if len(shown) == 0 {
fmt.Println("nothing is waiting for your approval")
return nil
lane := readsGoThroughLane()
v, err := readMachine("device-approvals", readOpts{}, readDeviceApprovals)
if err != nil {
return laneRefusalError("device-approvals", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printDeviceApprovals(os.Stdout, v, lane)
if !lane {
shown := []string{}
for _, a := range v.Asks {
shown = append(shown, a.ID)
}
if p, err := deviceShownPath(); err == nil {
raw, _ := json.Marshal(shown)
_ = atomicWrite(p, raw)
}
}
fmt.Printf("approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all\n")
return nil
}
// printDeviceApprovals prints the asks; lane: they were read from the
// machine's bastion, so they are answered as its administrator.
func printDeviceApprovals(w io.Writer, v deviceApprovalsRead, lane bool) {
for _, a := range v.Asks {
fmt.Fprintf(w, "[%s] on %s, asked %s\n%s\n\n", a.ID[:min(8, len(a.ID))], a.Server, a.At, a.Words)
}
switch {
case len(v.Asks) == 0:
fmt.Fprintln(w, "nothing is waiting for your approval")
case lane:
fmt.Fprintln(w, "These wait on the machine's bastion. As the administrator: sudo -H privatae-bastion device-approvals, "+
"then device-approve <id> or device-approve all")
default:
fmt.Fprintln(w, "approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all")
}
}
func cmdDeviceApprove(prefix string) error { return cmdDeviceApproveCode(prefix, "") }
func cmdDeviceApproveCode(prefix, code string) error {
@@ -947,19 +999,25 @@ func cmdDeviceApproveCode(prefix, code string) error {
return nil
}
// cmdDeviceDeny refuses one ask waiting on this approval device (through the
// owner's lane beside the machine's bastion: denying takes nothing).
func cmdDeviceDeny(prefix string) error {
return printOrAct(agentAskRequest{Act: "device-deny", ID: prefix}, "device-deny "+prefix,
func() (string, error) { return deviceDenyText(prefix) })
}
func deviceDenyText(prefix string) (string, error) {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
id, err := findDeviceRequest(m, prefix)
if err != nil {
return err
return "", err
}
if err := deviceDeny(id); err != nil {
return err
return "", err
}
fmt.Printf("denied %s\n", id)
return nil
return "denied " + id, nil
}
func findDeviceRequest(m map[string]deviceRequest, prefix string) (string, error) {
+360
View File
@@ -0,0 +1,360 @@
package main
// doctor.go — what is wrong with this install, and the fix for each one
// (plan Addendum 6, slice 3).
//
// privatae-bastion doctor [--json]
//
// Each check is a fact the bastion already holds, said as a problem and the
// command that fixes it. The machine's checks are built where its state is:
// in the daemon for the owner's account, through the lane (reads.go), or
// directly for the administrator and a per-account install. The one thing
// only the asking side can find out is whether the lane answers it at all,
// and that becomes the check when it does not. Exit status 1 when any check
// finds a problem, so a script can branch on it.
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const doctorUsage = "usage: privatae-bastion doctor [--json]"
type doctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"` // what was found, when all is well
Problem string `json:"problem,omitempty"`
Fix string `json:"fix,omitempty"` // the command, or what to do
}
type doctorRead struct {
Version string `json:"version"`
Checks []doctorCheck `json:"checks"`
}
func (d doctorRead) problems() int {
n := 0
for _, c := range d.Checks {
if !c.OK {
n++
}
}
return n
}
// systemInstalled: the bastion is installed for the whole machine.
var systemInstalled = func() bool {
if runtime.GOOS == "linux" {
return linuxSystemUnitPresent()
}
return systemService.installed
}
// doctorSandbox is opSandboxAvailable, replaceable in a test: its probe
// re-executes this binary.
var doctorSandbox = opSandboxAvailable
// asAdmin is a bastion command said the way it has to be typed here: the
// machine's bastion keeps its state as the administrator when it is
// installed for the whole machine.
func asAdmin(cmd string) string {
switch {
case !systemInstalled():
return "privatae-bastion " + cmd
case runtime.GOOS == "windows":
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
}
return "sudo -H privatae-bastion " + cmd
}
// installerCommand is how this platform installs the bastion, and so how a
// machine no Host updates moves to a newer version.
func installerCommand() string {
if runtime.GOOS == "windows" {
return "irm https://privatae.ai/bastion/install.ps1 | iex"
}
return "curl -fsSL https://privatae.ai/bastion/install.sh | sh"
}
// doctorLatest is the newest published version, or "" when it cannot be
// known: the daemon's last answer, else the last time doctor asked
// privatae.ai, if that was within updateCheckEvery, else one fetch that
// waits at most doctorLatestWait. A request to privatae.ai carries this
// machine's address to the platform, so doctor keeps update.go's rate
// (four a day): a run in its own process, or an agent polling it, asks again
// only once that time has passed, whether the last ask was answered or not.
// Running with no route to privatae.ai is supported (update.go), so "" is
// not a problem.
var (
doctorLatestWait = 10 * time.Second
doctorLatest = func() string {
latestMu.Lock()
v := latestPublished
latestMu.Unlock()
if v != "" {
return v
}
if r, ok := doctorAskedLoad(); ok && doctorNow().Sub(r.AskedAt) < updateCheckEvery {
return r.Version
}
got := make(chan string, 1)
go func() {
v, err := doctorFetch()
if err != nil {
v = ""
}
got <- strings.TrimSpace(v)
}()
select {
case v = <-got:
case <-time.After(doctorLatestWait):
}
if v != "" {
recordLatestPublished(v)
} else if r, ok := doctorAskedLoad(); ok {
v = r.Version // no answer this time: the last one stands, as in update.go
}
doctorAskedSave(doctorAsked{Version: v, AskedAt: doctorNow()})
return v
}
doctorNow = time.Now
doctorFetch = func() (string, error) {
m, err := fetchManifest()
if err != nil {
return "", err
}
return m.Version, nil
}
)
// doctorAsked is when doctor last asked privatae.ai, and what it knew after.
type doctorAsked struct {
Version string `json:"version"`
AskedAt time.Time `json:"asked_at"`
}
func doctorAskedPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "doctor_latest.json"), nil
}
func doctorAskedLoad() (doctorAsked, bool) {
var r doctorAsked
p, err := doctorAskedPath()
if err != nil {
return r, false
}
raw, err := os.ReadFile(p)
if err != nil || json.Unmarshal(raw, &r) != nil || r.AskedAt.IsZero() {
return doctorAsked{}, false
}
return r, true
}
func doctorAskedSave(r doctorAsked) {
p, err := doctorAskedPath()
if err != nil {
return
}
if raw, err := json.Marshal(r); err == nil {
_ = atomicWrite(p, raw)
}
}
// logHint is where the bastion's own log is read on this platform.
func logHint() string {
switch runtime.GOOS {
case "linux":
if systemInstalled() {
return "journalctl -u privatae-bastion"
}
return "journalctl --user -u privatae-bastion"
case "windows":
return "the bastion's window, or Event Viewer when it runs as a service"
}
return "Console.app, process privatae-bastion"
}
func readDoctor(readOpts) (doctorRead, error) {
d := doctorRead{Version: clientVersion, Checks: []doctorCheck{}}
add := func(c doctorCheck) { d.Checks = append(d.Checks, c) }
s, err := readStatus(readOpts{})
if err != nil {
add(doctorCheck{Name: "pairings", Problem: sentenceCase(err.Error()) + ".",
Fix: "The bastion's log says why: " + logHint()})
return d, nil
}
paired := len(s.Pairings) > 0
start := runCommandHint()
if runtime.GOOS == "linux" && systemInstalled() {
start = "sudo " + start
}
switch {
case s.Running == "no":
add(doctorCheck{Name: "running", Problem: "The bastion is not running, so nothing on this machine is watched.",
Fix: start})
case !paired:
add(doctorCheck{Name: "running", OK: true, Detail: "Running on its own; no Host is connected."})
default:
add(doctorCheck{Name: "running", OK: true, Detail: fmt.Sprintf("Running, paired with %d Host(s).", len(s.Pairings))})
}
switch latest := doctorLatest(); {
case latest == "":
add(doctorCheck{Name: "version", OK: true, Detail: "This is " + clientVersion +
"; the latest published version could not be read from privatae.ai."})
case versionNewer(latest, clientVersion):
fix := "Run the installer again: " + installerCommand()
if paired {
fix = "Your Host can update it (Network app, Update), or run the installer again: " + installerCommand()
}
add(doctorCheck{Name: "version", Problem: "Version " + latest + " is published; this machine runs " +
clientVersion + ".", Fix: fix})
default:
add(doctorCheck{Name: "version", OK: true, Detail: clientVersion + " is the latest published version."})
}
if systemInstalled() {
_, name, present, err := ownerAccountLoad()
fix := "sudo -H privatae-bastion approvals-owner --account <your account>"
if runtime.GOOS == "windows" {
fix = "privatae-bastion approvals-owner --sid <your account's SID> (from an Administrator terminal)"
}
switch {
case err != nil:
add(doctorCheck{Name: "owner", Problem: "This machine's owner account cannot be read (" + err.Error() + ").",
Fix: strings.Replace(fix, "approvals-owner ", "approvals-owner --replace ", 1)})
case !present:
add(doctorCheck{Name: "owner", Problem: "No account is recorded as this machine's owner, so the console " +
"opens only for the administrator, and agents in your account can neither read the machine nor ask it " +
"to stop an agent.", Fix: fix})
default:
add(doctorCheck{Name: "owner", OK: true, Detail: "The machine's owner account is " + name + "."})
}
}
factor := existingMachineFactorDir()
if stepupEnrolled(factor) {
add(doctorCheck{Name: "second factor", OK: true, Detail: "The machine's owner has a second factor here."})
} else {
add(doctorCheck{Name: "second factor", Problem: "This machine has no second factor of its own, so a stop, " +
"and every ask an agent files, cannot be approved.", Fix: asAdmin("stepup-enroll --machine")})
}
now := time.Now()
var held []string
for account, e := range askQuarantineLoad(factor) {
if quarantinedAt(e, now) {
held = append(held, account)
}
}
sort.Strings(held)
for _, account := range held {
add(doctorCheck{Name: "held asker", Problem: sentenceCase(askHoldRefusal(account, askHeldUntil(factor, account, now))) + ".",
Fix: asAdmin("quarantine clear --asker " + account)})
}
for _, p := range s.Pairings {
switch {
case p.Problem != "":
add(doctorCheck{Name: "host " + p.Host, Problem: "The pairing with " + p.Host + " is not usable: " +
p.Problem + ".", Fix: "The bastion's log says more: " + logHint()})
case !p.Answering && s.Running != "no":
add(doctorCheck{Name: "host " + p.Host, Problem: "The bastion is not serving " + p.Host +
" (nothing answers on " + p.Local + ").", Fix: "The bastion's log says why: " + logHint()})
}
}
if paired {
localDenyMu.Lock()
_, derr := localDenyLoad()
localDenyMu.Unlock()
if derr != nil {
add(doctorCheck{Name: "switches", Problem: localDenyUnreadableText(derr), Fix: asAdmin("switch --reset")})
}
if ok, why := doctorSandbox(); !ok {
c := doctorCheck{Name: "sandbox", Problem: "Ops a Host sends cannot run sandboxed here: " + why + "."}
if !processIsRoot() {
c.Fix = "Install the bastion for the whole machine: run the installer again without --just-me: " +
installerCommand()
}
add(c)
}
}
return d, nil
}
func cmdDoctor(args []string) error {
asJSON, err := jsonOnly(doctorUsage, args)
if err != nil {
return err
}
d, err := readMachine("doctor", readOpts{}, readDoctor)
var le *laneError
if errors.As(err, &le) {
// The one check the machine cannot make for this account.
d = doctorRead{Version: clientVersion, Checks: []doctorCheck{laneCheck(le)}}
err = nil
}
if err != nil {
return err
}
if asJSON {
if err := printJSON(os.Stdout, d); err != nil {
return err
}
} else {
printDoctor(os.Stdout, d)
}
if d.problems() > 0 {
return quietExit{code: 1}
}
return nil
}
// laneCheck is the lane not answering this account, as a check.
func laneCheck(le *laneError) doctorCheck {
if le.down {
fix := "Start it: sudo " + runCommandHintFor(runtime.GOOS) + " (see whether it runs: systemctl status privatae-bastion)"
if runtime.GOOS != "linux" {
fix = "Start it: " + runCommandHint()
}
return doctorCheck{Name: "running", Problem: "The machine's bastion does not answer this account: " + le.why + ".",
Fix: fix}
}
return doctorCheck{Name: "reads", Problem: sentenceCase(le.why) + ".",
Fix: "As the administrator: sudo -H privatae-bastion doctor"}
}
func printDoctor(w io.Writer, d doctorRead) {
switch n := d.problems(); n {
case 0:
fmt.Fprintf(w, "privatae-bastion %s: nothing wrong found.\n", d.Version)
case 1:
fmt.Fprintf(w, "privatae-bastion %s: 1 problem.\n", d.Version)
default:
fmt.Fprintf(w, "privatae-bastion %s: %d problems.\n", d.Version, n)
}
for _, c := range d.Checks {
if c.OK {
fmt.Fprintln(w, " ok "+c.Detail)
continue
}
fmt.Fprintln(w, " PROBLEM "+c.Problem)
if c.Fix != "" {
fmt.Fprintln(w, " Fix: "+c.Fix)
}
}
}
+1 -1
View File
@@ -117,7 +117,7 @@ func buildEnforcingReport(dir string, allowed []string) map[string]any {
b, _ := strconv.ParseInt(ids[j], 10, 64)
return a < b
})
status := faManagedStatus()
status := faManagedStatus(dir)
for _, k := range ids {
id, err := strconv.ParseInt(k, 10, 64)
if err != nil {
+10 -10
View File
@@ -223,8 +223,8 @@ func freeagentDo(c freeagentConfig, method, path string, body any, d time.Durati
// faConfigFor is the client config for a store: the MANAGED instance the
// bastion runs for it (freeagent_managed.go) when there is one, else the
// operator's PRIVATAE_BASTION_AGENT_* env for a hand-run agent.
func faConfigFor(storeID int64) (freeagentConfig, bool) {
if c, ok := faManagedConfig(storeID); ok {
func faConfigFor(dcnDir string, storeID int64) (freeagentConfig, bool) {
if c, ok := faManagedConfig(dcnDir, storeID); ok {
return c, true
}
return freeagentEnv()
@@ -232,8 +232,8 @@ func faConfigFor(storeID int64) (freeagentConfig, bool) {
// faAbsentReason says WHY there is no local agent to ask, in the words the
// host should relay: a missing Python is the member's one manual step.
func faAbsentReason(storeID int64) string {
inst, ok := faSnapshot(storeID)
func faAbsentReason(dcnDir string, storeID int64) string {
inst, ok := faSnapshot(dcnDir, storeID)
if ok {
switch inst.State {
case "missing-python":
@@ -358,9 +358,9 @@ func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
if terms == nil {
return
}
c, on := faConfigFor(storeID)
c, on := faConfigFor(dir, storeID)
if !on {
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(storeID))
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(dir, storeID))
return
}
q := strings.TrimSpace(a.Query)
@@ -419,9 +419,9 @@ func handleDcnCatalog(ws *websocket.Conn, dir string, args []string) {
"catalog needs the 'index' mask on this grant (re-approve the store with index)")
return
}
c, on := faConfigFor(storeID)
c, on := faConfigFor(dir, storeID)
if !on {
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(storeID))
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(dir, storeID))
return
}
path := "/catalog"
@@ -471,9 +471,9 @@ func handleDcnReadRangeDoc(ws *websocket.Conn, dir string, storeID int64, a *dcn
if terms == nil {
return
}
c, on := faConfigFor(storeID)
c, on := faConfigFor(dir, storeID)
if !on {
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, faAbsentReason(storeID))
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, faAbsentReason(dir, storeID))
return
}
start := a.StartByte
+48 -25
View File
@@ -123,20 +123,29 @@ type faInstance struct {
}
// faSnapshot copies the fields a reader needs, under the lock.
func faSnapshot(storeID int64) (faInstance, bool) {
func faSnapshot(dcnDir string, storeID int64) (faInstance, bool) {
faMgr.Lock()
defer faMgr.Unlock()
inst, ok := faMgr.inst[storeID]
inst, ok := faMgr.inst[faKey{dcnDir, storeID}]
if !ok {
return faInstance{}, false
}
return *inst, true
}
// faKey is one pairing's store: store ids are each Host's own, so the same id
// from two Hosts is two stores. Keyed by store id alone, a machine paired with
// two Hosts ran one agent for both, and a search from one Host for its store
// was answered by the agent over the other Host's folder.
type faKey struct {
Dir string // the pairing's DCN state dir (Config.dcnDir)
ID int64
}
var faMgr = struct {
sync.Mutex
inst map[int64]*faInstance
}{inst: map[int64]*faInstance{}}
inst map[faKey]*faInstance
}{inst: map[faKey]*faInstance{}}
// ── locations ───────────────────────────────────────────────────────────────
@@ -664,8 +673,8 @@ func faRecipientOf(inst *faInstance) string {
// faManagedConfig is the client config for a store's managed instance, or
// false when none is running (port file absent).
func faManagedConfig(storeID int64) (freeagentConfig, bool) {
inst, ok := faSnapshot(storeID)
func faManagedConfig(dcnDir string, storeID int64) (freeagentConfig, bool) {
inst, ok := faSnapshot(dcnDir, storeID)
if !ok {
return freeagentConfig{}, false
}
@@ -697,10 +706,11 @@ func faEnsureStore(dcnDir string, storeID int64, terms *dcnTerms, sweep, recipie
}
cn := "store-" + strconv.FormatInt(storeID, 10)
faMgr.Lock()
inst, ok := faMgr.inst[storeID]
key := faKey{dcnDir, storeID}
inst, ok := faMgr.inst[key]
if !ok {
inst = &faInstance{StoreID: storeID, Dir: dir, PrivDir: priv, PubDir: pub, CN: cn, State: "installing"}
faMgr.inst[storeID] = inst
faMgr.inst[key] = inst
} else if inst.State == "installing" || inst.ensuring {
faMgr.Unlock()
return // one ensure at a time per store — see faInstance.ensuring
@@ -993,22 +1003,27 @@ func sweepFromCanonical(termsB64 string) string {
return ""
}
// faManagedProbe reports (anyManaged, allServing) and refreshes each
// instance's Serving flag by probing its /health over mTLS.
func faManagedProbe() (bool, bool) {
// faManagedProbe reports (anyManaged, allServing) for ONE pairing's instances
// and refreshes their Serving flags by probing each /health over mTLS. Another
// pairing's agents are never counted: its health is not this Host's to hear,
// and this Host reads the capability as "my stores here are served" when its
// own freeagent_stores is empty (faManagedStatus). "" probes none.
func faManagedProbe(dcnDir string) (bool, bool) {
faMgr.Lock()
ids := make([]int64, 0, len(faMgr.inst))
for id := range faMgr.inst {
ids = append(ids, id)
keys := make([]faKey, 0, len(faMgr.inst))
for k := range faMgr.inst {
if dcnDir != "" && k.Dir == dcnDir {
keys = append(keys, k)
}
}
faMgr.Unlock()
if len(ids) == 0 {
if len(keys) == 0 {
return false, false
}
all := true
for _, id := range ids {
for _, k := range keys {
serving := false
if c, ok := faManagedConfig(id); ok {
if c, ok := faManagedConfig(k.Dir, k.ID); ok {
status, body, err := freeagentCallTimeout(c, http.MethodGet, "/health", nil, freeagentProbeTimeout)
if err == nil && status == http.StatusOK {
var h struct {
@@ -1019,7 +1034,7 @@ func faManagedProbe() (bool, bool) {
}
}
faMgr.Lock()
if inst, ok := faMgr.inst[id]; ok {
if inst, ok := faMgr.inst[k]; ok {
inst.Serving = serving
}
faMgr.Unlock()
@@ -1030,13 +1045,20 @@ func faManagedProbe() (bool, bool) {
return true, all
}
// faManagedStatus is the heartbeat's per-store detail.
func faManagedStatus() map[string]any {
// faManagedStatus is one pairing's per-store detail for its heartbeat: only
// its own stores, never another Host's.
func faManagedStatus(dcnDir string) map[string]any {
faMgr.Lock()
defer faMgr.Unlock()
out := map[string]any{}
for id, inst := range faMgr.inst {
out[strconv.FormatInt(id, 10)] = map[string]any{
if dcnDir == "" {
return out
}
for k, inst := range faMgr.inst {
if k.Dir != dcnDir {
continue
}
out[strconv.FormatInt(k.ID, 10)] = map[string]any{
"state": inst.State, "serving": inst.Serving, "version": inst.Version, "detail": inst.Detail,
}
}
@@ -1045,14 +1067,15 @@ func faManagedStatus() map[string]any {
// faStop tears down a store's managed instance (grant revoked / re-synced
// without search).
func faStop(storeID int64) {
func faStop(dcnDir string, storeID int64) {
faMgr.Lock()
defer faMgr.Unlock()
if inst, ok := faMgr.inst[storeID]; ok {
key := faKey{dcnDir, storeID}
if inst, ok := faMgr.inst[key]; ok {
if inst.cancel != nil {
inst.cancel()
}
delete(faMgr.inst, storeID)
delete(faMgr.inst, key)
}
}
+56 -21
View File
@@ -182,29 +182,64 @@ func TestFaManifestText(t *testing.T) {
func TestFaManagedConfigFromPortFile(t *testing.T) {
dir := t.TempDir()
faMgr.Lock()
faMgr.inst[4242] = &faInstance{StoreID: 4242, Dir: dir, Cert: "c", Key: "k"}
faMgr.inst[faKey{"dcn-test", 4242}] = &faInstance{StoreID: 4242, Dir: dir, Cert: "c", Key: "k"}
faMgr.Unlock()
t.Cleanup(func() { faStop(4242) })
if _, ok := faManagedConfig(4242); ok {
t.Cleanup(func() { faStop("dcn-test", 4242) })
if _, ok := faManagedConfig("dcn-test", 4242); ok {
t.Fatal("no port file → not configured")
}
os.WriteFile(filepath.Join(dir, "port"), []byte("18473\n"), 0o600)
c, ok := faManagedConfig(4242)
c, ok := faManagedConfig("dcn-test", 4242)
if !ok || c.Addr != "127.0.0.1:18473" || c.CA != "c" {
t.Fatalf("port file → config, got %+v %v", c, ok)
}
if _, ok := faConfigFor(4242); !ok {
if _, ok := faConfigFor("dcn-test", 4242); !ok {
t.Fatal("faConfigFor must prefer the managed instance")
}
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "")
if _, ok := faConfigFor(1); ok {
if _, ok := faConfigFor("dcn-test", 1); ok {
t.Fatal("unknown store with no env → absent")
}
if !strings.Contains(faAbsentReason(1), "no local agent") {
if !strings.Contains(faAbsentReason("dcn-test", 1), "no local agent") {
t.Fatal("absent reason for an unknown store")
}
}
// A beat's freeagent capability and freeagent_ok are its own pairing's agents
// only. Probed across every pairing, Host B's beat carried Host A's agent's
// health, and with B's freeagent_stores now empty the Host reads the
// capability alone (the Host's store-card check: `"freeagent"
// in caps and not stores`), so A's serving agent marked every one of B's
// stores on this machine as served.
func TestHeartbeatProbesOnlyItsOwnPairingsAgents(t *testing.T) {
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "") // no hand-run agent
dcnA, dcnB := t.TempDir(), t.TempDir()
faMgr.Lock()
// no port file: A's agent is managed and not serving
faMgr.inst[faKey{dcnA, 7}] = &faInstance{StoreID: 7, Dir: t.TempDir(), Cert: "a", Key: "a"}
faMgr.Unlock()
t.Cleanup(func() { faStop(dcnA, 7) })
beat := func(dir string) map[string]any {
var body map[string]any
raw := buildHeartbeatBodyOn(beatLanes{dcnDir: dir}, "", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatal(err)
}
return body
}
// MUTATION: probe every pairing's instances (no k.Dir filter in
// faManagedProbe). RESULT: red, B's beat carries freeagent_ok false.
if b, ok := beat(dcnB)["freeagent_ok"]; ok {
t.Errorf("Host B holds no agent here, yet its beat says freeagent_ok %v", b)
}
if b, ok := beat(dcnA)["freeagent_ok"]; !ok || b != false {
t.Errorf("Host A's beat must carry its own agent's health: %v %v", b, ok)
}
if b, ok := beat("")["freeagent_ok"]; ok {
t.Errorf("a beat with no DCN state probes no managed agent: %v", b)
}
}
// End to end with the real artifact: FREEAGENT_TEST_PYZ=/path/to/freeagent-x.pyz
func TestFaManagedEndToEnd(t *testing.T) {
pyzPath := os.Getenv("FREEAGENT_TEST_PYZ")
@@ -264,7 +299,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
}
faReconcile(dcnDir) // what the daemon does at start
t.Cleanup(func() { faStop(1) })
t.Cleanup(func() { faStop(dcnDir, 1) })
stateDir, err := faStoreDir(dcnDir, 1)
if err != nil {
t.Fatal(err)
@@ -274,7 +309,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
var c freeagentConfig
for time.Now().Before(deadline) {
faMgr.Lock()
inst := faMgr.inst[1]
inst := faMgr.inst[faKey{dcnDir, 1}]
state, detail := "", ""
if inst != nil {
state, detail = inst.State, inst.Detail
@@ -283,7 +318,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
if state == "failed" || state == "missing-python" {
t.Fatalf("managed install %s: %s", state, detail)
}
if cfg, ok := faManagedConfig(1); ok {
if cfg, ok := faManagedConfig(dcnDir, 1); ok {
c = cfg
break
}
@@ -395,11 +430,11 @@ func TestFaManagedEndToEnd(t *testing.T) {
}
}
// heartbeat sees it
adv, allOK := faManagedProbe()
adv, allOK := faManagedProbe(dcnDir)
if !adv || !allOK {
t.Fatalf("managed probe: %v %v", adv, allOK)
}
st := faManagedStatus()["1"].(map[string]any)
st := faManagedStatus(dcnDir)["1"].(map[string]any)
if st["state"] != "running" || st["serving"] != true || st["version"] != "9.9.9" {
t.Fatalf("status: %v", st)
}
@@ -417,7 +452,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
// a re-approved grant with narrower terms restarts
// the instance under the new manifest (new excludes, new serve port)
before, _ := faSnapshot(1)
before, _ := faSnapshot(dcnDir, 1)
terms2 := map[string]any{}
for k, v := range terms {
terms2[k] = v
@@ -432,9 +467,9 @@ func TestFaManagedEndToEnd(t *testing.T) {
deadline = time.Now().Add(60 * time.Second)
var after faInstance
for time.Now().Before(deadline) {
after, _ = faSnapshot(1)
after, _ = faSnapshot(dcnDir, 1)
if after.State == "running" && after.Text != before.Text {
if c2, ok := faManagedConfig(1); ok && c2.Addr != c.Addr {
if c2, ok := faManagedConfig(dcnDir, 1); ok && c2.Addr != c.Addr {
break
}
}
@@ -448,10 +483,10 @@ func TestFaManagedEndToEnd(t *testing.T) {
t.Fatal("re-rendered manifest must carry the new excludes and be re-signed")
}
// unchanged terms on the next reconcile: no restart (same port)
c3, _ := faManagedConfig(1)
c3, _ := faManagedConfig(dcnDir, 1)
faReconcile(dcnDir)
time.Sleep(time.Second)
c4, _ := faManagedConfig(1)
c4, _ := faManagedConfig(dcnDir, 1)
if c3.Addr != c4.Addr {
t.Fatal("identical terms must not restart the instance")
}
@@ -459,7 +494,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
// A fresh identity with a surviving manifest (new layout, wiped base, a
// re-pair): the manifest must be re-signed under the new cert, or the
// gate refuses it and serve answers 503 forever.
faStop(1)
faStop(dcnDir, 1)
_, privDir, _, _ := faStoreDirs(dcnDir, 1)
if err := os.RemoveAll(filepath.Join(privDir, "host.key")); err != nil {
t.Fatal(err)
@@ -470,15 +505,15 @@ func TestFaManagedEndToEnd(t *testing.T) {
deadline = time.Now().Add(60 * time.Second)
var c9 freeagentConfig
for time.Now().Before(deadline) {
if cfg, ok := faManagedConfig(1); ok && cfg.Addr != "" {
if st, _ := faSnapshot(1); st.State == "running" && st.Cert != "" {
if cfg, ok := faManagedConfig(dcnDir, 1); ok && cfg.Addr != "" {
if st, _ := faSnapshot(dcnDir, 1); st.State == "running" && st.Cert != "" {
if _, body, err := freeagentCall(cfg, http.MethodGet, "/health", nil); err == nil && strings.Contains(string(body), `"serving": true`) {
c9 = cfg
break
}
}
}
if st, _ := faSnapshot(1); st.State == "failed" {
if st, _ := faSnapshot(dcnDir, 1); st.State == "failed" {
t.Fatalf("re-identity: %s", st.Detail)
}
time.Sleep(500 * time.Millisecond)
+1 -1
View File
@@ -149,7 +149,7 @@ func cmdGitCredential(host string, action string) error {
case len(cfgs) == 1:
cfg = cfgs[0]
case len(cfgs) == 0:
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
default:
return fmt.Errorf("%d hosts are paired — configure the helper with --host: "+
"credential.https://git.privatae.ai.helper \"!privatae-bastion git-credential --host <host>\"", len(cfgs))
+1 -1
View File
@@ -915,7 +915,7 @@ func TestDeviceApproveAllSignsOnlyWhatWasShown(t *testing.T) {
if err := cmdDeviceApprove("all"); err == nil {
t.Fatal("nothing shown yet: all must refuse")
}
if err := cmdDeviceApprovals(); err != nil {
if err := cmdDeviceApprovals(nil); err != nil {
t.Fatal(err)
}
b := file("harness:run") // filed AFTER the person looked
+4
View File
@@ -377,6 +377,10 @@ exit 0
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
"curl": `for a; do o=$a; done; case "$2" in */version.json) cp "$STAND/version.json" "$o" ;; *) cp "$STAND/bastion.bin" "$o" ;; esac`,
"printf": `exec /usr/bin/printf "$@"`,
// No openssl that does Ed25519 here, so the release signature is
// noted as not checked (install_sig_sh_test.go tests the check): this
// stand-in manifest is signed by no release key.
"openssl": `exit 1`,
}
for name, body := range stand {
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n")
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// ed25519SPKIPrefix is the DER that wraps a raw Ed25519 public key in the
// SubjectPublicKeyInfo openssl reads (RFC 8410).
var ed25519SPKIPrefix = []byte{0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00}
func installShVar(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`(?m)^` + name + `='([^']*)'$`).FindSubmatch(raw)
if m == nil {
t.Fatalf("install.sh sets no %s", name)
}
return string(m[1])
}
func spkiToRaw(t *testing.T, b64 string) []byte {
t.Helper()
der, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(der) != len(ed25519SPKIPrefix)+ed25519.PublicKeySize || !bytes.HasPrefix(der, ed25519SPKIPrefix) {
t.Fatalf("not an Ed25519 public key: %q (%v)", b64, err)
}
return der[len(ed25519SPKIPrefix):]
}
// The installer checks a release with exactly the keys a bastion checks its
// updates with: a key rotated in release_sign.go and not here would make
// every install refuse, or accept a key the bastions no longer do.
func TestInstallShPinsTheReleaseKeysTheBastionChecks(t *testing.T) {
keys := strings.Fields(installShVar(t, "RELEASE_KEYS"))
if len(keys) != len(productionReleaseKeys) {
t.Fatalf("install.sh pins %d release keys, release_sign.go %d", len(keys), len(productionReleaseKeys))
}
for i, k := range keys {
if got := base64.StdEncoding.EncodeToString(spkiToRaw(t, k)); got != productionReleaseKeys[i] {
t.Errorf("install.sh key %d is %s, release_sign.go has %s", i, got, productionReleaseKeys[i])
}
}
}
// The probe is what tells "this openssl cannot check" from "the signature is
// wrong". A probe that did not verify would make every system read as unable,
// and every install skip the signature with a note.
func TestInstallShProbeSignatureVerifies(t *testing.T) {
pub := spkiToRaw(t, installShVar(t, "SIG_PROBE_KEY"))
sig, err := base64.StdEncoding.DecodeString(installShVar(t, "SIG_PROBE_SIG"))
if err != nil {
t.Fatal(err)
}
if !ed25519.Verify(ed25519.PublicKey(pub), []byte("privatae-installer-probe-v1\n"), sig) {
t.Fatal("the probe signature does not verify under the probe key")
}
}
// sigHarness is release_signature_check from install.sh, with the release
// keys replaced by key and the download host by base, run on manifest.
func sigHarness(t *testing.T, pub ed25519.PublicKey, base string, manifest []byte, onlyPATH ...bool) (string, error) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
s := string(raw)
vars := regexp.MustCompile(`(?ms)^RELEASE_KEYS=.*?^SIG_PROBE_SIG='[^']*'$`).FindString(s)
fn := regexp.MustCompile(`(?ms)^release_signature_check\(\) \{.*?^\}$`).FindString(s)
if vars == "" || fn == "" {
t.Fatal("install.sh's release_signature_check or its keys moved")
}
if len(onlyPATH) > 0 && onlyPATH[0] {
// Only the openssl on PATH: a Mac with Homebrew's openssl@3 would
// otherwise find a real one at its fixed path.
loop := regexp.MustCompile(`(?m)^ for c in openssl [^;]*; do$`)
if !loop.MatchString(fn) {
t.Fatal("install.sh's openssl candidates moved")
}
fn = loop.ReplaceAllString(fn, " for c in openssl; do")
}
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
vars = regexp.MustCompile(`(?m)^RELEASE_KEYS='[^']*'$`).ReplaceAllString(vars,
"RELEASE_KEYS='"+base64.StdEncoding.EncodeToString(der)+"'")
d := t.TempDir()
if err := os.WriteFile(filepath.Join(d, "man.json"), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(d, "v"), 0o700); err != nil {
t.Fatal(err)
}
script := "die() { echo \"ERROR: $*\" >&2; exit 1; }\nSHACMD=sha256sum\nsha_of() { $SHACMD \"$1\" | cut -d' ' -f1; }\n" +
"base='" + base + "'\n" + vars + "\n" + fn + "\nrelease_signature_check \"$1\" \"$2\"\necho RETURNED\n"
sh := filepath.Join(d, "h.sh")
if err := os.WriteFile(sh, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command("sh", sh, filepath.Join(d, "man.json"), filepath.Join(d, "v")).CombinedOutput()
return string(out), err
}
func releaseSigFor(priv ed25519.PrivateKey, manifest []byte, version string) (name, b64 string) {
sum := sha256.Sum256(manifest)
stmt := releaseStatement("bastion", version, hex.EncodeToString(sum[:]))
return "/version.json." + hex.EncodeToString(sum[:8]) + ".sig",
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, stmt))
}
func TestInstallShRefusesAReleaseWhoseSignatureDoesNotVerify(t *testing.T) {
requireTools(t, "sh", "openssl", "sha256sum", "curl")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
_, other, _ := ed25519.GenerateKey(rand.Reader)
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
name, good := releaseSigFor(priv, manifest, "9.9.9")
_, wrongKey := releaseSigFor(other, manifest, "9.9.9")
_, wrongVersion := releaseSigFor(priv, manifest, "9.9.8")
serve := map[string]string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v, ok := serve[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(v))
}))
defer srv.Close()
// The check needs an openssl that does Ed25519; without one it only
// notes it, which TestInstallShSaysWhenItCannotCheckTheSignature covers.
// Whether this one can is asked of openssl here, never read off the
// installer's note: a probe broken in install.sh would print that note on
// every machine, and this test would skip while every install skipped the
// signature.
// MUTATION: break the probe's pkeyutl flags in install.sh. RESULT: red here
// (it skipped before).
if !opensslChecksEd25519(t) {
skipOrFail(t, "the openssl here does not check Ed25519 signatures")
}
serve[name] = good
out, err := sigHarness(t, pub, srv.URL, manifest)
if err != nil || strings.Contains(out, "no openssl here can check") ||
!strings.Contains(out, "Release signature verified (version 9.9.9)") || !strings.Contains(out, "RETURNED") {
t.Fatalf("a good signature: %v\n%s", err, out)
}
for what, sig := range map[string]string{"another key's": wrongKey, "another version's": wrongVersion} {
serve[name] = sig
// MUTATION: return 0 after the key loop. RESULT: red, these install.
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || strings.Contains(out, "RETURNED") ||
!strings.Contains(out, "does not verify") {
t.Errorf("%s signature: %v\n%s", what, err, out)
}
}
serve[name] = "not base64 at all !"
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "malformed") {
t.Errorf("a malformed signature: %v\n%s", err, out)
}
delete(serve, name)
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "cannot fetch the release's signature") {
t.Errorf("no signature: %v\n%s", err, out)
}
}
// opensslChecksEd25519 says, without install.sh, whether the openssl on PATH
// verifies an Ed25519 signature made here, and refuses one over another
// message.
func opensslChecksEd25519(t *testing.T) bool {
t.Helper()
if _, err := exec.LookPath("openssl"); err != nil {
return false
}
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
d := t.TempDir()
files := map[string][]byte{
"k.pem": []byte("-----BEGIN PUBLIC KEY-----\n" + base64.StdEncoding.EncodeToString(der) + "\n-----END PUBLIC KEY-----\n"),
"m": []byte("capability\n"),
"other": []byte("capability?\n"),
"s": ed25519.Sign(priv, []byte("capability\n")),
}
for n, b := range files {
if err := os.WriteFile(filepath.Join(d, n), b, 0o600); err != nil {
t.Fatal(err)
}
}
verify := func(msg string) bool {
return exec.Command("openssl", "pkeyutl", "-verify", "-pubin", "-inkey", filepath.Join(d, "k.pem"), "-rawin",
"-in", filepath.Join(d, msg), "-sigfile", filepath.Join(d, "s")).Run() == nil
}
return verify("m") && !verify("other")
}
// An openssl that passes every signature is no check: the installer must not
// take it for one and print "verified" over a signature no pinned key made.
// It is set aside like one that cannot check; a later candidate that can
// still checks.
func TestInstallShTakesNoOpensslThatPassesEverySignature(t *testing.T) {
requireTools(t, "sh", "openssl", "sha256sum", "curl")
systemSSL, _ := exec.LookPath("openssl")
fake := t.TempDir()
if err := os.WriteFile(filepath.Join(fake, "openssl"),
[]byte("#!/bin/sh\ncase \"$1\" in pkeyutl) exit 0 ;; esac\nexec '"+systemSSL+"' \"$@\"\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
pub, _, _ := ed25519.GenerateKey(rand.Reader)
_, other, _ := ed25519.GenerateKey(rand.Reader)
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
name, forged := releaseSigFor(other, manifest, "9.9.9")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != name {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(forged))
}))
defer srv.Close()
// MUTATION: drop the probe's second verify (the one that must fail).
// RESULT: red, "Release signature verified" over the forged signature.
out, _ := sigHarness(t, pub, srv.URL, manifest)
if strings.Contains(out, "Release signature verified") ||
!(strings.Contains(out, "no openssl here can check") || strings.Contains(out, "does not verify")) {
t.Errorf("an openssl that passes everything was taken for a check:\n%s", out)
}
}
// SECURITY.md sends a reader who does not trust our website to a copy of
// install.sh read from this repository. Run without sudo, that copy installs
// for the whole machine through a root step that fetches the installer from
// the website again (whole_machine_script) and runs that, so the copy read
// checks nothing itself. The page has to say how the copy must be run.
func TestSecurityMdSaysHowAReadCopyOfTheInstallerMustRun(t *testing.T) {
sh, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(sh), `whole_machine_script="https://privatae.ai/bastion/install.sh"`) {
t.Fatal("install.sh's root step no longer fetches the website's installer: re-read SECURITY.md's first-install paragraph, then this test")
}
raw, err := os.ReadFile(filepath.Join("..", "SECURITY.md"))
if err != nil {
t.Fatal(err)
}
sec := strings.Join(strings.Fields(string(raw)), " ")
for _, want := range []string{
"`sudo sh install.sh`",
"`sh install.sh --just-me`",
"fetches the installer from our website again",
"an older release we signed",
} {
if !strings.Contains(sec, want) {
t.Errorf("SECURITY.md does not say %q", want)
}
}
}
// With no openssl that can check Ed25519, the check says so and returns, so
// the digest check still runs; it never reads as verified.
func TestInstallShSaysWhenItCannotCheckTheSignature(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("no sh")
}
fake := t.TempDir()
if err := os.WriteFile(filepath.Join(fake, "openssl"), []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
pub, _, _ := ed25519.GenerateKey(rand.Reader)
out, err := sigHarness(t, pub, "http://127.0.0.1:1", []byte("{\"version\": \"9.9.9\"}\n"), true)
if err != nil || !strings.Contains(out, "no openssl here can check") || strings.Contains(out, "verified") ||
!strings.Contains(out, "RETURNED") {
t.Errorf("no capable openssl: %v\n%s", err, out)
}
}
// The check runs on every install, after the manifest is fetched and before
// any digest is read out of it: a digest from an unchecked manifest is the
// thing the signature exists to stop.
func TestInstallShChecksTheSignatureBeforeReadingTheDigest(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
s := string(raw)
fetch := strings.Index(s, `curl -fsSL "$base/version.json" -o "$man"`)
check := strings.Index(s, "\nrelease_signature_check \"$man\" \"$vdir\"\n")
digest := strings.Index(s, "expected=$(awk")
if fetch < 0 || check < 0 || digest < 0 || !(fetch < check && check < digest) {
t.Fatalf("install.sh must fetch the manifest, check its signature, then read its digest (at %d, %d, %d)",
fetch, check, digest)
}
}
// requireTools skips without one of these tools, unless CI says the check
// must run (PRIVATAE_REQUIRE_OPENSSL=1, .gitea/workflows/ci.yml): there a
// missing tool is a failure, so the signature check cannot quietly go untested.
func requireTools(t *testing.T, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
skipOrFail(t, tool+" is not installed here")
}
}
}
func skipOrFail(t *testing.T, why string) {
t.Helper()
if os.Getenv("PRIVATAE_REQUIRE_OPENSSL") == "1" {
t.Fatalf("%s, and PRIVATAE_REQUIRE_OPENSSL=1 says this check must run", why)
}
t.Skip(why)
}
+32 -11
View File
@@ -111,22 +111,25 @@ Usage:
anchor anywhere.
privatae-bastion approvals [--host <host> | --machine] [--json]
show what your host is asking to do on this machine, in plain words
privatae-bastion approve [--host <host>] <id>
privatae-bastion approve [--host <host> | --machine] <id>
approve one request with a code from the authenticator enrolled here
privatae-bastion deny [--host <host>] <id>
refuse one request
privatae-bastion deny [--host <host> | --machine] <id>
refuse one request. From the machine's owner account, beside the
bastion installed for the whole machine, both act on that bastion,
and so do switch (off with nothing, on with the owner's code) and,
with nothing, approvals drop, grants drop, ops drop and device-deny
privatae-bastion approvals drop [--host <host>] <id>
take back an approval this machine issued and has not used yet
privatae-bastion grants drop <store id> [--host <host>]
remove a folder grant held on this machine; the host is told
privatae-bastion device-init make THIS machine your approval device: from then on a write a
host asks of any machine you govern is approved with a tap here
privatae-bastion device-approvals what your machines are asking you to approve, in their words
privatae-bastion device-approvals [--json] what your machines are asking you to approve, in their words
privatae-bastion device-approve <id>|all / device-deny <id>
sudo -H privatae-bastion pin-device <key>
on a machine you govern: accept approvals signed by your device
(the key device-init printed). unpin-device <key> withdraws it
privatae-bastion ops [--host <host>] the ops your host wrote that this machine holds, and the
privatae-bastion ops [--host <host>] [--json] the ops your host wrote that this machine holds, and the
standing op grants; ops drop <grant> takes one back
sudo -H privatae-bastion root-exec grant [--host <host>] --for <30d>
let the anchor host run commands AS ROOT here, one at a time,
@@ -145,11 +148,18 @@ Usage:
switch one capability family off for a host on this machine,
or clear that refusal (the console page's switch). Off only
removes reach; on grants nothing past the host's signed grant
privatae-bastion switch --list every paired host's families, on or off, one per line
privatae-bastion switch --list [--json]
every paired host's families, on or off, one per line
privatae-bastion agents [--json] [--prompts]
every AI agent on this machine and what it is doing: its
session name and title, whether it waits for you, its folder
and branch, what it is running now (--prompts: its last prompt)
privatae-bastion agents show <pid or session> [--json] [--prompts]
one agent with everything known about it: what it is, the file
it runs, how it was recognised, what it says about itself
privatae-bastion doctor [--json]
what is wrong with this install, and the fix for each one.
Exit 1 when something is
privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]
write what this bastion knows as one Markdown file: agents,
scheduled agents, MCP tool-server problems, the baseline, activity.
@@ -173,8 +183,10 @@ Usage:
denied. Exit 0 stopped (or asked), 1 not, 3 still waiting
From the machine's owner account, beside the bastion installed for
the whole machine, the reads (status, agents, schedules,
tool-servers, posture, baseline, activity, report, approvals)
ask that bastion and show the whole machine
tool-servers, posture, baseline, activity, report, approvals,
switch --list, ops, device-approvals, backup key show,
backup requests, backup shares, doctor) ask that bastion and
show the whole machine
privatae-bastion help this text
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan h[:p],...]
[--image-prefix <registry/path/>|default]
@@ -613,7 +625,7 @@ func main() {
case "device-init":
err = cmdDeviceInit()
case "device-approvals":
err = cmdDeviceApprovals()
err = cmdDeviceApprovals(os.Args[2:])
case "device-approve", "device-deny":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: privatae-bastion "+os.Args[1]+" <request id> [--code <code>]")
@@ -685,7 +697,7 @@ func main() {
}
os.Exit(opActionChildMain(os.Args[2]))
case "ops":
// ops [--host <host>] what ops and op grants this machine holds
// ops [--host <host>] [--json] what ops and op grants this machine holds
// ops drop <grant> [--host <host>] take back a held op grant (the host is told)
// ops drop --all [--host <host>] take back every op grant the host holds here
host, args := takeHostFlag(os.Args[2:])
@@ -693,7 +705,12 @@ func main() {
err = cmdOpsDrop(host, args[1])
break
}
err = cmdOpsList(host)
asJSON, jerr := jsonOnly("usage: privatae-bastion ops [--host <host>] [--json] | ops drop <grant>|--all [--host <host>]", args)
if jerr != nil {
fmt.Fprintln(os.Stderr, jerr)
os.Exit(2)
}
err = cmdOpsList(host, asJSON)
case "root-exec":
// root-exec [--host <host>] [grant --for <d> | revoke] (rootexec.go): the
// standing grant for the anchor host's root commands, given and taken
@@ -752,6 +769,10 @@ func main() {
// baseline show|diff [--json], baseline record [--force]
// (machine_cli.go): observe:baseline* under the words people use.
err = cmdBaseline(os.Args[2:])
case "doctor":
// doctor [--json] (doctor.go): what is wrong with this install, and
// the fix for each one.
err = cmdDoctor(os.Args[2:])
case "help", "-h", "--help":
usage()
case "switch":
+311
View File
@@ -0,0 +1,311 @@
package main
// owneracts.go — the machine's owner acts on its bastion from their own
// account (plan Addendum 6, slice 4).
//
// Installed for the whole machine, the bastion keeps its approvals and its
// local switch in root's state, so `approve`, `deny` and `switch` from the
// owner's account acted on an empty bastion of that account's own: an ask
// waiting for the owner was "not found", and a switch was refused for want of
// sudo. The owner's console session (consolelane.go) has always been able to
// do these, so ask.sock now does exactly the same for the same account, with
// the same rules:
//
// - approve (the machine's own asks, or a Host's) takes the owner's code,
// spent and counted as the console spends it (machineCodeSpend for the
// machine's, codeSpend for a Host's), so wrong codes lock the same way;
// - switching a family back on takes the owner's code (ownerCodeRefusal),
// as the console's switch does;
// - deny, and switching a family off, never do: they only take reach away.
//
// Only the owner's account is answered (readLaneRefusal), as for the reads.
// Each act is one line in the machine's audit log naming the account, beside
// the approve or deny row its own function writes.
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"strings"
"time"
)
type agentActReply struct {
OK bool `json:"ok"`
Why string `json:"why,omitempty"`
Done string `json:"done,omitempty"`
// Failed: answered for this account, and not done (Why says why); the
// administrator would be told the same, so it is not a refusal.
Failed bool `json:"failed,omitempty"`
User string `json:"user,omitempty"`
}
var actNow = time.Now
func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) {
r.User = user
if why := readLaneRefusal(uid, user); why != "" {
r.Why = actWording(why)
return r
}
defer func() {
// The root daemon serves every pairing: an act that panics is that
// act's failure, never the daemon's end (as runMachineRead does).
if p := recover(); p != nil {
uilogf("owner act %q panicked: %v\n", req.Act, p)
r = agentActReply{User: user, Failed: true, Why: "this act failed inside the machine's bastion; its log says why"}
}
}()
done, what, err := runOwnerAct(req)
if err != nil {
r.Failed, r.Why = true, err.Error()
return r
}
ownerActAudit(what, user)
r.OK, r.Done = true, done
return r
}
// runOwnerAct does one act; what names it, as resolved, for the audit row.
func runOwnerAct(req agentAskRequest) (done, what string, err error) {
switch req.Act {
case "approve", "deny":
t, err := resolveStepupTarget(req.Host, req.Machine)
if err != nil {
return "", "", err
}
if !t.machine() {
// The console's rule for a Host's request, approve and deny alike
// (ui.go approve and deny both take approvalTarget): only a pairing
// this machine serves is answered.
if _, err := approvalTarget(t.host); err != nil {
return "", "", err
}
}
m := lockedRequests(t.dir)
id, err := findRequest(m, req.ID)
if err != nil {
return "", "", err
}
target := "the machine's"
if !t.machine() {
target = "a Host's (" + t.host + ")"
}
what = req.Act + " " + target + " request " + id
if req.Act == "deny" {
if err := stepupDenyRequest(t.dir, id); err != nil {
return "", "", err
}
return "denied.", what, nil
}
if !stepupEnrolled(t.dir) {
return "", "", fmt.Errorf("no second factor is enrolled %s", t.forWhom())
}
code := strings.TrimSpace(req.Code)
if code == "" {
return "", "", errors.New("approving takes the code from your authenticator")
}
spend := func() (string, error) { return stepupApproveRequest(t.dir, id, code) }
var err2 error
if t.machine() {
_, err2 = machineCodeSpend(t.dir, actNow(), spend)
} else {
_, err2 = codeSpend(t.dir, actNow(), hostCodeLockedHint, spend)
}
if err2 != nil {
return "", "", err2
}
if t.machine() {
return "approved.", what, nil
}
return "approved. " + t.host + " picks this up on its next check-in, within a minute.", what, nil
case "switch":
host, err := switchResolveHost(req.Host)
if err != nil {
return "", "", err
}
family := strings.TrimSpace(req.Family)
if !req.Off {
if why := ownerCodeRefusal(req.Code, "local_switch_on|"+host+"|"+family); why != "" {
return "", "", errors.New(strings.TrimSuffix(why, "."))
}
}
if err := localDenySet(host, family, req.Off); err != nil {
return "", "", err
}
state := "on"
if req.Off {
state = "off"
}
return switchDoneText(host, family, req.Off), "switch " + cleanText(host, 80) + " " + cleanText(family, 40) + " " + state, nil
case "approvals-drop", "grants-drop", "ops-drop", "device-deny":
// Narrowing acts: each only takes reach away, so no code, and each
// is the command's own core, run where the machine's state is.
var run func() (string, error)
switch req.Act {
case "approvals-drop":
run = func() (string, error) { return stepupDropText(req.Host, req.Machine, req.ID) }
case "grants-drop":
run = func() (string, error) { return grantsDropText(req.Host, req.ID) }
case "ops-drop":
run = func() (string, error) { return opsDropText(req.Host, req.ID) }
default:
run = func() (string, error) { return deviceDenyText(req.ID) }
}
done, err := run()
if err != nil {
return "", "", err
}
what = req.Act + " " + cleanText(req.ID, 40)
if req.Host != "" {
what += " for " + cleanText(req.Host, 80)
}
return done, what, nil
}
return "", "", fmt.Errorf("this bastion has no act called %q", cleanText(req.Act, 40))
}
// lockedRequests reads a factor's requests under stepupMu, released however
// the read ends: the daemon recovers a panicking act (serveAct), and a lock
// left held would hang every approval after it.
func lockedRequests(dir string) map[string]stepupRequest {
stepupMu.Lock()
defer stepupMu.Unlock()
requestsLoadHook()
return requestsLoad(dir)
}
// requestsLoadHook is a seam for a test that makes the read panic.
var requestsLoadHook = func() {}
// switchDoneText is what a switch says it did, from the terminal or the lane.
func switchDoneText(host, family string, off bool) string {
if off {
return host + " " + family + " off: this machine now refuses it, and tells the host why. Its grant is untouched."
}
return host + " " + family + " on: the refusal is cleared, up to whatever its signed grant already allowed."
}
// ownerActAudit is the act's line in the machine's audit log. The owner's
// factor directory is made if it is not there yet (a deny or a switch off
// needs no code set up): dcnAuditWith joins its directory onto audit.jsonl,
// and "" would be the daemon's working directory.
func ownerActAudit(what, user string) {
dir, err := machineFactorDir()
if err != nil {
uilogf("owner act not audited (%v): %s, from %s\n", err, what, user)
return
}
dcnAuditWith(dir, "owner_lane_act", 0, "",
what+", from the owner's account "+user+" through its ask socket", nil)
}
// ── the asking side ─────────────────────────────────────────────────────────
func actViaSock(req agentAskRequest) (string, 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(30 * time.Second))
req.Op = "act"
if err := json.NewEncoder(c).Encode(req); err != nil {
return "", &laneError{down: true, why: "the act could not be sent (" + err.Error() + ")"}
}
var r agentActReply
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&r); err != nil {
return "", &laneError{down: true, why: "the machine's bastion did not answer (" + err.Error() + ")"}
}
if !r.OK && r.Failed {
return "", errors.New(r.Why)
}
if !r.OK {
return "", &laneError{why: r.Why}
}
return r.Done, nil
}
// ownerApproveViaLane shows the request the machine's bastion holds, takes
// the owner's code at this terminal, and asks the bastion to approve it.
func ownerApproveViaLane(host string, machine bool, prefix string) error {
v, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
if err != nil {
// Refused, the read is refused as the approve it was read for.
var le *laneError
if errors.As(err, &le) && !le.down {
err = &laneError{why: actWording(le.why)}
}
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
}
if !v.CodeSetUp {
return fmt.Errorf("no second factor is enrolled %s, so nothing can be approved yet", v.For)
}
var hit *approvalWaiting
for i := range v.Waiting {
if strings.HasPrefix(v.Waiting[i].ID, prefix) || strings.HasPrefix(prefix, v.Waiting[i].ID) {
if hit != nil {
return fmt.Errorf("more than one waiting request starts with %q; give more of its id", prefix)
}
hit = &v.Waiting[i]
}
}
if hit == nil {
return fmt.Errorf("nothing waiting %s starts with %q; `privatae-bastion approvals%s` lists them",
v.For, prefix, map[bool]string{true: " --machine", false: ""}[machine])
}
fmt.Println(hit.Text)
fmt.Println()
code, ok := promptOwnerCode("Approve this? Enter the code from your authenticator (or leave empty to cancel): ")
if !ok {
return nil
}
// The bastion finds the request again by this id: the one shown, never
// fewer characters of it than shown. Sent as typed, a short prefix would
// approve whatever request matched it by then (one an agent filed while
// the person typed, say), which is not what they read.
id := hit.ID
if len(prefix) > len(id) {
id = prefix
}
done, err := actViaSock(agentAskRequest{Act: "approve", Host: host, Machine: machine, ID: id, Code: code})
if err != nil {
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
}
fmt.Println(done)
return nil
}
// actWording is the reads' refusal said of an act.
func actWording(why string) string {
return strings.Replace(why, "answers reads only for", "acts only for", 1)
}
// stepupCommand is approve or deny as the administrator would type it here.
func stepupCommand(verb, host string, machine bool, id string) string {
switch {
case machine:
return verb + " --machine " + id
case host != "":
return verb + " --host " + host + " " + id
}
return verb + " " + id
}
// promptOwnerCode reads a code from this terminal; ok is false when the
// person left it empty, and then nothing is done (said here).
var promptOwnerCode = func(prompt string) (string, bool) {
fmt.Print(prompt)
code, _ := bufio.NewReader(os.Stdin).ReadString('\n')
code = strings.TrimSpace(code)
if code == "" {
fmt.Println("cancelled — nothing done")
return "", false
}
return code, true
}
+504
View File
@@ -0,0 +1,504 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// stubCode answers the owner's code prompt with code ("" = left empty).
func stubCode(t *testing.T, code string) *int {
t.Helper()
asked := 0
prev := promptOwnerCode
t.Cleanup(func() { promptOwnerCode = prev })
promptOwnerCode = func(string) (string, bool) { asked++; return code, code != "" }
return &asked
}
// From the owner's account, beside the machine's bastion, approve answers a
// stop ask the machine holds, with the owner's code: before, it looked in
// that account's own empty state and said the request did not exist.
func TestTheOwnerApprovesTheMachinesAskThroughTheLane(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatalf("ask refused: %s", r.Why)
}
laneOwner(t, laneTestUID)
laneSock(t)
out := captureStdout(t)
stubCode(t, codeFor(t, machineTestSecret))
if err := cmdStepupApprove("", true, r.Request); err != nil {
t.Fatalf("approve through the lane: %v", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "approved" {
t.Errorf("the machine's request is %q after the owner approved it", got)
}
if got := printedSince(out, 0, "approved."); !strings.Contains(got, "approved.") {
t.Errorf("printed %q", got)
}
// The kernel's name for this account: the harness maps only its uid to
// the owner's, so the name is the test process's own.
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
if !strings.Contains(string(raw), `"op":"owner_lane_act"`) ||
!strings.Contains(string(raw), "from the owner's account ") {
t.Errorf("the act is not in the audit log with the account that acted:\n%s", raw)
}
}
// A wrong code approves nothing and counts against the owner's code, as the
// console's does; an empty one sends nothing.
func TestAWrongCodeThroughTheLaneApprovesNothing(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
stubCode(t, "000000")
err := cmdStepupApprove("", true, r.Request)
var le *laneError
if err == nil || errors.As(err, &le) {
t.Errorf("a wrong code: %v (want the bastion's refusal, not a lane refusal)", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "pending" {
t.Errorf("a wrong code left the request %q", got)
}
asked := stubCode(t, "")
if err := cmdStepupApprove("", true, r.Request); err != nil || *asked != 1 {
t.Errorf("an empty code: %v, asked %d", err, *asked)
}
// MUTATION: spend the code without machineCodeSpend. RESULT: red, a wrong
// code no longer counts.
stepupMu.Lock()
n := pipeThrottleLoad(d).Failures
stepupMu.Unlock()
if n < 1 {
t.Errorf("the wrong code was not counted (%d)", n)
}
}
// Deny takes no code: refusing only takes reach away.
func TestTheOwnerDeniesThroughTheLaneWithNoCode(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
asked := stubCode(t, "")
if err := cmdStepupDeny("", true, r.Request); err != nil {
t.Fatalf("deny through the lane: %v", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "denied" || *asked != 0 {
t.Errorf("deny left %q, asked for a code %d times", got, *asked)
}
}
// The local switch from the owner's account: off with nothing, on only with
// the owner's code (the console's switch rule).
func TestTheOwnerSwitchesThroughTheLane(t *testing.T) {
askFlowWorld(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
if err := cmdSwitch([]string{host, "ops", "off"}); err != nil {
t.Fatalf("switch off: %v", err)
}
if !localDenied(host, "ops") {
t.Fatal("ops is not off after the owner switched it off")
}
stubCode(t, "000000")
if err := cmdSwitch([]string{host, "ops", "on"}); err == nil || !localDenied(host, "ops") {
t.Errorf("switch on with a wrong code: %v, still off %v", err, localDenied(host, "ops"))
}
stubCode(t, codeFor(t, machineTestSecret))
if err := cmdSwitch([]string{host, "ops", "on"}); err != nil || localDenied(host, "ops") {
t.Errorf("switch on with the owner's code: %v, still off %v", err, localDenied(host, "ops"))
}
}
// Any other account is refused every act, with the administrator's command.
func TestAnotherAccountIsRefusedEveryAct(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID+1)
laneSock(t)
captureStdout(t)
stubCode(t, codeFor(t, machineTestSecret))
for name, run := range map[string]func() error{
"approve": func() error { return cmdStepupApprove("", true, r.Request) },
"deny": func() error { return cmdStepupDeny("", true, r.Request) },
"switch off": func() error { return cmdSwitch([]string{"box-one.example", "ops", "off"}) },
} {
err := run()
if err == nil || !strings.Contains(err.Error(), "acts only for its owner's account") || !strings.Contains(err.Error(), "sudo -H") {
t.Errorf("%s from another account: %v", name, err)
}
if (name == "approve" || name == "deny") && !strings.Contains(err.Error(), "sudo -H privatae-bastion "+name+" --machine "+r.Request) {
t.Errorf("the administrator's command does not name the request: %v", err)
}
}
// The list, refused, is given back as the administrator types it for the
// same requests: the machine's own, not a Host's.
if err := cmdStepupApprovals("", true, false); err == nil || !strings.Contains(err.Error(), "answers reads only for its owner's account") ||
!strings.HasSuffix(err.Error(), "sudo -H privatae-bastion approvals --machine") {
t.Errorf("approvals --machine from another account: %v", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "pending" {
t.Errorf("another account's deny left the request %q", got)
}
if rep := serveAct(laneTestUID, "lee", agentAskRequest{Act: "approve", Machine: true, ID: r.Request,
Code: codeFor(t, machineTestSecret)}); rep.OK {
t.Error("another account approved the machine's request")
}
}
func TestAnActTheBastionDoesNotHaveFails(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "uninstall"}); rep.OK || !rep.Failed ||
!strings.Contains(rep.Why, "no act called") {
t.Errorf("an unknown act: %+v", rep)
}
}
// Approve through the lane approves the request it showed, or nothing: a
// short prefix sent as typed was found again by the bastion after the code was
// entered, so a request that came to match it meanwhile (the shown one denied,
// another filed) was approved in its place.
func TestTheLaneApprovesOnlyTheRequestItShowed(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatalf("ask refused: %s", r.Why)
}
shown := fullRequestID(t, d, r.Request)
// Same first character, different from the second on.
other := shown[:1] + string("0123456789abcdef"[(strings.IndexByte("0123456789abcdef", shown[1])+1)%16]) + shown[2:]
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
code := codeFor(t, machineTestSecret)
prev := promptOwnerCode
t.Cleanup(func() { promptOwnerCode = prev })
promptOwnerCode = func(string) (string, bool) {
// While the person types: the shown request is answered, and another
// matching the typed prefix is waiting.
stepupMu.Lock()
m := requestsLoad(d)
rec := m[shown]
m[other] = rec
_ = requestsSave(d, m)
stepupMu.Unlock()
if err := stepupDenyRequest(d, shown); err != nil {
t.Fatal(err)
}
return code, true
}
// MUTATION: send the typed prefix (ID: prefix). RESULT: red, the other
// request is approved.
err := cmdStepupApprove("", true, shown[:1])
if got := requestsLoad(d)[other].State; got != "pending" {
t.Fatalf("a request the person was never shown is %q after they approved the one they were shown (%v)", got, err)
}
if err == nil {
t.Error("approving a request answered meanwhile said nothing")
}
}
// A Host's request is approved through the lane only for a pairing this
// machine serves, as the console's approve (approvalTarget) refuses the rest.
func TestTheLaneDoesNotApproveForAPairingThisMachineDoesNotServe(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
const host, id = "box-two.example", "c0ffee00c0ffee00c0ffee00c0ffee00"
waiting := func(state string) string {
dcn := givenPairing(t, host, state)
enrolFactorAt(t, dcn, hostTestSecret)
if err := requestsSave(dcn, map[string]stepupRequest{id: {Purpose: "test|host", State: "pending",
At: time.Now().UTC().Format(time.RFC3339)}}); err != nil {
t.Fatal(err)
}
return dcn
}
dcn := waiting(pairingPending)
// MUTATION: drop the approvalTarget check. RESULT: red, approved.
rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "approve", Host: host, ID: id[:8], Code: codeFor(t, hostTestSecret)})
if rep.OK || !strings.Contains(rep.Why, "does not serve") {
t.Errorf("approve for a pairing the anchor has not approved: %+v", rep)
}
if got := requestsLoad(dcn)[id].State; got != "pending" {
t.Errorf("the request of a pairing this machine does not serve is %q", got)
}
// The same act for an active pairing is answered: the refusal above is
// the pairing's state, not the test's setup.
dcn = waiting(pairingActive)
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "approve", Host: host, ID: id[:8],
Code: codeFor(t, hostTestSecret)}); !rep.OK {
t.Errorf("approve for an active pairing: %+v", rep)
}
if got := requestsLoad(dcn)[id].State; got != "approved" {
t.Errorf("the active pairing's request is %q", got)
}
}
// The act's audit line lands in the machine's log, and says what was acted
// on as the bastion resolved it. With no owner's code set up (a switch off
// needs none) it was written to audit.jsonl in the daemon's working
// directory; and a request reached with no --machine, the machine's because
// no Host is paired, was logged as a Host's.
func TestAnOwnerActIsAuditedInTheMachinesLogAsResolved(t *testing.T) {
uiTestConfigDir(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
laneOwner(t, laneTestUID)
cwd := t.TempDir()
t.Chdir(cwd)
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "switch", Host: host, Family: "ops", Off: true}); !rep.OK {
t.Fatalf("switch off: %+v", rep)
}
// MUTATION: audit to existingMachineFactorDir(). RESULT: red, the line is
// in the working directory.
if _, err := os.Stat(filepath.Join(cwd, "audit.jsonl")); err == nil {
t.Error("the act's audit line was written into the daemon's working directory")
}
dir, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "audit.jsonl"))
if !strings.Contains(string(raw), `"op":"owner_lane_act"`) || !strings.Contains(string(raw), "switch "+host+" ops off") {
t.Errorf("the switch is not in the machine's audit log:\n%s", raw)
}
}
func TestAnOwnerActOnTheMachinesRequestIsAuditedAsTheMachines(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID)
// No --machine, and no Host paired: the machine's request.
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", ID: r.Request}); !rep.OK {
t.Fatalf("deny: %+v", rep)
}
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
// MUTATION: name the target from req.Machine. RESULT: red, "a Host's ()".
if want := "deny the machine's request " + fullRequestID(t, d, r.Request); !strings.Contains(string(raw), want) {
t.Errorf("the audit line does not say %q:\n%s", want, raw)
}
}
// Deny for a Host's request is refused, as the console refuses it, for a
// pairing this machine does not serve.
func TestTheLaneDoesNotDenyForAPairingThisMachineDoesNotServe(t *testing.T) {
askFlowWorld(t)
const host = "box-pending.example"
givenPairing(t, host, pairingPending)
laneOwner(t, laneTestUID)
rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", Host: host, ID: "abcd1234"})
if rep.OK || !strings.Contains(rep.Why, "does not serve") {
t.Errorf("deny for a pending pairing: %+v", rep)
}
}
// An act that panics inside the daemon leaves the request lock free: the
// next approval does not hang.
func TestAPanickingActLeavesTheRequestLockFree(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
prev := requestsLoadHook
t.Cleanup(func() { requestsLoadHook = prev })
requestsLoadHook = func() { panic("boom") }
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", Machine: true, ID: "abcd1234"}); rep.OK || !rep.Failed {
t.Fatalf("a panicking act: %+v", rep)
}
requestsLoadHook = func() {}
done := make(chan struct{})
go func() { lockedRequests(existingMachineFactorDir()); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the request lock was left held by the panicking act")
}
}
// The narrowing acts go through the lane from the owner's account with no
// code: an unused approval is dropped, a device ask denied, a store grant
// dropped, every op grant dropped.
func TestTheOwnerTakesReachBackThroughTheLane(t *testing.T) {
d := askFlowWorld(t)
const host = "box-one.example"
dcn := givenPairing(t, host, pairingActive)
givenStoreGrants(t, dcn, map[string]map[string]any{"7": givenMachineStore("/srv/data", nil, nil, "")})
approval, err := stepupIssueApproval(d, codeFor(t, machineTestSecret), "local_stop|101|aider")
if err != nil {
t.Fatal(err)
}
if _, err := deviceInit(); err != nil {
t.Fatal(err)
}
ask, err := deviceNewRequest(map[string]any{"kind": "mgmt_op", "op": "ctx_del", "server": "box-7", "args": []any{}})
if err != nil {
t.Fatal(err)
}
laneOwner(t, laneTestUID)
laneSock(t)
out := captureStdout(t)
asked := stubCode(t, "")
if err := cmdStepupDrop("", true, approval[:8]); err != nil {
t.Fatalf("approvals drop: %v", err)
}
if !approvalsLoad(d)[approval].Used {
t.Error("the machine's approval is still usable after the owner dropped it")
}
if err := cmdDeviceDeny(ask[:8]); err != nil {
t.Fatalf("device-deny: %v", err)
}
deviceMu.Lock()
state := deviceRequestsLoad()[ask].State
deviceMu.Unlock()
if state != "denied" {
t.Errorf("the device ask is %q after the owner denied it", state)
}
if err := cmdGrantsDrop(host, "7"); err != nil {
t.Fatalf("grants drop: %v", err)
}
if err := cmdOpsDrop(host, "--all"); err != nil {
t.Fatalf("ops drop --all: %v", err)
}
got := printedSince(out, 0, "op grant(s) dropped")
for _, want := range []string{"dropped approval " + approval[:8], "denied " + ask, "store 7 dropped for " + host,
"0 op grant(s) dropped"} {
if !strings.Contains(got, want) {
t.Errorf("printed no %q:\n%s", want, got)
}
}
if *asked != 0 {
t.Errorf("a narrowing act asked for a code %d times", *asked)
}
// MUTATION: route grants-drop to the account's own state. RESULT: red,
// a second drop through the lane still finds the store.
if err := cmdGrantsDrop(host, "7"); err != nil || !strings.Contains(printedSince(out, len(got), "not held"), "was not held here") {
t.Errorf("a second drop of the same store: %v", err)
}
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
for _, act := range []string{"approvals-drop", "device-deny", "grants-drop 7 for " + host, "ops-drop --all for " + host} {
if !strings.Contains(string(raw), act) {
t.Errorf("no audit row for %s", act)
}
}
}
// Any other account is refused the narrowing acts too, with the
// administrator's command.
func TestAnotherAccountIsRefusedTheNarrowingActs(t *testing.T) {
askFlowWorld(t)
givenPairing(t, "box-one.example", pairingActive)
laneOwner(t, laneTestUID+1)
laneSock(t)
captureStdout(t)
for name, run := range map[string]func() error{
"approvals drop": func() error { return cmdStepupDrop("", true, "abcd1234") },
"device-deny": func() error { return cmdDeviceDeny("abcd1234") },
"grants drop": func() error { return cmdGrantsDrop("box-one.example", "7") },
"ops drop": func() error { return cmdOpsDrop("box-one.example", "--all") },
} {
err := run()
if err == nil || !strings.Contains(err.Error(), "acts only for its owner's account") ||
!strings.Contains(err.Error(), "sudo -H privatae-bastion ") {
t.Errorf("%s from another account: %v", name, err)
}
}
}
// `ops drop ""` (an unset variable in a script, say) names no grant: it is
// refused, as an empty id is for approvals drop and device-deny, and the one
// grant held is kept. It used to match every nonce, so with one grant held
// it dropped that one.
//
// MUTATION: take the empty-id refusal out of opsDropText. RESULT: red, the
// held grant is dropped.
func TestAnEmptyOpGrantIDDropsNothing(t *testing.T) {
askFlowWorld(t)
const host = "box-one.example"
dcn := givenPairing(t, host, pairingActive)
const nonce = "0123456789abcdef0123"
f := opsLoad(dcn)
f.Grants[nonce] = opGrantRec{UsesLeft: 1}
if err := opsSave(dcn, f); err != nil {
t.Fatal(err)
}
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
for _, id := range []string{"", " "} {
err := cmdOpsDrop(host, id)
if err == nil || !strings.Contains(err.Error(), "which op grant") {
t.Errorf("ops drop %q: %v", id, err)
}
}
if rec := opsLoad(dcn).Grants[nonce]; rec.Dropped != "" || rec.UsesLeft != 1 {
t.Errorf("an empty id dropped the one grant held: %+v", rec)
}
}
// Store ids are each Host's own: two Hosts' store 7 are two stores, with two
// agents. Kept by store id alone, a machine paired with both ran one agent,
// so a search from one Host for its store 7 was answered by the agent over the
// other Host's folder, and dropping one Host's store stopped the other's.
func TestTwoHostsWithTheSameStoreIDGetTheirOwnAgents(t *testing.T) {
uiTestConfigDir(t)
dcnA := givenPairing(t, "box-a.example", pairingActive)
dcnB := givenPairing(t, "box-b.example", pairingActive)
givenStoreGrants(t, dcnB, map[string]map[string]any{"7": givenMachineStore("/srv/b", nil, nil, "")})
dirA, dirB := t.TempDir(), t.TempDir()
for _, d := range []string{dirA, dirB} {
if err := os.WriteFile(filepath.Join(d, "port"), []byte(map[string]string{dirA: "41001", dirB: "41002"}[d]), 0o600); err != nil {
t.Fatal(err)
}
}
stopped := map[string]int{}
faMgr.Lock()
faMgr.inst[faKey{dcnA, 7}] = &faInstance{StoreID: 7, Dir: dirA, Cert: "a", Key: "a", cancel: func() { stopped["a"]++ }}
faMgr.inst[faKey{dcnB, 7}] = &faInstance{StoreID: 7, Dir: dirB, Cert: "b", Key: "b", cancel: func() { stopped["b"]++ }}
faMgr.inst[faKey{dcnB, 9}] = &faInstance{StoreID: 9, Dir: dirB, State: "running"}
faMgr.Unlock()
t.Cleanup(func() {
faMgr.Lock()
delete(faMgr.inst, faKey{dcnA, 7})
delete(faMgr.inst, faKey{dcnB, 7})
delete(faMgr.inst, faKey{dcnB, 9})
faMgr.Unlock()
})
// A search on each pairing's lane goes to that pairing's own agent.
if c, ok := faConfigFor(dcnA, 7); !ok || c.Addr != "127.0.0.1:41001" {
t.Errorf("Host A's store 7 resolves to %+v", c)
}
if c, ok := faConfigFor(dcnB, 7); !ok || c.Addr != "127.0.0.1:41002" {
t.Errorf("Host B's store 7 resolves to %+v", c)
}
// Each beat reports its own pairing's stores only: B's store 9 is not
// A's to hear about.
// MUTATION: drop the k.Dir filter in faManagedStatus. RESULT: red.
if st := faManagedStatus(dcnA); len(st) != 1 || st["9"] != nil {
t.Errorf("Host A's beat reports %v", st)
}
if st := faManagedStatus(dcnB); len(st) != 2 {
t.Errorf("Host B's beat reports %v", st)
}
if st := faManagedStatus(""); len(st) != 0 {
t.Errorf("a beat with no DCN state reports %d stores", len(st))
}
// A's drop of a store it does not hold stops A's own orphan, never B's.
if present, err := dcnDropStore(dcnA, 7, "test"); err != nil || present || stopped["a"] != 1 || stopped["b"] != 0 {
t.Fatalf("A's drop: present %v err %v, stopped %v", present, err, stopped)
}
if present, err := dcnDropStore(dcnB, 7, "test"); err != nil || !present || stopped["b"] != 1 {
t.Fatalf("B's drop of its own store: present %v err %v, stopped %v", present, err, stopped)
}
}
+46 -19
View File
@@ -11,9 +11,12 @@ package main
// 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,
// lane (consolelane.go) already shows that account, and more that the console
// has no page for: posture (the machine's firewall checks, read as the
// administrator); the ops and op grants each Host holds here, its backup
// requests and the folders shared with it; this bastion's backup keys (public
// halves only); and doctor. All of it is about the owner's own machine, and
// the lane answers no other account. 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
@@ -31,6 +34,7 @@ import (
"net"
"os"
"runtime"
"runtime/debug"
"strconv"
"sync"
"time"
@@ -73,16 +77,23 @@ type machineRead struct {
// 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) }},
"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) }},
"switches": {build: func(o readOpts) (any, error) { return readSwitches(o) }},
"ops": {build: func(o readOpts) (any, error) { return readOps(o) }},
"device-approvals": {build: func(o readOpts) (any, error) { return readDeviceApprovals(o) }},
"backup-keys": {build: func(o readOpts) (any, error) { return readBackupKeys(o) }},
"backup-requests": {build: func(o readOpts) (any, error) { return readBackupRequests(o) }},
"backup-shares": {build: func(o readOpts) (any, error) { return readBackupShares(o) }},
"doctor": {build: func(o readOpts) (any, error) { return readDoctor(o) }, heavy: true},
}
// readsGoThroughLane: the machine's bastion is a system service this account
@@ -170,6 +181,9 @@ func readViaSock(name string, o readOpts, v any) error {
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 && r.Failed {
return errors.New(r.Why)
}
if !r.OK {
return &laneError{why: r.Why}
}
@@ -183,10 +197,14 @@ func readViaSock(name string, o readOpts, v any) error {
// ── 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"`
OK bool `json:"ok"`
Why string `json:"why,omitempty"`
// Failed: the read was answered for this account and could not be
// built (Why says why): the administrator would be told the same, so it
// is not a refusal. A bastion older than this field says it as one.
Failed bool `json:"failed,omitempty"`
User string `json:"user,omitempty"`
View json.RawMessage `json:"view,omitempty"`
}
// readLaneRefusal says why account uid may not read through the lane, or "".
@@ -252,7 +270,7 @@ func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
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()
r.Why, r.Failed = err.Error(), true
return r
}
if len(view) > readMaxBytes-readEnvelope {
@@ -267,7 +285,16 @@ func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
}
func runMachineRead(name string, spec machineRead, o readOpts) ([]byte, error) {
build := func() ([]byte, error) {
build := func() (b []byte, err error) {
// A read runs inside the daemon that serves every pairing, from code
// written for a command's own process: one that panics is that read's
// failure, never the daemon's end.
defer func() {
if p := recover(); p != nil {
uilogf("read %s panicked: %v\n%s", name, p, debug.Stack())
b, err = nil, fmt.Errorf("the read %s failed inside this bastion (%v); its log has the trace", name, p)
}
}()
v, err := spec.build(o)
if err != nil {
return nil, err
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"errors"
"os"
"strings"
"testing"
)
// The owner's agent reads the switch list, the doctor and the device's asks
// from the machine's bastion over its socket; a read the bastion answers but
// cannot build comes back as that failure, not as a refusal with the
// administrator's command (the administrator would be told the same).
func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
laneSock(t)
prevSys, prevLatest, prevSandbox := systemInstalled, doctorLatest, doctorSandbox
t.Cleanup(func() { systemInstalled, doctorLatest, doctorSandbox = prevSys, prevLatest, prevSandbox })
systemInstalled = func() bool { return true }
doctorLatest = func() string { return "" }
doctorSandbox = func() (bool, string) { return true, "" }
out := captureStdout(t)
if err := cmdSwitch([]string{"--list", "--json"}); err != nil {
t.Fatalf("switch --list --json: %v", err)
}
var sw switchesRead
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &sw); err != nil {
t.Fatalf("switch --list --json printed %q (%v)", out(), err)
}
before := len(out())
err := cmdDoctor([]string{"--json"})
var q quietExit
if err != nil && !errors.As(err, &q) {
t.Fatalf("doctor through the lane: %v", err)
}
var d doctorRead
if err := json.Unmarshal([]byte(printedSince(out, before, "}\n")), &d); err != nil || len(d.Checks) == 0 {
t.Fatalf("doctor --json printed %q (%v)", out()[before:], err)
}
if c, ok := doctorCheckNamed(d, "owner"); !ok || !c.OK {
t.Errorf("the machine's doctor, read by its owner, does not find the owner: %+v", d.Checks)
}
p, err := deviceShownPath()
if err != nil {
t.Fatal(err)
}
if err := cmdDeviceApprovals(nil); err != nil {
t.Fatalf("device-approvals through the lane: %v", err)
}
if _, err := os.Stat(p); err == nil {
t.Error("device-approvals read through the lane recorded what it printed as shown")
}
// MUTATION: drop `r.Failed = true` in serveRead. RESULT: red, the error
// carries the administrator's command.
err = cmdOpsList("", true)
var le *laneError
if err == nil || errors.As(err, &le) || strings.Contains(err.Error(), "As the administrator") {
t.Errorf("ops with nothing paired, through the lane: %v", err)
}
}
// `switch --list` passes the guard beside the system unit, as status does;
// `switch` does too since the owner's account switches through the lane
// (owneracts.go), and only on Windows is it still refused here.
func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) {
prevArgs, prevUnit, prevAuth := os.Args, linuxSystemUnitPresent, systemService.authority
t.Cleanup(func() { os.Args, linuxSystemUnitPresent, systemService.authority = prevArgs, prevUnit, prevAuth })
linuxSystemUnitPresent = func() bool { return true }
systemService.authority = false
os.Args = []string{"privatae-bastion", "switch", "--list", "--json"}
if err := guardServiceFork("switch"); err != nil {
t.Errorf("switch --list beside the system unit: %v", err)
}
os.Args = []string{"privatae-bastion", "switch", "box", "ops", "off"}
if err := guardServiceFork("switch"); err != nil {
t.Errorf("switch from the owner's account beside the system unit, before the lane could decide: %v", err)
}
if err := serviceGuard("windows", serviceGuardKey("switch", os.Args), true, false); err == nil {
t.Error("switch from an ordinary account beside a Windows service was let through")
}
}
+520
View File
@@ -0,0 +1,520 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
)
// The switch list as JSON has every row the words have, each family of each
// paired host, and an unreadable list reads as every family off.
func TestTheSwitchListReadHasEveryRowTheWordsHave(t *testing.T) {
uiTestConfigDir(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
if err := localDenySet(host, "ops", true); err != nil {
t.Fatal(err)
}
v, err := readSwitches(readOpts{})
if err != nil {
t.Fatal(err)
}
if len(v.Switches) != len(localSwitchFamilies()) {
t.Fatalf("want one row per family (%d), got %+v", len(localSwitchFamilies()), v.Switches)
}
var buf bytes.Buffer
printSwitches(&buf, v)
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != len(v.Switches) {
t.Fatalf("%d rows, %d lines:\n%s", len(v.Switches), len(lines), buf.String())
}
for i, r := range v.Switches {
f := strings.Fields(lines[i])
if f[0] != r.Host || f[1] != r.Family || (f[2] == "off") != r.Off {
t.Errorf("row %+v printed as %q", r, lines[i])
}
if r.Family == "ops" && (!r.Off || r.Since == "") {
t.Errorf("ops was switched off: %+v", r)
}
}
// MUTATION: build rows from f.Off only, ignoring readErr. RESULT: red,
// the families read on while the gate refuses them all.
path, err := localDenyPath()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
v, err = readSwitches(readOpts{})
if err != nil {
t.Fatal(err)
}
if v.Unreadable == "" {
t.Error("an unreadable list is not said")
}
for _, r := range v.Switches {
if !r.Off {
t.Errorf("with the list unreadable, %s %s reads on", r.Host, r.Family)
}
}
}
// Read as JSON, the device's asks are a program's, not a person's: nothing is
// recorded as shown, so `device-approve all` signs nothing an agent read.
func TestDeviceApprovalsAsJSONRecordsNothingShown(t *testing.T) {
uiTestConfigDir(t)
p, err := deviceShownPath()
if err != nil {
t.Fatal(err)
}
if err := cmdDeviceApprovals([]string{"--json"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err == nil {
t.Error("device-approvals --json recorded what it printed as shown")
}
if err := cmdDeviceApprovals(nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err != nil {
t.Errorf("the words listing recorded nothing as shown: %v", err)
}
if err := cmdDeviceApprovals([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("a stray flag: %v", err)
}
}
// A restore request read leaves the sealed key the host sent, and the one
// resealed for it, out of the answer; their digest and the words stay.
func TestTheBackupRequestsReadLeavesTheSealedKeysOut(t *testing.T) {
uiTestConfigDir(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
cfg, err := stepupPairing(host)
if err != nil {
t.Fatal(err)
}
const sealed, resealed = "-----BEGIN AGE ENCRYPTED FILE-----SIDECAR", "-----BEGIN AGE ENCRYPTED FILE-----RESEALED"
at := time.Now().UTC().Format(time.RFC3339)
// r1 waits, so it still carries the sidecar it came with; r2 was
// answered and carries the resealed key.
if err := resealSave(cfg.dcnDir(), map[string]*resealRequest{
"r1": {ID: "r1", Stamp: "20261101T000000Z", Pairing: host, HostNumber: 7, HostLabel: "home", To: "age1xyz",
ToFP: "fp", Sidecar: sealed, SidecarSH: "abc123", At: at, State: "pending"},
"r2": {ID: "r2", Stamp: "20261101T000000Z", Pairing: host, HostNumber: 7, HostLabel: "home", To: "age1xyz",
ToFP: "fp", SidecarSH: "def456", At: at, State: "done", Resealed: resealed},
}); err != nil {
t.Fatal(err)
}
stored, err := resealRead(cfg.dcnDir())
if err != nil || stored["r1"] == nil || stored["r1"].Sidecar != sealed || stored["r2"] == nil || stored["r2"].Resealed != resealed {
t.Fatalf("the fixture does not hold both sealed keys: %+v %v", stored, err)
}
// MUTATION: build the view from *resealRequest itself. RESULT: red, both
// sealed keys are in the answer.
v, err := readBackupRequests(readOpts{Host: host})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(v)
if strings.Contains(string(b), "SIDECAR") || strings.Contains(string(b), "RESEALED") {
t.Errorf("the read carries a sealed key: %s", b)
}
if len(v.Requests) != 2 || v.Requests[0].SidecarSHA == "" || !strings.Contains(v.Requests[0].Words, "home") {
t.Errorf("the requests as read: %+v", v.Requests)
}
}
// The backup key read carries the public halves only.
func TestTheBackupKeysReadHasNoSecretHalf(t *testing.T) {
b, err := json.Marshal(backupKeysRead{Keys: []*backupKeyFile{{Identity: "AGE-SECRET-KEY-1SECRET", Recipient: "age1pub",
Fingerprint: "fp"}}, Current: "fp"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "AGE-SECRET-KEY") {
t.Errorf("the backup keys read carries the secret: %s", b)
}
}
// agents show takes a pid, or a session name that matches exactly one, the
// rule stop keeps.
func TestAgentsShowPicksExactlyOneAgent(t *testing.T) {
views := []agentView{
{agentObs: agentObs{PID: 101, Signature: "claude-code"}, Self: &agentSelf{Name: "shop"}},
{agentObs: agentObs{PID: 102, Signature: "claude-code"}, Self: &agentSelf{Name: "twin"}},
{agentObs: agentObs{PID: 103, Signature: "aider"}, Self: &agentSelf{Name: "twin"}},
}
if v, err := pickAgentView(views, "101"); err != nil || v.PID != 101 {
t.Errorf("by pid: %+v %v", v, err)
}
if v, err := pickAgentView(views, "shop"); err != nil || v.PID != 101 {
t.Errorf("by name: %+v %v", v, err)
}
if _, err := pickAgentView(views, "twin"); err == nil || !strings.Contains(err.Error(), "give the pid") {
t.Errorf("two sessions with one name: %v", err)
}
if _, err := pickAgentView(views, "999"); err == nil {
t.Error("a pid no agent has was shown")
}
if _, err := pickAgentView(views, "nobody"); err == nil {
t.Error("a name no session has was shown")
}
if err := cmdAgents([]string{"show"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("show with no agent: %v", err)
}
if err := cmdAgents([]string{"show", "--json"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("show --json with no agent: %v", err)
}
}
// The detail view names the file the kernel says the agent runs, and keeps
// its command line (which can carry a key) out of the words.
func TestAgentsShowKeepsTheCommandLineOutOfTheWords(t *testing.T) {
v := agentView{agentObs: anObservedAgent("aider", "/usr/local/bin/aider --api-key sk-THEKEY", 4242)}
var b bytes.Buffer
printAgentDetail(&b, v, time.Now())
out := b.String()
if strings.Contains(out, "sk-THEKEY") {
t.Errorf("the words carry the command line:\n%s", out)
}
if !strings.Contains(out, "/usr/local/bin/aider") || !strings.Contains(out, "4242") {
t.Errorf("the words do not name the program and pid:\n%s", out)
}
}
// The file an agent runs is a path whoever placed it chose, so a path built
// of terminal control sequences is shown as such, never sent to the terminal.
//
// MUTATION: row("program", v.Exe). RESULT: red, the escape reaches the words.
func TestAgentsShowNeverPrintsControlCharactersFromTheProgramPath(t *testing.T) {
v := agentView{agentObs: anObservedAgent("claude-code", "claude", 4242)}
v.Exe = "/tmp/\x1b]52;c;cHduZWQ=\x07/\x1b[2Jclaude"
var b bytes.Buffer
printAgentDetail(&b, v, time.Now())
out := b.String()
if strings.ContainsAny(out, "\x1b\x07") {
t.Errorf("the words carry the program path's control characters: %q", out)
}
if !strings.Contains(out, `\x1b]52`) {
t.Errorf("the program path is not shown as what it is: %q", out)
}
v.Exe = "/usr/local/bin/claude"
b.Reset()
printAgentDetail(&b, v, time.Now())
if !strings.Contains(b.String(), "program: /usr/local/bin/claude\n") {
t.Errorf("a plain path is not shown as is: %q", b.String())
}
}
// A read is built in the root daemon now, from code that ran only in a
// command's own process before (readOps slices names it finds on disk). One
// that panics is that read's failure, and the daemon serving every pairing
// stays up.
//
// MUTATION: drop the recover in runMachineRead. RESULT: red, the suite
// itself panics.
func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) {
uiTestConfigDir(t)
laneOwner(t, 1000)
for _, heavy := range []bool{false, true} {
machineReads["test-panics"] = machineRead{heavy: heavy, build: func(readOpts) (any, error) {
var s []string
return s[3], nil
}}
r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "test-panics"})
if r.OK || !r.Failed || !strings.Contains(r.Why, "test-panics") {
t.Errorf("a read that panics (heavy %v): %+v", heavy, r)
}
}
delete(machineReads, "test-panics")
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "status"}); !r.OK {
t.Errorf("after a read panicked, status: %+v", r)
}
}
// doctor from an ordinary account beside a Windows system service would check
// that account's own empty bastion and report it as the machine's (not
// running, no owner, no factor), so it is refused there as status is; on
// Linux and macOS it reads the machine's bastion through the lane.
//
// MUTATION: drop "doctor" from serviceGuardedCommands. RESULT: red on windows.
func TestDoctorBesideAWindowsServiceIsRefusedAsStatusIs(t *testing.T) {
if err := serviceGuard("windows", "doctor", true, false); err == nil {
t.Error("doctor from an ordinary account beside a Windows service was let through")
}
for _, goos := range []string{"linux", "darwin"} {
if err := serviceGuard(goos, "doctor", true, false); err != nil {
t.Errorf("%s: doctor beside the service is a lane read: %v", goos, err)
}
}
if err := serviceGuard("windows", "doctor", true, true); err != nil {
t.Errorf("the administrator's doctor beside a Windows service: %v", err)
}
}
// doctorWorld is a machine doctor reads from fixtures only.
func doctorWorld(t *testing.T, system bool, latest string) {
t.Helper()
uiTestConfigDir(t)
prevSys, prevLatest, prevSandbox := systemInstalled, doctorLatest, doctorSandbox
t.Cleanup(func() { systemInstalled, doctorLatest, doctorSandbox = prevSys, prevLatest, prevSandbox })
systemInstalled = func() bool { return system }
doctorLatest = func() string { return latest }
doctorSandbox = func() (bool, string) { return true, "" }
}
func doctorCheckNamed(d doctorRead, name string) (doctorCheck, bool) {
for _, c := range d.Checks {
if c.Name == name {
return c, true
}
}
return doctorCheck{}, false
}
// With no second factor, doctor says what cannot be approved and the command
// that sets one up; with one, it is not a problem.
func TestDoctorFindsAMissingSecondFactorAndNamesTheFix(t *testing.T) {
doctorWorld(t, false, "")
d, err := readDoctor(readOpts{})
if err != nil {
t.Fatal(err)
}
c, ok := doctorCheckNamed(d, "second factor")
if !ok || c.OK || !strings.Contains(c.Fix, "stepup-enroll --machine") || strings.Contains(c.Fix, "sudo") {
t.Errorf("no factor, a per-account install: %+v", c)
}
systemInstalled = func() bool { return true }
d, _ = readDoctor(readOpts{})
if c, _ := doctorCheckNamed(d, "second factor"); !strings.HasPrefix(c.Fix, "sudo -H privatae-bastion stepup-enroll") &&
!strings.Contains(c.Fix, "Administrator") {
t.Errorf("no factor, installed for the machine: %+v", c)
}
dir, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
enrolFactorAt(t, dir, machineTestSecret)
d, _ = readDoctor(readOpts{})
if c, _ := doctorCheckNamed(d, "second factor"); !c.OK {
t.Errorf("with a factor enrolled: %+v", c)
}
}
// A newer published version is a problem with the installer as its fix; a
// latest version nobody could read is not one.
func TestDoctorSaysANewerVersionAndNeverAlarmsOffline(t *testing.T) {
doctorWorld(t, false, "999.0.0")
prev := clientVersion
t.Cleanup(func() { clientVersion = prev })
clientVersion = "1.0.0"
d, _ := readDoctor(readOpts{})
c, _ := doctorCheckNamed(d, "version")
if c.OK || !strings.Contains(c.Problem, "999.0.0") || !strings.Contains(c.Fix, "privatae.ai/bastion/install") {
t.Errorf("a newer version: %+v", c)
}
doctorLatest = func() string { return "" }
d, _ = readDoctor(readOpts{})
if c, _ := doctorCheckNamed(d, "version"); !c.OK {
t.Errorf("no answer from privatae.ai reads as a problem: %+v", c)
}
doctorLatest = func() string { return "1.0.0" }
d, _ = readDoctor(readOpts{})
if c, _ := doctorCheckNamed(d, "version"); !c.OK {
t.Errorf("the latest version reads as a problem: %+v", c)
}
}
// Installed for the whole machine with no owner recorded, doctor says what
// that stops and how to record one; recorded, it names the account.
func TestDoctorFindsNoOwnerAccountOnAMachineInstall(t *testing.T) {
doctorWorld(t, true, "")
d, _ := readDoctor(readOpts{})
c, ok := doctorCheckNamed(d, "owner")
if !ok || c.OK || !strings.Contains(c.Fix, "approvals-owner") {
t.Errorf("no owner: %+v", c)
}
laneOwner(t, 1000)
d, _ = readDoctor(readOpts{})
if c, _ := doctorCheckNamed(d, "owner"); !c.OK || !strings.Contains(c.Detail, "sam") {
t.Errorf("an owner recorded: %+v", c)
}
systemInstalled = func() bool { return false }
d, _ = readDoctor(readOpts{})
if _, ok := doctorCheckNamed(d, "owner"); ok {
t.Error("a per-account install was checked for a machine owner")
}
}
// An account whose agents' asks are held is named, with the command that
// lifts the hold.
func TestDoctorNamesAHeldAsker(t *testing.T) {
doctorWorld(t, false, "")
dir, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
enrolFactorAt(t, dir, machineTestSecret)
now := time.Now()
for i := 0; i < quarantineStrikes; i++ {
askStrike(dir, "lee", now)
}
d, _ := readDoctor(readOpts{})
c, ok := doctorCheckNamed(d, "held asker")
if !ok || c.OK || !strings.Contains(c.Problem, "lee") || !strings.Contains(c.Fix, "quarantine clear --asker lee") {
t.Errorf("a held asker: %+v (checks %+v)", c, d.Checks)
}
}
// doctor exits 1 when it finds a problem, so a script can branch, and the
// lane not answering becomes the check rather than an error.
func TestDoctorExitsOneOnAProblemAndSaysTheLane(t *testing.T) {
doctorWorld(t, false, "")
err := cmdDoctor(nil)
var q quietExit
if !errors.As(err, &q) || q.code != 1 {
t.Errorf("no factor: want exit 1, got %v", err)
}
if err := cmdDoctor([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("a stray flag: %v", err)
}
if c := laneCheck(&laneError{down: true, why: "nothing answers"}); c.OK || c.Name != "running" || c.Fix == "" {
t.Errorf("the lane down: %+v", c)
}
if c := laneCheck(&laneError{why: "this machine's bastion answers reads only for its owner's account (sam), not lee"}); c.OK ||
c.Name != "reads" || !strings.Contains(c.Fix, "doctor") {
t.Errorf("the lane refusing: %+v", c)
}
}
func TestTheSliceThreeReadCommandsRefuseStrayFlags(t *testing.T) {
for name, cmd := range map[string]func([]string) error{
"switch --list": func(a []string) error { return cmdSwitch(append([]string{"--list"}, a...)) },
"backup key show": func(a []string) error {
return cmdBackup(append([]string{"key", "show"}, a...))
},
"backup requests": func(a []string) error { return cmdBackup(append([]string{"requests"}, a...)) },
"backup shares": func(a []string) error {
return cmdBackup(append([]string{"shares", "--with", "h"}, a...))
},
} {
if err := cmd([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage") {
t.Errorf("%s --jsno: %v", name, err)
}
}
}
// Not paired, the command that pairs is said the way it is typed here:
// with sudo -H where the bastion is installed for the whole machine.
func TestNotPairedSaysThePairCommandForThisInstall(t *testing.T) {
doctorWorld(t, true, "")
_, err := stepupPairing("")
if err == nil || !strings.Contains(err.Error(), asAdmin("pair <token>")) {
t.Errorf("not paired, a machine install: %v", err)
}
if runtimeIsUnix() && !strings.Contains(err.Error(), "sudo -H privatae-bastion pair") {
t.Errorf("not paired, a machine install, says no sudo: %v", err)
}
systemInstalled = func() bool { return false }
if _, err := stepupPairing(""); err == nil || strings.Contains(err.Error(), "sudo") {
t.Errorf("not paired, a per-account install: %v", err)
}
}
func runtimeIsUnix() bool { return os.PathSeparator == '/' }
// doctor asks privatae.ai at update.go's rate: once, then not again within
// updateCheckEvery, whether that ask was answered or not; the last answer
// stands meanwhile. MUTATION: drop the doctorAskedLoad shortcut. RESULT: red,
// every run fetches.
func TestDoctorAsksPrivataeAtTheUpdateCheckRate(t *testing.T) {
uiTestConfigDir(t)
prevFetch, prevNow := doctorFetch, doctorNow
t.Cleanup(func() { doctorFetch, doctorNow = prevFetch, prevNow })
forget := func() { latestMu.Lock(); latestPublished = ""; latestMu.Unlock() }
t.Cleanup(forget)
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
doctorNow = func() time.Time { return now }
fetches, answer := 0, "0.61.0"
doctorFetch = func() (string, error) {
fetches++
if answer == "" {
return "", errors.New("no route")
}
return answer, nil
}
forget()
if v := doctorLatest(); v != "0.61.0" || fetches != 1 {
t.Fatalf("first run: %q after %d fetches", v, fetches)
}
forget() // a new process: nothing in memory
now = now.Add(updateCheckEvery - time.Minute)
if v := doctorLatest(); v != "0.61.0" || fetches != 1 {
t.Errorf("inside the window: %q after %d fetches", v, fetches)
}
forget()
now = now.Add(2 * time.Minute)
answer = ""
if v := doctorLatest(); v != "0.61.0" || fetches != 2 {
t.Errorf("past the window, no answer: %q after %d fetches (the last answer should stand)", v, fetches)
}
forget()
now = now.Add(time.Hour)
if v := doctorLatest(); v != "0.61.0" || fetches != 2 {
t.Errorf("a failed ask is not repeated inside the window: %q after %d fetches", v, fetches)
}
}
// A folder the agent chose, holding an escape sequence, is printed quoted in
// agents and agents show, never sent to the terminal raw.
func TestAnAgentsFolderNeverReachesTheTerminalRaw(t *testing.T) {
v := agentView{agentObs: anObservedAgent("aider", "/usr/local/bin/aider", 4242),
Folder: "/home/u/\x1b]52;c;cGF3bmVk\x07work", GitBranch: "main"}
var b bytes.Buffer
printAgents(&b, []agentView{v}, time.Now())
printAgentDetail(&b, v, time.Now())
if strings.ContainsAny(b.String(), "\x1b\x07") {
t.Errorf("a control character reached the output: %q", b.String())
}
if !strings.Contains(b.String(), `\x1b]52`) {
t.Errorf("the folder is not shown at all: %q", b.String())
}
}
// On Windows, where nothing goes over the socket, the slice-3 reads from an
// ordinary account beside the service are refused as status is; on Linux and
// macOS they ask the machine's bastion. The rest of backup is not guarded.
func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) {
for _, line := range [][]string{
{"privatae-bastion", "ops", "--json"},
{"privatae-bastion", "device-approvals"},
{"privatae-bastion", "backup", "requests"},
{"privatae-bastion", "backup", "shares", "--with", "h"},
{"privatae-bastion", "backup", "key", "show"},
} {
key := serviceGuardKey(line[1], line)
if err := serviceGuard("windows", key, true, false); err == nil {
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
}
for _, goos := range []string{"linux", "darwin"} {
if err := serviceGuard(goos, key, true, false); err != nil {
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
}
}
}
for _, line := range [][]string{
{"privatae-bastion", "backup", "reseal", "--sidecar", "f", "--to", "k"},
{"privatae-bastion", "backup", "restore-plan", "--dir", "d"},
} {
if err := serviceGuard("windows", serviceGuardKey(line[1], line), true, false); err != nil {
t.Errorf("windows: %v works offline and was refused: %v", line[1:], err)
}
}
}
+41
View File
@@ -134,6 +134,26 @@ func TestEveryReadPrintsTheSameAfterTheLane(t *testing.T) {
func(w *bytes.Buffer, v any) { _ = printDelta(w, v.(deltaReport)) })
rep := buildReport(now, now.Add(-7*24*time.Hour))
add("report", rep, func(w *bytes.Buffer, v any) { renderReportMarkdown(w, v.(machineReport)) })
add("switches", switchesRead{Switches: []switchRow{{Host: "home", Family: "ops", Off: true, Since: "2026-11-01T00:00:00Z"},
{Host: "home", Family: "observe"}}, Unreadable: "the list cannot be read"},
func(w *bytes.Buffer, v any) { printSwitches(w, v.(switchesRead)) })
add("ops", opsRead{Host: "home", SandboxUnavailable: "the kernel offers no overlay filesystem",
Ops: []opRow{{ID: "0123456789ab", Name: "restart-web", Version: "1", Privilege: "root", Summary: "restart nginx"}, {ID: "ba9876543210", NotVerified: "bad signature"}},
Grants: []opGrantRow{{ID: "abcd1234", Op: "restart-web", Version: "1", RunsLeft: 2, Until: "2026-11-02", Within: "nginx only"}}},
func(w *bytes.Buffer, v any) { printOps(w, v.(opsRead)) })
add("device-approvals", deviceApprovalsRead{Asks: []deviceAsk{{ID: "0123456789abcdef", Server: "web-1", At: "2026-11-01", Words: "Restart nginx."}}},
func(w *bytes.Buffer, v any) { printDeviceApprovals(w, v.(deviceApprovalsRead), true) })
add("backup-keys", backupKeysRead{Keys: []*backupKeyFile{{Recipient: "age1pub", PubB64: "cHVi", Fingerprint: "fp1",
CreatedAt: "2026-10-01", Path: "/x/key"}, {Fingerprint: "fp0"}}, Current: "fp1"},
func(w *bytes.Buffer, v any) { printBackupKeys(w, v.(backupKeysRead)) })
add("backup-requests", backupRequestsRead{Host: "home", SetAside: []string{"/x/reseal.json.aside-1"},
Requests: []backupRequestView{{ID: "r1", State: "pending", Words: "r1 [pending]\n Sent by the pairing home."}}},
func(w *bytes.Buffer, v any) { printBackupRequests(w, v.(backupRequestsRead)) })
add("backup-shares", backupSharesRead{With: "new-home", Shares: []backupShare{{Slug: "old", Dir: "/srv/backups/old", Added: "2026-10-01"}}},
func(w *bytes.Buffer, v any) { printBackupShares(w, v.(backupSharesRead)) })
add("doctor", doctorRead{Version: "9.9.9", Checks: []doctorCheck{{Name: "running", OK: true, Detail: "Running on its own."},
{Name: "second factor", Problem: "No second factor.", Fix: "privatae-bastion stepup-enroll --machine"}}},
func(w *bytes.Buffer, v any) { printDoctor(w, v.(doctorRead)) })
for name := range machineReads {
if name == "baseline" {
@@ -223,6 +243,27 @@ func zeroLike(v any) decoded {
case machineReport:
var x machineReport
return decoded{&x, func() any { return x }}
case switchesRead:
var x switchesRead
return decoded{&x, func() any { return x }}
case opsRead:
var x opsRead
return decoded{&x, func() any { return x }}
case deviceApprovalsRead:
var x deviceApprovalsRead
return decoded{&x, func() any { return x }}
case backupKeysRead:
var x backupKeysRead
return decoded{&x, func() any { return x }}
case backupRequestsRead:
var x backupRequestsRead
return decoded{&x, func() any { return x }}
case backupSharesRead:
var x backupSharesRead
return decoded{&x, func() any { return x }}
case doctorRead:
var x doctorRead
return decoded{&x, func() any { return x }}
}
panic("no case")
}
+25 -3
View File
@@ -606,7 +606,13 @@ func runPairing(ctx context.Context, cfg *Config) error {
// (posture). The device fingerprint is NOT sent here: it is bound at
// pairing from the operator CSR; the reverse lanes are admitted on the bearer's
// binding to that cert, which the host enforces. Authed by the bearer.
// Each beat's request carries the run's context, and a stopped run waits
// for the heartbeat before it returns, so no beat of this run records a
// time, reconciles grants into its folder or acts on a directive after
// the pairing has stopped (an unpair or revoke removes those next).
beatDone := make(chan struct{})
go func() {
defer close(beatDone)
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
hostname, _ := os.Hostname()
wgPub := ""
@@ -616,8 +622,11 @@ func runPairing(ctx context.Context, cfg *Config) error {
}
}
beat := func() {
if ctx.Err() != nil {
return
}
payload := heartbeatPayload(cfg, dev, hostname, wgPub)
req, err := http.NewRequest(http.MethodPost, hbURL, strings.NewReader(payload))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hbURL, strings.NewReader(payload))
if err != nil {
return
}
@@ -629,6 +638,9 @@ func runPairing(ctx context.Context, cfg *Config) error {
if !ok {
return // a reply past its bound is no answer (lane_bounds.go)
}
if ctx.Err() != nil {
return // the pairing stopped while this beat was out
}
if resp.StatusCode < 300 {
// last_ok per pairing (anchor.go): the anchor rotation's
// silence path reads this — 14 days with no 2xx from the
@@ -694,6 +706,7 @@ func runPairing(ctx context.Context, cfg *Config) error {
select {
case <-ctx.Done():
_ = srv.Close()
<-beatDone
return nil
case err := <-errc:
return err
@@ -842,8 +855,10 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
// The heartbeat: the same body (no WireGuard device, so no allowed IPs
// and no endpoint) and every directive its reply carries except the two
// that move a tunnel.
// that move a tunnel. A stopped run waits for it, as runPairing's does.
beatDone := make(chan struct{})
go func() {
defer close(beatDone)
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
hostname, _ := os.Hostname()
wgPub := ""
@@ -853,7 +868,10 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
}
}
beat := func() {
req, err := http.NewRequest(http.MethodPost, hbURL, strings.NewReader(heartbeatPayload(cfg, nil, hostname, wgPub)))
if ctx.Err() != nil {
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hbURL, strings.NewReader(heartbeatPayload(cfg, nil, hostname, wgPub)))
if err != nil {
return
}
@@ -868,6 +886,9 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
if !ok {
return // a reply past its bound is no answer (lane_bounds.go)
}
if ctx.Err() != nil {
return // the pairing stopped while this beat was out
}
if resp.StatusCode < 300 {
recordPairingOK(cfg.HostName)
}
@@ -899,6 +920,7 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
select {
case <-ctx.Done():
_ = srv.Close()
<-beatDone
return nil
case err := <-errc:
return err
+58
View File
@@ -0,0 +1,58 @@
//go:build linux
// A pairing's run and its heartbeat stop together (run.go): uses the serve
// lane's fixtures (servelane_linux_test.go), so it carries the same build tag.
package main
import (
"context"
"os"
"testing"
"time"
)
// A pairing stopped while a beat is out acts on nothing that beat brings
// back. The run waits for its heartbeat and the beat's request ends with the
// run, so its last-OK time, and anything else the reply would have set off
// (a grant reconcile into the pairing's folder, a contain, an update), never
// lands after the stop, when an unpair or a revoke removes what is left.
func TestAStoppedPairingsLastBeatWritesNothingAfterTheStop(t *testing.T) {
h, cfg := serveLaneBox(t, "beat-stop.example.test")
arrived, release := make(chan struct{}, 1), make(chan struct{})
h.mu.Lock()
h.beatArrived, h.beatRelease = arrived, release
h.mu.Unlock()
released := false
t.Cleanup(func() {
if !released {
close(release)
}
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- runPairing(ctx, cfg) }()
select {
case <-arrived:
case <-time.After(10 * time.Second):
t.Fatal("no beat reached the Host")
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the pairing did not stop while its beat was out")
}
// The Host answers the beat only now, after the run has returned.
close(release)
released = true
time.Sleep(500 * time.Millisecond)
p, err := pairingStatePath()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err == nil {
t.Fatal("the stopped pairing's last beat recorded its time after the stop")
}
}
+16 -5
View File
@@ -40,12 +40,15 @@ import (
// serveHost is a Host's lane port as the serve lane meets it: the lane
// port's mutual TLS, every worker route kept parked, each beat recorded
// whole, and each /bastion-serve worker handed to the test once it has sent
// its hello.
// its hello. With beatRelease set, each beat's reply waits for it to close,
// and the first beat to arrive says so on beatArrived.
type serveHost struct {
mu sync.Mutex
beats []map[string]any
hellos []string
workers chan *websocket.Conn
mu sync.Mutex
beats []map[string]any
hellos []string
workers chan *websocket.Conn
beatArrived chan struct{}
beatRelease chan struct{}
}
func startServeHost(t *testing.T, raw net.Listener, cert tls.Certificate, clientCA *testCA) *serveHost {
@@ -62,7 +65,15 @@ func startServeHost(t *testing.T, raw net.Listener, cert tls.Certificate, client
_ = json.NewDecoder(r.Body).Decode(&m)
h.mu.Lock()
h.beats = append(h.beats, m)
arrived, release := h.beatArrived, h.beatRelease
h.mu.Unlock()
if release != nil {
select {
case arrived <- struct{}{}:
default:
}
<-release
}
_, _ = w.Write([]byte("{}"))
})
mux.HandleFunc("/bastion-serve", func(w http.ResponseWriter, r *http.Request) {
+13 -2
View File
@@ -1162,7 +1162,7 @@ func stepupPairing(host string) (*Config, error) {
return nil, err
}
if len(cfgs) == 0 {
return nil, fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
return nil, fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
}
switch {
case host != "":
@@ -1341,7 +1341,7 @@ func readApprovals(o readOpts) (approvalsRead, error) {
func cmdStepupApprovals(host string, machine, asJSON bool) error {
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
if err != nil {
return laneRefusalError("approvals", err)
return laneRefusalError(strings.TrimSpace(stepupCommand("approvals", host, machine, "")), err)
}
if asJSON {
return printJSON(os.Stdout, r)
@@ -1388,6 +1388,9 @@ func findRequest(m map[string]stepupRequest, prefix string) (string, error) {
// privatae-bastion approve [--host <host> | --machine] <request id>
func cmdStepupApprove(host string, machine bool, prefix string) error {
if readsGoThroughLane() {
return ownerApproveViaLane(host, machine, prefix)
}
t, err := resolveStepupTarget(host, machine)
if err != nil {
return err
@@ -1425,6 +1428,14 @@ func cmdStepupApprove(host string, machine bool, prefix string) error {
// privatae-bastion deny [--host <host> | --machine] <request id>
func cmdStepupDeny(host string, machine bool, prefix string) error {
if readsGoThroughLane() {
done, err := actViaSock(agentAskRequest{Act: "deny", Host: host, Machine: machine, ID: prefix})
if err != nil {
return laneRefusalError(stepupCommand("deny", host, machine, prefix), err)
}
fmt.Println(done)
return nil
}
t, err := resolveStepupTarget(host, machine)
if err != nil {
return err
+113 -44
View File
@@ -3,7 +3,7 @@ package main
// switch_cli.go — the local switch (localdeny.go) from a terminal.
//
// privatae-bastion switch <host> <family> on|off
// privatae-bastion switch --list
// privatae-bastion switch --list [--json]
//
// The console page has had this switch since localdeny.go was written; a
// machine with no browser and no person at it (a server the bastion guards
@@ -14,10 +14,11 @@ package main
// MACHINE AUTHORITY, for both directions. On a machine that serves a Host it
// does not belong to the person using that Host, the switch is part of how
// the machine was set up, and the one who stands behind that is whoever
// administers the box. --list only reads, and needs no authority of its own;
// beside a system service on macOS or Windows an ordinary account is refused
// it (serviceGuard, sysservice.go), because it would read that account's own
// state rather than the machine's.
// administers the box. --list only reads, and needs no authority of its own:
// beside a system service on Linux or macOS the owner's account reads the
// machine's list through the lane (reads.go); on Windows an ordinary account
// is refused it (serviceGuard, sysservice.go), because it would read that
// account's own state rather than the machine's.
import (
"errors"
@@ -29,14 +30,26 @@ import (
)
const switchUsage = "usage: sudo -H privatae-bastion switch <host> <family> on|off\n" +
" privatae-bastion switch --list\n" +
" privatae-bastion switch --list [--json]\n" +
" sudo -H privatae-bastion switch --reset (only when the list cannot be read)"
// cmdSwitch parses the arguments and runs one switch, the listing, or the
// reset of an unreadable list.
func cmdSwitch(args []string) error {
if len(args) == 1 && args[0] == "--list" {
return switchList(os.Stdout)
if len(args) >= 1 && args[0] == "--list" {
asJSON, err := jsonOnly(switchUsage, args[1:])
if err != nil {
return err
}
v, err := readMachine("switches", readOpts{}, readSwitches)
if err != nil {
return laneRefusalError("switch --list", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printSwitches(os.Stdout, v)
return nil
}
if len(args) == 1 && args[0] == "--reset" {
// The same authority as switching anything back on: a reset leaves
@@ -64,6 +77,25 @@ func cmdSwitch(args []string) error {
default:
return fmt.Errorf("the last word is on or off, not %q\n%s", args[2], switchUsage)
}
if readsGoThroughLane() {
// The owner's account, beside the machine's bastion: the console's
// rule, through its ask socket (owneracts.go). Off takes reach away and
// needs nothing; on takes the owner's code.
req := agentAskRequest{Act: "switch", Host: args[0], Family: args[1], Off: off}
if !off {
code, ok := promptOwnerCode("Switch " + args[0] + " " + args[1] + " back on? Enter the code from your authenticator (or leave empty to cancel): ")
if !ok {
return nil
}
req.Code = code
}
done, err := actViaSock(req)
if err != nil {
return laneRefusalError("switch "+strings.Join(args, " "), err)
}
fmt.Println(done)
return nil
}
if err := requireMachineAuthority("switch"); err != nil {
return err
}
@@ -75,11 +107,7 @@ func cmdSwitch(args []string) error {
if err := localDenySet(host, family, off); err != nil {
return err
}
if off {
fmt.Printf("%s %s off: this machine now refuses it, and tells the host why. Its grant is untouched.\n", host, family)
} else {
fmt.Printf("%s %s on: the refusal is cleared, up to whatever its signed grant already allowed.\n", host, family)
}
fmt.Println(switchDoneText(host, family, off))
return nil
}
@@ -106,56 +134,97 @@ func switchResolveHost(key string) (string, error) {
return c.HostName, nil
}
// switchList prints one line per paired host and family:
//
// <host> <family> on|off [since <time>]
//
// Plain columns so a setup script can read it as well as a person.
func switchList(w io.Writer) error {
// switchRow is one family of one paired host: off (refused here) or on.
type switchRow struct {
Host string `json:"host"`
Family string `json:"family"`
Off bool `json:"off"`
Since string `json:"since,omitempty"`
}
// switchesRead is the local switch as it stands. Unreadable: the list cannot
// be read, so the gate refuses everything (localDenied) and every row is off.
type switchesRead struct {
Switches []switchRow `json:"switches"`
Unreadable string `json:"unreadable,omitempty"`
// StateDir is the bastion state that was read, named when nothing is
// paired in it: an ordinary account's own state is not the machine's.
StateDir string `json:"state_dir,omitempty"`
}
func readSwitches(readOpts) (switchesRead, error) {
v := switchesRead{Switches: []switchRow{}}
cfgs, err := loadPairings()
if err != nil {
return err
return v, err
}
if len(cfgs) == 0 {
// Name the state that was read. On Linux the root service keeps its
// state under /root, and an ordinary account's --list reads its own,
// so "no host is paired with this machine" would be untrue there.
dir, _ := configDirPath()
fmt.Fprintf(w, "no host is paired in this account's bastion state (%s); "+
"the machine's bastion may keep its state under another account (%s)\n", dir, adminHowTo())
return nil
v.StateDir, _ = configDirPath()
return v, nil
}
localDenyMu.Lock()
f, readErr := localDenyLoad()
localDenyMu.Unlock()
if readErr != nil {
// The gate refuses everything while the list cannot be read
// (localDenied), so every line says off, and the warning says why.
fmt.Fprintf(w, "warning: %s\n", localDenyUnreadableText(readErr))
v.Unreadable = localDenyUnreadableText(readErr)
}
names := hostNames(cfgs)
sort.Strings(names)
fams := localSwitchFamilies()
for _, host := range names {
off := map[string]bool{}
for _, fam := range f.Off[host] {
off[fam] = true
if readErr == nil {
for _, fam := range f.Off[host] {
off[fam] = true
}
}
for _, fam := range fams {
if readErr != nil {
fmt.Fprintln(w, host+" "+fam+" off")
continue
r := switchRow{Host: host, Family: fam, Off: readErr != nil || off[fam]}
if readErr == nil && off[fam] {
r.Since = f.At[host+"/"+fam]
}
if off[fam] {
line := host + " " + fam + " off"
if at := f.At[host+"/"+fam]; at != "" {
line += " since " + at
}
fmt.Fprintln(w, line)
continue
}
fmt.Fprintln(w, host+" "+fam+" on")
v.Switches = append(v.Switches, r)
}
}
return v, nil
}
// switchList is the listing of this process's own state, in words.
func switchList(w io.Writer) error {
v, err := readSwitches(readOpts{})
if err != nil {
return err
}
printSwitches(w, v)
return nil
}
// printSwitches prints one line per paired host and family:
//
// <host> <family> on|off [since <time>]
//
// Plain columns so a setup script can read it as well as a person.
func printSwitches(w io.Writer, v switchesRead) {
if len(v.Switches) == 0 {
// Name the state that was read: an ordinary account's own is not
// the machine's, so "nothing is paired with this machine" could be
// untrue.
fmt.Fprintf(w, "no host is paired in the bastion state at %s\n", v.StateDir)
return
}
if v.Unreadable != "" {
// The gate refuses everything while the list cannot be read
// (localDenied), so every line says off, and the warning says why.
fmt.Fprintf(w, "warning: %s\n", v.Unreadable)
}
for _, r := range v.Switches {
line := r.Host + " " + r.Family + " on"
if r.Off {
line = r.Host + " " + r.Family + " off"
if r.Since != "" {
line += " since " + r.Since
}
}
fmt.Fprintln(w, line)
}
}
+51 -6
View File
@@ -98,17 +98,33 @@ func serviceStateEnv(goos string, installed, authority bool, systemRoot string)
}
// serviceGuardedCommands would each act on a bastion in the caller's own
// profile. Beside a system service that is a second bastion (pair, run) or a
// view of an empty one (status, `switch --list`, activity and report, which
// would print the caller's own records as though they were the machine's).
// profile. Beside a system service that is a second bastion (pair, run), a
// switch of its own, or a view of an empty one (status, `switch --list`,
// activity, report and doctor, which would print the caller's own records,
// or check its own empty bastion, as though they were the machine's).
var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status": true, "switch": true,
"activity": true, "report": true}
"switch --list": true, "activity": true, "report": true, "doctor": true, "ops": true,
"device-approvals": true, "backup read": true,
// The narrowing acts (owneracts.go), and device-approve, which stays the
// administrator's (the console keeps it adminOnly): from an ordinary
// account each read that account's own, empty approvals, grants or asks.
"approvals drop": true, "grants": true, "device-deny": true, "device-approve": true,
// The machine's approvals: read, approved and denied through the owner's
// socket on Linux and macOS (reads.go, owneracts.go); on Windows the tray
// answers them over its own pipe, and these would read the account's own.
"approvals": true, "approve": true, "deny": true}
// serviceLaneReads are the guarded commands that, on Linux and macOS, read
// the machine's bastion itself over its socket (reads.go) rather than a
// bastion of the caller's own: not refused here, and refused there for any
// account but the owner's.
var serviceLaneReads = map[string]bool{"status": true, "activity": true, "report": true}
var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true,
"doctor": true, "ops": true, "device-approvals": true, "backup read": true,
// Not reads: the owner's account switches, and takes reach back (`ops
// drop` is guarded as "ops"), through the same socket, with the console's
// rules (owneracts.go), and any other account is refused there.
"switch": true, "approvals drop": true, "grants": true, "device-deny": true,
"approvals": true, "approve": true, "deny": true}
// serviceGuard refuses a guarded command from an account that does not
// administer a machine whose bastion is a system service.
@@ -372,8 +388,11 @@ func adoptSystemServiceState() {
}
}
// guardServiceFork is serviceGuard for this process.
// guardServiceFork is serviceGuard for this process. `switch --list` is a
// read and `switch` a change, and the backup reads are not the rest of
// backup, so each is guarded apart.
func guardServiceFork(cmd string) error {
cmd = serviceGuardKey(cmd, os.Args)
installed := systemService.installed
if runtime.GOOS == "linux" {
installed = linuxSystemUnitPresent()
@@ -381,6 +400,32 @@ func guardServiceFork(cmd string) error {
return serviceGuard(runtime.GOOS, cmd, installed, systemService.authority)
}
// serviceGuardKey is the name the guard knows a command line by.
func serviceGuardKey(cmd string, args []string) string {
arg := func(i int) string {
if len(args) > i {
return args[i]
}
return ""
}
switch {
case cmd == "switch" && arg(2) == "--list":
return "switch --list"
case cmd == "backup" && (arg(2) == "requests" || arg(2) == "shares" || (arg(2) == "key" && arg(3) == "show")):
// The backup reads; the rest of backup works offline (reseal,
// restore-plan) or is not yet asked of the machine's bastion.
return "backup read"
case cmd == "approvals" && len(args) > 2:
// `approvals drop` is an act, the rest of approvals a read; the
// flags may come first, as main takes them (takeHostFlag).
_, rest := takeHostFlag(args[2:])
if _, rest = takeMachineFlag(rest); len(rest) > 0 && rest[0] == "drop" {
return "approvals drop"
}
}
return cmd
}
// cmdService is `privatae-bastion service install|uninstall|status`.
func cmdService(args []string) error {
if len(args) != 1 {
+76 -3
View File
@@ -165,9 +165,9 @@ func TestWindowsSystemAppDataIsLocalSystemsProfile(t *testing.T) {
func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
for _, goos := range []string{"darwin", "windows", "linux"} {
for _, cmd := range []string{"pair", "run", "status", "switch", "activity", "report"} {
for _, cmd := range []string{"pair", "run", "status", "switch", "switch --list", "activity", "report"} {
err := serviceGuard(goos, cmd, true, false)
if (cmd == "status" || cmd == "activity" || cmd == "report") && goos != "windows" {
if (cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report") && goos != "windows" {
// These read the machine's bastion over its socket (reads.go),
// which answers the owner's account and refuses any other.
if err != nil {
@@ -199,7 +199,8 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
}
}
// Commands that do not start or show a bastion of the caller's own pass.
for _, cmd := range []string{"context", "version", "approvals", "ui", "service"} {
// (approvals did: TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused.)
for _, cmd := range []string{"context", "version", "ui", "service"} {
if err := serviceGuard(goos, cmd, true, false); err != nil {
t.Errorf("%s: `%s` was refused but does not fork the bastion: %v", goos, cmd, err)
}
@@ -443,3 +444,75 @@ func TestKeepOneLogAsideEmptiesInPlaceAndKeepsTheLastOne(t *testing.T) {
t.Fatalf("the open writer's next line went elsewhere: the log holds %q", b)
}
}
// The narrowing acts beside a system service: on Linux and macOS they go
// through the owner's lane (owneracts.go), so the guard lets them by; on
// Windows there is no lane, and the ordinary account's own profile is a
// separate, empty bastion, so they are refused with the administrator's way,
// as `ops` (and so `ops drop`) already was. device-approve is the
// administrator's on every system: the console keeps it adminOnly, and from
// an ordinary account it read that account's own, empty asks.
//
// MUTATION: take "device-deny" out of serviceGuardedCommands. RESULT: red on
// windows. Take "device-approve" out. RESULT: red on every system.
func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
lines := map[string][]string{
"approvals drop": {"privatae-bastion", "approvals", "--host", "box-one.example", "drop", "abcd1234"},
"grants": {"privatae-bastion", "grants", "drop", "7"},
"device-deny": {"privatae-bastion", "device-deny", "abcd1234"},
"ops": {"privatae-bastion", "ops", "drop", "--all"},
}
for want, args := range lines {
if got := serviceGuardKey(args[1], args); got != want {
t.Errorf("%v is guarded as %q, want %q", args[1:], got, want)
}
}
if got := serviceGuardKey("approvals", []string{"privatae-bastion", "approvals", "--machine"}); got != "approvals" {
t.Errorf("the approvals read is guarded as %q", got)
}
approve := serviceGuardKey("device-approve", []string{"privatae-bastion", "device-approve", "all"})
for _, goos := range []string{"linux", "darwin", "windows"} {
for key := range lines {
err := serviceGuard(goos, key, true, false)
if goos == "windows" && err == nil {
t.Errorf("windows: `%s` from an ordinary account acted on its own empty bastion", key)
}
if goos != "windows" && err != nil {
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, key, err)
}
if err := serviceGuard(goos, key, true, true); err != nil {
t.Errorf("%s: the administrator's `%s` was refused: %v", goos, key, err)
}
}
err := serviceGuard(goos, approve, true, false)
if err == nil || !strings.Contains(err.Error(), "`device-approve`") {
t.Errorf("%s: device-approve from an ordinary account beside the service: %v", goos, err)
}
if err := serviceGuard(goos, approve, true, true); err != nil {
t.Errorf("%s: the administrator's device-approve was refused: %v", goos, err)
}
if err := serviceGuard(goos, approve, false, false); err != nil {
t.Errorf("%s: device-approve on a machine with no system service was refused: %v", goos, err)
}
}
}
// The machine's approvals beside a system service: through the owner's
// socket on Linux and macOS, refused on Windows (the tray answers them there).
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
for _, line := range [][]string{
{"privatae-bastion", "approvals", "--machine"},
{"privatae-bastion", "approve", "--machine", "abcd1234"},
{"privatae-bastion", "deny", "abcd1234"},
} {
key := serviceGuardKey(line[1], line)
if err := serviceGuard("windows", key, true, false); err == nil {
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
}
for _, goos := range []string{"linux", "darwin"} {
if err := serviceGuard(goos, key, true, false); err != nil {
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
}
}
}
}