Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79646bda13 | |||
| d4594d18b7 | |||
| 1176f7fd0b |
@@ -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 ;;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -22,6 +22,69 @@ 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.2
|
||||
|
||||
- **A machine a Host holds stops dialling it.** When a Host's owner
|
||||
quarantines this machine there, the Host refuses each of the bastion's lane
|
||||
connections, and three of the five lanes dialled again at once, the other
|
||||
two a second later: tens of connections a second, each a TLS handshake.
|
||||
Now a refusal holds all of that pairing's lanes for 5 seconds, twice as long
|
||||
after each further refusal up to 5 minutes; and while the Host's heartbeat
|
||||
reply says the machine is quarantined, the lanes do not dial at all. The
|
||||
first reply that no longer says so brings them back. Other Hosts the
|
||||
machine is paired with are not slowed.
|
||||
|
||||
## 0.61.1
|
||||
|
||||
- **An op that creates a folder runs on every Linux kernel.** Where the
|
||||
kernel's overlay keeps an index (or has `redirect_dir` or `metacopy` on, as
|
||||
Azure's kernel does), it marks each new folder opaque, and the bastion took
|
||||
that for a replaced folder: every op that created one was refused as "a path
|
||||
outside the effects in disguise". A new folder is now read as created, with
|
||||
what it holds; a folder that replaces one the machine holds is still
|
||||
refused.
|
||||
|
||||
## 0.61.0
|
||||
|
||||
- **An agent on the machine reads the rest of it.** `agents show <pid|session>`
|
||||
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 <id>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](LICENSE)
|
||||
[](client-go/go.mod)
|
||||
[](#supported-platforms)
|
||||
[](https://git.privatae.ai/privatae/privatae-bastion/actions)
|
||||
[](https://github.com/Privatae/bastion/actions/workflows/ci.yml)
|
||||
|
||||
**See every AI agent running on your computer, what each one is doing and what it can reach, and stop any of them. One program, no account, no AI of its own.**
|
||||
|
||||
@@ -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))
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
@@ -33,7 +33,7 @@ func cmdAddFolder(host, path, name string) error {
|
||||
return err
|
||||
}
|
||||
if len(cfgs) == 0 {
|
||||
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
|
||||
return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
|
||||
}
|
||||
// Multi-homed boxes must NAME the host (0.10.0): guessing here is how a folder
|
||||
// ends up proposed to the wrong entity. One pairing needs no flag.
|
||||
|
||||
+12
-1
@@ -39,7 +39,7 @@ const (
|
||||
)
|
||||
|
||||
type agentAskRequest struct {
|
||||
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go) or "read" (reads.go)
|
||||
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go), "read" (reads.go) or "act" (owneracts.go)
|
||||
PID int `json:"pid"`
|
||||
Request string `json:"request,omitempty"` // "status": the id `stop` printed
|
||||
// "read": which one, and its flags (readOpts).
|
||||
@@ -48,6 +48,12 @@ type agentAskRequest struct {
|
||||
Prompts bool `json:"prompts,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Machine bool `json:"machine,omitempty"`
|
||||
// "act": approve, deny or switch (owneracts.go), and what it acts on.
|
||||
Act string `json:"act,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Off bool `json:"off,omitempty"`
|
||||
}
|
||||
|
||||
type agentAskReply struct {
|
||||
@@ -145,6 +151,11 @@ func askSockServe(c net.Conn) {
|
||||
_ = json.NewEncoder(c).Encode(serveRead(uid, user, req))
|
||||
return
|
||||
}
|
||||
if req.Op == "act" {
|
||||
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
_ = json.NewEncoder(c).Encode(serveAct(uid, user, req))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c)))
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -528,10 +528,13 @@ type beatLanes struct {
|
||||
// noCoders: a tcp pairing, whose coder runs have no control plane to use
|
||||
// (pair_tcp.go coderTransportRefusal), so it is never offered one.
|
||||
noCoders bool
|
||||
// dcnDir: the pairing's DCN state, whose managed agents this beat probes
|
||||
// and reports (faManagedProbe, faManagedStatus); "" reports none.
|
||||
dcnDir string
|
||||
}
|
||||
|
||||
func beatLanesFor(cfg *Config) beatLanes {
|
||||
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport()}
|
||||
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport(), dcnDir: cfg.dcnDir()}
|
||||
}
|
||||
|
||||
// buildHeartbeatBodyOn is buildHeartbeatBody for a pairing whose lanes are lanes.
|
||||
@@ -658,7 +661,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
|
||||
faAdvertised, faOK := freeagentProbe()
|
||||
// managed instances (freeagent_managed.go) count the same way: any
|
||||
// serving instance advertises the capability; all must serve for ok
|
||||
if mAdv, mOK := faManagedProbe(); mAdv {
|
||||
if mAdv, mOK := faManagedProbe(lanes.dcnDir); mAdv {
|
||||
if !faAdvertised {
|
||||
faAdvertised, faOK = true, mOK
|
||||
} else {
|
||||
@@ -1067,7 +1070,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
|
||||
}
|
||||
if faAdvertised {
|
||||
body["freeagent_ok"] = faOK
|
||||
if st := faManagedStatus(); len(st) > 0 {
|
||||
if st := faManagedStatus(lanes.dcnDir); len(st) > 0 {
|
||||
body["freeagent_stores"] = st
|
||||
}
|
||||
}
|
||||
|
||||
+122
-3
@@ -18,11 +18,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]"
|
||||
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]\n" +
|
||||
" privatae-bastion agents show <pid|session> [--json] [--prompts]"
|
||||
|
||||
var familyLabels = map[string]string{
|
||||
"claude-code": "Claude Code", "aider": "aider", "goose": "goose", "cline": "Cline",
|
||||
@@ -75,6 +78,13 @@ func readAgents(o readOpts) (agentsRead, error) {
|
||||
}
|
||||
|
||||
func cmdAgents(args []string) error {
|
||||
show := ""
|
||||
if len(args) >= 1 && args[0] == "show" {
|
||||
if len(args) < 2 || strings.HasPrefix(args[1], "--") {
|
||||
return errors.New(agentsUsage)
|
||||
}
|
||||
show, args = args[1], args[2:]
|
||||
}
|
||||
asJSON, prompts := false, false
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
@@ -90,6 +100,17 @@ func cmdAgents(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if show != "" {
|
||||
a, err := pickAgentView(v.Agents, show)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, a)
|
||||
}
|
||||
printAgentDetail(os.Stdout, a, sessionNowFn())
|
||||
return nil
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
@@ -97,6 +118,104 @@ func cmdAgents(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickAgentView is the agent `agents show` means: a pid, or a session's
|
||||
// name as `agents` shows it, which must match exactly one (the rule `stop`
|
||||
// keeps, resolveStopTarget).
|
||||
func pickAgentView(views []agentView, key string) (agentView, error) {
|
||||
if pid, err := strconv.Atoi(key); err == nil {
|
||||
for _, v := range views {
|
||||
if v.PID == pid {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return agentView{}, fmt.Errorf("no AI agent here has pid %d; `privatae-bastion agents` lists them", pid)
|
||||
}
|
||||
var hits []agentView
|
||||
for _, v := range views {
|
||||
if v.Self != nil && v.Self.Name == key {
|
||||
hits = append(hits, v)
|
||||
}
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return agentView{}, fmt.Errorf("no running session is named %q; `privatae-bastion agents` lists them", key)
|
||||
case 1:
|
||||
return hits[0], nil
|
||||
}
|
||||
return agentView{}, fmt.Errorf("%d sessions are named %q; give the pid instead", len(hits), key)
|
||||
}
|
||||
|
||||
// printAgentDetail is one agent with everything a read holds about it: the
|
||||
// lines `agents` prints, then what it is, how it was recognised, and what it
|
||||
// says about itself. Its command line is in --json only: arguments can carry
|
||||
// a key, and words are what gets pasted.
|
||||
func printAgentDetail(w io.Writer, v agentView, now time.Time) {
|
||||
if v.Role == "service" {
|
||||
fmt.Fprintf(w, "background service (pid %d): starts background sessions; not a session itself\n", v.PID)
|
||||
} else {
|
||||
l := agentLinesOf(v, now)
|
||||
fmt.Fprintln(w, l.Head)
|
||||
if l.State != "" {
|
||||
fmt.Fprintln(w, " "+l.State)
|
||||
}
|
||||
if l.Where != "" {
|
||||
fmt.Fprintln(w, " "+l.Where)
|
||||
}
|
||||
}
|
||||
row := func(k, val string) {
|
||||
if val != "" {
|
||||
fmt.Fprintf(w, " %-15s %s\n", k+":", val)
|
||||
}
|
||||
}
|
||||
row("agent", familyLabel(v.Signature))
|
||||
row("kind", v.Kind)
|
||||
row("pid", strconv.Itoa(v.PID))
|
||||
if v.PPID > 0 {
|
||||
row("parent pid", strconv.Itoa(v.PPID))
|
||||
}
|
||||
// The kernel names the file, but whoever placed it chose its path.
|
||||
row("program", shownPath(v.Exe))
|
||||
if v.Exe == "" {
|
||||
row("program", "unknown (the system did not say which file it runs)")
|
||||
}
|
||||
row("account", v.User)
|
||||
if len(v.RunningNow) > 0 {
|
||||
row("running now", strings.Join(v.RunningNow, "; "))
|
||||
}
|
||||
if len(v.ToolServers) > 0 {
|
||||
row("tool servers", strings.Join(v.ToolServers, ", "))
|
||||
}
|
||||
if s := v.Self; s != nil {
|
||||
src := s.Source
|
||||
if s.Verified {
|
||||
src += " (names this process)"
|
||||
} else {
|
||||
src += " (does not name this process)"
|
||||
}
|
||||
row("self-reported", src)
|
||||
row("session id", s.SessionID)
|
||||
row("last asked", quoteIf(s.LastPrompt))
|
||||
}
|
||||
fmt.Fprintln(w, "Names, titles and statuses are what the agent says about itself.")
|
||||
}
|
||||
|
||||
// shownPath is a path the agent chose (its folder) or placed (the file it
|
||||
// runs), as printed: quoted when it holds control characters, so an escape
|
||||
// sequence in a name never reaches the terminal or the page.
|
||||
func shownPath(p string) string {
|
||||
if strings.ContainsFunc(p, unicode.IsControl) {
|
||||
return strconv.Quote(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func quoteIf(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "\"" + s + "\""
|
||||
}
|
||||
|
||||
// printAgents writes the views grouped by agent, sessions waiting for the
|
||||
// person first, apps last.
|
||||
func printAgents(w io.Writer, views []agentView, now time.Time) {
|
||||
@@ -228,7 +347,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
|
||||
// No session record: name it by what it is and where it works.
|
||||
l.Head = familyLabel(v.Signature)
|
||||
if v.Folder != "" {
|
||||
l.Head += " in " + filepath.Base(v.Folder)
|
||||
l.Head += " in " + shownPath(filepath.Base(v.Folder))
|
||||
}
|
||||
}
|
||||
if v.Kind == kindApp {
|
||||
@@ -255,7 +374,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
|
||||
l.State = strings.Join(state, " · ")
|
||||
var where []string
|
||||
if v.Folder != "" {
|
||||
f := "in " + v.Folder
|
||||
f := "in " + shownPath(v.Folder)
|
||||
if v.GitBranch != "" {
|
||||
f += " (" + v.GitBranch + ")"
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ func gitBranchOf(dir string) string {
|
||||
return cleanText(b, 80)
|
||||
}
|
||||
if len(h) >= 12 {
|
||||
return "detached at " + h[:12]
|
||||
return "detached at " + cleanText(h[:12], 12)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
+90
-27
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -22,13 +23,13 @@ const backupUsage = `usage:
|
||||
make this bastion's backup key here and print what to register on your
|
||||
host (Settings > Backup > keys: kind, label, public key). --replace makes
|
||||
a new one and keeps the old one, which still opens the backups made with it
|
||||
privatae-bastion backup key show
|
||||
privatae-bastion backup key show [--json]
|
||||
the key this bastion holds, its fingerprint, and any older keys it keeps
|
||||
privatae-bastion backup reseal --sidecar <file> --to <host key> [--stamp <stamp>]
|
||||
[--out <file>] [--identity <key file>]
|
||||
seal one backup's key to a host's own key (age1... or base64), so that
|
||||
host, and only it, can open that one backup
|
||||
privatae-bastion backup requests [--host <host>]
|
||||
privatae-bastion backup requests [--host <host>] [--json]
|
||||
restore requests your host sent this machine
|
||||
privatae-bastion backup approve [--host <host>] <id> | deny [--host <host>] <id>
|
||||
answer one
|
||||
@@ -39,7 +40,7 @@ const backupUsage = `usage:
|
||||
let the host paired as <host> list and send the backups in another
|
||||
host's backup folder here (or any folder) — how a host that replaces a
|
||||
lost one reaches the copies the lost one kept on this machine
|
||||
privatae-bastion backup shares --with <host> / unshare --with <host> <name>
|
||||
privatae-bastion backup shares --with <host> [--json] / unshare --with <host> <name>
|
||||
the folders shared with a host, and taking one back`
|
||||
|
||||
func cmdBackup(args []string) error {
|
||||
@@ -65,7 +66,11 @@ func cmdBackup(args []string) error {
|
||||
return cmdBackupKeyInit(replace, flags["--label"])
|
||||
}
|
||||
if len(args) >= 2 && args[1] == "show" {
|
||||
return cmdBackupKeyShow()
|
||||
asJSON, err := jsonOnly(backupUsage, args[2:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cmdBackupKeyShow(asJSON)
|
||||
}
|
||||
case "reseal":
|
||||
flags, rest := backupFlags(args[1:], "--sidecar", "--to", "--stamp", "--out", "--identity")
|
||||
@@ -75,10 +80,11 @@ func cmdBackup(args []string) error {
|
||||
return cmdBackupReseal(flags["--sidecar"], flags["--to"], flags["--stamp"], flags["--out"], flags["--identity"])
|
||||
case "requests":
|
||||
host, rest := takeHostFlag(args[1:])
|
||||
if len(rest) != 0 {
|
||||
return errors.New(backupUsage)
|
||||
asJSON, err := jsonOnly(backupUsage, rest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cmdBackupRequests(host)
|
||||
return cmdBackupRequests(host, asJSON)
|
||||
case "approve", "deny":
|
||||
host, rest := takeHostFlag(args[1:])
|
||||
if len(rest) != 1 {
|
||||
@@ -93,10 +99,11 @@ func cmdBackup(args []string) error {
|
||||
return cmdBackupShare(flags["--with"], flags["--from"], flags["--dir"])
|
||||
case "shares":
|
||||
flags, rest := backupFlags(args[1:], "--with")
|
||||
if len(rest) != 0 || flags["--with"] == "" {
|
||||
asJSON, err := jsonOnly(backupUsage, rest)
|
||||
if err != nil || flags["--with"] == "" {
|
||||
return errors.New(backupUsage)
|
||||
}
|
||||
return cmdBackupShares(flags["--with"])
|
||||
return cmdBackupShares(flags["--with"], asJSON)
|
||||
case "unshare":
|
||||
flags, rest := backupFlags(args[1:], "--with")
|
||||
if len(rest) != 1 || flags["--with"] == "" {
|
||||
@@ -163,24 +170,53 @@ func cmdBackupKeyInit(replace bool, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdBackupKeyShow() error {
|
||||
// backupKeysRead is every backup key this bastion holds, the current one
|
||||
// marked. The secret half never leaves its file (backupKeyFile.Identity).
|
||||
type backupKeysRead struct {
|
||||
Keys []*backupKeyFile `json:"keys"`
|
||||
Current string `json:"current,omitempty"` // its fingerprint
|
||||
}
|
||||
|
||||
func readBackupKeys(readOpts) (backupKeysRead, error) {
|
||||
active, all, err := backupKeysLoad()
|
||||
if err != nil {
|
||||
return err
|
||||
return backupKeysRead{}, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
fmt.Println("no backup key on this machine — make one with `privatae-bastion backup key init`")
|
||||
return nil
|
||||
v := backupKeysRead{Keys: all}
|
||||
if v.Keys == nil {
|
||||
v.Keys = []*backupKeyFile{}
|
||||
}
|
||||
for _, k := range all {
|
||||
if active != nil {
|
||||
v.Current = active.Fingerprint
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func cmdBackupKeyShow(asJSON bool) error {
|
||||
v, err := readMachine("backup-keys", readOpts{}, readBackupKeys)
|
||||
if err != nil {
|
||||
return laneRefusalError("backup key show", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printBackupKeys(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printBackupKeys(w io.Writer, v backupKeysRead) {
|
||||
if len(v.Keys) == 0 {
|
||||
fmt.Fprintln(w, "no backup key on this machine — make one with `privatae-bastion backup key init`")
|
||||
return
|
||||
}
|
||||
for _, k := range v.Keys {
|
||||
state := "older key (still opens the backups made with it)"
|
||||
if active != nil && k.Fingerprint == active.Fingerprint {
|
||||
if k.Fingerprint == v.Current {
|
||||
state = "current key"
|
||||
}
|
||||
fmt.Printf("%s %s\n public key: %s\n age recipient: %s\n made: %s\n key file: %s\n\n",
|
||||
fmt.Fprintf(w, "%s %s\n public key: %s\n age recipient: %s\n made: %s\n key file: %s\n\n",
|
||||
k.Fingerprint, state, k.PubB64, k.Recipient, k.CreatedAt, k.Path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupIDs(identityFile string) ([]age.Identity, error) {
|
||||
@@ -339,6 +375,9 @@ func pairingByName(name string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil && len(cfgs) == 0 {
|
||||
return nil, fmt.Errorf("no host is paired with this bastion, so none is named '%s'", name)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("no pairing matches '%s' — paired hosts:\n %s", name, strings.Join(hostNames(cfgs), "\n "))
|
||||
}
|
||||
@@ -379,22 +418,46 @@ func cmdBackupShare(with, from, dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdBackupShares(with string) error {
|
||||
reader, err := pairingByName(with)
|
||||
// backupSharesRead is the folders shared with one pairing.
|
||||
type backupSharesRead struct {
|
||||
With string `json:"with"`
|
||||
Shares []backupShare `json:"shares"`
|
||||
}
|
||||
|
||||
func readBackupShares(o readOpts) (backupSharesRead, error) {
|
||||
reader, err := pairingByName(o.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
return backupSharesRead{}, err
|
||||
}
|
||||
list := sharesFor(reader.HostName)
|
||||
if len(list) == 0 {
|
||||
fmt.Println("no folders are shared with", reader.HostName)
|
||||
return nil
|
||||
v := backupSharesRead{With: reader.HostName, Shares: sharesFor(reader.HostName)}
|
||||
if v.Shares == nil {
|
||||
v.Shares = []backupShare{}
|
||||
}
|
||||
for _, s := range list {
|
||||
fmt.Printf("shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func cmdBackupShares(with string, asJSON bool) error {
|
||||
v, err := readMachine("backup-shares", readOpts{Host: with}, readBackupShares)
|
||||
if err != nil {
|
||||
return laneRefusalError("backup shares --with "+with, err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printBackupShares(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printBackupShares(w io.Writer, v backupSharesRead) {
|
||||
if len(v.Shares) == 0 {
|
||||
fmt.Fprintln(w, "no folders are shared with", v.With)
|
||||
return
|
||||
}
|
||||
for _, s := range v.Shares {
|
||||
fmt.Fprintf(w, "shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdBackupUnshare(with, slug string) error {
|
||||
reader, err := pairingByName(with)
|
||||
if err != nil {
|
||||
|
||||
+68
-17
@@ -45,6 +45,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -579,28 +580,46 @@ func describeReseal(r *resealRequest) string {
|
||||
|
||||
// ── the person's side, on this machine ──────────────────────────────────────
|
||||
|
||||
func cmdBackupRequests(host string) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
// backupRequestView is one restore request as a read shows it: the armored
|
||||
// key the host sent, and the one resealed for it, are left out (the digest of
|
||||
// the one the host sent stays); both are sealed, and a read has no use for
|
||||
// them.
|
||||
type backupRequestView struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Stamp string `json:"stamp"`
|
||||
Pairing string `json:"pairing"`
|
||||
HostNumber int64 `json:"host_number"`
|
||||
HostLabel string `json:"host_label"`
|
||||
To string `json:"to"`
|
||||
ToFP string `json:"to_fp"`
|
||||
SidecarSHA string `json:"sidecar_sha256"`
|
||||
At string `json:"at"`
|
||||
DecidedAt string `json:"decided_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
type backupRequestsRead struct {
|
||||
Host string `json:"host"`
|
||||
Requests []backupRequestView `json:"requests"`
|
||||
// SetAside are request files found unreadable and kept, not read.
|
||||
SetAside []string `json:"set_aside,omitempty"`
|
||||
}
|
||||
|
||||
func readBackupRequests(o readOpts) (backupRequestsRead, error) {
|
||||
cfg, err := stepupPairing(o.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
return backupRequestsRead{}, err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
v := backupRequestsRead{Host: cfg.HostName, Requests: []backupRequestView{}}
|
||||
// Request files set aside as unreadable (resealLoad) are named here, so
|
||||
// the person learns of them whichever process found them.
|
||||
if aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*"); len(aside) > 0 {
|
||||
fmt.Println("set aside as unreadable (kept, not read):")
|
||||
for _, a := range aside {
|
||||
fmt.Println(" " + a)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
v.SetAside, _ = filepath.Glob(resealPath(dir) + resealAsideSuffix + "*")
|
||||
m, err := resealRead(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m) == 0 {
|
||||
fmt.Println("no backup requests from", cfg.HostName)
|
||||
return nil
|
||||
return v, err
|
||||
}
|
||||
ids := make([]string, 0, len(m))
|
||||
for id := range m {
|
||||
@@ -608,12 +627,44 @@ func cmdBackupRequests(host string) error {
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
|
||||
for _, id := range ids {
|
||||
fmt.Println(describeReseal(m[id]))
|
||||
fmt.Println()
|
||||
r := m[id]
|
||||
v.Requests = append(v.Requests, backupRequestView{ID: r.ID, State: r.State, Stamp: r.Stamp,
|
||||
Pairing: r.Pairing, HostNumber: r.HostNumber, HostLabel: r.HostLabel, To: r.To, ToFP: r.ToFP,
|
||||
SidecarSHA: r.SidecarSH, At: r.At, DecidedAt: r.DecidedAt, Error: r.Error, Words: describeReseal(r)})
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func cmdBackupRequests(host string, asJSON bool) error {
|
||||
v, err := readMachine("backup-requests", readOpts{Host: host}, readBackupRequests)
|
||||
if err != nil {
|
||||
return laneRefusalError("backup requests", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printBackupRequests(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printBackupRequests(w io.Writer, v backupRequestsRead) {
|
||||
if len(v.SetAside) > 0 {
|
||||
fmt.Fprintln(w, "set aside as unreadable (kept, not read):")
|
||||
for _, a := range v.SetAside {
|
||||
fmt.Fprintln(w, " "+a)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if len(v.Requests) == 0 {
|
||||
fmt.Fprintln(w, "no backup requests from", v.Host)
|
||||
return
|
||||
}
|
||||
for _, r := range v.Requests {
|
||||
fmt.Fprintln(w, r.Words)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdBackupDecide(host, prefix string, approve bool) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
if err != nil {
|
||||
|
||||
+156
-44
@@ -8,22 +8,32 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cmdStepupDrop revokes one unused approval this box issued for a pairing.
|
||||
// From the owner's account beside the machine's bastion it asks that
|
||||
// bastion (owneracts.go): dropping only takes reach away, so no code.
|
||||
func cmdStepupDrop(host string, machine bool, prefix string) error {
|
||||
return printOrAct(agentAskRequest{Act: "approvals-drop", Host: host, Machine: machine, ID: prefix},
|
||||
stepupCommand("approvals drop", host, machine, prefix),
|
||||
func() (string, error) { return stepupDropText(host, machine, prefix) })
|
||||
}
|
||||
|
||||
func stepupDropText(host string, machine bool, prefix string) (string, error) {
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
dir := t.dir
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return errors.New("which approval? (the id `approve` printed)")
|
||||
return "", errors.New("which approval? (the id `approve` printed)")
|
||||
}
|
||||
stepupMu.Lock()
|
||||
defer stepupMu.Unlock()
|
||||
@@ -36,42 +46,45 @@ func cmdStepupDrop(host string, machine bool, prefix string) error {
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
|
||||
return "", fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
|
||||
case 1:
|
||||
default:
|
||||
return fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
|
||||
return "", fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
|
||||
}
|
||||
a := m[hits[0]]
|
||||
a.Used = true // burned without ever having run: the same end state a use reaches
|
||||
m[hits[0]] = a
|
||||
if err := approvalsSave(dir, m); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("dropped approval %s (%s) — nothing can run on it now\n", hits[0][:8], a.Purpose)
|
||||
return nil
|
||||
return fmt.Sprintf("dropped approval %s (%s) — nothing can run on it now", hits[0][:8], a.Purpose), nil
|
||||
}
|
||||
|
||||
// cmdGrantsDrop removes a held store grant from this box.
|
||||
// cmdGrantsDrop removes a held store grant from this box (through the
|
||||
// owner's lane beside the machine's bastion, as cmdStepupDrop).
|
||||
func cmdGrantsDrop(host, store string) error {
|
||||
return printOrAct(agentAskRequest{Act: "grants-drop", Host: host, ID: store},
|
||||
"grants drop "+store+hostFlagWords(host), func() (string, error) { return grantsDropText(host, store) })
|
||||
}
|
||||
|
||||
func grantsDropText(host, store string) (string, error) {
|
||||
cfg, err := stepupPairing(host)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(store), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New("store id must be a number (the id on the host's store card)")
|
||||
return "", errors.New("store id must be a number (the id on the host's store card)")
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
present, err := dcnDropStore(dir, id, "dropped at this machine's console")
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if !present {
|
||||
fmt.Printf("store %d was not held here for %s\n", id, cfg.HostName)
|
||||
return nil
|
||||
return fmt.Sprintf("store %d was not held here for %s", id, cfg.HostName), nil
|
||||
}
|
||||
fmt.Printf("store %d dropped for %s — the host's reads of it stop now and it learns on the next check-in\n", id, cfg.HostName)
|
||||
return nil
|
||||
return fmt.Sprintf("store %d dropped for %s — the host's reads of it stop now and it learns on the next check-in", id, cfg.HostName), nil
|
||||
}
|
||||
|
||||
// cmdQuarantineClear lifts this box's hold on a pairing's write lanes.
|
||||
@@ -90,65 +103,140 @@ func cmdQuarantineClear(host string) error {
|
||||
}
|
||||
|
||||
// cmdOpsList prints the ops and op grants a pairing holds here (ops.go).
|
||||
func cmdOpsList(host string) error {
|
||||
cfg, err := stepupPairing(host)
|
||||
// opRow is one op a Host holds here, verified against its signature or not.
|
||||
type opRow struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Privilege string `json:"privilege,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
NotVerified string `json:"not_verified,omitempty"`
|
||||
}
|
||||
|
||||
// opGrantRow is one op grant this machine holds and has not dropped.
|
||||
type opGrantRow struct {
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
RunsLeft int `json:"runs_left"`
|
||||
Until string `json:"until,omitempty"`
|
||||
Within string `json:"within,omitempty"`
|
||||
NotVerified string `json:"not_verified,omitempty"`
|
||||
}
|
||||
|
||||
// opsRead is what ops and op grants one pairing holds on this machine.
|
||||
type opsRead struct {
|
||||
Host string `json:"host"`
|
||||
Sandbox bool `json:"sandbox"`
|
||||
SandboxUnavailable string `json:"sandbox_unavailable,omitempty"`
|
||||
Ops []opRow `json:"ops"`
|
||||
Grants []opGrantRow `json:"grants"`
|
||||
}
|
||||
|
||||
func readOps(o readOpts) (opsRead, error) {
|
||||
cfg, err := stepupPairing(o.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
return opsRead{}, err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
ok, why := opSandboxAvailable()
|
||||
if ok {
|
||||
fmt.Println("sandbox: available")
|
||||
} else {
|
||||
fmt.Println("sandbox: NOT available —", why)
|
||||
}
|
||||
n := 0
|
||||
v := opsRead{Host: cfg.HostName, Ops: []opRow{}, Grants: []opGrantRow{}}
|
||||
v.Sandbox, v.SandboxUnavailable = opSandboxAvailable()
|
||||
if entries, err := os.ReadDir(opsDir(dir)); err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == opsRunsDirName || strings.HasSuffix(e.Name(), ".tmp") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
r := opRow{ID: e.Name()[:12]}
|
||||
if m, err := opLoad(dir, e.Name()); err == nil {
|
||||
fmt.Printf("op %s %s as %s [%s]\n %s\n", m.Name, m.Version, m.Privilege, e.Name()[:12], m.Summary)
|
||||
r.Name, r.Version, r.Privilege, r.Summary = m.Name, m.Version, m.Privilege, m.Summary
|
||||
} else {
|
||||
fmt.Printf("op [%s] NOT VERIFIED: %v\n", e.Name()[:12], err)
|
||||
r.NotVerified = err.Error()
|
||||
}
|
||||
v.Ops = append(v.Ops, r)
|
||||
}
|
||||
}
|
||||
f := opsLoad(dir)
|
||||
nonces := make([]string, 0, len(f.Grants))
|
||||
for nonce, rec := range f.Grants {
|
||||
if rec.Dropped != "" {
|
||||
continue
|
||||
if rec.Dropped == "" {
|
||||
nonces = append(nonces, nonce)
|
||||
}
|
||||
t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant)
|
||||
if err != nil {
|
||||
fmt.Printf("grant [%s] NOT VERIFIED: %v\n", nonce[:8], err)
|
||||
continue
|
||||
}
|
||||
sort.Strings(nonces)
|
||||
for _, nonce := range nonces {
|
||||
rec := f.Grants[nonce]
|
||||
r := opGrantRow{ID: nonce[:8], RunsLeft: rec.UsesLeft}
|
||||
if t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant); err != nil {
|
||||
r.NotVerified = err.Error()
|
||||
} else {
|
||||
r.Op, r.Version, r.Until, r.Within = t.Op, t.Version, t.Expires, t.Bounds
|
||||
}
|
||||
fmt.Printf("grant [%s] %s %s %d run(s) left, until %s\n within: %s\n", nonce[:8], t.Op, t.Version, rec.UsesLeft, t.Expires, t.Bounds)
|
||||
v.Grants = append(v.Grants, r)
|
||||
}
|
||||
if n == 0 && len(f.Grants) == 0 {
|
||||
fmt.Println("no ops held for", cfg.HostName)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func cmdOpsList(host string, asJSON bool) error {
|
||||
v, err := readMachine("ops", readOpts{Host: host}, readOps)
|
||||
if err != nil {
|
||||
return laneRefusalError("ops", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printOps(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func printOps(w io.Writer, v opsRead) {
|
||||
if v.Sandbox {
|
||||
fmt.Fprintln(w, "sandbox: available")
|
||||
} else {
|
||||
fmt.Fprintln(w, "sandbox: NOT available —", v.SandboxUnavailable)
|
||||
}
|
||||
for _, r := range v.Ops {
|
||||
if r.NotVerified != "" {
|
||||
fmt.Fprintf(w, "op [%s] NOT VERIFIED: %s\n", r.ID, r.NotVerified)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "op %s %s as %s [%s]\n %s\n", r.Name, r.Version, r.Privilege, r.ID, r.Summary)
|
||||
}
|
||||
for _, r := range v.Grants {
|
||||
if r.NotVerified != "" {
|
||||
fmt.Fprintf(w, "grant [%s] NOT VERIFIED: %s\n", r.ID, r.NotVerified)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "grant [%s] %s %s %d run(s) left, until %s\n within: %s\n", r.ID, r.Op, r.Version, r.RunsLeft, r.Until, r.Within)
|
||||
}
|
||||
if len(v.Ops) == 0 && len(v.Grants) == 0 {
|
||||
fmt.Fprintln(w, "no ops held for", v.Host)
|
||||
}
|
||||
}
|
||||
|
||||
// cmdOpsDrop takes a held op grant back at the console.
|
||||
func cmdOpsDrop(host, prefix string) error {
|
||||
return printOrAct(agentAskRequest{Act: "ops-drop", Host: host, ID: prefix},
|
||||
"ops drop "+prefix+hostFlagWords(host), func() (string, error) { return opsDropText(host, prefix) })
|
||||
}
|
||||
|
||||
func opsDropText(host, prefix string) (string, error) {
|
||||
cfg, err := stepupPairing(host)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
dir := cfg.dcnDir()
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
// Every nonce starts with "": with one grant held, an empty id
|
||||
// would drop it unnamed.
|
||||
return "", errors.New("which op grant? (the id `privatae-bastion ops` lists, or --all)")
|
||||
}
|
||||
if prefix == "--all" {
|
||||
dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true })
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("%d op grant(s) dropped — the host cannot run under any of them again\n", len(dropped))
|
||||
return nil
|
||||
return fmt.Sprintf("%d op grant(s) dropped — the host cannot run under any of them again", len(dropped)), nil
|
||||
}
|
||||
var hits []string
|
||||
for nonce, rec := range opsLoad(dir).Grants {
|
||||
@@ -158,14 +246,38 @@ func cmdOpsDrop(host, prefix string) error {
|
||||
}
|
||||
switch len(hits) {
|
||||
case 0:
|
||||
return fmt.Errorf("no held op grant starts with %q", prefix)
|
||||
return "", fmt.Errorf("no held op grant starts with %q", prefix)
|
||||
case 1:
|
||||
default:
|
||||
return fmt.Errorf("%d grants start with %q — give more of the id", len(hits), prefix)
|
||||
return "", fmt.Errorf("%d grants start with %q — give more of the id", len(hits), prefix)
|
||||
}
|
||||
if _, err := opsDropGrant(dir, hits[0], "dropped at this machine's console"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("op grant %s dropped — the host cannot run under it again", hits[0][:8]), nil
|
||||
}
|
||||
|
||||
// printOrAct runs a narrowing act: through the owner's lane beside the
|
||||
// machine's bastion (owneracts.go), else here, printing what it did either way.
|
||||
func printOrAct(req agentAskRequest, adminCmd string, here func() (string, error)) error {
|
||||
var done string
|
||||
var err error
|
||||
if readsGoThroughLane() {
|
||||
done, err = actViaSock(req)
|
||||
if err != nil {
|
||||
return laneRefusalError(adminCmd, err)
|
||||
}
|
||||
} else if done, err = here(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("op grant %s dropped — the host cannot run under it again\n", hits[0][:8])
|
||||
fmt.Println(done)
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostFlagWords is " --host <host>" when one was named.
|
||||
func hostFlagWords(host string) string {
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return " --host " + host
|
||||
}
|
||||
|
||||
+5
-3
@@ -680,7 +680,7 @@ func handleDcnGrantSync(ws *websocket.Conn, dir string, args []string) {
|
||||
if dcnHasMask(terms, "search") {
|
||||
faEnsureStore(dir, payload.StoreID, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient)
|
||||
} else {
|
||||
faStop(payload.StoreID)
|
||||
faStop(dir, payload.StoreID)
|
||||
}
|
||||
dcnResult(ws, dir, "grant_sync", payload.StoreID, "", map[string]any{
|
||||
"state": "ok", "root": terms.Root, "mask": terms.Mask,
|
||||
@@ -708,14 +708,16 @@ func dcnDropStoreLocked(dir string, storeID int64, why string) (bool, error) {
|
||||
}
|
||||
key := strconv.FormatInt(storeID, 10)
|
||||
if _, ok := f.Stores[key]; !ok {
|
||||
faStop(storeID) // nothing on disk, but never leave an orphan instance running
|
||||
// Nothing on disk, but never leave an orphan instance running: this
|
||||
// pairing's own (faKey), never another Host's for the same store id.
|
||||
faStop(dir, storeID)
|
||||
return false, nil
|
||||
}
|
||||
delete(f.Stores, key)
|
||||
if err := dcnSaveFile(dir, f); err != nil {
|
||||
return true, err
|
||||
}
|
||||
faStop(storeID)
|
||||
faStop(dir, storeID)
|
||||
dcnAudit(dir, "grant_drop", storeID, "", why)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
+73
-15
@@ -33,6 +33,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -847,31 +848,82 @@ func deviceShownPath() (string, error) {
|
||||
return filepath.Join(d, "device_shown.json"), nil
|
||||
}
|
||||
|
||||
func cmdDeviceApprovals() error {
|
||||
// deviceAsk is one ask waiting on this approval device.
|
||||
type deviceAsk struct {
|
||||
ID string `json:"id"`
|
||||
Server string `json:"server"`
|
||||
At string `json:"at"`
|
||||
Words string `json:"words"`
|
||||
}
|
||||
|
||||
type deviceApprovalsRead struct {
|
||||
Asks []deviceAsk `json:"asks"`
|
||||
}
|
||||
|
||||
func readDeviceApprovals(readOpts) (deviceApprovalsRead, error) {
|
||||
deviceMu.Lock()
|
||||
m := deviceRequestsLoad()
|
||||
deviceMu.Unlock()
|
||||
shown := []string{}
|
||||
v := deviceApprovalsRead{Asks: []deviceAsk{}}
|
||||
for _, id := range sortedDeviceRequests(m) {
|
||||
r := m[id]
|
||||
if r.State != "pending" {
|
||||
continue
|
||||
}
|
||||
shown = append(shown, id)
|
||||
fmt.Printf("[%s] on %s, asked %s\n%s\n\n", id[:8], r.Server, r.At, r.Words)
|
||||
v.Asks = append(v.Asks, deviceAsk{ID: id, Server: r.Server, At: r.At, Words: r.Words})
|
||||
}
|
||||
if p, err := deviceShownPath(); err == nil {
|
||||
raw, _ := json.Marshal(shown)
|
||||
_ = atomicWrite(p, raw)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// cmdDeviceApprovals lists the asks waiting here. Read in words by this
|
||||
// process, it records them as shown, which is all `device-approve all` will
|
||||
// sign. --json is for a program, not a person, and records nothing; nor does
|
||||
// a list read through the lane, which `device-approve` from this account
|
||||
// would not find.
|
||||
func cmdDeviceApprovals(args []string) error {
|
||||
asJSON, err := jsonOnly("usage: privatae-bastion device-approvals [--json]", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(shown) == 0 {
|
||||
fmt.Println("nothing is waiting for your approval")
|
||||
return nil
|
||||
lane := readsGoThroughLane()
|
||||
v, err := readMachine("device-approvals", readOpts{}, readDeviceApprovals)
|
||||
if err != nil {
|
||||
return laneRefusalError("device-approvals", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printDeviceApprovals(os.Stdout, v, lane)
|
||||
if !lane {
|
||||
shown := []string{}
|
||||
for _, a := range v.Asks {
|
||||
shown = append(shown, a.ID)
|
||||
}
|
||||
if p, err := deviceShownPath(); err == nil {
|
||||
raw, _ := json.Marshal(shown)
|
||||
_ = atomicWrite(p, raw)
|
||||
}
|
||||
}
|
||||
fmt.Printf("approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// printDeviceApprovals prints the asks; lane: they were read from the
|
||||
// machine's bastion, so they are answered as its administrator.
|
||||
func printDeviceApprovals(w io.Writer, v deviceApprovalsRead, lane bool) {
|
||||
for _, a := range v.Asks {
|
||||
fmt.Fprintf(w, "[%s] on %s, asked %s\n%s\n\n", a.ID[:min(8, len(a.ID))], a.Server, a.At, a.Words)
|
||||
}
|
||||
switch {
|
||||
case len(v.Asks) == 0:
|
||||
fmt.Fprintln(w, "nothing is waiting for your approval")
|
||||
case lane:
|
||||
fmt.Fprintln(w, "These wait on the machine's bastion. As the administrator: sudo -H privatae-bastion device-approvals, "+
|
||||
"then device-approve <id> or device-approve all")
|
||||
default:
|
||||
fmt.Fprintln(w, "approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all")
|
||||
}
|
||||
}
|
||||
|
||||
func cmdDeviceApprove(prefix string) error { return cmdDeviceApproveCode(prefix, "") }
|
||||
|
||||
func cmdDeviceApproveCode(prefix, code string) error {
|
||||
@@ -947,19 +999,25 @@ func cmdDeviceApproveCode(prefix, code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cmdDeviceDeny refuses one ask waiting on this approval device (through the
|
||||
// owner's lane beside the machine's bastion: denying takes nothing).
|
||||
func cmdDeviceDeny(prefix string) error {
|
||||
return printOrAct(agentAskRequest{Act: "device-deny", ID: prefix}, "device-deny "+prefix,
|
||||
func() (string, error) { return deviceDenyText(prefix) })
|
||||
}
|
||||
|
||||
func deviceDenyText(prefix string) (string, error) {
|
||||
deviceMu.Lock()
|
||||
m := deviceRequestsLoad()
|
||||
deviceMu.Unlock()
|
||||
id, err := findDeviceRequest(m, prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if err := deviceDeny(id); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("denied %s\n", id)
|
||||
return nil
|
||||
return "denied " + id, nil
|
||||
}
|
||||
|
||||
func findDeviceRequest(m map[string]deviceRequest, prefix string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
// doctor.go — what is wrong with this install, and the fix for each one
|
||||
// (plan Addendum 6, slice 3).
|
||||
//
|
||||
// privatae-bastion doctor [--json]
|
||||
//
|
||||
// Each check is a fact the bastion already holds, said as a problem and the
|
||||
// command that fixes it. The machine's checks are built where its state is:
|
||||
// in the daemon for the owner's account, through the lane (reads.go), or
|
||||
// directly for the administrator and a per-account install. The one thing
|
||||
// only the asking side can find out is whether the lane answers it at all,
|
||||
// and that becomes the check when it does not. Exit status 1 when any check
|
||||
// finds a problem, so a script can branch on it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const doctorUsage = "usage: privatae-bastion doctor [--json]"
|
||||
|
||||
type doctorCheck struct {
|
||||
Name string `json:"name"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail,omitempty"` // what was found, when all is well
|
||||
Problem string `json:"problem,omitempty"`
|
||||
Fix string `json:"fix,omitempty"` // the command, or what to do
|
||||
}
|
||||
|
||||
type doctorRead struct {
|
||||
Version string `json:"version"`
|
||||
Checks []doctorCheck `json:"checks"`
|
||||
}
|
||||
|
||||
func (d doctorRead) problems() int {
|
||||
n := 0
|
||||
for _, c := range d.Checks {
|
||||
if !c.OK {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// systemInstalled: the bastion is installed for the whole machine.
|
||||
var systemInstalled = func() bool {
|
||||
if runtime.GOOS == "linux" {
|
||||
return linuxSystemUnitPresent()
|
||||
}
|
||||
return systemService.installed
|
||||
}
|
||||
|
||||
// doctorSandbox is opSandboxAvailable, replaceable in a test: its probe
|
||||
// re-executes this binary.
|
||||
var doctorSandbox = opSandboxAvailable
|
||||
|
||||
// asAdmin is a bastion command said the way it has to be typed here: the
|
||||
// machine's bastion keeps its state as the administrator when it is
|
||||
// installed for the whole machine.
|
||||
func asAdmin(cmd string) string {
|
||||
switch {
|
||||
case !systemInstalled():
|
||||
return "privatae-bastion " + cmd
|
||||
case runtime.GOOS == "windows":
|
||||
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
|
||||
}
|
||||
return "sudo -H privatae-bastion " + cmd
|
||||
}
|
||||
|
||||
// installerCommand is how this platform installs the bastion, and so how a
|
||||
// machine no Host updates moves to a newer version.
|
||||
func installerCommand() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "irm https://privatae.ai/bastion/install.ps1 | iex"
|
||||
}
|
||||
return "curl -fsSL https://privatae.ai/bastion/install.sh | sh"
|
||||
}
|
||||
|
||||
// doctorLatest is the newest published version, or "" when it cannot be
|
||||
// known: the daemon's last answer, else the last time doctor asked
|
||||
// privatae.ai, if that was within updateCheckEvery, else one fetch that
|
||||
// waits at most doctorLatestWait. A request to privatae.ai carries this
|
||||
// machine's address to the platform, so doctor keeps update.go's rate
|
||||
// (four a day): a run in its own process, or an agent polling it, asks again
|
||||
// only once that time has passed, whether the last ask was answered or not.
|
||||
// Running with no route to privatae.ai is supported (update.go), so "" is
|
||||
// not a problem.
|
||||
var (
|
||||
doctorLatestWait = 10 * time.Second
|
||||
doctorLatest = func() string {
|
||||
latestMu.Lock()
|
||||
v := latestPublished
|
||||
latestMu.Unlock()
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
if r, ok := doctorAskedLoad(); ok && doctorNow().Sub(r.AskedAt) < updateCheckEvery {
|
||||
return r.Version
|
||||
}
|
||||
got := make(chan string, 1)
|
||||
go func() {
|
||||
v, err := doctorFetch()
|
||||
if err != nil {
|
||||
v = ""
|
||||
}
|
||||
got <- strings.TrimSpace(v)
|
||||
}()
|
||||
select {
|
||||
case v = <-got:
|
||||
case <-time.After(doctorLatestWait):
|
||||
}
|
||||
if v != "" {
|
||||
recordLatestPublished(v)
|
||||
} else if r, ok := doctorAskedLoad(); ok {
|
||||
v = r.Version // no answer this time: the last one stands, as in update.go
|
||||
}
|
||||
doctorAskedSave(doctorAsked{Version: v, AskedAt: doctorNow()})
|
||||
return v
|
||||
}
|
||||
doctorNow = time.Now
|
||||
doctorFetch = func() (string, error) {
|
||||
m, err := fetchManifest()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.Version, nil
|
||||
}
|
||||
)
|
||||
|
||||
// doctorAsked is when doctor last asked privatae.ai, and what it knew after.
|
||||
type doctorAsked struct {
|
||||
Version string `json:"version"`
|
||||
AskedAt time.Time `json:"asked_at"`
|
||||
}
|
||||
|
||||
func doctorAskedPath() (string, error) {
|
||||
d, err := configDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(d, "doctor_latest.json"), nil
|
||||
}
|
||||
|
||||
func doctorAskedLoad() (doctorAsked, bool) {
|
||||
var r doctorAsked
|
||||
p, err := doctorAskedPath()
|
||||
if err != nil {
|
||||
return r, false
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil || json.Unmarshal(raw, &r) != nil || r.AskedAt.IsZero() {
|
||||
return doctorAsked{}, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
func doctorAskedSave(r doctorAsked) {
|
||||
p, err := doctorAskedPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if raw, err := json.Marshal(r); err == nil {
|
||||
_ = atomicWrite(p, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// logHint is where the bastion's own log is read on this platform.
|
||||
func logHint() string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if systemInstalled() {
|
||||
return "journalctl -u privatae-bastion"
|
||||
}
|
||||
return "journalctl --user -u privatae-bastion"
|
||||
case "windows":
|
||||
return "the bastion's window, or Event Viewer when it runs as a service"
|
||||
}
|
||||
return "Console.app, process privatae-bastion"
|
||||
}
|
||||
|
||||
func readDoctor(readOpts) (doctorRead, error) {
|
||||
d := doctorRead{Version: clientVersion, Checks: []doctorCheck{}}
|
||||
add := func(c doctorCheck) { d.Checks = append(d.Checks, c) }
|
||||
|
||||
s, err := readStatus(readOpts{})
|
||||
if err != nil {
|
||||
add(doctorCheck{Name: "pairings", Problem: sentenceCase(err.Error()) + ".",
|
||||
Fix: "The bastion's log says why: " + logHint()})
|
||||
return d, nil
|
||||
}
|
||||
paired := len(s.Pairings) > 0
|
||||
start := runCommandHint()
|
||||
if runtime.GOOS == "linux" && systemInstalled() {
|
||||
start = "sudo " + start
|
||||
}
|
||||
switch {
|
||||
case s.Running == "no":
|
||||
add(doctorCheck{Name: "running", Problem: "The bastion is not running, so nothing on this machine is watched.",
|
||||
Fix: start})
|
||||
case !paired:
|
||||
add(doctorCheck{Name: "running", OK: true, Detail: "Running on its own; no Host is connected."})
|
||||
default:
|
||||
add(doctorCheck{Name: "running", OK: true, Detail: fmt.Sprintf("Running, paired with %d Host(s).", len(s.Pairings))})
|
||||
}
|
||||
|
||||
switch latest := doctorLatest(); {
|
||||
case latest == "":
|
||||
add(doctorCheck{Name: "version", OK: true, Detail: "This is " + clientVersion +
|
||||
"; the latest published version could not be read from privatae.ai."})
|
||||
case versionNewer(latest, clientVersion):
|
||||
fix := "Run the installer again: " + installerCommand()
|
||||
if paired {
|
||||
fix = "Your Host can update it (Network app, Update), or run the installer again: " + installerCommand()
|
||||
}
|
||||
add(doctorCheck{Name: "version", Problem: "Version " + latest + " is published; this machine runs " +
|
||||
clientVersion + ".", Fix: fix})
|
||||
default:
|
||||
add(doctorCheck{Name: "version", OK: true, Detail: clientVersion + " is the latest published version."})
|
||||
}
|
||||
|
||||
if systemInstalled() {
|
||||
_, name, present, err := ownerAccountLoad()
|
||||
fix := "sudo -H privatae-bastion approvals-owner --account <your account>"
|
||||
if runtime.GOOS == "windows" {
|
||||
fix = "privatae-bastion approvals-owner --sid <your account's SID> (from an Administrator terminal)"
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
add(doctorCheck{Name: "owner", Problem: "This machine's owner account cannot be read (" + err.Error() + ").",
|
||||
Fix: strings.Replace(fix, "approvals-owner ", "approvals-owner --replace ", 1)})
|
||||
case !present:
|
||||
add(doctorCheck{Name: "owner", Problem: "No account is recorded as this machine's owner, so the console " +
|
||||
"opens only for the administrator, and agents in your account can neither read the machine nor ask it " +
|
||||
"to stop an agent.", Fix: fix})
|
||||
default:
|
||||
add(doctorCheck{Name: "owner", OK: true, Detail: "The machine's owner account is " + name + "."})
|
||||
}
|
||||
}
|
||||
|
||||
factor := existingMachineFactorDir()
|
||||
if stepupEnrolled(factor) {
|
||||
add(doctorCheck{Name: "second factor", OK: true, Detail: "The machine's owner has a second factor here."})
|
||||
} else {
|
||||
add(doctorCheck{Name: "second factor", Problem: "This machine has no second factor of its own, so a stop, " +
|
||||
"and every ask an agent files, cannot be approved.", Fix: asAdmin("stepup-enroll --machine")})
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var held []string
|
||||
for account, e := range askQuarantineLoad(factor) {
|
||||
if quarantinedAt(e, now) {
|
||||
held = append(held, account)
|
||||
}
|
||||
}
|
||||
sort.Strings(held)
|
||||
for _, account := range held {
|
||||
add(doctorCheck{Name: "held asker", Problem: sentenceCase(askHoldRefusal(account, askHeldUntil(factor, account, now))) + ".",
|
||||
Fix: asAdmin("quarantine clear --asker " + account)})
|
||||
}
|
||||
|
||||
for _, p := range s.Pairings {
|
||||
switch {
|
||||
case p.Problem != "":
|
||||
add(doctorCheck{Name: "host " + p.Host, Problem: "The pairing with " + p.Host + " is not usable: " +
|
||||
p.Problem + ".", Fix: "The bastion's log says more: " + logHint()})
|
||||
case !p.Answering && s.Running != "no":
|
||||
add(doctorCheck{Name: "host " + p.Host, Problem: "The bastion is not serving " + p.Host +
|
||||
" (nothing answers on " + p.Local + ").", Fix: "The bastion's log says why: " + logHint()})
|
||||
}
|
||||
}
|
||||
if paired {
|
||||
localDenyMu.Lock()
|
||||
_, derr := localDenyLoad()
|
||||
localDenyMu.Unlock()
|
||||
if derr != nil {
|
||||
add(doctorCheck{Name: "switches", Problem: localDenyUnreadableText(derr), Fix: asAdmin("switch --reset")})
|
||||
}
|
||||
if ok, why := doctorSandbox(); !ok {
|
||||
c := doctorCheck{Name: "sandbox", Problem: "Ops a Host sends cannot run sandboxed here: " + why + "."}
|
||||
if !processIsRoot() {
|
||||
c.Fix = "Install the bastion for the whole machine: run the installer again without --just-me: " +
|
||||
installerCommand()
|
||||
}
|
||||
add(c)
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func cmdDoctor(args []string) error {
|
||||
asJSON, err := jsonOnly(doctorUsage, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := readMachine("doctor", readOpts{}, readDoctor)
|
||||
var le *laneError
|
||||
if errors.As(err, &le) {
|
||||
// The one check the machine cannot make for this account.
|
||||
d = doctorRead{Version: clientVersion, Checks: []doctorCheck{laneCheck(le)}}
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
if err := printJSON(os.Stdout, d); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
printDoctor(os.Stdout, d)
|
||||
}
|
||||
if d.problems() > 0 {
|
||||
return quietExit{code: 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// laneCheck is the lane not answering this account, as a check.
|
||||
func laneCheck(le *laneError) doctorCheck {
|
||||
if le.down {
|
||||
fix := "Start it: sudo " + runCommandHintFor(runtime.GOOS) + " (see whether it runs: systemctl status privatae-bastion)"
|
||||
if runtime.GOOS != "linux" {
|
||||
fix = "Start it: " + runCommandHint()
|
||||
}
|
||||
return doctorCheck{Name: "running", Problem: "The machine's bastion does not answer this account: " + le.why + ".",
|
||||
Fix: fix}
|
||||
}
|
||||
return doctorCheck{Name: "reads", Problem: sentenceCase(le.why) + ".",
|
||||
Fix: "As the administrator: sudo -H privatae-bastion doctor"}
|
||||
}
|
||||
|
||||
func printDoctor(w io.Writer, d doctorRead) {
|
||||
switch n := d.problems(); n {
|
||||
case 0:
|
||||
fmt.Fprintf(w, "privatae-bastion %s: nothing wrong found.\n", d.Version)
|
||||
case 1:
|
||||
fmt.Fprintf(w, "privatae-bastion %s: 1 problem.\n", d.Version)
|
||||
default:
|
||||
fmt.Fprintf(w, "privatae-bastion %s: %d problems.\n", d.Version, n)
|
||||
}
|
||||
for _, c := range d.Checks {
|
||||
if c.OK {
|
||||
fmt.Fprintln(w, " ok "+c.Detail)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, " PROBLEM "+c.Problem)
|
||||
if c.Fix != "" {
|
||||
fmt.Fprintln(w, " Fix: "+c.Fix)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,11 @@ func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *t
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
hold := laneHoldFor(self)
|
||||
for pctx.Err() == nil {
|
||||
if !hold.waitClear(pctx) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||
cancel()
|
||||
@@ -137,12 +141,17 @@ func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *t
|
||||
sleepCtx(pctx, time.Second)
|
||||
continue
|
||||
}
|
||||
held := watchHeldClose(ws)
|
||||
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||
took := handleEgressConn(&wsConn{ws: ws}, dialOut, self) // blocks until a job, serves it, then returns
|
||||
unpark()
|
||||
stopKA()
|
||||
_ = ws.Close()
|
||||
if held() {
|
||||
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||
continue
|
||||
}
|
||||
if !took {
|
||||
sleepCtx(pctx, egressRefusedWait)
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func buildEnforcingReport(dir string, allowed []string) map[string]any {
|
||||
b, _ := strconv.ParseInt(ids[j], 10, 64)
|
||||
return a < b
|
||||
})
|
||||
status := faManagedStatus()
|
||||
status := faManagedStatus(dir)
|
||||
for _, k := range ids {
|
||||
id, err := strconv.ParseInt(k, 10, 64)
|
||||
if err != nil {
|
||||
|
||||
+10
-10
@@ -223,8 +223,8 @@ func freeagentDo(c freeagentConfig, method, path string, body any, d time.Durati
|
||||
// faConfigFor is the client config for a store: the MANAGED instance the
|
||||
// bastion runs for it (freeagent_managed.go) when there is one, else the
|
||||
// operator's PRIVATAE_BASTION_AGENT_* env for a hand-run agent.
|
||||
func faConfigFor(storeID int64) (freeagentConfig, bool) {
|
||||
if c, ok := faManagedConfig(storeID); ok {
|
||||
func faConfigFor(dcnDir string, storeID int64) (freeagentConfig, bool) {
|
||||
if c, ok := faManagedConfig(dcnDir, storeID); ok {
|
||||
return c, true
|
||||
}
|
||||
return freeagentEnv()
|
||||
@@ -232,8 +232,8 @@ func faConfigFor(storeID int64) (freeagentConfig, bool) {
|
||||
|
||||
// faAbsentReason says WHY there is no local agent to ask, in the words the
|
||||
// host should relay: a missing Python is the member's one manual step.
|
||||
func faAbsentReason(storeID int64) string {
|
||||
inst, ok := faSnapshot(storeID)
|
||||
func faAbsentReason(dcnDir string, storeID int64) string {
|
||||
inst, ok := faSnapshot(dcnDir, storeID)
|
||||
if ok {
|
||||
switch inst.State {
|
||||
case "missing-python":
|
||||
@@ -358,9 +358,9 @@ func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
|
||||
if terms == nil {
|
||||
return
|
||||
}
|
||||
c, on := faConfigFor(storeID)
|
||||
c, on := faConfigFor(dir, storeID)
|
||||
if !on {
|
||||
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(storeID))
|
||||
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(dir, storeID))
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(a.Query)
|
||||
@@ -419,9 +419,9 @@ func handleDcnCatalog(ws *websocket.Conn, dir string, args []string) {
|
||||
"catalog needs the 'index' mask on this grant (re-approve the store with index)")
|
||||
return
|
||||
}
|
||||
c, on := faConfigFor(storeID)
|
||||
c, on := faConfigFor(dir, storeID)
|
||||
if !on {
|
||||
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(storeID))
|
||||
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(dir, storeID))
|
||||
return
|
||||
}
|
||||
path := "/catalog"
|
||||
@@ -471,9 +471,9 @@ func handleDcnReadRangeDoc(ws *websocket.Conn, dir string, storeID int64, a *dcn
|
||||
if terms == nil {
|
||||
return
|
||||
}
|
||||
c, on := faConfigFor(storeID)
|
||||
c, on := faConfigFor(dir, storeID)
|
||||
if !on {
|
||||
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, faAbsentReason(storeID))
|
||||
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, faAbsentReason(dir, storeID))
|
||||
return
|
||||
}
|
||||
start := a.StartByte
|
||||
|
||||
@@ -123,20 +123,29 @@ type faInstance struct {
|
||||
}
|
||||
|
||||
// faSnapshot copies the fields a reader needs, under the lock.
|
||||
func faSnapshot(storeID int64) (faInstance, bool) {
|
||||
func faSnapshot(dcnDir string, storeID int64) (faInstance, bool) {
|
||||
faMgr.Lock()
|
||||
defer faMgr.Unlock()
|
||||
inst, ok := faMgr.inst[storeID]
|
||||
inst, ok := faMgr.inst[faKey{dcnDir, storeID}]
|
||||
if !ok {
|
||||
return faInstance{}, false
|
||||
}
|
||||
return *inst, true
|
||||
}
|
||||
|
||||
// faKey is one pairing's store: store ids are each Host's own, so the same id
|
||||
// from two Hosts is two stores. Keyed by store id alone, a machine paired with
|
||||
// two Hosts ran one agent for both, and a search from one Host for its store
|
||||
// was answered by the agent over the other Host's folder.
|
||||
type faKey struct {
|
||||
Dir string // the pairing's DCN state dir (Config.dcnDir)
|
||||
ID int64
|
||||
}
|
||||
|
||||
var faMgr = struct {
|
||||
sync.Mutex
|
||||
inst map[int64]*faInstance
|
||||
}{inst: map[int64]*faInstance{}}
|
||||
inst map[faKey]*faInstance
|
||||
}{inst: map[faKey]*faInstance{}}
|
||||
|
||||
// ── locations ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -664,8 +673,8 @@ func faRecipientOf(inst *faInstance) string {
|
||||
|
||||
// faManagedConfig is the client config for a store's managed instance, or
|
||||
// false when none is running (port file absent).
|
||||
func faManagedConfig(storeID int64) (freeagentConfig, bool) {
|
||||
inst, ok := faSnapshot(storeID)
|
||||
func faManagedConfig(dcnDir string, storeID int64) (freeagentConfig, bool) {
|
||||
inst, ok := faSnapshot(dcnDir, storeID)
|
||||
if !ok {
|
||||
return freeagentConfig{}, false
|
||||
}
|
||||
@@ -697,10 +706,11 @@ func faEnsureStore(dcnDir string, storeID int64, terms *dcnTerms, sweep, recipie
|
||||
}
|
||||
cn := "store-" + strconv.FormatInt(storeID, 10)
|
||||
faMgr.Lock()
|
||||
inst, ok := faMgr.inst[storeID]
|
||||
key := faKey{dcnDir, storeID}
|
||||
inst, ok := faMgr.inst[key]
|
||||
if !ok {
|
||||
inst = &faInstance{StoreID: storeID, Dir: dir, PrivDir: priv, PubDir: pub, CN: cn, State: "installing"}
|
||||
faMgr.inst[storeID] = inst
|
||||
faMgr.inst[key] = inst
|
||||
} else if inst.State == "installing" || inst.ensuring {
|
||||
faMgr.Unlock()
|
||||
return // one ensure at a time per store — see faInstance.ensuring
|
||||
@@ -993,22 +1003,27 @@ func sweepFromCanonical(termsB64 string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// faManagedProbe reports (anyManaged, allServing) and refreshes each
|
||||
// instance's Serving flag by probing its /health over mTLS.
|
||||
func faManagedProbe() (bool, bool) {
|
||||
// faManagedProbe reports (anyManaged, allServing) for ONE pairing's instances
|
||||
// and refreshes their Serving flags by probing each /health over mTLS. Another
|
||||
// pairing's agents are never counted: its health is not this Host's to hear,
|
||||
// and this Host reads the capability as "my stores here are served" when its
|
||||
// own freeagent_stores is empty (faManagedStatus). "" probes none.
|
||||
func faManagedProbe(dcnDir string) (bool, bool) {
|
||||
faMgr.Lock()
|
||||
ids := make([]int64, 0, len(faMgr.inst))
|
||||
for id := range faMgr.inst {
|
||||
ids = append(ids, id)
|
||||
keys := make([]faKey, 0, len(faMgr.inst))
|
||||
for k := range faMgr.inst {
|
||||
if dcnDir != "" && k.Dir == dcnDir {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
faMgr.Unlock()
|
||||
if len(ids) == 0 {
|
||||
if len(keys) == 0 {
|
||||
return false, false
|
||||
}
|
||||
all := true
|
||||
for _, id := range ids {
|
||||
for _, k := range keys {
|
||||
serving := false
|
||||
if c, ok := faManagedConfig(id); ok {
|
||||
if c, ok := faManagedConfig(k.Dir, k.ID); ok {
|
||||
status, body, err := freeagentCallTimeout(c, http.MethodGet, "/health", nil, freeagentProbeTimeout)
|
||||
if err == nil && status == http.StatusOK {
|
||||
var h struct {
|
||||
@@ -1019,7 +1034,7 @@ func faManagedProbe() (bool, bool) {
|
||||
}
|
||||
}
|
||||
faMgr.Lock()
|
||||
if inst, ok := faMgr.inst[id]; ok {
|
||||
if inst, ok := faMgr.inst[k]; ok {
|
||||
inst.Serving = serving
|
||||
}
|
||||
faMgr.Unlock()
|
||||
@@ -1030,13 +1045,20 @@ func faManagedProbe() (bool, bool) {
|
||||
return true, all
|
||||
}
|
||||
|
||||
// faManagedStatus is the heartbeat's per-store detail.
|
||||
func faManagedStatus() map[string]any {
|
||||
// faManagedStatus is one pairing's per-store detail for its heartbeat: only
|
||||
// its own stores, never another Host's.
|
||||
func faManagedStatus(dcnDir string) map[string]any {
|
||||
faMgr.Lock()
|
||||
defer faMgr.Unlock()
|
||||
out := map[string]any{}
|
||||
for id, inst := range faMgr.inst {
|
||||
out[strconv.FormatInt(id, 10)] = map[string]any{
|
||||
if dcnDir == "" {
|
||||
return out
|
||||
}
|
||||
for k, inst := range faMgr.inst {
|
||||
if k.Dir != dcnDir {
|
||||
continue
|
||||
}
|
||||
out[strconv.FormatInt(k.ID, 10)] = map[string]any{
|
||||
"state": inst.State, "serving": inst.Serving, "version": inst.Version, "detail": inst.Detail,
|
||||
}
|
||||
}
|
||||
@@ -1045,14 +1067,15 @@ func faManagedStatus() map[string]any {
|
||||
|
||||
// faStop tears down a store's managed instance (grant revoked / re-synced
|
||||
// without search).
|
||||
func faStop(storeID int64) {
|
||||
func faStop(dcnDir string, storeID int64) {
|
||||
faMgr.Lock()
|
||||
defer faMgr.Unlock()
|
||||
if inst, ok := faMgr.inst[storeID]; ok {
|
||||
key := faKey{dcnDir, storeID}
|
||||
if inst, ok := faMgr.inst[key]; ok {
|
||||
if inst.cancel != nil {
|
||||
inst.cancel()
|
||||
}
|
||||
delete(faMgr.inst, storeID)
|
||||
delete(faMgr.inst, key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,29 +182,64 @@ func TestFaManifestText(t *testing.T) {
|
||||
func TestFaManagedConfigFromPortFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
faMgr.Lock()
|
||||
faMgr.inst[4242] = &faInstance{StoreID: 4242, Dir: dir, Cert: "c", Key: "k"}
|
||||
faMgr.inst[faKey{"dcn-test", 4242}] = &faInstance{StoreID: 4242, Dir: dir, Cert: "c", Key: "k"}
|
||||
faMgr.Unlock()
|
||||
t.Cleanup(func() { faStop(4242) })
|
||||
if _, ok := faManagedConfig(4242); ok {
|
||||
t.Cleanup(func() { faStop("dcn-test", 4242) })
|
||||
if _, ok := faManagedConfig("dcn-test", 4242); ok {
|
||||
t.Fatal("no port file → not configured")
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "port"), []byte("18473\n"), 0o600)
|
||||
c, ok := faManagedConfig(4242)
|
||||
c, ok := faManagedConfig("dcn-test", 4242)
|
||||
if !ok || c.Addr != "127.0.0.1:18473" || c.CA != "c" {
|
||||
t.Fatalf("port file → config, got %+v %v", c, ok)
|
||||
}
|
||||
if _, ok := faConfigFor(4242); !ok {
|
||||
if _, ok := faConfigFor("dcn-test", 4242); !ok {
|
||||
t.Fatal("faConfigFor must prefer the managed instance")
|
||||
}
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "")
|
||||
if _, ok := faConfigFor(1); ok {
|
||||
if _, ok := faConfigFor("dcn-test", 1); ok {
|
||||
t.Fatal("unknown store with no env → absent")
|
||||
}
|
||||
if !strings.Contains(faAbsentReason(1), "no local agent") {
|
||||
if !strings.Contains(faAbsentReason("dcn-test", 1), "no local agent") {
|
||||
t.Fatal("absent reason for an unknown store")
|
||||
}
|
||||
}
|
||||
|
||||
// A beat's freeagent capability and freeagent_ok are its own pairing's agents
|
||||
// only. Probed across every pairing, Host B's beat carried Host A's agent's
|
||||
// health, and with B's freeagent_stores now empty the Host reads the
|
||||
// capability alone (the Host's store-card check: `"freeagent"
|
||||
// in caps and not stores`), so A's serving agent marked every one of B's
|
||||
// stores on this machine as served.
|
||||
func TestHeartbeatProbesOnlyItsOwnPairingsAgents(t *testing.T) {
|
||||
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "") // no hand-run agent
|
||||
dcnA, dcnB := t.TempDir(), t.TempDir()
|
||||
faMgr.Lock()
|
||||
// no port file: A's agent is managed and not serving
|
||||
faMgr.inst[faKey{dcnA, 7}] = &faInstance{StoreID: 7, Dir: t.TempDir(), Cert: "a", Key: "a"}
|
||||
faMgr.Unlock()
|
||||
t.Cleanup(func() { faStop(dcnA, 7) })
|
||||
beat := func(dir string) map[string]any {
|
||||
var body map[string]any
|
||||
raw := buildHeartbeatBodyOn(beatLanes{dcnDir: dir}, "", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
||||
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
// MUTATION: probe every pairing's instances (no k.Dir filter in
|
||||
// faManagedProbe). RESULT: red, B's beat carries freeagent_ok false.
|
||||
if b, ok := beat(dcnB)["freeagent_ok"]; ok {
|
||||
t.Errorf("Host B holds no agent here, yet its beat says freeagent_ok %v", b)
|
||||
}
|
||||
if b, ok := beat(dcnA)["freeagent_ok"]; !ok || b != false {
|
||||
t.Errorf("Host A's beat must carry its own agent's health: %v %v", b, ok)
|
||||
}
|
||||
if b, ok := beat("")["freeagent_ok"]; ok {
|
||||
t.Errorf("a beat with no DCN state probes no managed agent: %v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end with the real artifact: FREEAGENT_TEST_PYZ=/path/to/freeagent-x.pyz
|
||||
func TestFaManagedEndToEnd(t *testing.T) {
|
||||
pyzPath := os.Getenv("FREEAGENT_TEST_PYZ")
|
||||
@@ -264,7 +299,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
faReconcile(dcnDir) // what the daemon does at start
|
||||
t.Cleanup(func() { faStop(1) })
|
||||
t.Cleanup(func() { faStop(dcnDir, 1) })
|
||||
stateDir, err := faStoreDir(dcnDir, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -274,7 +309,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
var c freeagentConfig
|
||||
for time.Now().Before(deadline) {
|
||||
faMgr.Lock()
|
||||
inst := faMgr.inst[1]
|
||||
inst := faMgr.inst[faKey{dcnDir, 1}]
|
||||
state, detail := "", ""
|
||||
if inst != nil {
|
||||
state, detail = inst.State, inst.Detail
|
||||
@@ -283,7 +318,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
if state == "failed" || state == "missing-python" {
|
||||
t.Fatalf("managed install %s: %s", state, detail)
|
||||
}
|
||||
if cfg, ok := faManagedConfig(1); ok {
|
||||
if cfg, ok := faManagedConfig(dcnDir, 1); ok {
|
||||
c = cfg
|
||||
break
|
||||
}
|
||||
@@ -395,11 +430,11 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// heartbeat sees it
|
||||
adv, allOK := faManagedProbe()
|
||||
adv, allOK := faManagedProbe(dcnDir)
|
||||
if !adv || !allOK {
|
||||
t.Fatalf("managed probe: %v %v", adv, allOK)
|
||||
}
|
||||
st := faManagedStatus()["1"].(map[string]any)
|
||||
st := faManagedStatus(dcnDir)["1"].(map[string]any)
|
||||
if st["state"] != "running" || st["serving"] != true || st["version"] != "9.9.9" {
|
||||
t.Fatalf("status: %v", st)
|
||||
}
|
||||
@@ -417,7 +452,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
|
||||
// a re-approved grant with narrower terms restarts
|
||||
// the instance under the new manifest (new excludes, new serve port)
|
||||
before, _ := faSnapshot(1)
|
||||
before, _ := faSnapshot(dcnDir, 1)
|
||||
terms2 := map[string]any{}
|
||||
for k, v := range terms {
|
||||
terms2[k] = v
|
||||
@@ -432,9 +467,9 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
deadline = time.Now().Add(60 * time.Second)
|
||||
var after faInstance
|
||||
for time.Now().Before(deadline) {
|
||||
after, _ = faSnapshot(1)
|
||||
after, _ = faSnapshot(dcnDir, 1)
|
||||
if after.State == "running" && after.Text != before.Text {
|
||||
if c2, ok := faManagedConfig(1); ok && c2.Addr != c.Addr {
|
||||
if c2, ok := faManagedConfig(dcnDir, 1); ok && c2.Addr != c.Addr {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -448,10 +483,10 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
t.Fatal("re-rendered manifest must carry the new excludes and be re-signed")
|
||||
}
|
||||
// unchanged terms on the next reconcile: no restart (same port)
|
||||
c3, _ := faManagedConfig(1)
|
||||
c3, _ := faManagedConfig(dcnDir, 1)
|
||||
faReconcile(dcnDir)
|
||||
time.Sleep(time.Second)
|
||||
c4, _ := faManagedConfig(1)
|
||||
c4, _ := faManagedConfig(dcnDir, 1)
|
||||
if c3.Addr != c4.Addr {
|
||||
t.Fatal("identical terms must not restart the instance")
|
||||
}
|
||||
@@ -459,7 +494,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
// A fresh identity with a surviving manifest (new layout, wiped base, a
|
||||
// re-pair): the manifest must be re-signed under the new cert, or the
|
||||
// gate refuses it and serve answers 503 forever.
|
||||
faStop(1)
|
||||
faStop(dcnDir, 1)
|
||||
_, privDir, _, _ := faStoreDirs(dcnDir, 1)
|
||||
if err := os.RemoveAll(filepath.Join(privDir, "host.key")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -470,15 +505,15 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
||||
deadline = time.Now().Add(60 * time.Second)
|
||||
var c9 freeagentConfig
|
||||
for time.Now().Before(deadline) {
|
||||
if cfg, ok := faManagedConfig(1); ok && cfg.Addr != "" {
|
||||
if st, _ := faSnapshot(1); st.State == "running" && st.Cert != "" {
|
||||
if cfg, ok := faManagedConfig(dcnDir, 1); ok && cfg.Addr != "" {
|
||||
if st, _ := faSnapshot(dcnDir, 1); st.State == "running" && st.Cert != "" {
|
||||
if _, body, err := freeagentCall(cfg, http.MethodGet, "/health", nil); err == nil && strings.Contains(string(body), `"serving": true`) {
|
||||
c9 = cfg
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if st, _ := faSnapshot(1); st.State == "failed" {
|
||||
if st, _ := faSnapshot(dcnDir, 1); st.State == "failed" {
|
||||
t.Fatalf("re-identity: %s", st.Detail)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
@@ -149,7 +149,7 @@ func cmdGitCredential(host string, action string) error {
|
||||
case len(cfgs) == 1:
|
||||
cfg = cfgs[0]
|
||||
case len(cfgs) == 0:
|
||||
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
|
||||
return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
|
||||
default:
|
||||
return fmt.Errorf("%d hosts are paired — configure the helper with --host: "+
|
||||
"credential.https://git.privatae.ai.helper \"!privatae-bastion git-credential --host <host>\"", len(cfgs))
|
||||
|
||||
@@ -114,7 +114,11 @@ func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string,
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
hold := laneHoldFor(self)
|
||||
for pctx.Err() == nil {
|
||||
if !hold.waitClear(pctx) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||
cancel()
|
||||
@@ -127,12 +131,16 @@ func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string,
|
||||
sleepCtx(pctx, time.Second)
|
||||
continue
|
||||
}
|
||||
held := watchHeldClose(ws)
|
||||
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||
handleHarnessConn(ws, self, dcnDir) // blocks until a job, serves it, then returns
|
||||
unpark()
|
||||
stopKA()
|
||||
_ = ws.Close()
|
||||
if held() {
|
||||
hold.refused(self) // the Host holds this machine (lanehold.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
|
||||
|
||||
@@ -377,6 +377,10 @@ exit 0
|
||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
||||
"curl": `for a; do o=$a; done; case "$2" in */version.json) cp "$STAND/version.json" "$o" ;; *) cp "$STAND/bastion.bin" "$o" ;; esac`,
|
||||
"printf": `exec /usr/bin/printf "$@"`,
|
||||
// No openssl that does Ed25519 here, so the release signature is
|
||||
// noted as not checked (install_sig_sh_test.go tests the check): this
|
||||
// stand-in manifest is signed by no release key.
|
||||
"openssl": `exit 1`,
|
||||
}
|
||||
for name, body := range stand {
|
||||
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n")
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ed25519SPKIPrefix is the DER that wraps a raw Ed25519 public key in the
|
||||
// SubjectPublicKeyInfo openssl reads (RFC 8410).
|
||||
var ed25519SPKIPrefix = []byte{0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00}
|
||||
|
||||
func installShVar(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^` + name + `='([^']*)'$`).FindSubmatch(raw)
|
||||
if m == nil {
|
||||
t.Fatalf("install.sh sets no %s", name)
|
||||
}
|
||||
return string(m[1])
|
||||
}
|
||||
|
||||
func spkiToRaw(t *testing.T, b64 string) []byte {
|
||||
t.Helper()
|
||||
der, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(der) != len(ed25519SPKIPrefix)+ed25519.PublicKeySize || !bytes.HasPrefix(der, ed25519SPKIPrefix) {
|
||||
t.Fatalf("not an Ed25519 public key: %q (%v)", b64, err)
|
||||
}
|
||||
return der[len(ed25519SPKIPrefix):]
|
||||
}
|
||||
|
||||
// The installer checks a release with exactly the keys a bastion checks its
|
||||
// updates with: a key rotated in release_sign.go and not here would make
|
||||
// every install refuse, or accept a key the bastions no longer do.
|
||||
func TestInstallShPinsTheReleaseKeysTheBastionChecks(t *testing.T) {
|
||||
keys := strings.Fields(installShVar(t, "RELEASE_KEYS"))
|
||||
if len(keys) != len(productionReleaseKeys) {
|
||||
t.Fatalf("install.sh pins %d release keys, release_sign.go %d", len(keys), len(productionReleaseKeys))
|
||||
}
|
||||
for i, k := range keys {
|
||||
if got := base64.StdEncoding.EncodeToString(spkiToRaw(t, k)); got != productionReleaseKeys[i] {
|
||||
t.Errorf("install.sh key %d is %s, release_sign.go has %s", i, got, productionReleaseKeys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The probe is what tells "this openssl cannot check" from "the signature is
|
||||
// wrong". A probe that did not verify would make every system read as unable,
|
||||
// and every install skip the signature with a note.
|
||||
func TestInstallShProbeSignatureVerifies(t *testing.T) {
|
||||
pub := spkiToRaw(t, installShVar(t, "SIG_PROBE_KEY"))
|
||||
sig, err := base64.StdEncoding.DecodeString(installShVar(t, "SIG_PROBE_SIG"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(pub), []byte("privatae-installer-probe-v1\n"), sig) {
|
||||
t.Fatal("the probe signature does not verify under the probe key")
|
||||
}
|
||||
}
|
||||
|
||||
// sigHarness is release_signature_check from install.sh, with the release
|
||||
// keys replaced by key and the download host by base, run on manifest.
|
||||
func sigHarness(t *testing.T, pub ed25519.PublicKey, base string, manifest []byte, onlyPATH ...bool) (string, error) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
vars := regexp.MustCompile(`(?ms)^RELEASE_KEYS=.*?^SIG_PROBE_SIG='[^']*'$`).FindString(s)
|
||||
fn := regexp.MustCompile(`(?ms)^release_signature_check\(\) \{.*?^\}$`).FindString(s)
|
||||
if vars == "" || fn == "" {
|
||||
t.Fatal("install.sh's release_signature_check or its keys moved")
|
||||
}
|
||||
if len(onlyPATH) > 0 && onlyPATH[0] {
|
||||
// Only the openssl on PATH: a Mac with Homebrew's openssl@3 would
|
||||
// otherwise find a real one at its fixed path.
|
||||
loop := regexp.MustCompile(`(?m)^ for c in openssl [^;]*; do$`)
|
||||
if !loop.MatchString(fn) {
|
||||
t.Fatal("install.sh's openssl candidates moved")
|
||||
}
|
||||
fn = loop.ReplaceAllString(fn, " for c in openssl; do")
|
||||
}
|
||||
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
|
||||
vars = regexp.MustCompile(`(?m)^RELEASE_KEYS='[^']*'$`).ReplaceAllString(vars,
|
||||
"RELEASE_KEYS='"+base64.StdEncoding.EncodeToString(der)+"'")
|
||||
d := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(d, "man.json"), manifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(d, "v"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := "die() { echo \"ERROR: $*\" >&2; exit 1; }\nSHACMD=sha256sum\nsha_of() { $SHACMD \"$1\" | cut -d' ' -f1; }\n" +
|
||||
"base='" + base + "'\n" + vars + "\n" + fn + "\nrelease_signature_check \"$1\" \"$2\"\necho RETURNED\n"
|
||||
sh := filepath.Join(d, "h.sh")
|
||||
if err := os.WriteFile(sh, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command("sh", sh, filepath.Join(d, "man.json"), filepath.Join(d, "v")).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func releaseSigFor(priv ed25519.PrivateKey, manifest []byte, version string) (name, b64 string) {
|
||||
sum := sha256.Sum256(manifest)
|
||||
stmt := releaseStatement("bastion", version, hex.EncodeToString(sum[:]))
|
||||
return "/version.json." + hex.EncodeToString(sum[:8]) + ".sig",
|
||||
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, stmt))
|
||||
}
|
||||
|
||||
func TestInstallShRefusesAReleaseWhoseSignatureDoesNotVerify(t *testing.T) {
|
||||
requireTools(t, "sh", "openssl", "sha256sum", "curl")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
|
||||
name, good := releaseSigFor(priv, manifest, "9.9.9")
|
||||
_, wrongKey := releaseSigFor(other, manifest, "9.9.9")
|
||||
_, wrongVersion := releaseSigFor(priv, manifest, "9.9.8")
|
||||
serve := map[string]string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := serve[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(v))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// The check needs an openssl that does Ed25519; without one it only
|
||||
// notes it, which TestInstallShSaysWhenItCannotCheckTheSignature covers.
|
||||
// Whether this one can is asked of openssl here, never read off the
|
||||
// installer's note: a probe broken in install.sh would print that note on
|
||||
// every machine, and this test would skip while every install skipped the
|
||||
// signature.
|
||||
// MUTATION: break the probe's pkeyutl flags in install.sh. RESULT: red here
|
||||
// (it skipped before).
|
||||
if !opensslChecksEd25519(t) {
|
||||
skipOrFail(t, "the openssl here does not check Ed25519 signatures")
|
||||
}
|
||||
serve[name] = good
|
||||
out, err := sigHarness(t, pub, srv.URL, manifest)
|
||||
if err != nil || strings.Contains(out, "no openssl here can check") ||
|
||||
!strings.Contains(out, "Release signature verified (version 9.9.9)") || !strings.Contains(out, "RETURNED") {
|
||||
t.Fatalf("a good signature: %v\n%s", err, out)
|
||||
}
|
||||
for what, sig := range map[string]string{"another key's": wrongKey, "another version's": wrongVersion} {
|
||||
serve[name] = sig
|
||||
// MUTATION: return 0 after the key loop. RESULT: red, these install.
|
||||
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || strings.Contains(out, "RETURNED") ||
|
||||
!strings.Contains(out, "does not verify") {
|
||||
t.Errorf("%s signature: %v\n%s", what, err, out)
|
||||
}
|
||||
}
|
||||
serve[name] = "not base64 at all !"
|
||||
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "malformed") {
|
||||
t.Errorf("a malformed signature: %v\n%s", err, out)
|
||||
}
|
||||
delete(serve, name)
|
||||
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "cannot fetch the release's signature") {
|
||||
t.Errorf("no signature: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// opensslChecksEd25519 says, without install.sh, whether the openssl on PATH
|
||||
// verifies an Ed25519 signature made here, and refuses one over another
|
||||
// message.
|
||||
func opensslChecksEd25519(t *testing.T) bool {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("openssl"); err != nil {
|
||||
return false
|
||||
}
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
|
||||
d := t.TempDir()
|
||||
files := map[string][]byte{
|
||||
"k.pem": []byte("-----BEGIN PUBLIC KEY-----\n" + base64.StdEncoding.EncodeToString(der) + "\n-----END PUBLIC KEY-----\n"),
|
||||
"m": []byte("capability\n"),
|
||||
"other": []byte("capability?\n"),
|
||||
"s": ed25519.Sign(priv, []byte("capability\n")),
|
||||
}
|
||||
for n, b := range files {
|
||||
if err := os.WriteFile(filepath.Join(d, n), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
verify := func(msg string) bool {
|
||||
return exec.Command("openssl", "pkeyutl", "-verify", "-pubin", "-inkey", filepath.Join(d, "k.pem"), "-rawin",
|
||||
"-in", filepath.Join(d, msg), "-sigfile", filepath.Join(d, "s")).Run() == nil
|
||||
}
|
||||
return verify("m") && !verify("other")
|
||||
}
|
||||
|
||||
// An openssl that passes every signature is no check: the installer must not
|
||||
// take it for one and print "verified" over a signature no pinned key made.
|
||||
// It is set aside like one that cannot check; a later candidate that can
|
||||
// still checks.
|
||||
func TestInstallShTakesNoOpensslThatPassesEverySignature(t *testing.T) {
|
||||
requireTools(t, "sh", "openssl", "sha256sum", "curl")
|
||||
systemSSL, _ := exec.LookPath("openssl")
|
||||
fake := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(fake, "openssl"),
|
||||
[]byte("#!/bin/sh\ncase \"$1\" in pkeyutl) exit 0 ;; esac\nexec '"+systemSSL+"' \"$@\"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
|
||||
name, forged := releaseSigFor(other, manifest, "9.9.9")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != name {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(forged))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// MUTATION: drop the probe's second verify (the one that must fail).
|
||||
// RESULT: red, "Release signature verified" over the forged signature.
|
||||
out, _ := sigHarness(t, pub, srv.URL, manifest)
|
||||
if strings.Contains(out, "Release signature verified") ||
|
||||
!(strings.Contains(out, "no openssl here can check") || strings.Contains(out, "does not verify")) {
|
||||
t.Errorf("an openssl that passes everything was taken for a check:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY.md sends a reader who does not trust our website to a copy of
|
||||
// install.sh read from this repository. Run without sudo, that copy installs
|
||||
// for the whole machine through a root step that fetches the installer from
|
||||
// the website again (whole_machine_script) and runs that, so the copy read
|
||||
// checks nothing itself. The page has to say how the copy must be run.
|
||||
func TestSecurityMdSaysHowAReadCopyOfTheInstallerMustRun(t *testing.T) {
|
||||
sh, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(sh), `whole_machine_script="https://privatae.ai/bastion/install.sh"`) {
|
||||
t.Fatal("install.sh's root step no longer fetches the website's installer: re-read SECURITY.md's first-install paragraph, then this test")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "SECURITY.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sec := strings.Join(strings.Fields(string(raw)), " ")
|
||||
for _, want := range []string{
|
||||
"`sudo sh install.sh`",
|
||||
"`sh install.sh --just-me`",
|
||||
"fetches the installer from our website again",
|
||||
"an older release we signed",
|
||||
} {
|
||||
if !strings.Contains(sec, want) {
|
||||
t.Errorf("SECURITY.md does not say %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no openssl that can check Ed25519, the check says so and returns, so
|
||||
// the digest check still runs; it never reads as verified.
|
||||
func TestInstallShSaysWhenItCannotCheckTheSignature(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("no sh")
|
||||
}
|
||||
fake := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(fake, "openssl"), []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
out, err := sigHarness(t, pub, "http://127.0.0.1:1", []byte("{\"version\": \"9.9.9\"}\n"), true)
|
||||
if err != nil || !strings.Contains(out, "no openssl here can check") || strings.Contains(out, "verified") ||
|
||||
!strings.Contains(out, "RETURNED") {
|
||||
t.Errorf("no capable openssl: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The check runs on every install, after the manifest is fetched and before
|
||||
// any digest is read out of it: a digest from an unchecked manifest is the
|
||||
// thing the signature exists to stop.
|
||||
func TestInstallShChecksTheSignatureBeforeReadingTheDigest(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
fetch := strings.Index(s, `curl -fsSL "$base/version.json" -o "$man"`)
|
||||
check := strings.Index(s, "\nrelease_signature_check \"$man\" \"$vdir\"\n")
|
||||
digest := strings.Index(s, "expected=$(awk")
|
||||
if fetch < 0 || check < 0 || digest < 0 || !(fetch < check && check < digest) {
|
||||
t.Fatalf("install.sh must fetch the manifest, check its signature, then read its digest (at %d, %d, %d)",
|
||||
fetch, check, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// requireTools skips without one of these tools, unless CI says the check
|
||||
// must run (PRIVATAE_REQUIRE_OPENSSL=1, .gitea/workflows/ci.yml): there a
|
||||
// missing tool is a failure, so the signature check cannot quietly go untested.
|
||||
func requireTools(t *testing.T, tools ...string) {
|
||||
t.Helper()
|
||||
for _, tool := range tools {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
skipOrFail(t, tool+" is not installed here")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skipOrFail(t *testing.T, why string) {
|
||||
t.Helper()
|
||||
if os.Getenv("PRIVATAE_REQUIRE_OPENSSL") == "1" {
|
||||
t.Fatalf("%s, and PRIVATAE_REQUIRE_OPENSSL=1 says this check must run", why)
|
||||
}
|
||||
t.Skip(why)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
// When a Host holds this machine (its owner quarantined it there), the Host
|
||||
// refuses every worker socket of every lane with close code 4403, and says
|
||||
// "quarantined": true on each beat it answers. The lane workers park a pool of
|
||||
// sockets per lane and used to dial again at once (mgmt, harness, session) or
|
||||
// a second after a close (egress, serve), so one held machine whose tunnel
|
||||
// stayed up dialled the Host 30 to 80 times a second, each one a TLS
|
||||
// handshake. A pairing's workers now share one hold:
|
||||
//
|
||||
// - a socket the Host closes with 4403 holds every worker of that pairing for
|
||||
// laneHoldFirst, doubling with each hold up to laneHoldMax;
|
||||
// - a beat that says quarantined stops them dialling until a beat no longer
|
||||
// does, which is how the owner's release reaches this machine.
|
||||
//
|
||||
// The hold is the pairing's, so another Host this machine is paired with is
|
||||
// never slowed by it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// laneHeldCloseCode is the close code a Host gives a worker socket of a
|
||||
// machine it holds (the Host's quarantine gate).
|
||||
const laneHeldCloseCode = 4403
|
||||
|
||||
// The first hold, and the longest. Variables so a test can shorten them.
|
||||
var (
|
||||
laneHoldFirst = 5 * time.Second
|
||||
laneHoldMax = 5 * time.Minute
|
||||
)
|
||||
|
||||
type laneHold struct {
|
||||
mu sync.Mutex
|
||||
wait time.Duration // the last hold's length; the next one doubles it
|
||||
until time.Time // no worker of the pairing dials before this
|
||||
last time.Time // when the last hold began
|
||||
quarantined bool // what the last beat said
|
||||
wake chan struct{} // closed when the beat lifts a quarantine
|
||||
}
|
||||
|
||||
var laneHolds sync.Map // host name -> *laneHold
|
||||
|
||||
func laneHoldFor(host string) *laneHold {
|
||||
if h, ok := laneHolds.Load(host); ok {
|
||||
return h.(*laneHold)
|
||||
}
|
||||
h, _ := laneHolds.LoadOrStore(host, &laneHold{wake: make(chan struct{})})
|
||||
return h.(*laneHold)
|
||||
}
|
||||
|
||||
// refused is a worker socket the Host closed with 4403. The workers of a pool
|
||||
// are refused together, so a refusal while a hold is on is that hold's and
|
||||
// does not lengthen it. A refusal long after the last hold starts again from
|
||||
// laneHoldFirst.
|
||||
func (h *laneHold) refused(host string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := time.Now()
|
||||
if now.Before(h.until) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case h.wait == 0 || now.Sub(h.last) > 2*laneHoldMax:
|
||||
h.wait = laneHoldFirst
|
||||
default:
|
||||
h.wait = min(2*h.wait, laneHoldMax)
|
||||
}
|
||||
h.last = now
|
||||
h.until = now.Add(h.wait)
|
||||
fmt.Printf("[%s] the Host refused a lane: it holds this machine; its lanes dial again in %s\n", host, h.wait)
|
||||
}
|
||||
|
||||
// fromBeat is what a beat's reply said. A release ends the hold at once and
|
||||
// starts the next one from laneHoldFirst.
|
||||
func (h *laneHold) fromBeat(host string, quarantined bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
switch {
|
||||
case quarantined && !h.quarantined:
|
||||
h.quarantined = true
|
||||
fmt.Printf("[%s] the Host holds this machine (quarantined there): its lanes wait until its owner releases it\n", host)
|
||||
case !quarantined && h.quarantined:
|
||||
h.quarantined = false
|
||||
h.wait, h.until = 0, time.Time{}
|
||||
close(h.wake)
|
||||
h.wake = make(chan struct{})
|
||||
fmt.Printf("[%s] the Host released this machine: its lanes are back\n", host)
|
||||
}
|
||||
}
|
||||
|
||||
// waitClear blocks a worker until it may dial: the hold is over and the last
|
||||
// beat did not say quarantined. A worker waits a little past the hold's end,
|
||||
// a different little for each, so a pool does not dial in one burst. False
|
||||
// when ctx ends first.
|
||||
func (h *laneHold) waitClear(ctx context.Context) bool {
|
||||
for {
|
||||
h.mu.Lock()
|
||||
quarantined, until, wake := h.quarantined, h.until, h.wake
|
||||
h.mu.Unlock()
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
var t *time.Timer
|
||||
var timer <-chan time.Time
|
||||
switch {
|
||||
case quarantined:
|
||||
// Only a beat lifts it.
|
||||
case time.Now().Before(until):
|
||||
d := time.Until(until)
|
||||
t = time.NewTimer(d + rand.N(d/10+time.Millisecond))
|
||||
timer = t.C
|
||||
default:
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-wake:
|
||||
case <-timer:
|
||||
}
|
||||
if t != nil {
|
||||
t.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchHeldClose reports, once the socket has been read to its end, whether
|
||||
// the Host closed it with laneHeldCloseCode.
|
||||
func watchHeldClose(ws *websocket.Conn) (held func() bool) {
|
||||
var got atomic.Bool
|
||||
def := ws.CloseHandler()
|
||||
ws.SetCloseHandler(func(code int, text string) error {
|
||||
if code == laneHeldCloseCode {
|
||||
got.Store(true)
|
||||
}
|
||||
return def(code, text)
|
||||
})
|
||||
return got.Load
|
||||
}
|
||||
|
||||
// beatSaysQuarantined reads the "quarantined" a Host puts on the beat's reply
|
||||
// for a machine it holds. A reply without it, or one that cannot be read, is
|
||||
// not a hold.
|
||||
func beatSaysQuarantined(body []byte) bool {
|
||||
var r struct {
|
||||
Quarantined bool `json:"quarantined"`
|
||||
}
|
||||
return json.Unmarshal(body, &r) == nil && r.Quarantined
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// heldHost stands in for a Host's lane port: it counts each worker's hello,
|
||||
// then either closes the socket with the code a Host gives a machine it holds,
|
||||
// or parks it.
|
||||
type heldHost struct {
|
||||
hellos atomic.Int32
|
||||
hold atomic.Bool
|
||||
tls *tls.Config
|
||||
dial func(ctx context.Context) (net.Conn, error)
|
||||
url string
|
||||
}
|
||||
|
||||
func startHeldHost(t *testing.T) *heldHost {
|
||||
t.Helper()
|
||||
h := &heldHost{}
|
||||
h.hold.Store(true)
|
||||
up := websocket.Upgrader{}
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
h.hellos.Add(1)
|
||||
if h.hold.Load() {
|
||||
_ = c.WriteControl(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(laneHeldCloseCode, "this bastion is quarantined"), time.Now().Add(time.Second))
|
||||
return
|
||||
}
|
||||
for {
|
||||
if _, _, err := c.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(srv.Certificate())
|
||||
h.tls = &tls.Config{RootCAs: pool, ServerName: "example.com"}
|
||||
addr := srv.Listener.Addr().String()
|
||||
h.dial = func(ctx context.Context) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
h.url = "wss://example.com/lane"
|
||||
return h
|
||||
}
|
||||
|
||||
// shortHolds makes the holds short enough for a test. Set before any worker
|
||||
// runs, and never from a parallel subtest.
|
||||
func shortHolds(t *testing.T, first, max time.Duration) {
|
||||
t.Helper()
|
||||
f, m := laneHoldFirst, laneHoldMax
|
||||
laneHoldFirst, laneHoldMax = first, max
|
||||
t.Cleanup(func() { laneHoldFirst, laneHoldMax = f, m })
|
||||
}
|
||||
|
||||
// ownHold gives a test its own pairing's hold, and forgets it after.
|
||||
func ownHold(t *testing.T, host string) {
|
||||
t.Helper()
|
||||
laneHolds.Delete(host)
|
||||
t.Cleanup(func() { laneHolds.Delete(host) })
|
||||
}
|
||||
|
||||
// Every lane's pool, so a lane added later without the hold is caught.
|
||||
func lanePools(dir string) map[string]func(ctx context.Context, host string, h *heldHost, n int) {
|
||||
return map[string]func(ctx context.Context, host string, h *heldHost, n int){
|
||||
"egress": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||
var d net.Dialer
|
||||
runEgressWorkers(ctx, host, h.url, h.tls, h.dial, "b", d.DialContext, n)
|
||||
},
|
||||
"serve": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||
runServeWorkers(ctx, host, dir, h.url, h.tls, h.dial, "b", n)
|
||||
},
|
||||
"mgmt": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||
runMgmtWorkers(ctx, host, h.url, h.tls, h.dial, "b", dir, dir, dir, n)
|
||||
},
|
||||
"harness": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||
runHarnessWorkers(ctx, host, dir, h.url, h.tls, h.dial, "b", n)
|
||||
},
|
||||
"session": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||
runSessionWorkers(ctx, host, h.url, h.tls, h.dial, "b", n,
|
||||
sessionEnv{CtxRoot: dir, DcnDir: dir, HostName: host, Server: "test"})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A Host that holds this machine closes every worker socket with 4403. No
|
||||
// worker of any lane dials it again before the hold is over: one hello per
|
||||
// worker, where a pool used to dial again at once or a second later, 30 to 80
|
||||
// times a second from one held machine.
|
||||
func TestEveryLanePoolWaitsOutAHostThatHoldsThisMachine(t *testing.T) {
|
||||
shortHolds(t, 3*time.Second, time.Minute)
|
||||
for lane, run := range lanePools(t.TempDir()) {
|
||||
t.Run(lane, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
host := "held-" + lane + ".example.test"
|
||||
ownHold(t, host)
|
||||
h := startHeldHost(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
const n = 2
|
||||
go run(ctx, host, h, n)
|
||||
time.Sleep(2 * time.Second)
|
||||
if got := h.hellos.Load(); got != n {
|
||||
t.Fatalf("%d dials within the hold from %d workers; want one each", got, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A beat whose reply says quarantined stops the lanes before any of them
|
||||
// dials, and the first beat that no longer says it brings them back at once.
|
||||
func TestABeatThatSaysQuarantinedStopsTheLanesUntilOneDoesNot(t *testing.T) {
|
||||
host := "quarantined.example.test"
|
||||
shortHolds(t, time.Hour, time.Hour)
|
||||
ownHold(t, host)
|
||||
h := startHeldHost(t)
|
||||
h.hold.Store(false)
|
||||
laneHoldFor(host).fromBeat(host, beatSaysQuarantined([]byte(`{"ok":true,"quarantined":true}`)))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
const n = 2
|
||||
go lanePools(t.TempDir())["mgmt"](ctx, host, h, n)
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
if got := h.hellos.Load(); got != 0 {
|
||||
t.Fatalf("%d dials while the Host says it holds this machine", got)
|
||||
}
|
||||
laneHoldFor(host).fromBeat(host, beatSaysQuarantined([]byte(`{"ok":true}`)))
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for h.hellos.Load() < n && time.Now().Before(deadline) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if got := h.hellos.Load(); got != n {
|
||||
t.Fatalf("after the release, %d of %d workers parked again", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
// A pool's workers are refused together, so a refusal inside a hold is that
|
||||
// hold's; each hold after one runs out is twice as long, up to the most; a
|
||||
// refusal long after the last starts again from the first; a release ends a
|
||||
// hold at once.
|
||||
func TestAHoldDoublesOncePerHoldUpToItsMost(t *testing.T) {
|
||||
host := "doubling.example.test"
|
||||
shortHolds(t, time.Second, 4*time.Second)
|
||||
ownHold(t, host)
|
||||
h := laneHoldFor(host)
|
||||
expire := func() {
|
||||
h.mu.Lock()
|
||||
h.until = time.Now().Add(-time.Millisecond)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
wait := func() time.Duration {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.wait
|
||||
}
|
||||
h.refused(host)
|
||||
h.refused(host)
|
||||
h.refused(host)
|
||||
if got := wait(); got != time.Second {
|
||||
t.Fatalf("three refusals in one hold: %s, want the first hold", got)
|
||||
}
|
||||
var got []string
|
||||
for range 3 {
|
||||
expire()
|
||||
h.refused(host)
|
||||
got = append(got, wait().String())
|
||||
}
|
||||
if strings.Join(got, " ") != "2s 4s 4s" {
|
||||
t.Fatalf("holds after the first: %v, want 2s 4s 4s", got)
|
||||
}
|
||||
expire()
|
||||
h.mu.Lock()
|
||||
h.last = time.Now().Add(-9 * time.Second)
|
||||
h.mu.Unlock()
|
||||
h.refused(host)
|
||||
if got := wait(); got != time.Second {
|
||||
t.Fatalf("a refusal after a long quiet: %s, want the first hold again", got)
|
||||
}
|
||||
h.fromBeat(host, true)
|
||||
h.fromBeat(host, false)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if !h.waitClear(ctx) {
|
||||
t.Fatal("a release must end the hold at once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeatSaysQuarantinedReadsOnlyTheHostsTrue(t *testing.T) {
|
||||
for body, want := range map[string]bool{
|
||||
`{"ok":true,"quarantined":true}`: true,
|
||||
`{"ok":true,"quarantined":false}`: false,
|
||||
`{"ok":true}`: false,
|
||||
`{"quarantined":"yes"}`: false,
|
||||
`not json`: false,
|
||||
``: false,
|
||||
} {
|
||||
if got := beatSaysQuarantined([]byte(body)); got != want {
|
||||
t.Errorf("%q: %v, want %v", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-11
@@ -111,22 +111,25 @@ Usage:
|
||||
anchor anywhere.
|
||||
privatae-bastion approvals [--host <host> | --machine] [--json]
|
||||
show what your host is asking to do on this machine, in plain words
|
||||
privatae-bastion approve [--host <host>] <id>
|
||||
privatae-bastion approve [--host <host> | --machine] <id>
|
||||
approve one request with a code from the authenticator enrolled here
|
||||
privatae-bastion deny [--host <host>] <id>
|
||||
refuse one request
|
||||
privatae-bastion deny [--host <host> | --machine] <id>
|
||||
refuse one request. From the machine's owner account, beside the
|
||||
bastion installed for the whole machine, both act on that bastion,
|
||||
and so do switch (off with nothing, on with the owner's code) and,
|
||||
with nothing, approvals drop, grants drop, ops drop and device-deny
|
||||
privatae-bastion approvals drop [--host <host>] <id>
|
||||
take back an approval this machine issued and has not used yet
|
||||
privatae-bastion grants drop <store id> [--host <host>]
|
||||
remove a folder grant held on this machine; the host is told
|
||||
privatae-bastion device-init make THIS machine your approval device: from then on a write a
|
||||
host asks of any machine you govern is approved with a tap here
|
||||
privatae-bastion device-approvals what your machines are asking you to approve, in their words
|
||||
privatae-bastion device-approvals [--json] what your machines are asking you to approve, in their words
|
||||
privatae-bastion device-approve <id>|all / device-deny <id>
|
||||
sudo -H privatae-bastion pin-device <key>
|
||||
on a machine you govern: accept approvals signed by your device
|
||||
(the key device-init printed). unpin-device <key> withdraws it
|
||||
privatae-bastion ops [--host <host>] the ops your host wrote that this machine holds, and the
|
||||
privatae-bastion ops [--host <host>] [--json] the ops your host wrote that this machine holds, and the
|
||||
standing op grants; ops drop <grant> takes one back
|
||||
sudo -H privatae-bastion root-exec grant [--host <host>] --for <30d>
|
||||
let the anchor host run commands AS ROOT here, one at a time,
|
||||
@@ -145,11 +148,18 @@ Usage:
|
||||
switch one capability family off for a host on this machine,
|
||||
or clear that refusal (the console page's switch). Off only
|
||||
removes reach; on grants nothing past the host's signed grant
|
||||
privatae-bastion switch --list every paired host's families, on or off, one per line
|
||||
privatae-bastion switch --list [--json]
|
||||
every paired host's families, on or off, one per line
|
||||
privatae-bastion agents [--json] [--prompts]
|
||||
every AI agent on this machine and what it is doing: its
|
||||
session name and title, whether it waits for you, its folder
|
||||
and branch, what it is running now (--prompts: its last prompt)
|
||||
privatae-bastion agents show <pid or session> [--json] [--prompts]
|
||||
one agent with everything known about it: what it is, the file
|
||||
it runs, how it was recognised, what it says about itself
|
||||
privatae-bastion doctor [--json]
|
||||
what is wrong with this install, and the fix for each one.
|
||||
Exit 1 when something is
|
||||
privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]
|
||||
write what this bastion knows as one Markdown file: agents,
|
||||
scheduled agents, MCP tool-server problems, the baseline, activity.
|
||||
@@ -173,8 +183,10 @@ Usage:
|
||||
denied. Exit 0 stopped (or asked), 1 not, 3 still waiting
|
||||
From the machine's owner account, beside the bastion installed for
|
||||
the whole machine, the reads (status, agents, schedules,
|
||||
tool-servers, posture, baseline, activity, report, approvals)
|
||||
ask that bastion and show the whole machine
|
||||
tool-servers, posture, baseline, activity, report, approvals,
|
||||
switch --list, ops, device-approvals, backup key show,
|
||||
backup requests, backup shares, doctor) ask that bastion and
|
||||
show the whole machine
|
||||
privatae-bastion help this text
|
||||
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan h[:p],...]
|
||||
[--image-prefix <registry/path/>|default]
|
||||
@@ -613,7 +625,7 @@ func main() {
|
||||
case "device-init":
|
||||
err = cmdDeviceInit()
|
||||
case "device-approvals":
|
||||
err = cmdDeviceApprovals()
|
||||
err = cmdDeviceApprovals(os.Args[2:])
|
||||
case "device-approve", "device-deny":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion "+os.Args[1]+" <request id> [--code <code>]")
|
||||
@@ -685,7 +697,7 @@ func main() {
|
||||
}
|
||||
os.Exit(opActionChildMain(os.Args[2]))
|
||||
case "ops":
|
||||
// ops [--host <host>] what ops and op grants this machine holds
|
||||
// ops [--host <host>] [--json] what ops and op grants this machine holds
|
||||
// ops drop <grant> [--host <host>] take back a held op grant (the host is told)
|
||||
// ops drop --all [--host <host>] take back every op grant the host holds here
|
||||
host, args := takeHostFlag(os.Args[2:])
|
||||
@@ -693,7 +705,12 @@ func main() {
|
||||
err = cmdOpsDrop(host, args[1])
|
||||
break
|
||||
}
|
||||
err = cmdOpsList(host)
|
||||
asJSON, jerr := jsonOnly("usage: privatae-bastion ops [--host <host>] [--json] | ops drop <grant>|--all [--host <host>]", args)
|
||||
if jerr != nil {
|
||||
fmt.Fprintln(os.Stderr, jerr)
|
||||
os.Exit(2)
|
||||
}
|
||||
err = cmdOpsList(host, asJSON)
|
||||
case "root-exec":
|
||||
// root-exec [--host <host>] [grant --for <d> | revoke] (rootexec.go): the
|
||||
// standing grant for the anchor host's root commands, given and taken
|
||||
@@ -752,6 +769,10 @@ func main() {
|
||||
// baseline show|diff [--json], baseline record [--force]
|
||||
// (machine_cli.go): observe:baseline* under the words people use.
|
||||
err = cmdBaseline(os.Args[2:])
|
||||
case "doctor":
|
||||
// doctor [--json] (doctor.go): what is wrong with this install, and
|
||||
// the fix for each one.
|
||||
err = cmdDoctor(os.Args[2:])
|
||||
case "help", "-h", "--help":
|
||||
usage()
|
||||
case "switch":
|
||||
|
||||
@@ -347,7 +347,11 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
hold := laneHoldFor(self)
|
||||
for pctx.Err() == nil {
|
||||
if !hold.waitClear(pctx) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||
cancel()
|
||||
@@ -360,6 +364,7 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
|
||||
sleepCtx(pctx, time.Second)
|
||||
continue
|
||||
}
|
||||
held := watchHeldClose(ws)
|
||||
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
||||
// A revoke while parked: closing the socket is what unblocks the read.
|
||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||
@@ -367,6 +372,9 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
|
||||
unpark()
|
||||
stopKA()
|
||||
_ = ws.Close()
|
||||
if held() {
|
||||
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
@@ -648,7 +649,12 @@ func opUpperChanges(upper, hostRoot string) ([]opChange, error) {
|
||||
out = append(out, opChange{Path: real, Kind: "special"})
|
||||
return nil
|
||||
case mode.IsDir():
|
||||
if opaque, _ := opXattrOpaque(p); opaque {
|
||||
// Opaque hides what the lower holds at this path: a replaced
|
||||
// tree, refused. With nothing there it hides nothing. A kernel
|
||||
// whose overlay has index, redirect_dir or metacopy on marks every
|
||||
// new folder under an existing one opaque, so such a folder is
|
||||
// created, and its contents are read like any other.
|
||||
if opaque, _ := opXattrOpaque(p); opaque && !errors.Is(lowerErr, fs.ErrNotExist) {
|
||||
out = append(out, opChange{Path: real, Kind: "opaque"})
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ── the upper-dir reader (no sandbox needed) ────────────────────────────────
|
||||
@@ -102,6 +104,42 @@ func TestUpperChangesReadCreatedModifiedAndLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A kernel whose overlay has index, redirect_dir or metacopy on marks each
|
||||
// new folder under an existing one opaque. Over nothing the machine holds,
|
||||
// such a folder is created and what it holds is read; over a folder the
|
||||
// machine holds, opaque is a replaced tree, and refused.
|
||||
func TestAnOpaqueNewFolderIsCreatedAndAReplacedOneIsRefused(t *testing.T) {
|
||||
lower := t.TempDir()
|
||||
upper := t.TempDir()
|
||||
_ = os.MkdirAll(filepath.Join(lower, "srv/kept"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(lower, "srv/kept/old.txt"), []byte("old\n"), 0o644)
|
||||
for _, d := range []string{"srv/new", "srv/kept"} {
|
||||
_ = os.MkdirAll(filepath.Join(upper, d), 0o755)
|
||||
if err := unix.Setxattr(filepath.Join(upper, d), "trusted.overlay.opaque", []byte("y"), 0); err != nil {
|
||||
t.Skipf("cannot set an overlay attribute here (%v): needs root on a filesystem with trusted attributes", err)
|
||||
}
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(upper, "srv/new/hello.txt"), []byte("hi\n"), 0o644)
|
||||
changes, err := opUpperChanges(upper, lower)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, c := range changes {
|
||||
got[strings.TrimPrefix(c.Path, lower)] = c.Kind
|
||||
}
|
||||
if got["/srv/new"] != "created_dir" || got["/srv/new/hello.txt"] != "created" {
|
||||
t.Fatalf("an opaque folder over nothing is created, with what it holds: %v", got)
|
||||
}
|
||||
if got["/srv/kept"] != "opaque" {
|
||||
t.Fatalf("an opaque folder over one the machine holds is a replaced tree: %v", got)
|
||||
}
|
||||
m := &opManifest{Effects: opEffects{Files: []string{lower + "/srv/"}}}
|
||||
if why := opEffectsViolation(m, &opReport{Changes: changes}); !strings.Contains(why, "opaque") {
|
||||
t.Fatalf("the replaced tree is refused even inside the effects: %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostMountsAndEffectPrefix(t *testing.T) {
|
||||
mounts := []opHostMount{{"/", "ext4"}, {"/srv", "xfs"}, {"/srv/share", "nfs4"}, {"/proc", "proc"}, {"/home", "ext4"}}
|
||||
for p, want := range map[string]string{
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package main
|
||||
|
||||
// owneracts.go — the machine's owner acts on its bastion from their own
|
||||
// account (plan Addendum 6, slice 4).
|
||||
//
|
||||
// Installed for the whole machine, the bastion keeps its approvals and its
|
||||
// local switch in root's state, so `approve`, `deny` and `switch` from the
|
||||
// owner's account acted on an empty bastion of that account's own: an ask
|
||||
// waiting for the owner was "not found", and a switch was refused for want of
|
||||
// sudo. The owner's console session (consolelane.go) has always been able to
|
||||
// do these, so ask.sock now does exactly the same for the same account, with
|
||||
// the same rules:
|
||||
//
|
||||
// - approve (the machine's own asks, or a Host's) takes the owner's code,
|
||||
// spent and counted as the console spends it (machineCodeSpend for the
|
||||
// machine's, codeSpend for a Host's), so wrong codes lock the same way;
|
||||
// - switching a family back on takes the owner's code (ownerCodeRefusal),
|
||||
// as the console's switch does;
|
||||
// - deny, and switching a family off, never do: they only take reach away.
|
||||
//
|
||||
// Only the owner's account is answered (readLaneRefusal), as for the reads.
|
||||
// Each act is one line in the machine's audit log naming the account, beside
|
||||
// the approve or deny row its own function writes.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type agentActReply struct {
|
||||
OK bool `json:"ok"`
|
||||
Why string `json:"why,omitempty"`
|
||||
Done string `json:"done,omitempty"`
|
||||
// Failed: answered for this account, and not done (Why says why); the
|
||||
// administrator would be told the same, so it is not a refusal.
|
||||
Failed bool `json:"failed,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
var actNow = time.Now
|
||||
|
||||
func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) {
|
||||
r.User = user
|
||||
if why := readLaneRefusal(uid, user); why != "" {
|
||||
r.Why = actWording(why)
|
||||
return r
|
||||
}
|
||||
defer func() {
|
||||
// The root daemon serves every pairing: an act that panics is that
|
||||
// act's failure, never the daemon's end (as runMachineRead does).
|
||||
if p := recover(); p != nil {
|
||||
uilogf("owner act %q panicked: %v\n", req.Act, p)
|
||||
r = agentActReply{User: user, Failed: true, Why: "this act failed inside the machine's bastion; its log says why"}
|
||||
}
|
||||
}()
|
||||
done, what, err := runOwnerAct(req)
|
||||
if err != nil {
|
||||
r.Failed, r.Why = true, err.Error()
|
||||
return r
|
||||
}
|
||||
ownerActAudit(what, user)
|
||||
r.OK, r.Done = true, done
|
||||
return r
|
||||
}
|
||||
|
||||
// runOwnerAct does one act; what names it, as resolved, for the audit row.
|
||||
func runOwnerAct(req agentAskRequest) (done, what string, err error) {
|
||||
switch req.Act {
|
||||
case "approve", "deny":
|
||||
t, err := resolveStepupTarget(req.Host, req.Machine)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !t.machine() {
|
||||
// The console's rule for a Host's request, approve and deny alike
|
||||
// (ui.go approve and deny both take approvalTarget): only a pairing
|
||||
// this machine serves is answered.
|
||||
if _, err := approvalTarget(t.host); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
m := lockedRequests(t.dir)
|
||||
id, err := findRequest(m, req.ID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
target := "the machine's"
|
||||
if !t.machine() {
|
||||
target = "a Host's (" + t.host + ")"
|
||||
}
|
||||
what = req.Act + " " + target + " request " + id
|
||||
if req.Act == "deny" {
|
||||
if err := stepupDenyRequest(t.dir, id); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return "denied.", what, nil
|
||||
}
|
||||
if !stepupEnrolled(t.dir) {
|
||||
return "", "", fmt.Errorf("no second factor is enrolled %s", t.forWhom())
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if code == "" {
|
||||
return "", "", errors.New("approving takes the code from your authenticator")
|
||||
}
|
||||
spend := func() (string, error) { return stepupApproveRequest(t.dir, id, code) }
|
||||
var err2 error
|
||||
if t.machine() {
|
||||
_, err2 = machineCodeSpend(t.dir, actNow(), spend)
|
||||
} else {
|
||||
_, err2 = codeSpend(t.dir, actNow(), hostCodeLockedHint, spend)
|
||||
}
|
||||
if err2 != nil {
|
||||
return "", "", err2
|
||||
}
|
||||
if t.machine() {
|
||||
return "approved.", what, nil
|
||||
}
|
||||
return "approved. " + t.host + " picks this up on its next check-in, within a minute.", what, nil
|
||||
case "switch":
|
||||
host, err := switchResolveHost(req.Host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
family := strings.TrimSpace(req.Family)
|
||||
if !req.Off {
|
||||
if why := ownerCodeRefusal(req.Code, "local_switch_on|"+host+"|"+family); why != "" {
|
||||
return "", "", errors.New(strings.TrimSuffix(why, "."))
|
||||
}
|
||||
}
|
||||
if err := localDenySet(host, family, req.Off); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
state := "on"
|
||||
if req.Off {
|
||||
state = "off"
|
||||
}
|
||||
return switchDoneText(host, family, req.Off), "switch " + cleanText(host, 80) + " " + cleanText(family, 40) + " " + state, nil
|
||||
case "approvals-drop", "grants-drop", "ops-drop", "device-deny":
|
||||
// Narrowing acts: each only takes reach away, so no code, and each
|
||||
// is the command's own core, run where the machine's state is.
|
||||
var run func() (string, error)
|
||||
switch req.Act {
|
||||
case "approvals-drop":
|
||||
run = func() (string, error) { return stepupDropText(req.Host, req.Machine, req.ID) }
|
||||
case "grants-drop":
|
||||
run = func() (string, error) { return grantsDropText(req.Host, req.ID) }
|
||||
case "ops-drop":
|
||||
run = func() (string, error) { return opsDropText(req.Host, req.ID) }
|
||||
default:
|
||||
run = func() (string, error) { return deviceDenyText(req.ID) }
|
||||
}
|
||||
done, err := run()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
what = req.Act + " " + cleanText(req.ID, 40)
|
||||
if req.Host != "" {
|
||||
what += " for " + cleanText(req.Host, 80)
|
||||
}
|
||||
return done, what, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("this bastion has no act called %q", cleanText(req.Act, 40))
|
||||
}
|
||||
|
||||
// lockedRequests reads a factor's requests under stepupMu, released however
|
||||
// the read ends: the daemon recovers a panicking act (serveAct), and a lock
|
||||
// left held would hang every approval after it.
|
||||
func lockedRequests(dir string) map[string]stepupRequest {
|
||||
stepupMu.Lock()
|
||||
defer stepupMu.Unlock()
|
||||
requestsLoadHook()
|
||||
return requestsLoad(dir)
|
||||
}
|
||||
|
||||
// requestsLoadHook is a seam for a test that makes the read panic.
|
||||
var requestsLoadHook = func() {}
|
||||
|
||||
// switchDoneText is what a switch says it did, from the terminal or the lane.
|
||||
func switchDoneText(host, family string, off bool) string {
|
||||
if off {
|
||||
return host + " " + family + " off: this machine now refuses it, and tells the host why. Its grant is untouched."
|
||||
}
|
||||
return host + " " + family + " on: the refusal is cleared, up to whatever its signed grant already allowed."
|
||||
}
|
||||
|
||||
// ownerActAudit is the act's line in the machine's audit log. The owner's
|
||||
// factor directory is made if it is not there yet (a deny or a switch off
|
||||
// needs no code set up): dcnAuditWith joins its directory onto audit.jsonl,
|
||||
// and "" would be the daemon's working directory.
|
||||
func ownerActAudit(what, user string) {
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
uilogf("owner act not audited (%v): %s, from %s\n", err, what, user)
|
||||
return
|
||||
}
|
||||
dcnAuditWith(dir, "owner_lane_act", 0, "",
|
||||
what+", from the owner's account "+user+" through its ask socket", nil)
|
||||
}
|
||||
|
||||
// ── the asking side ─────────────────────────────────────────────────────────
|
||||
|
||||
func actViaSock(req agentAskRequest) (string, error) {
|
||||
p := readSockPath()
|
||||
c, err := net.DialTimeout("unix", p, 3*time.Second)
|
||||
if err != nil {
|
||||
return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
|
||||
}
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
req.Op = "act"
|
||||
if err := json.NewEncoder(c).Encode(req); err != nil {
|
||||
return "", &laneError{down: true, why: "the act could not be sent (" + err.Error() + ")"}
|
||||
}
|
||||
var r agentActReply
|
||||
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&r); err != nil {
|
||||
return "", &laneError{down: true, why: "the machine's bastion did not answer (" + err.Error() + ")"}
|
||||
}
|
||||
if !r.OK && r.Failed {
|
||||
return "", errors.New(r.Why)
|
||||
}
|
||||
if !r.OK {
|
||||
return "", &laneError{why: r.Why}
|
||||
}
|
||||
return r.Done, nil
|
||||
}
|
||||
|
||||
// ownerApproveViaLane shows the request the machine's bastion holds, takes
|
||||
// the owner's code at this terminal, and asks the bastion to approve it.
|
||||
func ownerApproveViaLane(host string, machine bool, prefix string) error {
|
||||
v, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
||||
if err != nil {
|
||||
// Refused, the read is refused as the approve it was read for.
|
||||
var le *laneError
|
||||
if errors.As(err, &le) && !le.down {
|
||||
err = &laneError{why: actWording(le.why)}
|
||||
}
|
||||
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
|
||||
}
|
||||
if !v.CodeSetUp {
|
||||
return fmt.Errorf("no second factor is enrolled %s, so nothing can be approved yet", v.For)
|
||||
}
|
||||
var hit *approvalWaiting
|
||||
for i := range v.Waiting {
|
||||
if strings.HasPrefix(v.Waiting[i].ID, prefix) || strings.HasPrefix(prefix, v.Waiting[i].ID) {
|
||||
if hit != nil {
|
||||
return fmt.Errorf("more than one waiting request starts with %q; give more of its id", prefix)
|
||||
}
|
||||
hit = &v.Waiting[i]
|
||||
}
|
||||
}
|
||||
if hit == nil {
|
||||
return fmt.Errorf("nothing waiting %s starts with %q; `privatae-bastion approvals%s` lists them",
|
||||
v.For, prefix, map[bool]string{true: " --machine", false: ""}[machine])
|
||||
}
|
||||
fmt.Println(hit.Text)
|
||||
fmt.Println()
|
||||
code, ok := promptOwnerCode("Approve this? Enter the code from your authenticator (or leave empty to cancel): ")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// The bastion finds the request again by this id: the one shown, never
|
||||
// fewer characters of it than shown. Sent as typed, a short prefix would
|
||||
// approve whatever request matched it by then (one an agent filed while
|
||||
// the person typed, say), which is not what they read.
|
||||
id := hit.ID
|
||||
if len(prefix) > len(id) {
|
||||
id = prefix
|
||||
}
|
||||
done, err := actViaSock(agentAskRequest{Act: "approve", Host: host, Machine: machine, ID: id, Code: code})
|
||||
if err != nil {
|
||||
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
|
||||
}
|
||||
fmt.Println(done)
|
||||
return nil
|
||||
}
|
||||
|
||||
// actWording is the reads' refusal said of an act.
|
||||
func actWording(why string) string {
|
||||
return strings.Replace(why, "answers reads only for", "acts only for", 1)
|
||||
}
|
||||
|
||||
// stepupCommand is approve or deny as the administrator would type it here.
|
||||
func stepupCommand(verb, host string, machine bool, id string) string {
|
||||
switch {
|
||||
case machine:
|
||||
return verb + " --machine " + id
|
||||
case host != "":
|
||||
return verb + " --host " + host + " " + id
|
||||
}
|
||||
return verb + " " + id
|
||||
}
|
||||
|
||||
// promptOwnerCode reads a code from this terminal; ok is false when the
|
||||
// person left it empty, and then nothing is done (said here).
|
||||
var promptOwnerCode = func(prompt string) (string, bool) {
|
||||
fmt.Print(prompt)
|
||||
code, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
fmt.Println("cancelled — nothing done")
|
||||
return "", false
|
||||
}
|
||||
return code, true
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubCode answers the owner's code prompt with code ("" = left empty).
|
||||
func stubCode(t *testing.T, code string) *int {
|
||||
t.Helper()
|
||||
asked := 0
|
||||
prev := promptOwnerCode
|
||||
t.Cleanup(func() { promptOwnerCode = prev })
|
||||
promptOwnerCode = func(string) (string, bool) { asked++; return code, code != "" }
|
||||
return &asked
|
||||
}
|
||||
|
||||
// From the owner's account, beside the machine's bastion, approve answers a
|
||||
// stop ask the machine holds, with the owner's code: before, it looked in
|
||||
// that account's own empty state and said the request did not exist.
|
||||
func TestTheOwnerApprovesTheMachinesAskThroughTheLane(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatalf("ask refused: %s", r.Why)
|
||||
}
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
out := captureStdout(t)
|
||||
stubCode(t, codeFor(t, machineTestSecret))
|
||||
if err := cmdStepupApprove("", true, r.Request); err != nil {
|
||||
t.Fatalf("approve through the lane: %v", err)
|
||||
}
|
||||
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "approved" {
|
||||
t.Errorf("the machine's request is %q after the owner approved it", got)
|
||||
}
|
||||
if got := printedSince(out, 0, "approved."); !strings.Contains(got, "approved.") {
|
||||
t.Errorf("printed %q", got)
|
||||
}
|
||||
// The kernel's name for this account: the harness maps only its uid to
|
||||
// the owner's, so the name is the test process's own.
|
||||
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
|
||||
if !strings.Contains(string(raw), `"op":"owner_lane_act"`) ||
|
||||
!strings.Contains(string(raw), "from the owner's account ") {
|
||||
t.Errorf("the act is not in the audit log with the account that acted:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong code approves nothing and counts against the owner's code, as the
|
||||
// console's does; an empty one sends nothing.
|
||||
func TestAWrongCodeThroughTheLaneApprovesNothing(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
stubCode(t, "000000")
|
||||
err := cmdStepupApprove("", true, r.Request)
|
||||
var le *laneError
|
||||
if err == nil || errors.As(err, &le) {
|
||||
t.Errorf("a wrong code: %v (want the bastion's refusal, not a lane refusal)", err)
|
||||
}
|
||||
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "pending" {
|
||||
t.Errorf("a wrong code left the request %q", got)
|
||||
}
|
||||
asked := stubCode(t, "")
|
||||
if err := cmdStepupApprove("", true, r.Request); err != nil || *asked != 1 {
|
||||
t.Errorf("an empty code: %v, asked %d", err, *asked)
|
||||
}
|
||||
// MUTATION: spend the code without machineCodeSpend. RESULT: red, a wrong
|
||||
// code no longer counts.
|
||||
stepupMu.Lock()
|
||||
n := pipeThrottleLoad(d).Failures
|
||||
stepupMu.Unlock()
|
||||
if n < 1 {
|
||||
t.Errorf("the wrong code was not counted (%d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny takes no code: refusing only takes reach away.
|
||||
func TestTheOwnerDeniesThroughTheLaneWithNoCode(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
asked := stubCode(t, "")
|
||||
if err := cmdStepupDeny("", true, r.Request); err != nil {
|
||||
t.Fatalf("deny through the lane: %v", err)
|
||||
}
|
||||
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "denied" || *asked != 0 {
|
||||
t.Errorf("deny left %q, asked for a code %d times", got, *asked)
|
||||
}
|
||||
}
|
||||
|
||||
// The local switch from the owner's account: off with nothing, on only with
|
||||
// the owner's code (the console's switch rule).
|
||||
func TestTheOwnerSwitchesThroughTheLane(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
const host = "box-one.example"
|
||||
givenPairing(t, host, pairingActive)
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
if err := cmdSwitch([]string{host, "ops", "off"}); err != nil {
|
||||
t.Fatalf("switch off: %v", err)
|
||||
}
|
||||
if !localDenied(host, "ops") {
|
||||
t.Fatal("ops is not off after the owner switched it off")
|
||||
}
|
||||
stubCode(t, "000000")
|
||||
if err := cmdSwitch([]string{host, "ops", "on"}); err == nil || !localDenied(host, "ops") {
|
||||
t.Errorf("switch on with a wrong code: %v, still off %v", err, localDenied(host, "ops"))
|
||||
}
|
||||
stubCode(t, codeFor(t, machineTestSecret))
|
||||
if err := cmdSwitch([]string{host, "ops", "on"}); err != nil || localDenied(host, "ops") {
|
||||
t.Errorf("switch on with the owner's code: %v, still off %v", err, localDenied(host, "ops"))
|
||||
}
|
||||
}
|
||||
|
||||
// Any other account is refused every act, with the administrator's command.
|
||||
func TestAnotherAccountIsRefusedEveryAct(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
laneOwner(t, laneTestUID+1)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
stubCode(t, codeFor(t, machineTestSecret))
|
||||
for name, run := range map[string]func() error{
|
||||
"approve": func() error { return cmdStepupApprove("", true, r.Request) },
|
||||
"deny": func() error { return cmdStepupDeny("", true, r.Request) },
|
||||
"switch off": func() error { return cmdSwitch([]string{"box-one.example", "ops", "off"}) },
|
||||
} {
|
||||
err := run()
|
||||
if err == nil || !strings.Contains(err.Error(), "acts only for its owner's account") || !strings.Contains(err.Error(), "sudo -H") {
|
||||
t.Errorf("%s from another account: %v", name, err)
|
||||
}
|
||||
if (name == "approve" || name == "deny") && !strings.Contains(err.Error(), "sudo -H privatae-bastion "+name+" --machine "+r.Request) {
|
||||
t.Errorf("the administrator's command does not name the request: %v", err)
|
||||
}
|
||||
}
|
||||
// The list, refused, is given back as the administrator types it for the
|
||||
// same requests: the machine's own, not a Host's.
|
||||
if err := cmdStepupApprovals("", true, false); err == nil || !strings.Contains(err.Error(), "answers reads only for its owner's account") ||
|
||||
!strings.HasSuffix(err.Error(), "sudo -H privatae-bastion approvals --machine") {
|
||||
t.Errorf("approvals --machine from another account: %v", err)
|
||||
}
|
||||
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "pending" {
|
||||
t.Errorf("another account's deny left the request %q", got)
|
||||
}
|
||||
if rep := serveAct(laneTestUID, "lee", agentAskRequest{Act: "approve", Machine: true, ID: r.Request,
|
||||
Code: codeFor(t, machineTestSecret)}); rep.OK {
|
||||
t.Error("another account approved the machine's request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActTheBastionDoesNotHaveFails(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID)
|
||||
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "uninstall"}); rep.OK || !rep.Failed ||
|
||||
!strings.Contains(rep.Why, "no act called") {
|
||||
t.Errorf("an unknown act: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// Approve through the lane approves the request it showed, or nothing: a
|
||||
// short prefix sent as typed was found again by the bastion after the code was
|
||||
// entered, so a request that came to match it meanwhile (the shown one denied,
|
||||
// another filed) was approved in its place.
|
||||
func TestTheLaneApprovesOnlyTheRequestItShowed(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
if !r.OK {
|
||||
t.Fatalf("ask refused: %s", r.Why)
|
||||
}
|
||||
shown := fullRequestID(t, d, r.Request)
|
||||
// Same first character, different from the second on.
|
||||
other := shown[:1] + string("0123456789abcdef"[(strings.IndexByte("0123456789abcdef", shown[1])+1)%16]) + shown[2:]
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
code := codeFor(t, machineTestSecret)
|
||||
prev := promptOwnerCode
|
||||
t.Cleanup(func() { promptOwnerCode = prev })
|
||||
promptOwnerCode = func(string) (string, bool) {
|
||||
// While the person types: the shown request is answered, and another
|
||||
// matching the typed prefix is waiting.
|
||||
stepupMu.Lock()
|
||||
m := requestsLoad(d)
|
||||
rec := m[shown]
|
||||
m[other] = rec
|
||||
_ = requestsSave(d, m)
|
||||
stepupMu.Unlock()
|
||||
if err := stepupDenyRequest(d, shown); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code, true
|
||||
}
|
||||
// MUTATION: send the typed prefix (ID: prefix). RESULT: red, the other
|
||||
// request is approved.
|
||||
err := cmdStepupApprove("", true, shown[:1])
|
||||
if got := requestsLoad(d)[other].State; got != "pending" {
|
||||
t.Fatalf("a request the person was never shown is %q after they approved the one they were shown (%v)", got, err)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("approving a request answered meanwhile said nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// A Host's request is approved through the lane only for a pairing this
|
||||
// machine serves, as the console's approve (approvalTarget) refuses the rest.
|
||||
func TestTheLaneDoesNotApproveForAPairingThisMachineDoesNotServe(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID)
|
||||
const host, id = "box-two.example", "c0ffee00c0ffee00c0ffee00c0ffee00"
|
||||
waiting := func(state string) string {
|
||||
dcn := givenPairing(t, host, state)
|
||||
enrolFactorAt(t, dcn, hostTestSecret)
|
||||
if err := requestsSave(dcn, map[string]stepupRequest{id: {Purpose: "test|host", State: "pending",
|
||||
At: time.Now().UTC().Format(time.RFC3339)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dcn
|
||||
}
|
||||
dcn := waiting(pairingPending)
|
||||
// MUTATION: drop the approvalTarget check. RESULT: red, approved.
|
||||
rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "approve", Host: host, ID: id[:8], Code: codeFor(t, hostTestSecret)})
|
||||
if rep.OK || !strings.Contains(rep.Why, "does not serve") {
|
||||
t.Errorf("approve for a pairing the anchor has not approved: %+v", rep)
|
||||
}
|
||||
if got := requestsLoad(dcn)[id].State; got != "pending" {
|
||||
t.Errorf("the request of a pairing this machine does not serve is %q", got)
|
||||
}
|
||||
// The same act for an active pairing is answered: the refusal above is
|
||||
// the pairing's state, not the test's setup.
|
||||
dcn = waiting(pairingActive)
|
||||
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "approve", Host: host, ID: id[:8],
|
||||
Code: codeFor(t, hostTestSecret)}); !rep.OK {
|
||||
t.Errorf("approve for an active pairing: %+v", rep)
|
||||
}
|
||||
if got := requestsLoad(dcn)[id].State; got != "approved" {
|
||||
t.Errorf("the active pairing's request is %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The act's audit line lands in the machine's log, and says what was acted
|
||||
// on as the bastion resolved it. With no owner's code set up (a switch off
|
||||
// needs none) it was written to audit.jsonl in the daemon's working
|
||||
// directory; and a request reached with no --machine, the machine's because
|
||||
// no Host is paired, was logged as a Host's.
|
||||
func TestAnOwnerActIsAuditedInTheMachinesLogAsResolved(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
const host = "box-one.example"
|
||||
givenPairing(t, host, pairingActive)
|
||||
laneOwner(t, laneTestUID)
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "switch", Host: host, Family: "ops", Off: true}); !rep.OK {
|
||||
t.Fatalf("switch off: %+v", rep)
|
||||
}
|
||||
// MUTATION: audit to existingMachineFactorDir(). RESULT: red, the line is
|
||||
// in the working directory.
|
||||
if _, err := os.Stat(filepath.Join(cwd, "audit.jsonl")); err == nil {
|
||||
t.Error("the act's audit line was written into the daemon's working directory")
|
||||
}
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, "audit.jsonl"))
|
||||
if !strings.Contains(string(raw), `"op":"owner_lane_act"`) || !strings.Contains(string(raw), "switch "+host+" ops off") {
|
||||
t.Errorf("the switch is not in the machine's audit log:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOwnerActOnTheMachinesRequestIsAuditedAsTheMachines(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
|
||||
laneOwner(t, laneTestUID)
|
||||
// No --machine, and no Host paired: the machine's request.
|
||||
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", ID: r.Request}); !rep.OK {
|
||||
t.Fatalf("deny: %+v", rep)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
|
||||
// MUTATION: name the target from req.Machine. RESULT: red, "a Host's ()".
|
||||
if want := "deny the machine's request " + fullRequestID(t, d, r.Request); !strings.Contains(string(raw), want) {
|
||||
t.Errorf("the audit line does not say %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny for a Host's request is refused, as the console refuses it, for a
|
||||
// pairing this machine does not serve.
|
||||
func TestTheLaneDoesNotDenyForAPairingThisMachineDoesNotServe(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
const host = "box-pending.example"
|
||||
givenPairing(t, host, pairingPending)
|
||||
laneOwner(t, laneTestUID)
|
||||
rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", Host: host, ID: "abcd1234"})
|
||||
if rep.OK || !strings.Contains(rep.Why, "does not serve") {
|
||||
t.Errorf("deny for a pending pairing: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// An act that panics inside the daemon leaves the request lock free: the
|
||||
// next approval does not hang.
|
||||
func TestAPanickingActLeavesTheRequestLockFree(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID)
|
||||
prev := requestsLoadHook
|
||||
t.Cleanup(func() { requestsLoadHook = prev })
|
||||
requestsLoadHook = func() { panic("boom") }
|
||||
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", Machine: true, ID: "abcd1234"}); rep.OK || !rep.Failed {
|
||||
t.Fatalf("a panicking act: %+v", rep)
|
||||
}
|
||||
requestsLoadHook = func() {}
|
||||
done := make(chan struct{})
|
||||
go func() { lockedRequests(existingMachineFactorDir()); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the request lock was left held by the panicking act")
|
||||
}
|
||||
}
|
||||
|
||||
// The narrowing acts go through the lane from the owner's account with no
|
||||
// code: an unused approval is dropped, a device ask denied, a store grant
|
||||
// dropped, every op grant dropped.
|
||||
func TestTheOwnerTakesReachBackThroughTheLane(t *testing.T) {
|
||||
d := askFlowWorld(t)
|
||||
const host = "box-one.example"
|
||||
dcn := givenPairing(t, host, pairingActive)
|
||||
givenStoreGrants(t, dcn, map[string]map[string]any{"7": givenMachineStore("/srv/data", nil, nil, "")})
|
||||
approval, err := stepupIssueApproval(d, codeFor(t, machineTestSecret), "local_stop|101|aider")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := deviceInit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ask, err := deviceNewRequest(map[string]any{"kind": "mgmt_op", "op": "ctx_del", "server": "box-7", "args": []any{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
out := captureStdout(t)
|
||||
asked := stubCode(t, "")
|
||||
|
||||
if err := cmdStepupDrop("", true, approval[:8]); err != nil {
|
||||
t.Fatalf("approvals drop: %v", err)
|
||||
}
|
||||
if !approvalsLoad(d)[approval].Used {
|
||||
t.Error("the machine's approval is still usable after the owner dropped it")
|
||||
}
|
||||
if err := cmdDeviceDeny(ask[:8]); err != nil {
|
||||
t.Fatalf("device-deny: %v", err)
|
||||
}
|
||||
deviceMu.Lock()
|
||||
state := deviceRequestsLoad()[ask].State
|
||||
deviceMu.Unlock()
|
||||
if state != "denied" {
|
||||
t.Errorf("the device ask is %q after the owner denied it", state)
|
||||
}
|
||||
if err := cmdGrantsDrop(host, "7"); err != nil {
|
||||
t.Fatalf("grants drop: %v", err)
|
||||
}
|
||||
if err := cmdOpsDrop(host, "--all"); err != nil {
|
||||
t.Fatalf("ops drop --all: %v", err)
|
||||
}
|
||||
got := printedSince(out, 0, "op grant(s) dropped")
|
||||
for _, want := range []string{"dropped approval " + approval[:8], "denied " + ask, "store 7 dropped for " + host,
|
||||
"0 op grant(s) dropped"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("printed no %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if *asked != 0 {
|
||||
t.Errorf("a narrowing act asked for a code %d times", *asked)
|
||||
}
|
||||
// MUTATION: route grants-drop to the account's own state. RESULT: red,
|
||||
// a second drop through the lane still finds the store.
|
||||
if err := cmdGrantsDrop(host, "7"); err != nil || !strings.Contains(printedSince(out, len(got), "not held"), "was not held here") {
|
||||
t.Errorf("a second drop of the same store: %v", err)
|
||||
}
|
||||
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
|
||||
for _, act := range []string{"approvals-drop", "device-deny", "grants-drop 7 for " + host, "ops-drop --all for " + host} {
|
||||
if !strings.Contains(string(raw), act) {
|
||||
t.Errorf("no audit row for %s", act)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any other account is refused the narrowing acts too, with the
|
||||
// administrator's command.
|
||||
func TestAnotherAccountIsRefusedTheNarrowingActs(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
givenPairing(t, "box-one.example", pairingActive)
|
||||
laneOwner(t, laneTestUID+1)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
for name, run := range map[string]func() error{
|
||||
"approvals drop": func() error { return cmdStepupDrop("", true, "abcd1234") },
|
||||
"device-deny": func() error { return cmdDeviceDeny("abcd1234") },
|
||||
"grants drop": func() error { return cmdGrantsDrop("box-one.example", "7") },
|
||||
"ops drop": func() error { return cmdOpsDrop("box-one.example", "--all") },
|
||||
} {
|
||||
err := run()
|
||||
if err == nil || !strings.Contains(err.Error(), "acts only for its owner's account") ||
|
||||
!strings.Contains(err.Error(), "sudo -H privatae-bastion ") {
|
||||
t.Errorf("%s from another account: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `ops drop ""` (an unset variable in a script, say) names no grant: it is
|
||||
// refused, as an empty id is for approvals drop and device-deny, and the one
|
||||
// grant held is kept. It used to match every nonce, so with one grant held
|
||||
// it dropped that one.
|
||||
//
|
||||
// MUTATION: take the empty-id refusal out of opsDropText. RESULT: red, the
|
||||
// held grant is dropped.
|
||||
func TestAnEmptyOpGrantIDDropsNothing(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
const host = "box-one.example"
|
||||
dcn := givenPairing(t, host, pairingActive)
|
||||
const nonce = "0123456789abcdef0123"
|
||||
f := opsLoad(dcn)
|
||||
f.Grants[nonce] = opGrantRec{UsesLeft: 1}
|
||||
if err := opsSave(dcn, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
captureStdout(t)
|
||||
for _, id := range []string{"", " "} {
|
||||
err := cmdOpsDrop(host, id)
|
||||
if err == nil || !strings.Contains(err.Error(), "which op grant") {
|
||||
t.Errorf("ops drop %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
if rec := opsLoad(dcn).Grants[nonce]; rec.Dropped != "" || rec.UsesLeft != 1 {
|
||||
t.Errorf("an empty id dropped the one grant held: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// Store ids are each Host's own: two Hosts' store 7 are two stores, with two
|
||||
// agents. Kept by store id alone, a machine paired with both ran one agent,
|
||||
// so a search from one Host for its store 7 was answered by the agent over the
|
||||
// other Host's folder, and dropping one Host's store stopped the other's.
|
||||
func TestTwoHostsWithTheSameStoreIDGetTheirOwnAgents(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
dcnA := givenPairing(t, "box-a.example", pairingActive)
|
||||
dcnB := givenPairing(t, "box-b.example", pairingActive)
|
||||
givenStoreGrants(t, dcnB, map[string]map[string]any{"7": givenMachineStore("/srv/b", nil, nil, "")})
|
||||
dirA, dirB := t.TempDir(), t.TempDir()
|
||||
for _, d := range []string{dirA, dirB} {
|
||||
if err := os.WriteFile(filepath.Join(d, "port"), []byte(map[string]string{dirA: "41001", dirB: "41002"}[d]), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stopped := map[string]int{}
|
||||
faMgr.Lock()
|
||||
faMgr.inst[faKey{dcnA, 7}] = &faInstance{StoreID: 7, Dir: dirA, Cert: "a", Key: "a", cancel: func() { stopped["a"]++ }}
|
||||
faMgr.inst[faKey{dcnB, 7}] = &faInstance{StoreID: 7, Dir: dirB, Cert: "b", Key: "b", cancel: func() { stopped["b"]++ }}
|
||||
faMgr.inst[faKey{dcnB, 9}] = &faInstance{StoreID: 9, Dir: dirB, State: "running"}
|
||||
faMgr.Unlock()
|
||||
t.Cleanup(func() {
|
||||
faMgr.Lock()
|
||||
delete(faMgr.inst, faKey{dcnA, 7})
|
||||
delete(faMgr.inst, faKey{dcnB, 7})
|
||||
delete(faMgr.inst, faKey{dcnB, 9})
|
||||
faMgr.Unlock()
|
||||
})
|
||||
// A search on each pairing's lane goes to that pairing's own agent.
|
||||
if c, ok := faConfigFor(dcnA, 7); !ok || c.Addr != "127.0.0.1:41001" {
|
||||
t.Errorf("Host A's store 7 resolves to %+v", c)
|
||||
}
|
||||
if c, ok := faConfigFor(dcnB, 7); !ok || c.Addr != "127.0.0.1:41002" {
|
||||
t.Errorf("Host B's store 7 resolves to %+v", c)
|
||||
}
|
||||
// Each beat reports its own pairing's stores only: B's store 9 is not
|
||||
// A's to hear about.
|
||||
// MUTATION: drop the k.Dir filter in faManagedStatus. RESULT: red.
|
||||
if st := faManagedStatus(dcnA); len(st) != 1 || st["9"] != nil {
|
||||
t.Errorf("Host A's beat reports %v", st)
|
||||
}
|
||||
if st := faManagedStatus(dcnB); len(st) != 2 {
|
||||
t.Errorf("Host B's beat reports %v", st)
|
||||
}
|
||||
if st := faManagedStatus(""); len(st) != 0 {
|
||||
t.Errorf("a beat with no DCN state reports %d stores", len(st))
|
||||
}
|
||||
// A's drop of a store it does not hold stops A's own orphan, never B's.
|
||||
if present, err := dcnDropStore(dcnA, 7, "test"); err != nil || present || stopped["a"] != 1 || stopped["b"] != 0 {
|
||||
t.Fatalf("A's drop: present %v err %v, stopped %v", present, err, stopped)
|
||||
}
|
||||
if present, err := dcnDropStore(dcnB, 7, "test"); err != nil || !present || stopped["b"] != 1 {
|
||||
t.Fatalf("B's drop of its own store: present %v err %v, stopped %v", present, err, stopped)
|
||||
}
|
||||
}
|
||||
+46
-19
@@ -11,9 +11,12 @@ package main
|
||||
// kernel which account connected. Asked for a read by the account recorded as
|
||||
// the machine's owner (owner_account.go), the daemon builds it with the
|
||||
// machine's own view and answers with it. That is what the owner's console
|
||||
// lane (consolelane.go) already shows that account, with one difference:
|
||||
// posture, which the console has no page for, adds the machine's firewall
|
||||
// checks, read as the administrator. The delegation tag an agent carries,
|
||||
// lane (consolelane.go) already shows that account, and more that the console
|
||||
// has no page for: posture (the machine's firewall checks, read as the
|
||||
// administrator); the ops and op grants each Host holds here, its backup
|
||||
// requests and the folders shared with it; this bastion's backup keys (public
|
||||
// halves only); and doctor. All of it is about the owner's own machine, and
|
||||
// the lane answers no other account. The delegation tag an agent carries,
|
||||
// which the console never shows, is in no read at all (readAgents).
|
||||
// Any other account is refused; where a read means something with its own
|
||||
// permissions (the agents, schedules and tool servers it can see), it gets
|
||||
@@ -31,6 +34,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -73,16 +77,23 @@ type machineRead struct {
|
||||
|
||||
// machineReads are what the daemon answers over ask.sock, by name.
|
||||
var machineReads = map[string]machineRead{
|
||||
"status": {build: func(o readOpts) (any, error) { return readStatus(o) }},
|
||||
"agents": {build: func(o readOpts) (any, error) { return readAgents(o) }},
|
||||
"schedules": {build: func(o readOpts) (any, error) { return readSchedules(o) }},
|
||||
"tool-servers": {build: func(o readOpts) (any, error) { return readToolServers(o) }},
|
||||
"posture": {build: func(o readOpts) (any, error) { return readPosture(o) }, heavy: true},
|
||||
"baseline": {build: func(o readOpts) (any, error) { return readBaseline(o) }},
|
||||
"baseline-diff": {build: func(o readOpts) (any, error) { return readBaselineDiff(o) }, heavy: true},
|
||||
"activity": {build: func(o readOpts) (any, error) { return readActivity(o) }},
|
||||
"report": {build: func(o readOpts) (any, error) { return readReport(o) }, heavy: true},
|
||||
"approvals": {build: func(o readOpts) (any, error) { return readApprovals(o) }},
|
||||
"status": {build: func(o readOpts) (any, error) { return readStatus(o) }},
|
||||
"agents": {build: func(o readOpts) (any, error) { return readAgents(o) }},
|
||||
"schedules": {build: func(o readOpts) (any, error) { return readSchedules(o) }},
|
||||
"tool-servers": {build: func(o readOpts) (any, error) { return readToolServers(o) }},
|
||||
"posture": {build: func(o readOpts) (any, error) { return readPosture(o) }, heavy: true},
|
||||
"baseline": {build: func(o readOpts) (any, error) { return readBaseline(o) }},
|
||||
"baseline-diff": {build: func(o readOpts) (any, error) { return readBaselineDiff(o) }, heavy: true},
|
||||
"activity": {build: func(o readOpts) (any, error) { return readActivity(o) }},
|
||||
"report": {build: func(o readOpts) (any, error) { return readReport(o) }, heavy: true},
|
||||
"approvals": {build: func(o readOpts) (any, error) { return readApprovals(o) }},
|
||||
"switches": {build: func(o readOpts) (any, error) { return readSwitches(o) }},
|
||||
"ops": {build: func(o readOpts) (any, error) { return readOps(o) }},
|
||||
"device-approvals": {build: func(o readOpts) (any, error) { return readDeviceApprovals(o) }},
|
||||
"backup-keys": {build: func(o readOpts) (any, error) { return readBackupKeys(o) }},
|
||||
"backup-requests": {build: func(o readOpts) (any, error) { return readBackupRequests(o) }},
|
||||
"backup-shares": {build: func(o readOpts) (any, error) { return readBackupShares(o) }},
|
||||
"doctor": {build: func(o readOpts) (any, error) { return readDoctor(o) }, heavy: true},
|
||||
}
|
||||
|
||||
// readsGoThroughLane: the machine's bastion is a system service this account
|
||||
@@ -170,6 +181,9 @@ func readViaSock(name string, o readOpts, v any) error {
|
||||
if err := json.NewDecoder(io.LimitReader(c, readMaxBytes)).Decode(&r); err != nil {
|
||||
return &laneError{down: true, why: "the machine's bastion did not answer the read (" + err.Error() + ")"}
|
||||
}
|
||||
if !r.OK && r.Failed {
|
||||
return errors.New(r.Why)
|
||||
}
|
||||
if !r.OK {
|
||||
return &laneError{why: r.Why}
|
||||
}
|
||||
@@ -183,10 +197,14 @@ func readViaSock(name string, o readOpts, v any) error {
|
||||
// ── the answering side (the daemon) ─────────────────────────────────────────
|
||||
|
||||
type agentReadReply struct {
|
||||
OK bool `json:"ok"`
|
||||
Why string `json:"why,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
View json.RawMessage `json:"view,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Failed: the read was answered for this account and could not be
|
||||
// built (Why says why): the administrator would be told the same, so it
|
||||
// is not a refusal. A bastion older than this field says it as one.
|
||||
Failed bool `json:"failed,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
View json.RawMessage `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
// readLaneRefusal says why account uid may not read through the lane, or "".
|
||||
@@ -252,7 +270,7 @@ func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
|
||||
o := readOpts{Since: req.Since, Prompts: req.Prompts, Host: req.Host, Machine: req.Machine}
|
||||
view, err := runMachineRead(req.Read, spec, o)
|
||||
if err != nil {
|
||||
r.Why = err.Error()
|
||||
r.Why, r.Failed = err.Error(), true
|
||||
return r
|
||||
}
|
||||
if len(view) > readMaxBytes-readEnvelope {
|
||||
@@ -267,7 +285,16 @@ func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
|
||||
}
|
||||
|
||||
func runMachineRead(name string, spec machineRead, o readOpts) ([]byte, error) {
|
||||
build := func() ([]byte, error) {
|
||||
build := func() (b []byte, err error) {
|
||||
// A read runs inside the daemon that serves every pairing, from code
|
||||
// written for a command's own process: one that panics is that read's
|
||||
// failure, never the daemon's end.
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
uilogf("read %s panicked: %v\n%s", name, p, debug.Stack())
|
||||
b, err = nil, fmt.Errorf("the read %s failed inside this bastion (%v); its log has the trace", name, p)
|
||||
}
|
||||
}()
|
||||
v, err := spec.build(o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The owner's agent reads the switch list, the doctor and the device's asks
|
||||
// from the machine's bastion over its socket; a read the bastion answers but
|
||||
// cannot build comes back as that failure, not as a refusal with the
|
||||
// administrator's command (the administrator would be told the same).
|
||||
func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
|
||||
askFlowWorld(t)
|
||||
laneOwner(t, laneTestUID)
|
||||
laneSock(t)
|
||||
prevSys, prevLatest, prevSandbox := systemInstalled, doctorLatest, doctorSandbox
|
||||
t.Cleanup(func() { systemInstalled, doctorLatest, doctorSandbox = prevSys, prevLatest, prevSandbox })
|
||||
systemInstalled = func() bool { return true }
|
||||
doctorLatest = func() string { return "" }
|
||||
doctorSandbox = func() (bool, string) { return true, "" }
|
||||
out := captureStdout(t)
|
||||
|
||||
if err := cmdSwitch([]string{"--list", "--json"}); err != nil {
|
||||
t.Fatalf("switch --list --json: %v", err)
|
||||
}
|
||||
var sw switchesRead
|
||||
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &sw); err != nil {
|
||||
t.Fatalf("switch --list --json printed %q (%v)", out(), err)
|
||||
}
|
||||
|
||||
before := len(out())
|
||||
err := cmdDoctor([]string{"--json"})
|
||||
var q quietExit
|
||||
if err != nil && !errors.As(err, &q) {
|
||||
t.Fatalf("doctor through the lane: %v", err)
|
||||
}
|
||||
var d doctorRead
|
||||
if err := json.Unmarshal([]byte(printedSince(out, before, "}\n")), &d); err != nil || len(d.Checks) == 0 {
|
||||
t.Fatalf("doctor --json printed %q (%v)", out()[before:], err)
|
||||
}
|
||||
if c, ok := doctorCheckNamed(d, "owner"); !ok || !c.OK {
|
||||
t.Errorf("the machine's doctor, read by its owner, does not find the owner: %+v", d.Checks)
|
||||
}
|
||||
|
||||
p, err := deviceShownPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdDeviceApprovals(nil); err != nil {
|
||||
t.Fatalf("device-approvals through the lane: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Error("device-approvals read through the lane recorded what it printed as shown")
|
||||
}
|
||||
|
||||
// MUTATION: drop `r.Failed = true` in serveRead. RESULT: red, the error
|
||||
// carries the administrator's command.
|
||||
err = cmdOpsList("", true)
|
||||
var le *laneError
|
||||
if err == nil || errors.As(err, &le) || strings.Contains(err.Error(), "As the administrator") {
|
||||
t.Errorf("ops with nothing paired, through the lane: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// `switch --list` passes the guard beside the system unit, as status does;
|
||||
// `switch` does too since the owner's account switches through the lane
|
||||
// (owneracts.go), and only on Windows is it still refused here.
|
||||
func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) {
|
||||
prevArgs, prevUnit, prevAuth := os.Args, linuxSystemUnitPresent, systemService.authority
|
||||
t.Cleanup(func() { os.Args, linuxSystemUnitPresent, systemService.authority = prevArgs, prevUnit, prevAuth })
|
||||
linuxSystemUnitPresent = func() bool { return true }
|
||||
systemService.authority = false
|
||||
os.Args = []string{"privatae-bastion", "switch", "--list", "--json"}
|
||||
if err := guardServiceFork("switch"); err != nil {
|
||||
t.Errorf("switch --list beside the system unit: %v", err)
|
||||
}
|
||||
os.Args = []string{"privatae-bastion", "switch", "box", "ops", "off"}
|
||||
if err := guardServiceFork("switch"); err != nil {
|
||||
t.Errorf("switch from the owner's account beside the system unit, before the lane could decide: %v", err)
|
||||
}
|
||||
if err := serviceGuard("windows", serviceGuardKey("switch", os.Args), true, false); err == nil {
|
||||
t.Error("switch from an ordinary account beside a Windows service was let through")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The switch list as JSON has every row the words have, each family of each
|
||||
// paired host, and an unreadable list reads as every family off.
|
||||
func TestTheSwitchListReadHasEveryRowTheWordsHave(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
const host = "box-one.example"
|
||||
givenPairing(t, host, pairingActive)
|
||||
if err := localDenySet(host, "ops", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err := readSwitches(readOpts{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(v.Switches) != len(localSwitchFamilies()) {
|
||||
t.Fatalf("want one row per family (%d), got %+v", len(localSwitchFamilies()), v.Switches)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
printSwitches(&buf, v)
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
if len(lines) != len(v.Switches) {
|
||||
t.Fatalf("%d rows, %d lines:\n%s", len(v.Switches), len(lines), buf.String())
|
||||
}
|
||||
for i, r := range v.Switches {
|
||||
f := strings.Fields(lines[i])
|
||||
if f[0] != r.Host || f[1] != r.Family || (f[2] == "off") != r.Off {
|
||||
t.Errorf("row %+v printed as %q", r, lines[i])
|
||||
}
|
||||
if r.Family == "ops" && (!r.Off || r.Since == "") {
|
||||
t.Errorf("ops was switched off: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// MUTATION: build rows from f.Off only, ignoring readErr. RESULT: red,
|
||||
// the families read on while the gate refuses them all.
|
||||
path, err := localDenyPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err = readSwitches(readOpts{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Unreadable == "" {
|
||||
t.Error("an unreadable list is not said")
|
||||
}
|
||||
for _, r := range v.Switches {
|
||||
if !r.Off {
|
||||
t.Errorf("with the list unreadable, %s %s reads on", r.Host, r.Family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read as JSON, the device's asks are a program's, not a person's: nothing is
|
||||
// recorded as shown, so `device-approve all` signs nothing an agent read.
|
||||
func TestDeviceApprovalsAsJSONRecordsNothingShown(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
p, err := deviceShownPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdDeviceApprovals([]string{"--json"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Error("device-approvals --json recorded what it printed as shown")
|
||||
}
|
||||
if err := cmdDeviceApprovals(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Errorf("the words listing recorded nothing as shown: %v", err)
|
||||
}
|
||||
if err := cmdDeviceApprovals([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("a stray flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A restore request read leaves the sealed key the host sent, and the one
|
||||
// resealed for it, out of the answer; their digest and the words stay.
|
||||
func TestTheBackupRequestsReadLeavesTheSealedKeysOut(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
const host = "box-one.example"
|
||||
givenPairing(t, host, pairingActive)
|
||||
cfg, err := stepupPairing(host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const sealed, resealed = "-----BEGIN AGE ENCRYPTED FILE-----SIDECAR", "-----BEGIN AGE ENCRYPTED FILE-----RESEALED"
|
||||
at := time.Now().UTC().Format(time.RFC3339)
|
||||
// r1 waits, so it still carries the sidecar it came with; r2 was
|
||||
// answered and carries the resealed key.
|
||||
if err := resealSave(cfg.dcnDir(), map[string]*resealRequest{
|
||||
"r1": {ID: "r1", Stamp: "20261101T000000Z", Pairing: host, HostNumber: 7, HostLabel: "home", To: "age1xyz",
|
||||
ToFP: "fp", Sidecar: sealed, SidecarSH: "abc123", At: at, State: "pending"},
|
||||
"r2": {ID: "r2", Stamp: "20261101T000000Z", Pairing: host, HostNumber: 7, HostLabel: "home", To: "age1xyz",
|
||||
ToFP: "fp", SidecarSH: "def456", At: at, State: "done", Resealed: resealed},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := resealRead(cfg.dcnDir())
|
||||
if err != nil || stored["r1"] == nil || stored["r1"].Sidecar != sealed || stored["r2"] == nil || stored["r2"].Resealed != resealed {
|
||||
t.Fatalf("the fixture does not hold both sealed keys: %+v %v", stored, err)
|
||||
}
|
||||
// MUTATION: build the view from *resealRequest itself. RESULT: red, both
|
||||
// sealed keys are in the answer.
|
||||
v, err := readBackupRequests(readOpts{Host: host})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
if strings.Contains(string(b), "SIDECAR") || strings.Contains(string(b), "RESEALED") {
|
||||
t.Errorf("the read carries a sealed key: %s", b)
|
||||
}
|
||||
if len(v.Requests) != 2 || v.Requests[0].SidecarSHA == "" || !strings.Contains(v.Requests[0].Words, "home") {
|
||||
t.Errorf("the requests as read: %+v", v.Requests)
|
||||
}
|
||||
}
|
||||
|
||||
// The backup key read carries the public halves only.
|
||||
func TestTheBackupKeysReadHasNoSecretHalf(t *testing.T) {
|
||||
b, err := json.Marshal(backupKeysRead{Keys: []*backupKeyFile{{Identity: "AGE-SECRET-KEY-1SECRET", Recipient: "age1pub",
|
||||
Fingerprint: "fp"}}, Current: "fp"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), "AGE-SECRET-KEY") {
|
||||
t.Errorf("the backup keys read carries the secret: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// agents show takes a pid, or a session name that matches exactly one, the
|
||||
// rule stop keeps.
|
||||
func TestAgentsShowPicksExactlyOneAgent(t *testing.T) {
|
||||
views := []agentView{
|
||||
{agentObs: agentObs{PID: 101, Signature: "claude-code"}, Self: &agentSelf{Name: "shop"}},
|
||||
{agentObs: agentObs{PID: 102, Signature: "claude-code"}, Self: &agentSelf{Name: "twin"}},
|
||||
{agentObs: agentObs{PID: 103, Signature: "aider"}, Self: &agentSelf{Name: "twin"}},
|
||||
}
|
||||
if v, err := pickAgentView(views, "101"); err != nil || v.PID != 101 {
|
||||
t.Errorf("by pid: %+v %v", v, err)
|
||||
}
|
||||
if v, err := pickAgentView(views, "shop"); err != nil || v.PID != 101 {
|
||||
t.Errorf("by name: %+v %v", v, err)
|
||||
}
|
||||
if _, err := pickAgentView(views, "twin"); err == nil || !strings.Contains(err.Error(), "give the pid") {
|
||||
t.Errorf("two sessions with one name: %v", err)
|
||||
}
|
||||
if _, err := pickAgentView(views, "999"); err == nil {
|
||||
t.Error("a pid no agent has was shown")
|
||||
}
|
||||
if _, err := pickAgentView(views, "nobody"); err == nil {
|
||||
t.Error("a name no session has was shown")
|
||||
}
|
||||
if err := cmdAgents([]string{"show"}); err == nil || !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("show with no agent: %v", err)
|
||||
}
|
||||
if err := cmdAgents([]string{"show", "--json"}); err == nil || !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("show --json with no agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The detail view names the file the kernel says the agent runs, and keeps
|
||||
// its command line (which can carry a key) out of the words.
|
||||
func TestAgentsShowKeepsTheCommandLineOutOfTheWords(t *testing.T) {
|
||||
v := agentView{agentObs: anObservedAgent("aider", "/usr/local/bin/aider --api-key sk-THEKEY", 4242)}
|
||||
var b bytes.Buffer
|
||||
printAgentDetail(&b, v, time.Now())
|
||||
out := b.String()
|
||||
if strings.Contains(out, "sk-THEKEY") {
|
||||
t.Errorf("the words carry the command line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/usr/local/bin/aider") || !strings.Contains(out, "4242") {
|
||||
t.Errorf("the words do not name the program and pid:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The file an agent runs is a path whoever placed it chose, so a path built
|
||||
// of terminal control sequences is shown as such, never sent to the terminal.
|
||||
//
|
||||
// MUTATION: row("program", v.Exe). RESULT: red, the escape reaches the words.
|
||||
func TestAgentsShowNeverPrintsControlCharactersFromTheProgramPath(t *testing.T) {
|
||||
v := agentView{agentObs: anObservedAgent("claude-code", "claude", 4242)}
|
||||
v.Exe = "/tmp/\x1b]52;c;cHduZWQ=\x07/\x1b[2Jclaude"
|
||||
var b bytes.Buffer
|
||||
printAgentDetail(&b, v, time.Now())
|
||||
out := b.String()
|
||||
if strings.ContainsAny(out, "\x1b\x07") {
|
||||
t.Errorf("the words carry the program path's control characters: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, `\x1b]52`) {
|
||||
t.Errorf("the program path is not shown as what it is: %q", out)
|
||||
}
|
||||
v.Exe = "/usr/local/bin/claude"
|
||||
b.Reset()
|
||||
printAgentDetail(&b, v, time.Now())
|
||||
if !strings.Contains(b.String(), "program: /usr/local/bin/claude\n") {
|
||||
t.Errorf("a plain path is not shown as is: %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A read is built in the root daemon now, from code that ran only in a
|
||||
// command's own process before (readOps slices names it finds on disk). One
|
||||
// that panics is that read's failure, and the daemon serving every pairing
|
||||
// stays up.
|
||||
//
|
||||
// MUTATION: drop the recover in runMachineRead. RESULT: red, the suite
|
||||
// itself panics.
|
||||
func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
laneOwner(t, 1000)
|
||||
for _, heavy := range []bool{false, true} {
|
||||
machineReads["test-panics"] = machineRead{heavy: heavy, build: func(readOpts) (any, error) {
|
||||
var s []string
|
||||
return s[3], nil
|
||||
}}
|
||||
r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "test-panics"})
|
||||
if r.OK || !r.Failed || !strings.Contains(r.Why, "test-panics") {
|
||||
t.Errorf("a read that panics (heavy %v): %+v", heavy, r)
|
||||
}
|
||||
}
|
||||
delete(machineReads, "test-panics")
|
||||
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "status"}); !r.OK {
|
||||
t.Errorf("after a read panicked, status: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// doctor from an ordinary account beside a Windows system service would check
|
||||
// that account's own empty bastion and report it as the machine's (not
|
||||
// running, no owner, no factor), so it is refused there as status is; on
|
||||
// Linux and macOS it reads the machine's bastion through the lane.
|
||||
//
|
||||
// MUTATION: drop "doctor" from serviceGuardedCommands. RESULT: red on windows.
|
||||
func TestDoctorBesideAWindowsServiceIsRefusedAsStatusIs(t *testing.T) {
|
||||
if err := serviceGuard("windows", "doctor", true, false); err == nil {
|
||||
t.Error("doctor from an ordinary account beside a Windows service was let through")
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin"} {
|
||||
if err := serviceGuard(goos, "doctor", true, false); err != nil {
|
||||
t.Errorf("%s: doctor beside the service is a lane read: %v", goos, err)
|
||||
}
|
||||
}
|
||||
if err := serviceGuard("windows", "doctor", true, true); err != nil {
|
||||
t.Errorf("the administrator's doctor beside a Windows service: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// doctorWorld is a machine doctor reads from fixtures only.
|
||||
func doctorWorld(t *testing.T, system bool, latest string) {
|
||||
t.Helper()
|
||||
uiTestConfigDir(t)
|
||||
prevSys, prevLatest, prevSandbox := systemInstalled, doctorLatest, doctorSandbox
|
||||
t.Cleanup(func() { systemInstalled, doctorLatest, doctorSandbox = prevSys, prevLatest, prevSandbox })
|
||||
systemInstalled = func() bool { return system }
|
||||
doctorLatest = func() string { return latest }
|
||||
doctorSandbox = func() (bool, string) { return true, "" }
|
||||
}
|
||||
|
||||
func doctorCheckNamed(d doctorRead, name string) (doctorCheck, bool) {
|
||||
for _, c := range d.Checks {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return doctorCheck{}, false
|
||||
}
|
||||
|
||||
// With no second factor, doctor says what cannot be approved and the command
|
||||
// that sets one up; with one, it is not a problem.
|
||||
func TestDoctorFindsAMissingSecondFactorAndNamesTheFix(t *testing.T) {
|
||||
doctorWorld(t, false, "")
|
||||
d, err := readDoctor(readOpts{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, ok := doctorCheckNamed(d, "second factor")
|
||||
if !ok || c.OK || !strings.Contains(c.Fix, "stepup-enroll --machine") || strings.Contains(c.Fix, "sudo") {
|
||||
t.Errorf("no factor, a per-account install: %+v", c)
|
||||
}
|
||||
systemInstalled = func() bool { return true }
|
||||
d, _ = readDoctor(readOpts{})
|
||||
if c, _ := doctorCheckNamed(d, "second factor"); !strings.HasPrefix(c.Fix, "sudo -H privatae-bastion stepup-enroll") &&
|
||||
!strings.Contains(c.Fix, "Administrator") {
|
||||
t.Errorf("no factor, installed for the machine: %+v", c)
|
||||
}
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, dir, machineTestSecret)
|
||||
d, _ = readDoctor(readOpts{})
|
||||
if c, _ := doctorCheckNamed(d, "second factor"); !c.OK {
|
||||
t.Errorf("with a factor enrolled: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A newer published version is a problem with the installer as its fix; a
|
||||
// latest version nobody could read is not one.
|
||||
func TestDoctorSaysANewerVersionAndNeverAlarmsOffline(t *testing.T) {
|
||||
doctorWorld(t, false, "999.0.0")
|
||||
prev := clientVersion
|
||||
t.Cleanup(func() { clientVersion = prev })
|
||||
clientVersion = "1.0.0"
|
||||
d, _ := readDoctor(readOpts{})
|
||||
c, _ := doctorCheckNamed(d, "version")
|
||||
if c.OK || !strings.Contains(c.Problem, "999.0.0") || !strings.Contains(c.Fix, "privatae.ai/bastion/install") {
|
||||
t.Errorf("a newer version: %+v", c)
|
||||
}
|
||||
doctorLatest = func() string { return "" }
|
||||
d, _ = readDoctor(readOpts{})
|
||||
if c, _ := doctorCheckNamed(d, "version"); !c.OK {
|
||||
t.Errorf("no answer from privatae.ai reads as a problem: %+v", c)
|
||||
}
|
||||
doctorLatest = func() string { return "1.0.0" }
|
||||
d, _ = readDoctor(readOpts{})
|
||||
if c, _ := doctorCheckNamed(d, "version"); !c.OK {
|
||||
t.Errorf("the latest version reads as a problem: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Installed for the whole machine with no owner recorded, doctor says what
|
||||
// that stops and how to record one; recorded, it names the account.
|
||||
func TestDoctorFindsNoOwnerAccountOnAMachineInstall(t *testing.T) {
|
||||
doctorWorld(t, true, "")
|
||||
d, _ := readDoctor(readOpts{})
|
||||
c, ok := doctorCheckNamed(d, "owner")
|
||||
if !ok || c.OK || !strings.Contains(c.Fix, "approvals-owner") {
|
||||
t.Errorf("no owner: %+v", c)
|
||||
}
|
||||
laneOwner(t, 1000)
|
||||
d, _ = readDoctor(readOpts{})
|
||||
if c, _ := doctorCheckNamed(d, "owner"); !c.OK || !strings.Contains(c.Detail, "sam") {
|
||||
t.Errorf("an owner recorded: %+v", c)
|
||||
}
|
||||
systemInstalled = func() bool { return false }
|
||||
d, _ = readDoctor(readOpts{})
|
||||
if _, ok := doctorCheckNamed(d, "owner"); ok {
|
||||
t.Error("a per-account install was checked for a machine owner")
|
||||
}
|
||||
}
|
||||
|
||||
// An account whose agents' asks are held is named, with the command that
|
||||
// lifts the hold.
|
||||
func TestDoctorNamesAHeldAsker(t *testing.T) {
|
||||
doctorWorld(t, false, "")
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enrolFactorAt(t, dir, machineTestSecret)
|
||||
now := time.Now()
|
||||
for i := 0; i < quarantineStrikes; i++ {
|
||||
askStrike(dir, "lee", now)
|
||||
}
|
||||
d, _ := readDoctor(readOpts{})
|
||||
c, ok := doctorCheckNamed(d, "held asker")
|
||||
if !ok || c.OK || !strings.Contains(c.Problem, "lee") || !strings.Contains(c.Fix, "quarantine clear --asker lee") {
|
||||
t.Errorf("a held asker: %+v (checks %+v)", c, d.Checks)
|
||||
}
|
||||
}
|
||||
|
||||
// doctor exits 1 when it finds a problem, so a script can branch, and the
|
||||
// lane not answering becomes the check rather than an error.
|
||||
func TestDoctorExitsOneOnAProblemAndSaysTheLane(t *testing.T) {
|
||||
doctorWorld(t, false, "")
|
||||
err := cmdDoctor(nil)
|
||||
var q quietExit
|
||||
if !errors.As(err, &q) || q.code != 1 {
|
||||
t.Errorf("no factor: want exit 1, got %v", err)
|
||||
}
|
||||
if err := cmdDoctor([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage:") {
|
||||
t.Errorf("a stray flag: %v", err)
|
||||
}
|
||||
if c := laneCheck(&laneError{down: true, why: "nothing answers"}); c.OK || c.Name != "running" || c.Fix == "" {
|
||||
t.Errorf("the lane down: %+v", c)
|
||||
}
|
||||
if c := laneCheck(&laneError{why: "this machine's bastion answers reads only for its owner's account (sam), not lee"}); c.OK ||
|
||||
c.Name != "reads" || !strings.Contains(c.Fix, "doctor") {
|
||||
t.Errorf("the lane refusing: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSliceThreeReadCommandsRefuseStrayFlags(t *testing.T) {
|
||||
for name, cmd := range map[string]func([]string) error{
|
||||
"switch --list": func(a []string) error { return cmdSwitch(append([]string{"--list"}, a...)) },
|
||||
"backup key show": func(a []string) error {
|
||||
return cmdBackup(append([]string{"key", "show"}, a...))
|
||||
},
|
||||
"backup requests": func(a []string) error { return cmdBackup(append([]string{"requests"}, a...)) },
|
||||
"backup shares": func(a []string) error {
|
||||
return cmdBackup(append([]string{"shares", "--with", "h"}, a...))
|
||||
},
|
||||
} {
|
||||
if err := cmd([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage") {
|
||||
t.Errorf("%s --jsno: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not paired, the command that pairs is said the way it is typed here:
|
||||
// with sudo -H where the bastion is installed for the whole machine.
|
||||
func TestNotPairedSaysThePairCommandForThisInstall(t *testing.T) {
|
||||
doctorWorld(t, true, "")
|
||||
_, err := stepupPairing("")
|
||||
if err == nil || !strings.Contains(err.Error(), asAdmin("pair <token>")) {
|
||||
t.Errorf("not paired, a machine install: %v", err)
|
||||
}
|
||||
if runtimeIsUnix() && !strings.Contains(err.Error(), "sudo -H privatae-bastion pair") {
|
||||
t.Errorf("not paired, a machine install, says no sudo: %v", err)
|
||||
}
|
||||
systemInstalled = func() bool { return false }
|
||||
if _, err := stepupPairing(""); err == nil || strings.Contains(err.Error(), "sudo") {
|
||||
t.Errorf("not paired, a per-account install: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeIsUnix() bool { return os.PathSeparator == '/' }
|
||||
|
||||
// doctor asks privatae.ai at update.go's rate: once, then not again within
|
||||
// updateCheckEvery, whether that ask was answered or not; the last answer
|
||||
// stands meanwhile. MUTATION: drop the doctorAskedLoad shortcut. RESULT: red,
|
||||
// every run fetches.
|
||||
func TestDoctorAsksPrivataeAtTheUpdateCheckRate(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
prevFetch, prevNow := doctorFetch, doctorNow
|
||||
t.Cleanup(func() { doctorFetch, doctorNow = prevFetch, prevNow })
|
||||
forget := func() { latestMu.Lock(); latestPublished = ""; latestMu.Unlock() }
|
||||
t.Cleanup(forget)
|
||||
now := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
||||
doctorNow = func() time.Time { return now }
|
||||
fetches, answer := 0, "0.61.0"
|
||||
doctorFetch = func() (string, error) {
|
||||
fetches++
|
||||
if answer == "" {
|
||||
return "", errors.New("no route")
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
forget()
|
||||
if v := doctorLatest(); v != "0.61.0" || fetches != 1 {
|
||||
t.Fatalf("first run: %q after %d fetches", v, fetches)
|
||||
}
|
||||
forget() // a new process: nothing in memory
|
||||
now = now.Add(updateCheckEvery - time.Minute)
|
||||
if v := doctorLatest(); v != "0.61.0" || fetches != 1 {
|
||||
t.Errorf("inside the window: %q after %d fetches", v, fetches)
|
||||
}
|
||||
forget()
|
||||
now = now.Add(2 * time.Minute)
|
||||
answer = ""
|
||||
if v := doctorLatest(); v != "0.61.0" || fetches != 2 {
|
||||
t.Errorf("past the window, no answer: %q after %d fetches (the last answer should stand)", v, fetches)
|
||||
}
|
||||
forget()
|
||||
now = now.Add(time.Hour)
|
||||
if v := doctorLatest(); v != "0.61.0" || fetches != 2 {
|
||||
t.Errorf("a failed ask is not repeated inside the window: %q after %d fetches", v, fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// A folder the agent chose, holding an escape sequence, is printed quoted in
|
||||
// agents and agents show, never sent to the terminal raw.
|
||||
func TestAnAgentsFolderNeverReachesTheTerminalRaw(t *testing.T) {
|
||||
v := agentView{agentObs: anObservedAgent("aider", "/usr/local/bin/aider", 4242),
|
||||
Folder: "/home/u/\x1b]52;c;cGF3bmVk\x07work", GitBranch: "main"}
|
||||
var b bytes.Buffer
|
||||
printAgents(&b, []agentView{v}, time.Now())
|
||||
printAgentDetail(&b, v, time.Now())
|
||||
if strings.ContainsAny(b.String(), "\x1b\x07") {
|
||||
t.Errorf("a control character reached the output: %q", b.String())
|
||||
}
|
||||
if !strings.Contains(b.String(), `\x1b]52`) {
|
||||
t.Errorf("the folder is not shown at all: %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
// On Windows, where nothing goes over the socket, the slice-3 reads from an
|
||||
// ordinary account beside the service are refused as status is; on Linux and
|
||||
// macOS they ask the machine's bastion. The rest of backup is not guarded.
|
||||
func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) {
|
||||
for _, line := range [][]string{
|
||||
{"privatae-bastion", "ops", "--json"},
|
||||
{"privatae-bastion", "device-approvals"},
|
||||
{"privatae-bastion", "backup", "requests"},
|
||||
{"privatae-bastion", "backup", "shares", "--with", "h"},
|
||||
{"privatae-bastion", "backup", "key", "show"},
|
||||
} {
|
||||
key := serviceGuardKey(line[1], line)
|
||||
if err := serviceGuard("windows", key, true, false); err == nil {
|
||||
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin"} {
|
||||
if err := serviceGuard(goos, key, true, false); err != nil {
|
||||
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, line := range [][]string{
|
||||
{"privatae-bastion", "backup", "reseal", "--sidecar", "f", "--to", "k"},
|
||||
{"privatae-bastion", "backup", "restore-plan", "--dir", "d"},
|
||||
} {
|
||||
if err := serviceGuard("windows", serviceGuardKey(line[1], line), true, false); err != nil {
|
||||
t.Errorf("windows: %v works offline and was refused: %v", line[1:], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,26 @@ func TestEveryReadPrintsTheSameAfterTheLane(t *testing.T) {
|
||||
func(w *bytes.Buffer, v any) { _ = printDelta(w, v.(deltaReport)) })
|
||||
rep := buildReport(now, now.Add(-7*24*time.Hour))
|
||||
add("report", rep, func(w *bytes.Buffer, v any) { renderReportMarkdown(w, v.(machineReport)) })
|
||||
add("switches", switchesRead{Switches: []switchRow{{Host: "home", Family: "ops", Off: true, Since: "2026-11-01T00:00:00Z"},
|
||||
{Host: "home", Family: "observe"}}, Unreadable: "the list cannot be read"},
|
||||
func(w *bytes.Buffer, v any) { printSwitches(w, v.(switchesRead)) })
|
||||
add("ops", opsRead{Host: "home", SandboxUnavailable: "the kernel offers no overlay filesystem",
|
||||
Ops: []opRow{{ID: "0123456789ab", Name: "restart-web", Version: "1", Privilege: "root", Summary: "restart nginx"}, {ID: "ba9876543210", NotVerified: "bad signature"}},
|
||||
Grants: []opGrantRow{{ID: "abcd1234", Op: "restart-web", Version: "1", RunsLeft: 2, Until: "2026-11-02", Within: "nginx only"}}},
|
||||
func(w *bytes.Buffer, v any) { printOps(w, v.(opsRead)) })
|
||||
add("device-approvals", deviceApprovalsRead{Asks: []deviceAsk{{ID: "0123456789abcdef", Server: "web-1", At: "2026-11-01", Words: "Restart nginx."}}},
|
||||
func(w *bytes.Buffer, v any) { printDeviceApprovals(w, v.(deviceApprovalsRead), true) })
|
||||
add("backup-keys", backupKeysRead{Keys: []*backupKeyFile{{Recipient: "age1pub", PubB64: "cHVi", Fingerprint: "fp1",
|
||||
CreatedAt: "2026-10-01", Path: "/x/key"}, {Fingerprint: "fp0"}}, Current: "fp1"},
|
||||
func(w *bytes.Buffer, v any) { printBackupKeys(w, v.(backupKeysRead)) })
|
||||
add("backup-requests", backupRequestsRead{Host: "home", SetAside: []string{"/x/reseal.json.aside-1"},
|
||||
Requests: []backupRequestView{{ID: "r1", State: "pending", Words: "r1 [pending]\n Sent by the pairing home."}}},
|
||||
func(w *bytes.Buffer, v any) { printBackupRequests(w, v.(backupRequestsRead)) })
|
||||
add("backup-shares", backupSharesRead{With: "new-home", Shares: []backupShare{{Slug: "old", Dir: "/srv/backups/old", Added: "2026-10-01"}}},
|
||||
func(w *bytes.Buffer, v any) { printBackupShares(w, v.(backupSharesRead)) })
|
||||
add("doctor", doctorRead{Version: "9.9.9", Checks: []doctorCheck{{Name: "running", OK: true, Detail: "Running on its own."},
|
||||
{Name: "second factor", Problem: "No second factor.", Fix: "privatae-bastion stepup-enroll --machine"}}},
|
||||
func(w *bytes.Buffer, v any) { printDoctor(w, v.(doctorRead)) })
|
||||
|
||||
for name := range machineReads {
|
||||
if name == "baseline" {
|
||||
@@ -223,6 +243,27 @@ func zeroLike(v any) decoded {
|
||||
case machineReport:
|
||||
var x machineReport
|
||||
return decoded{&x, func() any { return x }}
|
||||
case switchesRead:
|
||||
var x switchesRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case opsRead:
|
||||
var x opsRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case deviceApprovalsRead:
|
||||
var x deviceApprovalsRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case backupKeysRead:
|
||||
var x backupKeysRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case backupRequestsRead:
|
||||
var x backupRequestsRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case backupSharesRead:
|
||||
var x backupSharesRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
case doctorRead:
|
||||
var x doctorRead
|
||||
return decoded{&x, func() any { return x }}
|
||||
}
|
||||
panic("no case")
|
||||
}
|
||||
|
||||
+28
-3
@@ -606,7 +606,13 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
||||
// (posture). The device fingerprint is NOT sent here: it is bound at
|
||||
// pairing from the operator CSR; the reverse lanes are admitted on the bearer's
|
||||
// binding to that cert, which the host enforces. Authed by the bearer.
|
||||
// Each beat's request carries the run's context, and a stopped run waits
|
||||
// for the heartbeat before it returns, so no beat of this run records a
|
||||
// time, reconciles grants into its folder or acts on a directive after
|
||||
// the pairing has stopped (an unpair or revoke removes those next).
|
||||
beatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(beatDone)
|
||||
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
|
||||
hostname, _ := os.Hostname()
|
||||
wgPub := ""
|
||||
@@ -616,8 +622,11 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
||||
}
|
||||
}
|
||||
beat := func() {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
payload := heartbeatPayload(cfg, dev, hostname, wgPub)
|
||||
req, err := http.NewRequest(http.MethodPost, hbURL, strings.NewReader(payload))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hbURL, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -629,11 +638,16 @@ 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
|
||||
// anchor host lets root re-anchor without its consent.
|
||||
recordPairingOK(cfg.HostName)
|
||||
// A Host that holds this machine says so; its lanes wait (lanehold.go).
|
||||
laneHoldFor(cfg.HostName).fromBeat(cfg.HostName, beatSaysQuarantined(body))
|
||||
}
|
||||
// Box-wide directives (update, contain, mirror) are anchor-only
|
||||
// on an anchored box — each takes this pairing's identity and
|
||||
@@ -694,6 +708,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 +857,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 +870,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,8 +888,12 @@ 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)
|
||||
laneHoldFor(cfg.HostName).fromBeat(cfg.HostName, beatSaysQuarantined(body))
|
||||
}
|
||||
maybeSelfUpdate(cfg.HostName, cfg.dcnDir(), body)
|
||||
maybeContain(cfg.HostName, cfg.dcnDir(), body)
|
||||
@@ -899,6 +923,7 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = srv.Close()
|
||||
<-beatDone
|
||||
return nil
|
||||
case err := <-errc:
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build linux
|
||||
|
||||
// A pairing's run and its heartbeat stop together (run.go): uses the serve
|
||||
// lane's fixtures (servelane_linux_test.go), so it carries the same build tag.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A pairing stopped while a beat is out acts on nothing that beat brings
|
||||
// back. The run waits for its heartbeat and the beat's request ends with the
|
||||
// run, so its last-OK time, and anything else the reply would have set off
|
||||
// (a grant reconcile into the pairing's folder, a contain, an update), never
|
||||
// lands after the stop, when an unpair or a revoke removes what is left.
|
||||
func TestAStoppedPairingsLastBeatWritesNothingAfterTheStop(t *testing.T) {
|
||||
h, cfg := serveLaneBox(t, "beat-stop.example.test")
|
||||
arrived, release := make(chan struct{}, 1), make(chan struct{})
|
||||
h.mu.Lock()
|
||||
h.beatArrived, h.beatRelease = arrived, release
|
||||
h.mu.Unlock()
|
||||
released := false
|
||||
t.Cleanup(func() {
|
||||
if !released {
|
||||
close(release)
|
||||
}
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- runPairing(ctx, cfg) }()
|
||||
select {
|
||||
case <-arrived:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("no beat reached the Host")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the pairing did not stop while its beat was out")
|
||||
}
|
||||
// The Host answers the beat only now, after the run has returned.
|
||||
close(release)
|
||||
released = true
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
p, err := pairingStatePath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Fatal("the stopped pairing's last beat recorded its time after the stop")
|
||||
}
|
||||
}
|
||||
@@ -616,7 +616,11 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
|
||||
spawn = func() {
|
||||
go func() {
|
||||
wait := 3 * time.Second
|
||||
hold := laneHoldFor(self)
|
||||
for pctx.Err() == nil {
|
||||
if !hold.waitClear(pctx) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||
ws, resp, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||
cancel()
|
||||
@@ -635,6 +639,7 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
|
||||
sleepCtx(pctx, time.Second)
|
||||
continue
|
||||
}
|
||||
held := watchHeldClose(ws)
|
||||
stopKA := armWSKeepalive(ws)
|
||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||
took := serveJob(ws, self, dcnDir, spawn)
|
||||
@@ -644,6 +649,10 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
|
||||
if took {
|
||||
return
|
||||
}
|
||||
if held() {
|
||||
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||
continue
|
||||
}
|
||||
// Closed while parked (a Host restart), or the job refused: not
|
||||
// straight back, so a Host that closes or misuses every worker is
|
||||
// not dialled in a loop.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -83,7 +83,11 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
hold := laneHoldFor(self)
|
||||
for pctx.Err() == nil {
|
||||
if !hold.waitClear(pctx) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||
cancel()
|
||||
@@ -96,6 +100,7 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
|
||||
sleepCtx(pctx, time.Second)
|
||||
continue
|
||||
}
|
||||
held := watchHeldClose(ws)
|
||||
stopKA := armWSKeepalive(ws)
|
||||
// A revoked pairing closes the socket under the session, which ends the
|
||||
// shell through the same path a host disconnect takes.
|
||||
@@ -104,6 +109,9 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
|
||||
unpark()
|
||||
stopKA()
|
||||
_ = ws.Close()
|
||||
if held() {
|
||||
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
+13
-2
@@ -1162,7 +1162,7 @@ func stepupPairing(host string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(cfgs) == 0 {
|
||||
return nil, fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
|
||||
return nil, fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
|
||||
}
|
||||
switch {
|
||||
case host != "":
|
||||
@@ -1341,7 +1341,7 @@ func readApprovals(o readOpts) (approvalsRead, error) {
|
||||
func cmdStepupApprovals(host string, machine, asJSON bool) error {
|
||||
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
||||
if err != nil {
|
||||
return laneRefusalError("approvals", err)
|
||||
return laneRefusalError(strings.TrimSpace(stepupCommand("approvals", host, machine, "")), err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, r)
|
||||
@@ -1388,6 +1388,9 @@ func findRequest(m map[string]stepupRequest, prefix string) (string, error) {
|
||||
|
||||
// privatae-bastion approve [--host <host> | --machine] <request id>
|
||||
func cmdStepupApprove(host string, machine bool, prefix string) error {
|
||||
if readsGoThroughLane() {
|
||||
return ownerApproveViaLane(host, machine, prefix)
|
||||
}
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1425,6 +1428,14 @@ func cmdStepupApprove(host string, machine bool, prefix string) error {
|
||||
|
||||
// privatae-bastion deny [--host <host> | --machine] <request id>
|
||||
func cmdStepupDeny(host string, machine bool, prefix string) error {
|
||||
if readsGoThroughLane() {
|
||||
done, err := actViaSock(agentAskRequest{Act: "deny", Host: host, Machine: machine, ID: prefix})
|
||||
if err != nil {
|
||||
return laneRefusalError(stepupCommand("deny", host, machine, prefix), err)
|
||||
}
|
||||
fmt.Println(done)
|
||||
return nil
|
||||
}
|
||||
t, err := resolveStepupTarget(host, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+113
-44
@@ -3,7 +3,7 @@ package main
|
||||
// switch_cli.go — the local switch (localdeny.go) from a terminal.
|
||||
//
|
||||
// privatae-bastion switch <host> <family> on|off
|
||||
// privatae-bastion switch --list
|
||||
// privatae-bastion switch --list [--json]
|
||||
//
|
||||
// The console page has had this switch since localdeny.go was written; a
|
||||
// machine with no browser and no person at it (a server the bastion guards
|
||||
@@ -14,10 +14,11 @@ package main
|
||||
// MACHINE AUTHORITY, for both directions. On a machine that serves a Host it
|
||||
// does not belong to the person using that Host, the switch is part of how
|
||||
// the machine was set up, and the one who stands behind that is whoever
|
||||
// administers the box. --list only reads, and needs no authority of its own;
|
||||
// beside a system service on macOS or Windows an ordinary account is refused
|
||||
// it (serviceGuard, sysservice.go), because it would read that account's own
|
||||
// state rather than the machine's.
|
||||
// administers the box. --list only reads, and needs no authority of its own:
|
||||
// beside a system service on Linux or macOS the owner's account reads the
|
||||
// machine's list through the lane (reads.go); on Windows an ordinary account
|
||||
// is refused it (serviceGuard, sysservice.go), because it would read that
|
||||
// account's own state rather than the machine's.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -29,14 +30,26 @@ import (
|
||||
)
|
||||
|
||||
const switchUsage = "usage: sudo -H privatae-bastion switch <host> <family> on|off\n" +
|
||||
" privatae-bastion switch --list\n" +
|
||||
" privatae-bastion switch --list [--json]\n" +
|
||||
" sudo -H privatae-bastion switch --reset (only when the list cannot be read)"
|
||||
|
||||
// cmdSwitch parses the arguments and runs one switch, the listing, or the
|
||||
// reset of an unreadable list.
|
||||
func cmdSwitch(args []string) error {
|
||||
if len(args) == 1 && args[0] == "--list" {
|
||||
return switchList(os.Stdout)
|
||||
if len(args) >= 1 && args[0] == "--list" {
|
||||
asJSON, err := jsonOnly(switchUsage, args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := readMachine("switches", readOpts{}, readSwitches)
|
||||
if err != nil {
|
||||
return laneRefusalError("switch --list", err)
|
||||
}
|
||||
if asJSON {
|
||||
return printJSON(os.Stdout, v)
|
||||
}
|
||||
printSwitches(os.Stdout, v)
|
||||
return nil
|
||||
}
|
||||
if len(args) == 1 && args[0] == "--reset" {
|
||||
// The same authority as switching anything back on: a reset leaves
|
||||
@@ -64,6 +77,25 @@ func cmdSwitch(args []string) error {
|
||||
default:
|
||||
return fmt.Errorf("the last word is on or off, not %q\n%s", args[2], switchUsage)
|
||||
}
|
||||
if readsGoThroughLane() {
|
||||
// The owner's account, beside the machine's bastion: the console's
|
||||
// rule, through its ask socket (owneracts.go). Off takes reach away and
|
||||
// needs nothing; on takes the owner's code.
|
||||
req := agentAskRequest{Act: "switch", Host: args[0], Family: args[1], Off: off}
|
||||
if !off {
|
||||
code, ok := promptOwnerCode("Switch " + args[0] + " " + args[1] + " back on? Enter the code from your authenticator (or leave empty to cancel): ")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
req.Code = code
|
||||
}
|
||||
done, err := actViaSock(req)
|
||||
if err != nil {
|
||||
return laneRefusalError("switch "+strings.Join(args, " "), err)
|
||||
}
|
||||
fmt.Println(done)
|
||||
return nil
|
||||
}
|
||||
if err := requireMachineAuthority("switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,11 +107,7 @@ func cmdSwitch(args []string) error {
|
||||
if err := localDenySet(host, family, off); err != nil {
|
||||
return err
|
||||
}
|
||||
if off {
|
||||
fmt.Printf("%s %s off: this machine now refuses it, and tells the host why. Its grant is untouched.\n", host, family)
|
||||
} else {
|
||||
fmt.Printf("%s %s on: the refusal is cleared, up to whatever its signed grant already allowed.\n", host, family)
|
||||
}
|
||||
fmt.Println(switchDoneText(host, family, off))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -106,56 +134,97 @@ func switchResolveHost(key string) (string, error) {
|
||||
return c.HostName, nil
|
||||
}
|
||||
|
||||
// switchList prints one line per paired host and family:
|
||||
//
|
||||
// <host> <family> on|off [since <time>]
|
||||
//
|
||||
// Plain columns so a setup script can read it as well as a person.
|
||||
func switchList(w io.Writer) error {
|
||||
// switchRow is one family of one paired host: off (refused here) or on.
|
||||
type switchRow struct {
|
||||
Host string `json:"host"`
|
||||
Family string `json:"family"`
|
||||
Off bool `json:"off"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// switchesRead is the local switch as it stands. Unreadable: the list cannot
|
||||
// be read, so the gate refuses everything (localDenied) and every row is off.
|
||||
type switchesRead struct {
|
||||
Switches []switchRow `json:"switches"`
|
||||
Unreadable string `json:"unreadable,omitempty"`
|
||||
// StateDir is the bastion state that was read, named when nothing is
|
||||
// paired in it: an ordinary account's own state is not the machine's.
|
||||
StateDir string `json:"state_dir,omitempty"`
|
||||
}
|
||||
|
||||
func readSwitches(readOpts) (switchesRead, error) {
|
||||
v := switchesRead{Switches: []switchRow{}}
|
||||
cfgs, err := loadPairings()
|
||||
if err != nil {
|
||||
return err
|
||||
return v, err
|
||||
}
|
||||
if len(cfgs) == 0 {
|
||||
// Name the state that was read. On Linux the root service keeps its
|
||||
// state under /root, and an ordinary account's --list reads its own,
|
||||
// so "no host is paired with this machine" would be untrue there.
|
||||
dir, _ := configDirPath()
|
||||
fmt.Fprintf(w, "no host is paired in this account's bastion state (%s); "+
|
||||
"the machine's bastion may keep its state under another account (%s)\n", dir, adminHowTo())
|
||||
return nil
|
||||
v.StateDir, _ = configDirPath()
|
||||
return v, nil
|
||||
}
|
||||
localDenyMu.Lock()
|
||||
f, readErr := localDenyLoad()
|
||||
localDenyMu.Unlock()
|
||||
if readErr != nil {
|
||||
// The gate refuses everything while the list cannot be read
|
||||
// (localDenied), so every line says off, and the warning says why.
|
||||
fmt.Fprintf(w, "warning: %s\n", localDenyUnreadableText(readErr))
|
||||
v.Unreadable = localDenyUnreadableText(readErr)
|
||||
}
|
||||
names := hostNames(cfgs)
|
||||
sort.Strings(names)
|
||||
fams := localSwitchFamilies()
|
||||
for _, host := range names {
|
||||
off := map[string]bool{}
|
||||
for _, fam := range f.Off[host] {
|
||||
off[fam] = true
|
||||
if readErr == nil {
|
||||
for _, fam := range f.Off[host] {
|
||||
off[fam] = true
|
||||
}
|
||||
}
|
||||
for _, fam := range fams {
|
||||
if readErr != nil {
|
||||
fmt.Fprintln(w, host+" "+fam+" off")
|
||||
continue
|
||||
r := switchRow{Host: host, Family: fam, Off: readErr != nil || off[fam]}
|
||||
if readErr == nil && off[fam] {
|
||||
r.Since = f.At[host+"/"+fam]
|
||||
}
|
||||
if off[fam] {
|
||||
line := host + " " + fam + " off"
|
||||
if at := f.At[host+"/"+fam]; at != "" {
|
||||
line += " since " + at
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(w, host+" "+fam+" on")
|
||||
v.Switches = append(v.Switches, r)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// switchList is the listing of this process's own state, in words.
|
||||
func switchList(w io.Writer) error {
|
||||
v, err := readSwitches(readOpts{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printSwitches(w, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSwitches prints one line per paired host and family:
|
||||
//
|
||||
// <host> <family> on|off [since <time>]
|
||||
//
|
||||
// Plain columns so a setup script can read it as well as a person.
|
||||
func printSwitches(w io.Writer, v switchesRead) {
|
||||
if len(v.Switches) == 0 {
|
||||
// Name the state that was read: an ordinary account's own is not
|
||||
// the machine's, so "nothing is paired with this machine" could be
|
||||
// untrue.
|
||||
fmt.Fprintf(w, "no host is paired in the bastion state at %s\n", v.StateDir)
|
||||
return
|
||||
}
|
||||
if v.Unreadable != "" {
|
||||
// The gate refuses everything while the list cannot be read
|
||||
// (localDenied), so every line says off, and the warning says why.
|
||||
fmt.Fprintf(w, "warning: %s\n", v.Unreadable)
|
||||
}
|
||||
for _, r := range v.Switches {
|
||||
line := r.Host + " " + r.Family + " on"
|
||||
if r.Off {
|
||||
line = r.Host + " " + r.Family + " off"
|
||||
if r.Since != "" {
|
||||
line += " since " + r.Since
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
}
|
||||
|
||||
+51
-6
@@ -98,17 +98,33 @@ func serviceStateEnv(goos string, installed, authority bool, systemRoot string)
|
||||
}
|
||||
|
||||
// serviceGuardedCommands would each act on a bastion in the caller's own
|
||||
// profile. Beside a system service that is a second bastion (pair, run) or a
|
||||
// view of an empty one (status, `switch --list`, activity and report, which
|
||||
// would print the caller's own records as though they were the machine's).
|
||||
// profile. Beside a system service that is a second bastion (pair, run), a
|
||||
// switch of its own, or a view of an empty one (status, `switch --list`,
|
||||
// activity, report and doctor, which would print the caller's own records,
|
||||
// or check its own empty bastion, as though they were the machine's).
|
||||
var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status": true, "switch": true,
|
||||
"activity": true, "report": true}
|
||||
"switch --list": true, "activity": true, "report": true, "doctor": true, "ops": true,
|
||||
"device-approvals": true, "backup read": true,
|
||||
// The narrowing acts (owneracts.go), and device-approve, which stays the
|
||||
// administrator's (the console keeps it adminOnly): from an ordinary
|
||||
// account each read that account's own, empty approvals, grants or asks.
|
||||
"approvals drop": true, "grants": true, "device-deny": true, "device-approve": true,
|
||||
// The machine's approvals: read, approved and denied through the owner's
|
||||
// socket on Linux and macOS (reads.go, owneracts.go); on Windows the tray
|
||||
// answers them over its own pipe, and these would read the account's own.
|
||||
"approvals": true, "approve": true, "deny": true}
|
||||
|
||||
// serviceLaneReads are the guarded commands that, on Linux and macOS, read
|
||||
// the machine's bastion itself over its socket (reads.go) rather than a
|
||||
// bastion of the caller's own: not refused here, and refused there for any
|
||||
// account but the owner's.
|
||||
var serviceLaneReads = map[string]bool{"status": true, "activity": true, "report": true}
|
||||
var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true,
|
||||
"doctor": true, "ops": true, "device-approvals": true, "backup read": true,
|
||||
// Not reads: the owner's account switches, and takes reach back (`ops
|
||||
// drop` is guarded as "ops"), through the same socket, with the console's
|
||||
// rules (owneracts.go), and any other account is refused there.
|
||||
"switch": true, "approvals drop": true, "grants": true, "device-deny": true,
|
||||
"approvals": true, "approve": true, "deny": true}
|
||||
|
||||
// serviceGuard refuses a guarded command from an account that does not
|
||||
// administer a machine whose bastion is a system service.
|
||||
@@ -372,8 +388,11 @@ func adoptSystemServiceState() {
|
||||
}
|
||||
}
|
||||
|
||||
// guardServiceFork is serviceGuard for this process.
|
||||
// guardServiceFork is serviceGuard for this process. `switch --list` is a
|
||||
// read and `switch` a change, and the backup reads are not the rest of
|
||||
// backup, so each is guarded apart.
|
||||
func guardServiceFork(cmd string) error {
|
||||
cmd = serviceGuardKey(cmd, os.Args)
|
||||
installed := systemService.installed
|
||||
if runtime.GOOS == "linux" {
|
||||
installed = linuxSystemUnitPresent()
|
||||
@@ -381,6 +400,32 @@ func guardServiceFork(cmd string) error {
|
||||
return serviceGuard(runtime.GOOS, cmd, installed, systemService.authority)
|
||||
}
|
||||
|
||||
// serviceGuardKey is the name the guard knows a command line by.
|
||||
func serviceGuardKey(cmd string, args []string) string {
|
||||
arg := func(i int) string {
|
||||
if len(args) > i {
|
||||
return args[i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case cmd == "switch" && arg(2) == "--list":
|
||||
return "switch --list"
|
||||
case cmd == "backup" && (arg(2) == "requests" || arg(2) == "shares" || (arg(2) == "key" && arg(3) == "show")):
|
||||
// The backup reads; the rest of backup works offline (reseal,
|
||||
// restore-plan) or is not yet asked of the machine's bastion.
|
||||
return "backup read"
|
||||
case cmd == "approvals" && len(args) > 2:
|
||||
// `approvals drop` is an act, the rest of approvals a read; the
|
||||
// flags may come first, as main takes them (takeHostFlag).
|
||||
_, rest := takeHostFlag(args[2:])
|
||||
if _, rest = takeMachineFlag(rest); len(rest) > 0 && rest[0] == "drop" {
|
||||
return "approvals drop"
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// cmdService is `privatae-bastion service install|uninstall|status`.
|
||||
func cmdService(args []string) error {
|
||||
if len(args) != 1 {
|
||||
|
||||
@@ -165,9 +165,9 @@ func TestWindowsSystemAppDataIsLocalSystemsProfile(t *testing.T) {
|
||||
|
||||
func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||
for _, goos := range []string{"darwin", "windows", "linux"} {
|
||||
for _, cmd := range []string{"pair", "run", "status", "switch", "activity", "report"} {
|
||||
for _, cmd := range []string{"pair", "run", "status", "switch", "switch --list", "activity", "report"} {
|
||||
err := serviceGuard(goos, cmd, true, false)
|
||||
if (cmd == "status" || cmd == "activity" || cmd == "report") && goos != "windows" {
|
||||
if (cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report") && goos != "windows" {
|
||||
// These read the machine's bastion over its socket (reads.go),
|
||||
// which answers the owner's account and refuses any other.
|
||||
if err != nil {
|
||||
@@ -199,7 +199,8 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Commands that do not start or show a bastion of the caller's own pass.
|
||||
for _, cmd := range []string{"context", "version", "approvals", "ui", "service"} {
|
||||
// (approvals did: TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused.)
|
||||
for _, cmd := range []string{"context", "version", "ui", "service"} {
|
||||
if err := serviceGuard(goos, cmd, true, false); err != nil {
|
||||
t.Errorf("%s: `%s` was refused but does not fork the bastion: %v", goos, cmd, err)
|
||||
}
|
||||
@@ -443,3 +444,75 @@ func TestKeepOneLogAsideEmptiesInPlaceAndKeepsTheLastOne(t *testing.T) {
|
||||
t.Fatalf("the open writer's next line went elsewhere: the log holds %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// The narrowing acts beside a system service: on Linux and macOS they go
|
||||
// through the owner's lane (owneracts.go), so the guard lets them by; on
|
||||
// Windows there is no lane, and the ordinary account's own profile is a
|
||||
// separate, empty bastion, so they are refused with the administrator's way,
|
||||
// as `ops` (and so `ops drop`) already was. device-approve is the
|
||||
// administrator's on every system: the console keeps it adminOnly, and from
|
||||
// an ordinary account it read that account's own, empty asks.
|
||||
//
|
||||
// MUTATION: take "device-deny" out of serviceGuardedCommands. RESULT: red on
|
||||
// windows. Take "device-approve" out. RESULT: red on every system.
|
||||
func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
|
||||
lines := map[string][]string{
|
||||
"approvals drop": {"privatae-bastion", "approvals", "--host", "box-one.example", "drop", "abcd1234"},
|
||||
"grants": {"privatae-bastion", "grants", "drop", "7"},
|
||||
"device-deny": {"privatae-bastion", "device-deny", "abcd1234"},
|
||||
"ops": {"privatae-bastion", "ops", "drop", "--all"},
|
||||
}
|
||||
for want, args := range lines {
|
||||
if got := serviceGuardKey(args[1], args); got != want {
|
||||
t.Errorf("%v is guarded as %q, want %q", args[1:], got, want)
|
||||
}
|
||||
}
|
||||
if got := serviceGuardKey("approvals", []string{"privatae-bastion", "approvals", "--machine"}); got != "approvals" {
|
||||
t.Errorf("the approvals read is guarded as %q", got)
|
||||
}
|
||||
approve := serviceGuardKey("device-approve", []string{"privatae-bastion", "device-approve", "all"})
|
||||
for _, goos := range []string{"linux", "darwin", "windows"} {
|
||||
for key := range lines {
|
||||
err := serviceGuard(goos, key, true, false)
|
||||
if goos == "windows" && err == nil {
|
||||
t.Errorf("windows: `%s` from an ordinary account acted on its own empty bastion", key)
|
||||
}
|
||||
if goos != "windows" && err != nil {
|
||||
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, key, err)
|
||||
}
|
||||
if err := serviceGuard(goos, key, true, true); err != nil {
|
||||
t.Errorf("%s: the administrator's `%s` was refused: %v", goos, key, err)
|
||||
}
|
||||
}
|
||||
err := serviceGuard(goos, approve, true, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "`device-approve`") {
|
||||
t.Errorf("%s: device-approve from an ordinary account beside the service: %v", goos, err)
|
||||
}
|
||||
if err := serviceGuard(goos, approve, true, true); err != nil {
|
||||
t.Errorf("%s: the administrator's device-approve was refused: %v", goos, err)
|
||||
}
|
||||
if err := serviceGuard(goos, approve, false, false); err != nil {
|
||||
t.Errorf("%s: device-approve on a machine with no system service was refused: %v", goos, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's approvals beside a system service: through the owner's
|
||||
// socket on Linux and macOS, refused on Windows (the tray answers them there).
|
||||
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
|
||||
for _, line := range [][]string{
|
||||
{"privatae-bastion", "approvals", "--machine"},
|
||||
{"privatae-bastion", "approve", "--machine", "abcd1234"},
|
||||
{"privatae-bastion", "deny", "abcd1234"},
|
||||
} {
|
||||
key := serviceGuardKey(line[1], line)
|
||||
if err := serviceGuard("windows", key, true, false); err == nil {
|
||||
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin"} {
|
||||
if err := serviceGuard(goos, key, true, false); err != nil {
|
||||
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.60.0
|
||||
0.61.2
|
||||
|
||||
@@ -24,8 +24,49 @@
|
||||
# reads a `go test -json` run instead and names every cited test that
|
||||
# skipped in it, with the reason it gave. It exits non-zero if any did: a CI job
|
||||
# that runs a claim's test where it cannot run proves nothing about the claim.
|
||||
#
|
||||
# deploy/check-claims.sh --failed <go test -json output>
|
||||
#
|
||||
# names every test that failed in such a run, with the last lines it printed,
|
||||
# and the package's own lines when the package failed outside any test (a
|
||||
# panic, a build error, a timeout), so a failed CI job says why. It exits
|
||||
# non-zero if anything failed.
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
if [ "${1:-}" = "--failed" ]; then
|
||||
python3 - "${2:?usage: deploy/check-claims.sh --failed <go test -json output>}" <<'PY'
|
||||
import json, sys
|
||||
|
||||
out, failed, pkg_out, pkg_failed = {}, [], [], False
|
||||
for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except ValueError:
|
||||
pkg_out.append(line.rstrip("\n")) # what go itself printed: a build error
|
||||
continue
|
||||
t, a = e.get("Test"), e.get("Action")
|
||||
if a == "output":
|
||||
(out.setdefault(t, []) if t else pkg_out).append(e.get("Output", "").rstrip("\n"))
|
||||
elif a == "fail":
|
||||
if t:
|
||||
failed.append(t)
|
||||
else:
|
||||
pkg_failed = True
|
||||
for t in failed[:40]:
|
||||
print("FAILED " + t)
|
||||
for l in out.get(t, [])[-60:]:
|
||||
print(" " + l)
|
||||
if len(failed) > 40:
|
||||
print("... and %d more failed tests" % (len(failed) - 40))
|
||||
if pkg_failed and not failed:
|
||||
print("the package failed outside any test:")
|
||||
for l in pkg_out[-80:]:
|
||||
print(" " + l)
|
||||
print("%d tests failed" % len(failed))
|
||||
sys.exit(1 if failed or pkg_failed else 0)
|
||||
PY
|
||||
exit $?
|
||||
fi
|
||||
if [ "${1:-}" = "--skipped" ]; then
|
||||
RUN="$(cd "$(dirname "${2:?usage: deploy/check-claims.sh --skipped <go test -json output> [repo root]}")" && pwd)/$(basename "$2")"
|
||||
ROOT="${3:-$HERE}"
|
||||
|
||||
+85
-2
@@ -466,11 +466,11 @@ if [ "$networked" = 1 ] && [ "$root" != 1 ]; then
|
||||
fi
|
||||
|
||||
key="$os-$arch"
|
||||
tmp=$(mktemp); man=$(mktemp)
|
||||
tmp=$(mktemp); man=$(mktemp); vdir=$(mktemp -d)
|
||||
# Any exit before the final mv must leave nothing behind and install nothing:
|
||||
# this script is piped to `sudo sh`, so a partial run is a root-privileged
|
||||
# partial install.
|
||||
trap 'rm -f "$tmp" "$man"' EXIT INT TERM
|
||||
trap 'rm -f "$tmp" "$man"; rm -rf "$vdir"' EXIT INT TERM
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
@@ -485,8 +485,91 @@ else die "no sha256sum or shasum on this system — cannot verify the download,
|
||||
fi
|
||||
sha_of() { $SHACMD "$1" | cut -d' ' -f1; }
|
||||
|
||||
# ── the release signature ──────────────────────────────────────────────────
|
||||
#
|
||||
# The manifest names each binary's digest, and the download is checked against
|
||||
# it. The manifest itself is signed with a release key whose public half is
|
||||
# pinned here, the same keys a running bastion checks every update with
|
||||
# (client-go/release_sign.go productionReleaseKeys; install_sig_sh_test.go
|
||||
# holds the two to each other). So a copy of this script read on GitHub and run
|
||||
# from there as root, or with --just-me, installs only a release we signed
|
||||
# (possibly an older one), whatever the download host serves. Run without
|
||||
# sudo, the copy does not install for the whole machine itself: its root step
|
||||
# fetches this installer from the website again (whole_machine_script) and
|
||||
# runs that. The signature is over one statement, as verifyReleaseSignature
|
||||
# builds it: privatae-release-signature-v1, the kind, the version, and the
|
||||
# manifest's sha256, a line each; it sits beside the manifest at
|
||||
# version.json.<the first 16 hex of that sha256>.sig, in base64.
|
||||
#
|
||||
# Checked with an openssl that does Ed25519 (OpenSSL 3, on most Linux; the
|
||||
# openssl@3 Homebrew installs on a Mac). A probe signature made for this tells
|
||||
# "this openssl cannot check" from "the signature is wrong": the first prints a
|
||||
# note and keeps the digest check alone, which is what the script did before;
|
||||
# the second refuses to install.
|
||||
RELEASE_KEYS='MCowBQYDK2VwAyEAtWOyZm38DwTwbawSnvExQG/CQsVJBKYWc46arCBZj9E= MCowBQYDK2VwAyEACKL+J0VSdqrfY4AmxChvylJ1ha4e8GvcrYF2o17Ah+g='
|
||||
SIG_PROBE_KEY='MCowBQYDK2VwAyEA1TRy5NeI2n7tieFf6cHe5VE69SV0XL7Vx4k6EwFlFFk='
|
||||
SIG_PROBE_SIG='Do3fnwjyv8QgR+9sJcc0A3Sr0XzSKqBW8DK8kzaJL29tS8KxuStc2tp+HheJvoGwXmrH2kg7YN+yDLKVDnDqDQ=='
|
||||
|
||||
# release_signature_check <manifest> <empty scratch dir>: returns when the
|
||||
# manifest's signature verifies, or when no openssl here can check one (said);
|
||||
# exits when it does not verify.
|
||||
release_signature_check() {
|
||||
rs_man=$1 rs_dir=$2
|
||||
rs_pem() { printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$1" > "$2"; }
|
||||
rs_ssl=""
|
||||
rs_pem "$SIG_PROBE_KEY" "$rs_dir/probe.pem"
|
||||
printf 'privatae-installer-probe-v1\n' > "$rs_dir/probe.msg"
|
||||
printf 'privatae-installer-probe-v0\n' > "$rs_dir/probe.other"
|
||||
# The probe signature must verify over its message and not over another: an
|
||||
# openssl that passes every signature is no check, and is set aside like one
|
||||
# that cannot check, never taken for one.
|
||||
for c in openssl /opt/homebrew/opt/openssl@3/bin/openssl /usr/local/opt/openssl@3/bin/openssl; do
|
||||
command -v "$c" >/dev/null 2>&1 || continue
|
||||
printf '%s' "$SIG_PROBE_SIG" | "$c" base64 -d -A -out "$rs_dir/probe.sig" 2>/dev/null || continue
|
||||
if "$c" pkeyutl -verify -pubin -inkey "$rs_dir/probe.pem" -rawin -in "$rs_dir/probe.msg" \
|
||||
-sigfile "$rs_dir/probe.sig" >/dev/null 2>&1 &&
|
||||
! "$c" pkeyutl -verify -pubin -inkey "$rs_dir/probe.pem" -rawin -in "$rs_dir/probe.other" \
|
||||
-sigfile "$rs_dir/probe.sig" >/dev/null 2>&1; then
|
||||
rs_ssl=$c; break
|
||||
fi
|
||||
done
|
||||
if [ -z "$rs_ssl" ]; then
|
||||
echo "NOTE: no openssl here can check an Ed25519 signature, so the release's signature is not checked;"
|
||||
echo " the download is still checked against the manifest's digest, fetched over HTTPS."
|
||||
if [ "$os" = darwin ]; then
|
||||
echo " To check the signature too, install OpenSSL 3 (brew install openssl@3) and run this again."
|
||||
else
|
||||
echo " To check the signature too, install OpenSSL 3 from your distribution and run this again."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
rs_sum=$(sha_of "$rs_man")
|
||||
case "$rs_sum" in *[!0-9a-f]* | "") die "cannot take the manifest's digest — refusing to install unverified" ;; esac
|
||||
rs_ver=$(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$rs_man" | head -n 1)
|
||||
case "$rs_ver" in
|
||||
"" | *[!0-9A-Za-z.+-]*) die "the manifest names no usable version — refusing to install unverified" ;;
|
||||
esac
|
||||
rs_short=$(printf '%s' "$rs_sum" | cut -c1-16)
|
||||
curl -fsSL --max-filesize 4096 "$base/version.json.$rs_short.sig" -o "$rs_dir/sig.b64" \
|
||||
|| die "cannot fetch the release's signature ($base/version.json.$rs_short.sig) — refusing to install unverified"
|
||||
tr -d ' \r\n' < "$rs_dir/sig.b64" | "$rs_ssl" base64 -d -A -out "$rs_dir/sig" 2>/dev/null \
|
||||
|| die "the release's signature is malformed — refusing to install"
|
||||
[ "$(wc -c < "$rs_dir/sig" | tr -d ' ')" = 64 ] || die "the release's signature is malformed — refusing to install"
|
||||
printf 'privatae-release-signature-v1\nbastion\n%s\n%s\n' "$rs_ver" "$rs_sum" > "$rs_dir/statement"
|
||||
for k in $RELEASE_KEYS; do
|
||||
rs_pem "$k" "$rs_dir/key.pem"
|
||||
if "$rs_ssl" pkeyutl -verify -pubin -inkey "$rs_dir/key.pem" -rawin -in "$rs_dir/statement" \
|
||||
-sigfile "$rs_dir/sig" >/dev/null 2>&1; then
|
||||
echo "Release signature verified (version $rs_ver)."
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
die "the release's signature does not verify against the pinned release keys — refusing to install"
|
||||
}
|
||||
|
||||
echo "Fetching manifest..."
|
||||
curl -fsSL "$base/version.json" -o "$man" || die "cannot fetch $base/version.json — refusing to install unverified"
|
||||
release_signature_check "$man" "$vdir"
|
||||
|
||||
# Pull this platform's expected digest out of version.json without needing jq or
|
||||
# python (neither is guaranteed on a clean macOS or a minimal container): find the
|
||||
|
||||
+8
-3
@@ -19,7 +19,10 @@ it shares with Linux is tested there, but it has not run on that system yet.
|
||||
| MCP tool servers and their problems | tested | built | built |
|
||||
| Changes since your baseline | tested | built | built |
|
||||
| Stop an agent with your code, from the console or `stop` | run | built | built |
|
||||
| An agent in another account asks (Waiting for you) | tested, with two accounts | built | not built (the ask socket is Unix only) |
|
||||
| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | not built (the ask socket is Unix only) |
|
||||
| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | not built (no ask pipe) |
|
||||
| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | not built (no ask pipe) |
|
||||
| The installer checks the release's signature | tested | built | not built (`install.ps1` checks the digest only) |
|
||||
| The console from the owner's own account, no sudo | run | built | the tray's approvals window only |
|
||||
| Activity, report | run | built | built |
|
||||
| `uninstall`, keeping the audit log | run | built | built |
|
||||
@@ -46,7 +49,9 @@ a relay image for one behind a home router.
|
||||
|
||||
- The bastion agent (folder search, a separate program) keeps the plain text it
|
||||
extracts from a shared folder's files in `<folder>/.agent`, inside the shared
|
||||
folder, where a sync client can copy it. The bastion never serves that
|
||||
folder. The fix moves it under the bastion's own state.
|
||||
folder, where a sync client can copy it, and two grants over the same
|
||||
folder (from two Hosts, say) share that one `.agent`. The bastion never
|
||||
serves that folder. The fix moves it under the bastion's own state, per
|
||||
pairing and store.
|
||||
- There is no MCP endpoint on the bastion, on purpose. An agent on the machine
|
||||
uses the command line ([for agents](skills/bastion.md)).
|
||||
|
||||
@@ -92,6 +92,7 @@ says so. Those parts are enforced only by the code as written.
|
||||
| | `ops_linux_test.go`: `TestOpDryRunShowsTheChangeAndApplyCommitsExactlyThat` | A dry run commits nothing; an apply must name the dry run's change list and carry an approval bound to that op, its parameters, the machine and that change list. Skipped where the sandbox is unavailable. |
|
||||
| | `ops_test.go`: `TestOpTermsMutationsAreEachRefused` | Each altered field of signed op terms is refused. |
|
||||
| | `ops_linux_test.go`: `TestOpSandboxShowsOnlyTheBaseAndWhatTheOpDeclared` | In a real sandbox, an op reads the file it declared and the system base; a file beside its own folder, a folder beside it and `/etc/shadow` read as empty; a declared path that has since become a link onto `/etc/ssh` refuses the run. Skipped where the sandbox cannot run. |
|
||||
| | `ops_linux_test.go`: `TestAnOpaqueNewFolderIsCreatedAndAReplacedOneIsRefused` | A folder an op left opaque over one the machine holds (a replaced tree) is refused, inside its effects or not; a new folder the kernel marks opaque is a created folder, and what it holds is read. |
|
||||
| | `ops_taskcap_linux_test.go`: `TestARootOpIsStoppedAtItsTaskCap` | A root op starting 100 background tasks is stopped at its scope's cap, and its report says the cap held. The scope is made by a stand-in for `systemd-run` that creates the cgroup directly. Skipped where the sandbox, systemd as init or a pids controller is unavailable. |
|
||||
| | `ops_view_test.go`: `TestOpSensitivePathsAreRefusedAtUnderOrAboveAndThroughLinks` | A declared path at, under or above a credential store, as written or through a link, is refused; a malformed `sensitive_paths` refuses every op. |
|
||||
| | `fuzz_paths_unix_test.go`: `FuzzAnOpEffectNeverCoversTheMachinesOwnRecord` | No accepted effect covers the bastion's own records or the system directories the sandbox masks. |
|
||||
@@ -313,6 +314,7 @@ says so. Those parts are enforced only by the code as written.
|
||||
| | `release_sign_agent_test.go`: `TestAnOlderSignedAgentManifestDoesNotDowngrade` | An older agent version is neither downloaded nor installed. |
|
||||
| | `claims_update_test.go`: `TestADigestMismatchNeverSwapsTheBinary` | A self-update whose download does not match the manifest's SHA-256 (another build's, empty, truncated) leaves the running binary byte-identical with nothing moved aside. |
|
||||
| | `claims_update_forward_test.go`: `TestTheForwardPathInstallsNothingButTheSignedTarget` | With no approval, a signed manifest for a version other than the target, and an unsigned manifest for the target, are never downloaded from; a signed manifest naming the target is. |
|
||||
| | `install_sig_sh_test.go`: `TestInstallShRefusesAReleaseWhoseSignatureDoesNotVerify` | Where the installer's openssl can check Ed25519, a manifest whose signature is another key's, another version's, malformed or missing is refused, and only one signed by a pinned release key passes; CI requires that openssl (`PRIVATAE_REQUIRE_OPENSSL`). |
|
||||
| | not covered by a test | Where the release key is kept; the installer's SHA-256 check. |
|
||||
| No secrets cached for git | `claims_git_test.go`: `TestTheGitHelperFetchesATokenPerAuthAndStoresNothing` | Each auth asks the daemon for a fresh token; store and erase do nothing; nothing is written to the bastion's config. |
|
||||
| | `claims_git_test.go`: `TestNoHelperOnTheMachineStoresTheForgeToken` | A real git reading the forge through the proxy, with git's `store` helper configured before `git-setup`'s lines, after them, or in the repository, authenticates with the host's token and writes it nowhere. |
|
||||
|
||||
+14
-8
@@ -22,11 +22,11 @@
|
||||
| `approvals drop [--host <host> \| --machine] <id>` | Take back an approval this machine issued and has not used yet. |
|
||||
| `grants drop <store id> [--host <host>]` | Remove a folder grant held on this machine. The host is told. |
|
||||
| `device-init` | Make this machine your approval device. |
|
||||
| `device-approvals` | What your machines are asking you to approve, in their words. |
|
||||
| `device-approvals [--json]` | What your machines are asking you to approve, in their words. The words listing records what it showed, which is all `device-approve all` signs; `--json` is for a program and records nothing. |
|
||||
| `device-approve <id>\|all [--code <code>]` / `device-deny <id>` | Approve or refuse from the device. `all` signs only the asks the last listing showed, and never a ship plan that runs root work: that takes `--code`. |
|
||||
| `pin-device <key>` / `unpin-device <key>` | On a machine you govern: accept (or stop accepting) approvals signed by your device. Root required. Prints the `pin-machine` line to run on the device. |
|
||||
| `pin-machine <host> <key>` / `unpin-machine <host>` | On your device: pin (or drop) a machine's key, as that machine printed it, so the device can sign ship plans that include it. |
|
||||
| `ops [--host <host>]` | The ops your host wrote that this machine holds, and the standing op grants. |
|
||||
| `ops [--host <host>] [--json]` | The ops your host wrote that this machine holds, and the standing op grants. |
|
||||
| `ops drop <grant> [--host <host>]` | Take back a standing op grant. The host is told. |
|
||||
| `coders set [--max N] [--mem MB] [--cpus F] [--engines a,b\|all] [--allow-lan host[:port],...] [--image-prefix <registry/path/>\|default]` | Size this machine for coding runs your hosts place here: how many at once, memory and CPUs per run, which engines, LAN hosts a run may reach, and where run images may come from (`<prefix>harness-<engine>@sha256:...`; default `git.privatae.ai/privatae-harness/`). Does not switch them on. |
|
||||
| `coders on` / `coders off` | Allow coding runs here (Linux, bastion running as root), filling unset sizes with defaults; or stop new ones. Off by default. Each run is a container with no network of its own. Through this bastion it reaches the public internet, and never this machine or its LAN unless you named that host with `--allow-lan`. |
|
||||
@@ -46,10 +46,12 @@
|
||||
| `report [--since 7d] [--json] [--out FILE \| --out -]` | Write what this bastion knows as one Markdown file (or JSON): a summary of the problems first, then the agents running now, scheduled jobs that start an agent, MCP tool servers and their problems, changes since the baseline, and the activity in the period. No AI model writes any of it; last prompts and secrets are left out. Default file: `reports/` in the config directory of the account that ran it; `--out -` prints it instead. |
|
||||
| `activity [--since 7d] [--json]` | What happened on this machine, newest first: stops (by you or a Host, and stops refused), your approvals, what each Host did here, Hosts paired or removed, programs marked as yours, capabilities switched off. Read from the records this bastion already keeps. |
|
||||
| `agents [--json] [--prompts]` | Every AI agent on this machine and what it is doing: for Claude Code, the session's name and title, whether it is waiting for you and on what, and its permission mode, read from Claude Code's own session files; for every agent, its folder and git branch, how long it has run, who started it, what it is running now (secrets redacted) and the tool servers it started with itself. Names and titles are what each agent says about itself. `--prompts` adds each session's last prompt. Nothing here is sent to a Host. |
|
||||
| `agents show <session name or pid> [--json] [--prompts]` | One agent with everything the bastion knows about it: the lines `agents` prints, then what it is, its pid and parent, the file the system says it runs, its account, what it runs now, its tool servers and what it says about itself. A session name must match exactly one session. Its command line is in `--json` only, since arguments can carry a key. |
|
||||
| `doctor [--json]` | What is wrong with this install, and the fix for each one: whether the bastion runs, whether a newer version is published, the machine's owner account (installed for the whole machine), its second factor, accounts whose asks are held, each Host pairing, the switch list, and the ops sandbox. Exit status 1 when it finds a problem. A newer version it could not read from privatae.ai is not one. It asks privatae.ai at most once in six hours, the rate the update check keeps, and otherwise uses the last answer. |
|
||||
| `stop <session name or pid> [--wait 5m] [--json]` | Stop an agent this bastion sees, and every process it started, as the machine's owner. At a terminal it asks for your code (`stepup-enroll --machine`); run by an agent, it only files a request that you approve under Waiting for you in the console (or with `approve --machine`). `--wait` follows the request until it is answered, as `request` does. When the bastion is installed for the whole machine and the command runs in another account, it asks over the bastion's local ask socket, which names the asking account (and on Linux the asking agent) from the kernel; one account may have five requests waiting. The running bastion carries it out within two minutes of the approval or not at all, and never stops an app you are using, the bastion itself, or a process the scan does not see as an agent. |
|
||||
| `request <id> [--wait 5m] [--json]` | Where an ask stands, for the account that asked it: `waiting`, `approved` (being carried out), `stopped`, `not_stopped` (with the reason), `denied`, or `unknown` (no such ask from this account, or older than a day). Exit status 0 for stopped, 1 for not, 3 when still waiting after `--wait`. Another account's requests read as unknown. Every ask, approval, denial and stop is recorded with the asking account, the asking agent and the target, each with how it was started. |
|
||||
| `help` | Print every command. |
|
||||
| `switch <host> <family> on\|off` / `switch --list` / `switch --reset` | Switch a capability family off for one host on this machine, or back on; `--list` shows every host's families. If the list of switched-off families cannot be read, every family is refused to every host until `--reset` sets the unreadable file aside (kept, not deleted). Switching and resetting need root. |
|
||||
| `switch <host> <family> on\|off` / `switch --list [--json]` / `switch --reset` | Switch a capability family off for one host on this machine, or back on; `--list` shows every host's families. If the list of switched-off families cannot be read, every family is refused to every host until `--reset` sets the unreadable file aside (kept, not deleted). Switching and resetting need root, except from the machine's owner account beside a whole-machine install, which switches through the bastion's ask socket as the console does: off with nothing, on with the owner's code ([Acts from the owner's account](#acts-from-the-owners-account)). |
|
||||
| `approvals-owner --sid <SID> [--replace]` | Windows, beside the system service: name the one account whose tray icon may list and answer approvals. Administrator only. |
|
||||
| `approvals-owner --account <name> [--replace]` | Linux and macOS, beside the system service: name the machine's owner account, whose `ui` opens a limited console session without sudo. The installer records the account that ran it through sudo. Another account takes it over only with `--replace`, typed at a terminal. Administrator only. |
|
||||
| `tray` | Windows, beside the system service: your icon and approvals window, started at every sign-in. |
|
||||
@@ -62,19 +64,23 @@
|
||||
| `baseline diff [--json]` | What is running that the baseline does not hold. Administrator only, or the owner's account through the bastion installed for the whole machine. |
|
||||
| `observe:baseline [--force]`, `observe:baseline:show`, `observe:delta [--json]` | The same as `baseline record`, `baseline show` and `baseline diff`, under their old names. |
|
||||
| `backup key init [--replace] [--label <name>]` | Make this bastion's backup key and print the fields to register on your host. `--replace` keeps the old key. |
|
||||
| `backup key show` | The backup key this bastion holds, and the older ones it keeps. |
|
||||
| `backup requests [--host <host>]` | Restore requests your host sent this machine. |
|
||||
| `backup key show [--json]` | The backup key this bastion holds, and the older ones it keeps. The secret half never leaves its file. |
|
||||
| `backup requests [--host <host>] [--json]` | Restore requests your host sent this machine. `--json` leaves out the sealed keys a request carries (the one the host sent, and the one resealed for it) and keeps the digest of the one the host sent. |
|
||||
| `backup approve [--host <host>] <id>` / `backup deny …` | Answer one: approving seals that one backup's key to the requesting host. |
|
||||
| `backup reseal --sidecar <file> --to <host key>` | Seal one backup's key to a host's own key, offline. |
|
||||
| `backup restore-plan --dir <folder> [--stamp <stamp> --to <host key>]` | List the backups in a folder; reseal one for the host that replaces a lost one. |
|
||||
| `backup share --with <host> (--from <host> \| --dir <folder>)` | Let a paired host list and ask for the backups in another host's folder (or any folder). |
|
||||
| `backup shares --with <host>` / `backup unshare --with <host> <name>` | The folders shared with a host; take one back. |
|
||||
| `backup shares --with <host> [--json]` / `backup unshare --with <host> <name>` | The folders shared with a host; take one back. |
|
||||
| `version` | Print the version. |
|
||||
|
||||
Run `privatae-bastion` with no arguments for the built-in help.
|
||||
|
||||
## Reads from the owner's account
|
||||
|
||||
Installed for the whole machine, the bastion runs as root and keeps its records in root's state. On Linux and macOS, the account recorded as the machine's owner (`approvals-owner --account`, which the installer writes) reads them without sudo: `status`, `agents`, `schedules`, `tool-servers`, `posture`, `baseline show`, `baseline diff`, `activity`, `report` and `approvals` ask the running bastion over its local ask socket, which knows from the kernel which account is asking, and print what it answers. That is what the console already shows the owner, so an agent the owner runs can read it too; `posture`, which the console has no page for, adds the firewall checks, read by the bastion as the administrator. No read prints the delegation tag an agent carries, as the console never shows one. Nothing that changes anything goes this way; those still take the owner's code.
|
||||
Installed for the whole machine, the bastion runs as root and keeps its records in root's state. On Linux and macOS, the account recorded as the machine's owner (`approvals-owner --account`, which the installer writes) reads them without sudo: `status`, `agents` (and `agents show`), `schedules`, `tool-servers`, `posture`, `baseline show`, `baseline diff`, `activity`, `report`, `approvals`, `switch --list`, `ops`, `device-approvals`, `backup key show`, `backup requests`, `backup shares` and `doctor` ask the running bastion over its local ask socket, which knows from the kernel which account is asking, and print what it answers. That is what the console already shows the owner, so an agent the owner runs can read it too, and more that the console has no page for, all of it about the owner's own machine: `posture` adds the firewall checks, read by the bastion as the administrator; `ops` and the backup reads show what each Host holds here; `doctor` checks the install. Read this way, `device-approvals` records nothing as shown, and says that the asks are answered as the administrator. No read prints the delegation tag an agent carries, as the console never shows one. The few acts that go this way follow the console's rules for them ([Acts from the owner's account](#acts-from-the-owners-account)): what widens reach takes the owner's code.
|
||||
|
||||
Any other account is refused. `agents`, `schedules`, `tool-servers` and `posture` then show what that account can read itself, and say so; the rest print the refusal and the administrator's command. Comparing against the baseline, the posture checks and the report are reused for 30 seconds, and the bastion answers four reads at a time, so a polling agent cannot slow the machine.
|
||||
Any other account is refused. `agents`, `schedules`, `tool-servers` and `posture` then show what that account can read itself, and say so; the rest print the refusal and the administrator's command. Comparing against the baseline, the posture checks, the report and `doctor` are reused for 30 seconds, and the bastion answers four reads at a time, so a polling agent cannot slow the machine.
|
||||
|
||||
## Acts from the owner's account
|
||||
|
||||
The same account acts on the machine's bastion through the same socket, with the rules the console uses for it: `approve` (the machine's own asks with `--machine`, or a Host's) shows the request, takes the code from the owner's authenticator at the terminal, and asks the bastion to approve it, where wrong codes are counted and lock as the console's do; `switch <host> <family> on` takes the owner's code too. `deny`, `switch … off`, `approvals drop`, `grants drop`, `ops drop` and `device-deny` take nothing, since they only take reach away. Each act is a line in the machine's audit log naming the account. Any other account is refused, with the administrator's command. On Windows these still run from an Administrator terminal, and an ordinary account is told so. `device-approve` stays the administrator's on every system, as the console keeps it.
|
||||
|
||||
@@ -257,8 +257,10 @@
|
||||
agent download is checked the same way, and an installed agent is never
|
||||
replaced with an older one. The release key never reaches the machine that
|
||||
builds and publishes: each release waits until the key holder signs it on
|
||||
their own computer. The installer checks the SHA-256; see
|
||||
[SECURITY.md](../SECURITY.md) for what that does and does not prove.
|
||||
their own computer. The installer checks the SHA-256, and `install.sh`
|
||||
also checks the manifest's signature against the same pinned keys where the
|
||||
machine's openssl can check Ed25519; see [SECURITY.md](../SECURITY.md) for
|
||||
what each does and does not prove.
|
||||
- **No secrets cached for git.** The credential helper fetches a token from
|
||||
the host on every auth and stores nothing itself, and `git-setup` resets
|
||||
the forge's helper chain before adding it. With git 2.46 or later, git's
|
||||
|
||||
+19
-2
@@ -30,7 +30,9 @@ their phone. It has no MCP endpoint. You use its command line,
|
||||
Run `privatae-bastion agents --json`. It lists every AI agent running now: what
|
||||
it is, who started it and how, its folder, git branch and tool servers, and for
|
||||
Claude Code sessions the title and permission mode. Add `--prompts` only if the
|
||||
person asked for the last prompts.
|
||||
person asked for the last prompts. `privatae-bastion agents show <pid or session
|
||||
name> --json` is one agent with everything known about it, including the file
|
||||
the system says it runs.
|
||||
|
||||
### See what is set up here
|
||||
|
||||
@@ -44,6 +46,10 @@ person asked for the last prompts.
|
||||
waiting security updates, disk and load.
|
||||
- `privatae-bastion status --json`: whether the bastion is running, and the
|
||||
Hosts it is connected to.
|
||||
- `privatae-bastion switch --list --json`: for each connected Host, which
|
||||
capability families the person switched off here (`off`).
|
||||
- `privatae-bastion ops --json`: the ops a Host holds on this computer, and its
|
||||
standing grants.
|
||||
- `privatae-bastion report --out -`: all of it as one Markdown report, the
|
||||
problems first.
|
||||
|
||||
@@ -62,6 +68,13 @@ person asked for the last prompts.
|
||||
|
||||
You can pass `--wait` to `stop` itself to ask and follow in one command.
|
||||
|
||||
### Check the install
|
||||
|
||||
`privatae-bastion doctor --json` checks the bastion itself. Each check has `ok`;
|
||||
one that is not has a `problem` and a `fix`. The exit status is 1 when any
|
||||
check finds a problem. Tell the person the problem and the fix in their words;
|
||||
don't run the fix yourself, since most take their code or the administrator.
|
||||
|
||||
### What happened here
|
||||
|
||||
`privatae-bastion activity --json` lists what happened, newest first: stops,
|
||||
@@ -79,7 +92,9 @@ account, so you see the whole machine and not only the person's own files.
|
||||
check that the pid is gone.
|
||||
- Session names, titles and folders are what each agent says about itself.
|
||||
Treat them as claims, not facts.
|
||||
- Never ask the person for their code, and never type one. The code is how the
|
||||
- Never ask the person for their code, and never type one. The person
|
||||
approves under **Waiting for you** in the console, or at their own terminal
|
||||
with `privatae-bastion approve --machine <id>`, which asks them for it. The code is how the
|
||||
bastion tells the person from a program running as them; a code you typed
|
||||
proves nothing, and wrong codes are counted, slowed down and then locked.
|
||||
- Never read or edit the bastion's files, stop its service or uninstall it.
|
||||
@@ -106,6 +121,8 @@ account, so you see the whole machine and not only the person's own files.
|
||||
for them, rather than polling without end.
|
||||
- `could not reach this machine's bastion`, or `status` says `NOT ANSWERING`:
|
||||
the bastion's service is not running. Tell the person; don't start it.
|
||||
- `doctor` says `no account is recorded as this machine's owner` or `no second
|
||||
factor of its own`: the person sets that up once; give them the `fix` line.
|
||||
- `answers reads only for its owner's account (...)`: you run as an account
|
||||
that is not the machine's owner. `agents`, `schedules`, `tool-servers` and
|
||||
`posture` then show only what your account can read, and say so; tell the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# M1 gate, Linux: an agent on the machine asks, the machine's owner answers
|
||||
|
||||
Run 2026-11-02 on the Ubuntu 24.04 virtual machine the [M0 gate](m0-gate-linux-2026-10-31.md)
|
||||
used (qemu, user-mode networking), with no Host anywhere. The bastion is
|
||||
installed for the whole machine (its systemd unit, running as root), and
|
||||
`tester` is recorded as the machine's owner. `other` is a second account with
|
||||
no sudo. The binary was built from the 0.61.0 release branch just before this
|
||||
record was added (`privatae-bastion version`: `0.61.0+090bdb8`) and put in
|
||||
place of the installed one.
|
||||
|
||||
The asker is an AI agent the bastion recognises: a program at
|
||||
`~/.local/bin/claude`, which it lists as Claude Code, started from a remote
|
||||
login (ssh). The target is `aider`, started every minute by a cron job in
|
||||
`tester`'s crontab. Both are stand-ins (`sh` and `sleep` under those names),
|
||||
which is how the bastion meets them anyway: by the program a process runs and
|
||||
how it was started.
|
||||
|
||||
macOS and Windows have not been run. On Windows an agent cannot ask yet,
|
||||
because the ask socket is Unix only ([STATUS](../STATUS.md)).
|
||||
|
||||
| Gate step | Result |
|
||||
|---|---|
|
||||
| The agent lists the AI agents on the machine (`agents --json`) | `aider` (started by a scheduled job) and Claude Code (started from a remote login), with pids and accounts |
|
||||
| The agent asks to stop `aider` (`stop <pid> --json`) | A request id and state `waiting`; the request names the agent that asked and the target |
|
||||
| The owner lists what waits, from their own account (`approvals --machine`) | The request, in the same words |
|
||||
| The owner approves with a wrong code | Refused; nothing stopped |
|
||||
| The owner approves with the code from their authenticator (`approve --machine <id>`) | Approved, and the bastion carried it out |
|
||||
| The agent follows its request (`request <id> --wait 30s --json`) | State `stopped`, outcome "You stopped aider"; the process is gone |
|
||||
| The agent asks to stop the next cron'd `aider`; the owner denies it (`deny --machine <id>`, no code) | Denied; the agent's `request` reads `denied` with exit status 1; `aider` keeps running |
|
||||
| The agent asks the same stop again | Refused: the owner refused this same stop within the last 24 hours |
|
||||
| `other` tries `approvals --machine`, `approve`, `deny`, `doctor` | Each refused, naming the owner's account, with the administrator's command as typed (`sudo -H privatae-bastion approve --machine <id>`); the request stays waiting |
|
||||
| `other` reads `agents --json` | Told the machine's bastion answers only its owner, and shown what that account can read itself |
|
||||
| `other` asks after the agent's request (`request <id>`) | `unknown`: no such request was asked from that account |
|
||||
| The machine's audit log | Each ask, approval and denial names the asking account, the asking agent (Claude Code, remote login) and the target (aider, scheduled job); each act from the owner's account is its own line naming that account |
|
||||
|
||||
Found and fixed while running M1 on this machine, before this record:
|
||||
`switch --list` was refused by the M0 guard meant for `switch`; a read the
|
||||
bastion answered but could not build came back as a refusal with the
|
||||
administrator's command; "not paired" named `pair` without `sudo -H`; an act
|
||||
refused for another account said "reads" and gave a bare `deny`; and
|
||||
`approve` and `approvals --machine` from another account were refused as a
|
||||
read and without `--machine`.
|
||||
|
||||
## Transcript (abridged)
|
||||
|
||||
```
|
||||
$ ~/.local/bin/claude -c 'privatae-bastion agents --json' # summarised
|
||||
aider pid 5162 origin scheduler user tester
|
||||
claude-code pid 5265 origin remote user tester
|
||||
|
||||
$ ~/.local/bin/claude -c 'privatae-bastion stop 5162 --json'
|
||||
{
|
||||
"request": "857285ef",
|
||||
"state": "waiting",
|
||||
"text": "Stop aider in /home/tester (pid 5162) on this machine, and every process it started. Asked from Claude Code (pid 5324), running as tester.",
|
||||
"asked_as": "tester"
|
||||
}
|
||||
|
||||
$ privatae-bastion approvals --machine
|
||||
[857285ef] asked 2026-11-02T05:23:07Z
|
||||
Stop aider in /home/tester (pid 5162) on this machine, and every process it started. Asked from Claude Code (pid 5324), running as tester.
|
||||
|
||||
$ privatae-bastion approve --machine 857285ef # a wrong code
|
||||
Approve this? Enter the code from your authenticator (or leave empty to cancel): error: code refused
|
||||
$ privatae-bastion approve --machine 857285ef
|
||||
Approve this? Enter the code from your authenticator (or leave empty to cancel): approved.
|
||||
|
||||
$ ~/.local/bin/claude -c 'privatae-bastion request 857285ef --wait 30s --json'
|
||||
{
|
||||
"request": "857285ef",
|
||||
"state": "stopped",
|
||||
"outcome": "You stopped aider (pid 5162) running on this machine.",
|
||||
...
|
||||
}
|
||||
|
||||
$ ~/.local/bin/claude -c 'privatae-bastion stop 6145 --json' # the next cron'd aider
|
||||
"request": "f090208b", "state": "waiting"
|
||||
|
||||
other$ privatae-bastion approve --machine f090208b
|
||||
error: this machine's bastion acts only for its owner's account (tester), not other
|
||||
As the administrator: sudo -H privatae-bastion approve --machine f090208b
|
||||
other$ privatae-bastion approvals --machine
|
||||
error: this machine's bastion answers reads only for its owner's account (tester), not other
|
||||
As the administrator: sudo -H privatae-bastion approvals --machine
|
||||
|
||||
$ privatae-bastion deny --machine f090208b
|
||||
denied.
|
||||
$ ~/.local/bin/claude -c 'privatae-bastion request f090208b --json'
|
||||
"state": "denied" (exit status 1)
|
||||
$ ~/.local/bin/claude -c 'privatae-bastion stop 6145 --json'
|
||||
"why": "the machine's owner refused this same stop within the last 24 hours, so it cannot be asked again yet"
|
||||
|
||||
# the machine's audit log (machine-factor/audit.jsonl), summarised
|
||||
05:23:07Z local_stop_ask asked to stop aider (pid 5162) asker: tester, claude-code, remote target: aider, scheduler
|
||||
05:23:08Z stepup_approve approved: local_stop|5162|aider asker: tester, claude-code, remote target: aider, scheduler
|
||||
05:23:08Z owner_lane_act approve the machine's request 857285ef…, from the owner's account tester through its ask socket
|
||||
05:24:04Z local_stop_ask asked to stop aider (pid 6145) asker: tester, claude-code, remote target: aider, scheduler
|
||||
05:24:06Z stepup_deny denied: local_stop|6145|aider asker: tester, claude-code, remote target: aider, scheduler
|
||||
05:24:06Z owner_lane_act deny the machine's request f090208b…, from the owner's account tester through its ask socket
|
||||
```
|
||||
Reference in New Issue
Block a user