bastion 0.61.0
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 + ")"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user