diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 716295a..12af2b6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -40,11 +40,13 @@ jobs: # The backup tests check this code against the age command itself: a # pinned age release, its archive checked against the SHA-256 below, # and PRIVATAE_REQUIRE_AGE makes those tests fail rather than skip if - # it is missing. + # it is missing. openssl is what install.sh checks a release's + # signature with; PRIVATAE_REQUIRE_OPENSSL makes install_sig_sh_test.go + # fail rather than skip without one that does Ed25519. run: | docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/client-go \ - -e GOTOOLCHAIN=local -e CGO_ENABLED=0 -e PRIVATAE_REQUIRE_AGE=1 "$GO_IMAGE" sh -euc ' - apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon + -e GOTOOLCHAIN=local -e CGO_ENABLED=0 -e PRIVATAE_REQUIRE_AGE=1 -e PRIVATAE_REQUIRE_OPENSSL=1 "$GO_IMAGE" sh -euc ' + apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon openssl case "$(uname -m)" in x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;; aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5faf36..6722f94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -243,6 +243,6 @@ jobs: sudo env PATH="/opt/go-pinned/bin:$PATH" HOME="$HOME" AGE_BIN="$AGE_BIN" PRIVATAE_REQUIRE_AGE=1 GOTOOLCHAIN=local CGO_ENABLED=0 \ GOFLAGS=-mod=readonly GOMODCACHE="$HOME/go/pkg/mod" GOCACHE="$HOME/.cache/go-build" \ go test -count=1 -json -run "^($tests)\$" . > "$RUNNER_TEMP/claims.json" || { - grep -h '"Action":"fail"' "$RUNNER_TEMP/claims.json" | head -40; exit 1; } + bash ../deploy/check-claims.sh --failed "$RUNNER_TEMP/claims.json"; exit 1; } - name: none of them skipped run: bash deploy/check-claims.sh --skipped "$RUNNER_TEMP/claims.json" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de82f8..48d5a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,47 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once that is settled. The newest entries may not be published yet; the manifest says which version is. Older entries are not marked. +## 0.61.0 + +- **An agent on the machine reads the rest of it.** `agents show ` + is one agent in full (its command line in `--json` only, since arguments can + carry a key). `switch --list`, `ops`, `device-approvals`, `backup key show`, + `backup requests` and `backup shares` print as one value with `--json`. + Beside a whole-machine install, the owner's account reads them from the + machine's bastion over its ask socket, as `agents` and `activity` already + were; no other account is answered. +- **`doctor [--json]` says what is wrong with the install**, each problem with + its fix: whether the bastion runs, whether a newer version is published, the + owner account, the machine's second factor, accounts whose asks are held, + each Host pairing, the switch list and the ops sandbox. Exit status 1 on a + problem. +- **The machine's owner answers from their own account.** `approve`, `deny` + and `switch` act on the machine's bastion over its ask socket instead of on + an empty bastion of the account's own. `approve` shows the request and takes + the owner's code at the terminal; `switch … on` takes the code; `deny` and + `switch … off` take nothing, since they only take reach away. Each act is a + line in the machine's audit log naming the account. +- **And takes reach back the same way:** `approvals drop`, `grants drop`, + `ops drop` and `device-deny`. Another account is refused each of them with + the command the administrator would type. +- **The installer checks the release's signature.** `install.sh` verifies the + manifest's signature against the release keys it carries before it downloads + the program, and refuses a signature that does not verify. Where no openssl can + check an Ed25519 signature it says so and keeps the digest check alone, as + before. `install.ps1` still checks the digest only. +- **One Host's folder is never answered for another's store.** On a machine + paired with two Hosts that each hold a store with the same id, one local agent + served both, so the second Host's searches and reads reached the first + Host's folder. Each pairing's stores now have their own agents, and each + Host's heartbeat reports only its own. +- **A stopped pairing's last heartbeat acts on nothing.** A beat out when its + pairing stopped (an unpair, a revoke) came back afterwards and could still + act on the reply, writing into the pairing's folder after an unpair had + removed it. A stopped pairing now waits for its heartbeat, whose request ends + with it. +- On Windows the owner's reads and acts are refused beside the bastion's + service with the administrator's command: there is no ask pipe there yet. + ## 0.60.0 - **An agent can follow its own stop request to the end.** `request diff --git a/README.md b/README.md index 446a333..e2a2d12 100644 --- a/README.md +++ b/README.md @@ -309,8 +309,9 @@ Each point in full: [docs/security-model.md](docs/security-model.md); the link a never read or moved. ([DCN stores](docs/context.md#distributed-context-network-dcn-stores)) - **Verified releases.** The self-updater checks the manifest's signature and the binary's SHA-256 before swapping; the release key never reaches the - build machine. The installer checks the SHA-256 ([SECURITY.md](SECURITY.md) - says what that proves). ([self-update](docs/updates.md)) + build machine. The installer checks the SHA-256, and `install.sh` checks the + manifest's signature where the machine's openssl can (OpenSSL 3) + ([SECURITY.md](SECURITY.md) says what each proves). ([self-update](docs/updates.md)) - **No secrets cached for git.** The credential helper fetches a token on every auth and stores nothing; which other helpers are kept out depends on the git version. ([git lane](docs/lanes.md#git-lane)) diff --git a/SECURITY.md b/SECURITY.md index 05e1bed..13bf1f2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -74,9 +74,22 @@ it was reinstalled by hand. **The first install.** `curl … | sudo sh` and `irm … | iex` run a script from our website before any bastion exists to check anything. The installer checks -the binary's digest against the manifest, but the installer and the manifest -come from the same place. To avoid trusting that one download, build from -source (see the README) or run `deploy/verify-release.sh` before installing. +the binary's digest against the manifest. `install.sh` also checks the +manifest's signature against the two pinned release keys, the ones a bastion +checks its updates with, where the machine's openssl can check Ed25519 +(OpenSSL 3: most Linux, or `openssl@3` from Homebrew on a Mac); where it +cannot, it says so and keeps the digest check alone, and it refuses a +signature that does not verify. A script piped from our website is still our +website's, so what the signature adds is for a copy of `install.sh` you read +first, from this repository, and run as root (`sudo sh install.sh`) or for +your account alone (`sh install.sh --just-me`): that copy installs only a +release we signed, whatever the download host serves, though an older release +we signed passes as well as the newest. Run without sudo and without +`--just-me`, it installs for the whole machine through a root step that +fetches the installer from our website again and runs that one, not the copy +you read. `install.ps1` does not check the signature +yet. To avoid trusting any download, build from source (see the README) or +run `deploy/verify-release.sh` before installing. **Bastion updates.** A bastion never replaces itself on its own. Your host has to ask, and what it can ask for is limited to what we signed: the update goes diff --git a/client-go/addfolder.go b/client-go/addfolder.go index 8dc540c..7b487fa 100644 --- a/client-go/addfolder.go +++ b/client-go/addfolder.go @@ -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 ` first") + return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair ")) } // 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. diff --git a/client-go/agentask.go b/client-go/agentask.go index 98c7c57..d3172e4 100644 --- a/client-go/agentask.go +++ b/client-go/agentask.go @@ -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))) } diff --git a/client-go/agents.go b/client-go/agents.go index 0ba68dd..eedf5cb 100644 --- a/client-go/agents.go +++ b/client-go/agents.go @@ -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 } } diff --git a/client-go/agents_cli.go b/client-go/agents_cli.go index 9d8ba4b..382fe1b 100644 --- a/client-go/agents_cli.go +++ b/client-go/agents_cli.go @@ -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 [--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 + ")" } diff --git a/client-go/agentsession.go b/client-go/agentsession.go index bcb528c..afcc3af 100644 --- a/client-go/agentsession.go +++ b/client-go/agentsession.go @@ -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 "" } diff --git a/client-go/backupcli.go b/client-go/backupcli.go index 0c42a44..f1f85b1 100644 --- a/client-go/backupcli.go +++ b/client-go/backupcli.go @@ -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 --to [--stamp ] [--out ] [--identity ] 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 ] + privatae-bastion backup requests [--host ] [--json] restore requests your host sent this machine privatae-bastion backup approve [--host ] | deny [--host ] answer one @@ -39,7 +40,7 @@ const backupUsage = `usage: let the host paired as 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 / unshare --with + privatae-bastion backup shares --with [--json] / unshare --with 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 { diff --git a/client-go/backupreseal.go b/client-go/backupreseal.go index 4ba3fc9..bb96bee 100644 --- a/client-go/backupreseal.go +++ b/client-go/backupreseal.go @@ -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 { diff --git a/client-go/console_hostops.go b/client-go/console_hostops.go index 969a8f6..08b82ed 100644 --- a/client-go/console_hostops.go +++ b/client-go/console_hostops.go @@ -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 " when one was named. +func hostFlagWords(host string) string { + if host == "" { + return "" + } + return " --host " + host +} diff --git a/client-go/dcn.go b/client-go/dcn.go index 4a069a0..6087f07 100644 --- a/client-go/dcn.go +++ b/client-go/dcn.go @@ -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 } diff --git a/client-go/device.go b/client-go/device.go index 11409a4..f6882d3 100644 --- a/client-go/device.go +++ b/client-go/device.go @@ -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 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 or device-approve all") + default: + fmt.Fprintln(w, "approve one: privatae-bastion device-approve 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) { diff --git a/client-go/doctor.go b/client-go/doctor.go new file mode 100644 index 0000000..4837cf8 --- /dev/null +++ b/client-go/doctor.go @@ -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 " + if runtime.GOOS == "windows" { + fix = "privatae-bastion approvals-owner --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) + } + } +} diff --git a/client-go/enforcing.go b/client-go/enforcing.go index c93dc76..5d793d2 100644 --- a/client-go/enforcing.go +++ b/client-go/enforcing.go @@ -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 { diff --git a/client-go/freeagent.go b/client-go/freeagent.go index f1ae310..f4dbd05 100644 --- a/client-go/freeagent.go +++ b/client-go/freeagent.go @@ -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 diff --git a/client-go/freeagent_managed.go b/client-go/freeagent_managed.go index 999a28f..106a717 100644 --- a/client-go/freeagent_managed.go +++ b/client-go/freeagent_managed.go @@ -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) } } diff --git a/client-go/freeagent_managed_test.go b/client-go/freeagent_managed_test.go index 82f87ec..b16d97a 100644 --- a/client-go/freeagent_managed_test.go +++ b/client-go/freeagent_managed_test.go @@ -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) diff --git a/client-go/gitlane.go b/client-go/gitlane.go index 7696e13..1c12489 100644 --- a/client-go/gitlane.go +++ b/client-go/gitlane.go @@ -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 ` first") + return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair ")) default: return fmt.Errorf("%d hosts are paired — configure the helper with --host: "+ "credential.https://git.privatae.ai.helper \"!privatae-bastion git-credential --host \"", len(cfgs)) diff --git a/client-go/hostops_test.go b/client-go/hostops_test.go index 36f58fa..2a6ed71 100644 --- a/client-go/hostops_test.go +++ b/client-go/hostops_test.go @@ -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 diff --git a/client-go/install_sh_test.go b/client-go/install_sh_test.go index 93eadad..5d33d3b 100644 --- a/client-go/install_sh_test.go +++ b/client-go/install_sh_test.go @@ -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") diff --git a/client-go/install_sig_sh_test.go b/client-go/install_sig_sh_test.go new file mode 100644 index 0000000..63eab5d --- /dev/null +++ b/client-go/install_sig_sh_test.go @@ -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) +} diff --git a/client-go/main.go b/client-go/main.go index 1b109af..73caa99 100644 --- a/client-go/main.go +++ b/client-go/main.go @@ -111,22 +111,25 @@ Usage: anchor anywhere. privatae-bastion approvals [--host | --machine] [--json] show what your host is asking to do on this machine, in plain words - privatae-bastion approve [--host ] + privatae-bastion approve [--host | --machine] approve one request with a code from the authenticator enrolled here - privatae-bastion deny [--host ] - refuse one request + privatae-bastion deny [--host | --machine] + 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 ] take back an approval this machine issued and has not used yet privatae-bastion grants drop [--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 |all / device-deny sudo -H privatae-bastion pin-device on a machine you govern: accept approvals signed by your device (the key device-init printed). unpin-device withdraws it - privatae-bastion ops [--host ] the ops your host wrote that this machine holds, and the + privatae-bastion ops [--host ] [--json] the ops your host wrote that this machine holds, and the standing op grants; ops drop takes one back sudo -H privatae-bastion root-exec grant [--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 [--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 |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]+" [--code ]") @@ -685,7 +697,7 @@ func main() { } os.Exit(opActionChildMain(os.Args[2])) case "ops": - // ops [--host ] what ops and op grants this machine holds + // ops [--host ] [--json] what ops and op grants this machine holds // ops drop [--host ] take back a held op grant (the host is told) // ops drop --all [--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 ] [--json] | ops drop |--all [--host ]", args) + if jerr != nil { + fmt.Fprintln(os.Stderr, jerr) + os.Exit(2) + } + err = cmdOpsList(host, asJSON) case "root-exec": // root-exec [--host ] [grant --for | 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": diff --git a/client-go/owneracts.go b/client-go/owneracts.go new file mode 100644 index 0000000..f0045f8 --- /dev/null +++ b/client-go/owneracts.go @@ -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 +} diff --git a/client-go/owneracts_linux_test.go b/client-go/owneracts_linux_test.go new file mode 100644 index 0000000..724f10c --- /dev/null +++ b/client-go/owneracts_linux_test.go @@ -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) + } +} diff --git a/client-go/reads.go b/client-go/reads.go index 65b2836..3a4f534 100644 --- a/client-go/reads.go +++ b/client-go/reads.go @@ -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 diff --git a/client-go/reads_slice3_linux_test.go b/client-go/reads_slice3_linux_test.go new file mode 100644 index 0000000..a116ae3 --- /dev/null +++ b/client-go/reads_slice3_linux_test.go @@ -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") + } +} diff --git a/client-go/reads_slice3_test.go b/client-go/reads_slice3_test.go new file mode 100644 index 0000000..5da8742 --- /dev/null +++ b/client-go/reads_slice3_test.go @@ -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 ")) { + 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) + } + } +} diff --git a/client-go/reads_test.go b/client-go/reads_test.go index 93f89be..3918759 100644 --- a/client-go/reads_test.go +++ b/client-go/reads_test.go @@ -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") } diff --git a/client-go/run.go b/client-go/run.go index 3814a92..829fc63 100644 --- a/client-go/run.go +++ b/client-go/run.go @@ -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 diff --git a/client-go/run_stop_linux_test.go b/client-go/run_stop_linux_test.go new file mode 100644 index 0000000..7827c4f --- /dev/null +++ b/client-go/run_stop_linux_test.go @@ -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") + } +} diff --git a/client-go/servelane_linux_test.go b/client-go/servelane_linux_test.go index 2874875..afc5224 100644 --- a/client-go/servelane_linux_test.go +++ b/client-go/servelane_linux_test.go @@ -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) { diff --git a/client-go/stepup.go b/client-go/stepup.go index 5fd34da..8c64d0a 100644 --- a/client-go/stepup.go +++ b/client-go/stepup.go @@ -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 ` first") + return nil, fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair ")) } 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 | --machine] 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 | --machine] 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 diff --git a/client-go/switch_cli.go b/client-go/switch_cli.go index 9afbd1a..9194757 100644 --- a/client-go/switch_cli.go +++ b/client-go/switch_cli.go @@ -3,7 +3,7 @@ package main // switch_cli.go — the local switch (localdeny.go) from a terminal. // // privatae-bastion switch 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 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: -// -// on|off [since