4 Commits

Author SHA1 Message Date
Privatae 2043fa5e9e bastion 0.62.0
ci / fuzz (push) Successful in 3m27s
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m39s
2026-11-02 21:31:58 +00:00
Privatae 79646bda13 bastion 0.61.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m56s
ci / fuzz (push) Successful in 3m48s
2026-11-02 15:46:29 +00:00
Privatae d4594d18b7 bastion 0.61.1
ci / test (push) Successful in 5m45s
ci / personal-data (push) Successful in 9s
ci / fuzz (push) Successful in 3m30s
2026-11-02 08:00:14 +00:00
Privatae 1176f7fd0b bastion 0.61.0
ci / personal-data (push) Successful in 8s
ci / test (push) Successful in 7m17s
ci / fuzz (push) Successful in 6m33s
2026-11-02 06:20:42 +00:00
75 changed files with 5864 additions and 450 deletions
+5 -3
View File
@@ -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 ;;
+1 -1
View File
@@ -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"
+95
View File
@@ -22,6 +22,101 @@ 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.62.0
Windows, with the bastion installed for the whole machine (the
`PrivataeBastion` service), from the account that owns the machine:
- **`privatae-bastion ui` opens the console from your own account,** with no
Administrator terminal, as it does on Linux and macOS. It asks the service
over a new pipe, which knows your account from Windows and serves only the
account recorded as the machine's owner. Before this it signed in with a
key of your account's own, which the service's console refused with a bare
401.
- **Your agents and you reach the machine's bastion as on Linux and macOS:**
an agent asks to stop another (`stop`, `request`), reads the machine
(`agents`, `activity`, `status`, `doctor`, the `--json` reads), and you
answer from your own account (`approvals`, `approve`, `deny`, `switch`, the
drops). These were refused beside the service.
- **Moving to the service keeps the machine's pairings.** When an install
moves the bastion from your account to the service, the service takes over
your account's bastion state (pairings, keys, grants, audit) before it first
starts, and the copy in your profile is removed. Every Host it served goes
on being served, and nothing is paired again. The service started empty
before, and the machine went silent to its Hosts.
- **No dead ends.** A console page opened with no session names the command
that opens it on this machine. A second start says which bastion is already
running (its process, its program, how it was started) and how to open,
check or restart it. It no longer suggests ending the running one in Task
Manager.
- **An update never leaves a sign-in task's bastion stopped.** After a
self-update, the new copy waits for the old one to exit before it takes the
machine's run lock. It could find the lock still held, refuse as a second
copy, and leave nothing running.
## 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>
+4 -3
View File
@@ -3,7 +3,7 @@
[![License: GPL v3+](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE)
[![Go 1.26+](https://img.shields.io/badge/go-1.26%2B-00ADD8.svg)](client-go/go.mod)
[![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](#supported-platforms)
[![ci](https://git.privatae.ai/privatae/privatae-bastion/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.privatae.ai/privatae/privatae-bastion/actions)
[![ci](https://github.com/Privatae/bastion/actions/workflows/ci.yml/badge.svg?branch=main)](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
View File
@@ -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
+1 -1
View File
@@ -33,7 +33,7 @@ func cmdAddFolder(host, path, name string) error {
return err
}
if len(cfgs) == 0 {
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
}
// Multi-homed boxes must NAME the host (0.10.0): guessing here is how a folder
// ends up proposed to the wrong entity. One pairing needs no flag.
+49 -24
View File
@@ -8,7 +8,8 @@ package main
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
// managing the bastion for the person must be able to do (2026-10-30).
// This is the context socket's mechanism (contextsock.go) for a second
// question: any local account may connect; WHO connected comes from the
// question, and on Windows the service's ask pipe (asklane_windows.go), which
// answers through the same askAnswer: any local account may connect; WHO connected comes from the
// kernel, never from the request; it may only ASK. The ask lands in the
// owner's factor as a pending request (stepup_machine.go), shown on Waiting
// for you and answered with the owner's code. Nothing here approves anything.
@@ -39,7 +40,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 +49,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 {
@@ -75,7 +82,13 @@ var askPeerIdentityFn = contextPeerIdentity
func startAskSock(done <-chan struct{}) {
if runtime.GOOS == "windows" {
return // a named pipe, as context.sock has, is not built yet
// The ask pipe (asklane_windows.go). Only the service serves it: a
// bastion run by an account is that account's, and nobody else's
// requests are its to answer.
if runningAsService {
startAskPipe(done)
}
return
}
p := askSockPath()
ln, err := contextSockListen(p)
@@ -117,8 +130,7 @@ func askSockServe(c net.Conn) {
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
uid, user, err := askPeerIdentityFn(c)
if err != nil {
_ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" +
err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."})
_ = json.NewEncoder(c).Encode(askUnattributable(err))
return
}
var req agentAskRequest
@@ -126,26 +138,42 @@ func askSockServe(c net.Conn) {
_ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
return
}
if req.Op == "console" {
r := agentAskReply{User: user}
if link, err := consoleLaneEntry(uid, user); err != nil {
extend := func(d time.Duration) { _ = c.SetDeadline(time.Now().Add(d)) }
_ = json.NewEncoder(c).Encode(askAnswer(askPeer{UID: uid, Name: user}, req, extend,
func() int { return askPeerPIDFn(c) }))
}
// askUnattributable is the answer to a connection whose account the
// operating system would not name.
func askUnattributable(err error) agentAskReply {
return agentAskReply{Why: "this machine could not establish who is asking (" + err.Error() +
"), so nothing was asked. An ask nobody can attribute is one nobody can answer."}
}
// askAnswer is the lane's answer to one request from peer, on either
// platform: ask.sock here, the ask pipe on Windows. extend gives the answer
// more time where it takes longer (a read, an act); pid is the connecting
// process, asked only for a stop.
func askAnswer(peer askPeer, req agentAskRequest, extend func(time.Duration), pid func() int) any {
switch req.Op {
case "console":
r := agentAskReply{User: peer.Name}
if link, err := consoleLaneEntryFor(peer); err != nil {
r.Why = err.Error()
} else {
r.OK, r.URL = true, link
}
_ = json.NewEncoder(c).Encode(r)
return
return r
case "status":
return askStatus(existingMachineFactorDir(), req.Request, peer.Name, false)
case "read":
extend(readDeadline)
return serveReadFor(peer, req)
case "act":
extend(30 * time.Second)
return serveActFor(peer, req)
}
if req.Op == "status" {
_ = json.NewEncoder(c).Encode(askStatus(existingMachineFactorDir(), req.Request, user, false))
return
}
if req.Op == "read" {
_ = c.SetDeadline(time.Now().Add(readDeadline))
_ = json.NewEncoder(c).Encode(serveRead(uid, user, req))
return
}
_ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c)))
return fileAsk(req, peer.Name, pid())
}
// fileAsk turns one ask into a pending request of the machine, or says why not.
@@ -247,10 +275,7 @@ func askPendingBy(dir, user, purpose string) (n int, same bool) {
// askViaSock is the asking side: `stop` run where it cannot write the
// machine's state itself.
func askViaSock(req agentAskRequest) (agentAskReply, error) {
if runtime.GOOS == "windows" {
return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet")
}
c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second)
c, err := dialAskLane(askSockPath())
if err != nil {
return agentAskReply{}, err
}
+6 -3
View File
@@ -528,10 +528,13 @@ type beatLanes struct {
// noCoders: a tcp pairing, whose coder runs have no control plane to use
// (pair_tcp.go coderTransportRefusal), so it is never offered one.
noCoders bool
// dcnDir: the pairing's DCN state, whose managed agents this beat probes
// and reports (faManagedProbe, faManagedStatus); "" reports none.
dcnDir string
}
func beatLanesFor(cfg *Config) beatLanes {
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport()}
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport(), dcnDir: cfg.dcnDir()}
}
// buildHeartbeatBodyOn is buildHeartbeatBody for a pairing whose lanes are lanes.
@@ -658,7 +661,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
faAdvertised, faOK := freeagentProbe()
// managed instances (freeagent_managed.go) count the same way: any
// serving instance advertises the capability; all must serve for ok
if mAdv, mOK := faManagedProbe(); mAdv {
if mAdv, mOK := faManagedProbe(lanes.dcnDir); mAdv {
if !faAdvertised {
faAdvertised, faOK = true, mOK
} else {
@@ -1067,7 +1070,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
}
if faAdvertised {
body["freeagent_ok"] = faOK
if st := faManagedStatus(); len(st) > 0 {
if st := faManagedStatus(lanes.dcnDir); len(st) > 0 {
body["freeagent_stores"] = st
}
}
+122 -3
View File
@@ -18,11 +18,14 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]"
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]\n" +
" privatae-bastion agents show <pid|session> [--json] [--prompts]"
var familyLabels = map[string]string{
"claude-code": "Claude Code", "aider": "aider", "goose": "goose", "cline": "Cline",
@@ -75,6 +78,13 @@ func readAgents(o readOpts) (agentsRead, error) {
}
func cmdAgents(args []string) error {
show := ""
if len(args) >= 1 && args[0] == "show" {
if len(args) < 2 || strings.HasPrefix(args[1], "--") {
return errors.New(agentsUsage)
}
show, args = args[1], args[2:]
}
asJSON, prompts := false, false
for _, a := range args {
switch a {
@@ -90,6 +100,17 @@ func cmdAgents(args []string) error {
if err != nil {
return err
}
if show != "" {
a, err := pickAgentView(v.Agents, show)
if err != nil {
return err
}
if asJSON {
return printJSON(os.Stdout, a)
}
printAgentDetail(os.Stdout, a, sessionNowFn())
return nil
}
if asJSON {
return printJSON(os.Stdout, v)
}
@@ -97,6 +118,104 @@ func cmdAgents(args []string) error {
return nil
}
// pickAgentView is the agent `agents show` means: a pid, or a session's
// name as `agents` shows it, which must match exactly one (the rule `stop`
// keeps, resolveStopTarget).
func pickAgentView(views []agentView, key string) (agentView, error) {
if pid, err := strconv.Atoi(key); err == nil {
for _, v := range views {
if v.PID == pid {
return v, nil
}
}
return agentView{}, fmt.Errorf("no AI agent here has pid %d; `privatae-bastion agents` lists them", pid)
}
var hits []agentView
for _, v := range views {
if v.Self != nil && v.Self.Name == key {
hits = append(hits, v)
}
}
switch len(hits) {
case 0:
return agentView{}, fmt.Errorf("no running session is named %q; `privatae-bastion agents` lists them", key)
case 1:
return hits[0], nil
}
return agentView{}, fmt.Errorf("%d sessions are named %q; give the pid instead", len(hits), key)
}
// printAgentDetail is one agent with everything a read holds about it: the
// lines `agents` prints, then what it is, how it was recognised, and what it
// says about itself. Its command line is in --json only: arguments can carry
// a key, and words are what gets pasted.
func printAgentDetail(w io.Writer, v agentView, now time.Time) {
if v.Role == "service" {
fmt.Fprintf(w, "background service (pid %d): starts background sessions; not a session itself\n", v.PID)
} else {
l := agentLinesOf(v, now)
fmt.Fprintln(w, l.Head)
if l.State != "" {
fmt.Fprintln(w, " "+l.State)
}
if l.Where != "" {
fmt.Fprintln(w, " "+l.Where)
}
}
row := func(k, val string) {
if val != "" {
fmt.Fprintf(w, " %-15s %s\n", k+":", val)
}
}
row("agent", familyLabel(v.Signature))
row("kind", v.Kind)
row("pid", strconv.Itoa(v.PID))
if v.PPID > 0 {
row("parent pid", strconv.Itoa(v.PPID))
}
// The kernel names the file, but whoever placed it chose its path.
row("program", shownPath(v.Exe))
if v.Exe == "" {
row("program", "unknown (the system did not say which file it runs)")
}
row("account", v.User)
if len(v.RunningNow) > 0 {
row("running now", strings.Join(v.RunningNow, "; "))
}
if len(v.ToolServers) > 0 {
row("tool servers", strings.Join(v.ToolServers, ", "))
}
if s := v.Self; s != nil {
src := s.Source
if s.Verified {
src += " (names this process)"
} else {
src += " (does not name this process)"
}
row("self-reported", src)
row("session id", s.SessionID)
row("last asked", quoteIf(s.LastPrompt))
}
fmt.Fprintln(w, "Names, titles and statuses are what the agent says about itself.")
}
// shownPath is a path the agent chose (its folder) or placed (the file it
// runs), as printed: quoted when it holds control characters, so an escape
// sequence in a name never reaches the terminal or the page.
func shownPath(p string) string {
if strings.ContainsFunc(p, unicode.IsControl) {
return strconv.Quote(p)
}
return p
}
func quoteIf(s string) string {
if s == "" {
return ""
}
return "\"" + s + "\""
}
// printAgents writes the views grouped by agent, sessions waiting for the
// person first, apps last.
func printAgents(w io.Writer, views []agentView, now time.Time) {
@@ -228,7 +347,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
// No session record: name it by what it is and where it works.
l.Head = familyLabel(v.Signature)
if v.Folder != "" {
l.Head += " in " + filepath.Base(v.Folder)
l.Head += " in " + shownPath(filepath.Base(v.Folder))
}
}
if v.Kind == kindApp {
@@ -255,7 +374,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
l.State = strings.Join(state, " · ")
var where []string
if v.Folder != "" {
f := "in " + v.Folder
f := "in " + shownPath(v.Folder)
if v.GitBranch != "" {
f += " (" + v.GitBranch + ")"
}
+1 -1
View File
@@ -380,7 +380,7 @@ func gitBranchOf(dir string) string {
return cleanText(b, 80)
}
if len(h) >= 12 {
return "detached at " + h[:12]
return "detached at " + cleanText(h[:12], 12)
}
return ""
}
+9
View File
@@ -177,6 +177,15 @@ func winPersonSID(sid string) bool {
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-")
}
// pipeClientNewer: a process created after the connection was made cannot be
// the one that made it. The service finds the connecting process by its id
// (GetNamedPipeClientProcessId), and an id is reused once its process has
// exited and nothing holds it, so a client that hands its pipe to another
// process and exits could otherwise be answered as whoever got its id next.
// The connection time is taken as the service starts serving it, a moment
// after the connect.
func pipeClientNewer(created, connected time.Time) bool { return created.After(connected) }
// pipeClientRefusal says why a connected process may not use the lane, or ""
// when it may: it must run as the person signed in to its own session, in a
// session someone is in front of.
+33 -8
View File
@@ -161,6 +161,7 @@ type approvalPipeServer struct {
open int
sa *windows.SecurityAttributes
name *uint16
label string // the pipe's name, for a message
// stopEv is set when the service stops; it ends a connect that is waiting.
stopEv windows.Handle
}
@@ -190,7 +191,7 @@ func (s *approvalPipeServer) create() (*pipeConn, error) {
windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa)
if err != nil {
if s.open == 0 {
return nil, fmt.Errorf("cannot create %s; if another program holds that name the tray stays off: %w", approvalPipeName, err)
return nil, fmt.Errorf("cannot create %s; if another program holds that name, this lane stays off: %w", s.label, err)
}
return nil, err
}
@@ -230,11 +231,17 @@ func startApprovalPipe(stop <-chan struct{}) {
}
func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
sd, err := windows.SecurityDescriptorFromString(approvalPipeSDDL)
return servePipe(stop, ready, approvalPipeName, approvalPipeSDDL, servePipeConn)
}
// servePipe serves one of the service's pipes, name with the ACL sddl, until
// stop is closed, handing each connection to serve.
func servePipe(stop <-chan struct{}, ready func(), pipeName, sddl string, serve func(*pipeConn)) error {
sd, err := windows.SecurityDescriptorFromString(sddl)
if err != nil {
return fmt.Errorf("cannot build the pipe's permissions: %w", err)
}
name, err := windows.UTF16PtrFromString(approvalPipeName)
name, err := windows.UTF16PtrFromString(pipeName)
if err != nil {
return err
}
@@ -249,11 +256,12 @@ func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
s := &approvalPipeServer{
sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd},
name: name,
label: pipeName,
stopEv: stopEv,
}
// The loop and its no-gap rule are in approvalpipe.go, where the suite runs them.
return pipeAcceptLoop[*pipeConn](s, stop, pipeMaxClients, time.Second,
func(f string, a ...any) { fmt.Printf(f, a...) }, ready, servePipeConn)
func(f string, a ...any) { fmt.Printf(f, a...) }, ready, serve)
}
// servePipeConn answers one request and waits, briefly, for the client to
@@ -291,7 +299,12 @@ func servePipeConn(c *pipeConn) {
// (GetNamedPipeClientProcessId), its user and session from that process's
// token, and the person signed in to that session from the session's own token
// (WTSQueryUserToken, which LocalSystem may call).
//
// The process is found by its id, after it connected. An id that was freed
// and given to a new process in between would name the wrong one, so a
// process created after the connection is refused (pipeClientNewer).
func pipeClientOf(h windows.Handle) (pipeClient, error) {
connected := time.Now()
var c pipeClient
if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil {
return c, fmt.Errorf("cannot tell which process connected: %w", err)
@@ -301,6 +314,13 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
return c, fmt.Errorf("cannot open the connecting process (pid %d): %w", c.PID, err)
}
defer windows.CloseHandle(p)
var created, exited, kernel, user windows.Filetime
if err := windows.GetProcessTimes(p, &created, &exited, &kernel, &user); err != nil {
return c, fmt.Errorf("cannot read when the connecting process started (pid %d): %w", c.PID, err)
}
if pipeClientNewer(time.Unix(0, created.Nanoseconds()), connected) {
return c, fmt.Errorf("process %d started after the connection was made, so it is not the process that connected", c.PID)
}
var tok windows.Token
if err := windows.OpenProcessToken(p, windows.TOKEN_QUERY, &tok); err != nil {
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
@@ -381,7 +401,12 @@ func pipeCall(req pipeRequest) (pipeResponse, error) {
// (read, write data, synchronize; never FILE_APPEND_DATA, which GENERIC_WRITE
// would ask for), and lets the server identify this process but not act as it.
func dialApprovalPipe() (*pipeConn, error) {
name, err := windows.UTF16PtrFromString(approvalPipeName)
return dialServicePipe(approvalPipeName)
}
// dialServicePipe opens one of the service's pipes (dialApprovalPipe).
func dialServicePipe(pipeName string) (*pipeConn, error) {
name, err := windows.UTF16PtrFromString(pipeName)
if err != nil {
return nil, err
}
@@ -401,7 +426,7 @@ func dialApprovalPipe() (*pipeConn, error) {
continue
}
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
return nil, errors.New("the bastion service is not running, or is older than this tray")
return nil, errors.New("the bastion service is not running, or is older than this program")
}
return nil, fmt.Errorf("cannot reach the bastion service: %w", err)
}
@@ -413,14 +438,14 @@ func dialApprovalPipe() (*pipeConn, error) {
func pipeServerIsService(h windows.Handle) error {
var pid uint32
if err := windows.GetNamedPipeServerProcessId(h, &pid); err != nil {
return fmt.Errorf("cannot tell which process serves the approvals pipe: %w", err)
return fmt.Errorf("cannot tell which process serves the pipe: %w", err)
}
svcPID, err := bastionServicePID()
if err != nil {
return err
}
if svcPID == 0 || pid != svcPID {
return fmt.Errorf("the approvals pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName)
return fmt.Errorf("the pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName)
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package main
import (
"net"
"time"
)
// dialAskLane connects to the machine's bastion's ask socket at p.
func dialAskLane(p string) (askConn, error) {
return net.DialTimeout("unix", p, 3*time.Second)
}
// askLaneAddr is where the lane is, as a message names it.
func askLaneAddr(p string) string { return p }
// startAskPipe is the Windows service's; ask.sock serves here (agentask.go).
func startAskPipe(<-chan struct{}) {}
+153
View File
@@ -0,0 +1,153 @@
//go:build windows
package main
// The ask pipe: ask.sock (agentask.go) for a bastion that runs as the Windows
// service.
//
// Installed for the whole machine, the bastion is a LocalSystem service and
// its state sits in LocalSystem's profile, where the person's own account can
// neither read nor write it. Before this pipe, that account had no way to the
// machine's bastion at all: `privatae-bastion ui` signed in with a key of its
// own and the console answered 401, the reads and owner acts were refused
// beside the service, and an agent could not ask for a stop. The pipe answers
// exactly what ask.sock answers, through the same code (askAnswer): a stop
// asked by any account, and the console, the reads and the owner's acts for
// the account recorded as the machine's owner (approvals-owner --sid), with
// the caller taken from the pipe client's token, never from the request.
//
// The pipe is the approvals pipe's machinery (approvalpipe_windows.go):
// overlapped I/O with a timeout on every call, a new instance created before
// the one it replaces is handed out, the first instance created with
// FILE_FLAG_FIRST_PIPE_INSTANCE, and the asking side checking the pipe's
// server is the service's own process before it sends anything, since an act
// carries the owner's code.
import (
"encoding/json"
"errors"
"fmt"
"io"
"time"
"golang.org/x/sys/windows"
)
// startAskPipe serves the ask pipe until done is closed, retrying with backoff
// when it cannot (pipeServeRetrying), as the approvals pipe does.
func startAskPipe(done <-chan struct{}) {
wait := func(d time.Duration) bool {
select {
case <-done:
return false
case <-time.After(d):
return true
}
}
go pipeServeRetrying(func(ready func()) error {
return servePipe(done, ready, askPipeName, askPipeSDDL, serveAskPipeConn)
}, wait, func(f string, a ...any) { fmt.Printf(f, a...) })
}
// serveAskPipeConn answers one ask, then waits briefly for the client to
// close: closing first would discard an answer it has not read yet.
func serveAskPipeConn(c *pipeConn) {
client, cerr := pipeClientOf(c.h)
raw, err := c.readLine(askMaxBytes, pipeIOTimeout)
if err != nil {
return
}
var reply any
switch {
case cerr != nil:
reply = askUnattributable(cerr)
default:
who := pipeWho(client)
var req agentAskRequest
if err := json.Unmarshal(raw, &req); err != nil {
reply = agentAskReply{User: who, Why: "the request could not be read"}
break
}
peer := askPeer{SID: client.UserSID, Name: who, PID: int(client.PID)}
reply = askAnswer(peer, req, func(time.Duration) {}, func() int { return int(client.PID) })
}
out, _ := json.Marshal(reply)
if err := c.write(append(out, '\n'), pipeIOTimeout); err != nil {
return
}
_, _ = c.read(pipeIOTimeout)
}
// dialAskLane opens the service's ask pipe; p, ask.sock's path, does not
// apply here.
func dialAskLane(string) (askConn, error) {
c, err := dialServicePipe(askPipeName)
if err != nil {
return nil, err
}
if err := pipeServerIsService(c.h); err != nil {
c.close()
return nil, err
}
return &askPipeClient{c: c}, nil
}
// askLaneAddr is where the lane is, as a message names it.
func askLaneAddr(string) string { return askPipeName }
// askPipeClient is the asking side's end of the pipe, read and written the
// way the lane's code reads and writes a socket: a deadline for the whole
// exchange, bytes in, bytes out.
type askPipeClient struct {
c *pipeConn
deadline time.Time
pending []byte
}
func (a *askPipeClient) SetDeadline(t time.Time) error {
a.deadline = t
return nil
}
// left is what remains of the deadline for one call; 0 waits as long as it
// takes, which only a caller that set no deadline gets.
func (a *askPipeClient) left() time.Duration {
if a.deadline.IsZero() {
return 0
}
if d := time.Until(a.deadline); d > 0 {
return d
}
return time.Millisecond
}
func (a *askPipeClient) Read(b []byte) (int, error) {
for len(a.pending) == 0 {
n, err := a.c.read(a.left())
a.pending = append(a.pending, a.c.buf[:n]...)
if err != nil && !errors.Is(err, windows.ERROR_MORE_DATA) {
if len(a.pending) > 0 {
break
}
if errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED) {
return 0, io.EOF
}
return 0, err
}
}
n := copy(b, a.pending)
a.pending = a.pending[n:]
return n, nil
}
func (a *askPipeClient) Write(b []byte) (int, error) {
if err := a.c.write(b, a.left()); err != nil {
return 0, err
}
return len(b), nil
}
func (a *askPipeClient) Close() error {
a.c.close()
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package main
// askpeer.go — who is on the other end of the owner's lane, on every platform.
//
// The lane (agentask.go: ask.sock on Linux and macOS, the ask pipe on Windows,
// asklane_windows.go) learns the connecting account from the operating system,
// never from the request: a uid from the socket's peer credentials, or a SID
// from the pipe client's token. The machine's owner is recorded the same two
// ways (approvals_owner.go). Every refusal on the lane asks here, so both
// platforms answer with the same rules, and only the commands they name differ.
import (
"strconv"
"strings"
"time"
)
type askPeer struct {
UID uint32 // Linux and macOS: the connecting process's account
SID string // Windows: the pipe client's token user; "" elsewhere
Name string // the account as messages name it
PID int // the connecting process; 0 when the platform does not say
}
// windows reports whether the peer came over the Windows pipe.
func (p askPeer) windows() bool { return p.SID != "" }
// laneOwnerMatch reads the recorded owner the way peer's platform names
// accounts. present is true when a record exists, readable or not; ownerName
// is how a refusal names the owner.
func laneOwnerMatch(p askPeer) (match bool, ownerName string, present bool, err error) {
if p.windows() {
sid, present, err := approvalsOwnerLoad()
if err != nil || !present {
return false, "", present, err
}
return strings.EqualFold(sid, p.SID), sid, true, nil
}
uid, name, present, err := ownerAccountLoad()
if err != nil || !present {
return false, "", present, err
}
if name == "" {
name = "uid " + strconv.FormatUint(uint64(uid), 10)
}
return uid == p.UID, name, true, nil
}
// ownerRecordCommand is the administrator's command that records peer's
// account as the machine's owner.
func ownerRecordCommand(p askPeer) string {
if p.windows() {
return adminCommandFor(true, "approvals-owner --sid "+p.SID)
}
return "sudo -H privatae-bastion approvals-owner --account " + accountForHint(p.Name)
}
// adminCommandFor is `privatae-bastion <cmd>` as the machine's administrator
// types it: in an Administrator terminal on Windows, with `sudo -H` elsewhere.
func adminCommandFor(windows bool, cmd string) string {
if windows {
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
}
return "sudo -H privatae-bastion " + cmd
}
// askConn is one connection of the lane as the asking side holds it: a unix
// socket (net.Conn), or the Windows pipe client (asklane_windows.go).
type askConn interface {
Read(b []byte) (int, error)
Write(b []byte) (int, error)
Close() error
SetDeadline(t time.Time) error
}
const (
// askPipeName is the Windows service's ask pipe, the counterpart of
// ask.sock: any account on the machine may connect and ask; who it is
// comes from the pipe client's token.
askPipeName = `\\.\pipe\privatae-bastion-ask`
// askPipeSDDL: SYSTEM and Administrators full control; Authenticated
// Users (AU) read, write and synchronize, as any local account may
// connect to ask.sock. Not FILE_CREATE_PIPE_INSTANCE (FILE_APPEND_DATA,
// 0x4, on a pipe): 0x12019b is the approvals pipe's mask for the same
// reason (approvalpipe.go), so no client can stand up its own end of this
// name. P protects the DACL from inheritance.
askPipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;AU)"
)
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
testOwnerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001"
testOtherSID = "S-1-5-21-1111111111-2222222222-3333333333-1002"
)
// writeSIDOwner records sid as the machine's owner, as `approvals-owner --sid`
// writes it on Windows.
func writeSIDOwner(t *testing.T, dir, sid string) {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(approvalsOwnerRecord{SID: sid, SetAt: time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil {
t.Fatal(err)
}
}
// The Windows ask pipe answers the owner's reads, acts and console for the
// account whose SID the installer recorded, and refuses every other one, in
// Windows' own commands: no sudo anywhere.
//
// MUTATION: compare p.UID in laneOwnerMatch for a Windows peer. RESULT: red
// (the owner's SID is refused).
func TestTheAskPipeServesTheOwnersSIDOnly(t *testing.T) {
dir := isolateConfigDir(t)
owner := askPeer{SID: testOwnerSID, Name: `DESKTOP\owner (` + testOwnerSID + `)`}
other := askPeer{SID: testOtherSID, Name: `DESKTOP\other (` + testOtherSID + `)`}
why := readLaneRefusalFor(owner)
if !strings.Contains(why, "approvals-owner --sid "+testOwnerSID) || !strings.Contains(why, "Administrator terminal") {
t.Errorf("with no owner recorded, the refusal does not give Windows' command: %q", why)
}
writeSIDOwner(t, dir, testOwnerSID)
if why := readLaneRefusalFor(owner); why != "" {
t.Errorf("the owner's SID was refused a read: %s", why)
}
if why := consoleLaneRefusalFor(owner); why != "" {
t.Errorf("the owner's SID was refused the console: %s", why)
}
// Case differs between a SID read from a token and one typed.
if why := readLaneRefusalFor(askPeer{SID: strings.ToLower(testOwnerSID), Name: "owner"}); why != "" {
t.Errorf("the owner's SID in other case was refused: %s", why)
}
for _, why := range []string{readLaneRefusalFor(other), consoleLaneRefusalFor(other)} {
if why == "" || !strings.Contains(why, testOwnerSID) || !strings.Contains(why, other.Name) {
t.Errorf("another account was not refused, naming both: %q", why)
}
if strings.Contains(why, "sudo") {
t.Errorf("a Windows refusal names sudo: %q", why)
}
}
if r := serveActFor(other, agentAskRequest{Act: "deny", ID: "abcd1234"}); r.OK || r.User != other.Name {
t.Errorf("another account's act: %+v", r)
}
// A record of the other platform's kind is no owner here.
uid := uint32(1000)
raw, _ := json.Marshal(approvalsOwnerRecord{UID: &uid, Account: "someone"})
if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil {
t.Fatal(err)
}
if why := readLaneRefusalFor(owner); why == "" {
t.Error("a uid record let a Windows SID through")
}
}
// One dispatcher answers both lanes, so the pipe answers what the socket does.
func TestAskAnswerIsTheSocketsAnswer(t *testing.T) {
isolateConfigDir(t)
peer := askPeer{SID: testOwnerSID, Name: "owner"}
var extended []time.Duration
extend := func(d time.Duration) { extended = append(extended, d) }
if r, ok := askAnswer(peer, agentAskRequest{Op: "read", Read: "approvals"}, extend, func() int { return 0 }).(agentReadReply); !ok || r.OK {
t.Errorf("a read with no owner recorded: %+v", r)
}
if r, ok := askAnswer(peer, agentAskRequest{Op: "console"}, extend, func() int { return 0 }).(agentAskReply); !ok || r.OK || r.Why == "" {
t.Errorf("the console with no owner recorded: %+v", r)
}
pid := 0
if r, ok := askAnswer(peer, agentAskRequest{Op: "stop", PID: 4242}, extend, func() int { pid = 7; return 7 }).(agentAskReply); !ok || r.OK {
t.Errorf("a stop with no code set up: %+v", r)
}
if pid != 7 {
t.Error("a stop was filed without asking which process asked")
}
if len(extended) != 1 || extended[0] != readDeadline {
t.Errorf("deadlines given: %v", extended)
}
}
func TestAdminCommandsAreEachPlatformsOwn(t *testing.T) {
if got := adminCommandFor(true, "ui"); strings.Contains(got, "sudo") || !strings.Contains(got, "Administrator terminal") {
t.Errorf("windows: %q", got)
}
if got := adminCommandFor(false, "ui"); got != "sudo -H privatae-bastion ui" {
t.Errorf("unix: %q", got)
}
if !strings.Contains(askPipeSDDL, "0x12019b;;;AU") || strings.Contains(askPipeSDDL, "GW;;;AU") || strings.Contains(askPipeSDDL, "GA;;;AU") {
t.Errorf("the ask pipe's ACL gives every account more than read and write: %s", askPipeSDDL)
}
}
// A page with no session names the command that opens it on this machine.
// The machine's console is not opened by "privatae-bastion ui" from just any
// account, and telling a person only that sent them back to a 401.
func TestNotSignedInNamesTheCommandThatOpensThisConsole(t *testing.T) {
if got := notSignedIn("ui", "windows", true); !strings.Contains(got, "Administrator terminal") ||
!strings.Contains(got, "owns this machine") || strings.Contains(got, "sudo") {
t.Errorf("the Windows service's console: %q", got)
}
if got := notSignedIn("ui", "linux", true); !strings.Contains(got, "sudo -H privatae-bastion ui") ||
!strings.Contains(got, "owns this machine") {
t.Errorf("the root bastion's console: %q", got)
}
if got := notSignedIn("ui", "windows", false); !strings.Contains(got, "`privatae-bastion ui`") || strings.Contains(got, "Administrator") {
t.Errorf("an account's own bastion: %q", got)
}
err := uiKeyRefused("127.0.0.1:7880", "401 Unauthorized", "windows", true, false)
if s := err.Error(); !strings.Contains(s, winServiceName) || strings.Contains(s, "sudo") || strings.Contains(s, "restart it") {
t.Errorf("a 401 beside the Windows service: %q", s)
}
}
// A second start says what is running and how to reach it, and never that it
// should be ended: ending it was what the old words sent a person to do.
//
// MUTATION: drop the PID from secondRunWords. RESULT: red.
func TestASecondStartNamesTheRunningBastion(t *testing.T) {
task := runHolder{PID: 4242, Program: `C:\Users\someone\AppData\Local\Programs\privatae-bastion\privatae-bastion.exe`,
Started: "2026-11-02T18:02:00Z"}
console, dialog := secondRunWords(task, true)
for _, want := range []string{"4242", task.Program, "privatae-bastion ui", "Stop-Process -Id 4242"} {
if !strings.Contains(console, want) || !strings.Contains(dialog.Body, want) {
t.Errorf("the refusal does not say %q:\n%s\n%s", want, console, dialog.Body)
}
}
svc, _ := secondRunWords(runHolder{PID: 9, Service: true}, true)
if !strings.Contains(svc, "as the "+winServiceName+" service") || !strings.Contains(svc, "Restart-Service "+winServiceName) {
t.Errorf("the service: %s", svc)
}
old, _ := secondRunWords(runHolder{}, false)
if !strings.Contains(old, "Get-Process privatae-bastion") {
t.Errorf("an older bastion holding the lock: %s", old)
}
for _, s := range []string{console, svc, old, dialog.Body} {
if strings.Contains(s, "Task Manager") || strings.Contains(s, "believe the running one is dead") {
t.Errorf("the refusal sends the person to end the running bastion: %s", s)
}
}
dir := t.TempDir()
writeRunHolder(dir, true)
if h, ok := readRunHolder(dir); !ok || h.PID != os.Getpid() || !h.Console {
t.Errorf("the holder record: %+v, %v", h, ok)
}
}
// A bastion relaunched by its predecessor's update waits for it to exit and
// then takes the lock, where it used to refuse as a second copy and leave the
// machine with none.
func TestARelaunchedBastionWaitsForThePredecessorsLock(t *testing.T) {
clock := time.Unix(0, 0)
now := func() time.Time { return clock }
sleep := func(d time.Duration) { clock = clock.Add(d) }
held := 3
try := func() error {
if held > 0 {
held--
return os.ErrExist
}
return nil
}
waited := 0
waitExit := func(pid int, d time.Duration) { waited = pid }
if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err != nil {
t.Fatalf("the lock was not taken once the predecessor let go: %v", err)
}
if waited != 4242 {
t.Errorf("waited on process %d", waited)
}
held = 1 << 30
if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err == nil {
t.Fatal("a lock held for ever was taken")
}
held, waited = 1, 0
if err := takeRunLock(try, 0, waitExit, 30*time.Second, now, sleep); err == nil || waited != 0 {
t.Errorf("a start that replaces nothing waited, or retried: %v, %d", err, waited)
}
t.Setenv(relaunchOfEnv, "4242")
if got := relaunchOf(); got != 4242 {
t.Errorf("relaunchOf = %d", got)
}
if _, set := os.LookupEnv(relaunchOfEnv); set {
t.Error("the predecessor's pid would pass on to what this bastion starts")
}
}
// The Windows files wire it together: they are not built on Linux, so this
// reads them, as TestServiceModeOpensNothingOnTheDesktop does.
func TestTheWindowsFilesWireTheLaneTheCarryAndTheRelaunchWait(t *testing.T) {
for file, wants := range map[string][]string{
"relaunch_windows.go": {`relaunchOfEnv+"="+strconv.Itoa(os.Getpid())`},
"runguard_windows.go": {"takeRunLock(", "relaunchOf()", "writeRunHolder(d,", "secondRunWords("},
"sysservice_windows.go": {"carryStateInto(from, to)", "serviceCarrySource(serviceCarryFromSID)"},
"asklane_windows.go": {"askAnswer(peer, req", "pipeServerIsService(c.h)", "askPipeSDDL"},
"agentask.go": {"startAskPipe(done)"},
"../deploy/install.ps1": {"service install --from-account $owner"},
} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, w := range wants {
if !strings.Contains(string(src), w) {
t.Errorf("%s no longer has %s", file, w)
}
}
}
// The carry runs before the service's first start, not after it.
src, _ := os.ReadFile("sysservice_windows.go")
s := string(src)
if c, st := strings.Index(s, "carryStateInto(from, to)"), strings.Index(s, "s.Start()"); c < 0 || st < 0 || c > st {
t.Error("the carry is not before the service starts, so the service would start with no pairing")
}
}
// A process that started after the connection was made is not the one that
// made it: its id was freed and reused, and answering it would answer a
// stranger as whoever holds that id now.
func TestAPipeClientStartedAfterItsConnectionIsNotTheCaller(t *testing.T) {
connected := time.Unix(1000, 0)
if !pipeClientNewer(connected.Add(time.Millisecond), connected) {
t.Error("a process created after the connection was taken for the caller")
}
if pipeClientNewer(connected.Add(-time.Hour), connected) {
t.Error("the process that connected was refused")
}
}
+89 -26
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@@ -22,13 +23,13 @@ const backupUsage = `usage:
make this bastion's backup key here and print what to register on your
host (Settings > Backup > keys: kind, label, public key). --replace makes
a new one and keeps the old one, which still opens the backups made with it
privatae-bastion backup key show
privatae-bastion backup key show [--json]
the key this bastion holds, its fingerprint, and any older keys it keeps
privatae-bastion backup reseal --sidecar <file> --to <host key> [--stamp <stamp>]
[--out <file>] [--identity <key file>]
seal one backup's key to a host's own key (age1... or base64), so that
host, and only it, can open that one backup
privatae-bastion backup requests [--host <host>]
privatae-bastion backup requests [--host <host>] [--json]
restore requests your host sent this machine
privatae-bastion backup approve [--host <host>] <id> | deny [--host <host>] <id>
answer one
@@ -39,7 +40,7 @@ const backupUsage = `usage:
let the host paired as <host> list and send the backups in another
host's backup folder here (or any folder) — how a host that replaces a
lost one reaches the copies the lost one kept on this machine
privatae-bastion backup shares --with <host> / unshare --with <host> <name>
privatae-bastion backup shares --with <host> [--json] / unshare --with <host> <name>
the folders shared with a host, and taking one back`
func cmdBackup(args []string) error {
@@ -65,7 +66,11 @@ func cmdBackup(args []string) error {
return cmdBackupKeyInit(replace, flags["--label"])
}
if len(args) >= 2 && args[1] == "show" {
return cmdBackupKeyShow()
asJSON, err := jsonOnly(backupUsage, args[2:])
if err != nil {
return err
}
return cmdBackupKeyShow(asJSON)
}
case "reseal":
flags, rest := backupFlags(args[1:], "--sidecar", "--to", "--stamp", "--out", "--identity")
@@ -75,10 +80,11 @@ func cmdBackup(args []string) error {
return cmdBackupReseal(flags["--sidecar"], flags["--to"], flags["--stamp"], flags["--out"], flags["--identity"])
case "requests":
host, rest := takeHostFlag(args[1:])
if len(rest) != 0 {
return errors.New(backupUsage)
asJSON, err := jsonOnly(backupUsage, rest)
if err != nil {
return err
}
return cmdBackupRequests(host)
return cmdBackupRequests(host, asJSON)
case "approve", "deny":
host, rest := takeHostFlag(args[1:])
if len(rest) != 1 {
@@ -93,10 +99,11 @@ func cmdBackup(args []string) error {
return cmdBackupShare(flags["--with"], flags["--from"], flags["--dir"])
case "shares":
flags, rest := backupFlags(args[1:], "--with")
if len(rest) != 0 || flags["--with"] == "" {
asJSON, err := jsonOnly(backupUsage, rest)
if err != nil || flags["--with"] == "" {
return errors.New(backupUsage)
}
return cmdBackupShares(flags["--with"])
return cmdBackupShares(flags["--with"], asJSON)
case "unshare":
flags, rest := backupFlags(args[1:], "--with")
if len(rest) != 1 || flags["--with"] == "" {
@@ -163,24 +170,53 @@ func cmdBackupKeyInit(replace bool, label string) error {
return nil
}
func cmdBackupKeyShow() error {
// backupKeysRead is every backup key this bastion holds, the current one
// marked. The secret half never leaves its file (backupKeyFile.Identity).
type backupKeysRead struct {
Keys []*backupKeyFile `json:"keys"`
Current string `json:"current,omitempty"` // its fingerprint
}
func readBackupKeys(readOpts) (backupKeysRead, error) {
active, all, err := backupKeysLoad()
if err != nil {
return err
return backupKeysRead{}, err
}
if len(all) == 0 {
fmt.Println("no backup key on this machine — make one with `privatae-bastion backup key init`")
v := backupKeysRead{Keys: all}
if v.Keys == nil {
v.Keys = []*backupKeyFile{}
}
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 all {
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,20 +418,44 @@ 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)
v := backupSharesRead{With: reader.HostName, Shares: sharesFor(reader.HostName)}
if v.Shares == nil {
v.Shares = []backupShare{}
}
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 list {
fmt.Printf("shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
for _, s := range v.Shares {
fmt.Fprintf(w, "shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
}
return nil
}
func cmdBackupUnshare(with, slug string) error {
+68 -17
View File
@@ -45,6 +45,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/netip"
"os"
"path/filepath"
@@ -579,28 +580,46 @@ func describeReseal(r *resealRequest) string {
// ── the person's side, on this machine ──────────────────────────────────────
func cmdBackupRequests(host string) error {
cfg, err := stepupPairing(host)
// backupRequestView is one restore request as a read shows it: the armored
// key the host sent, and the one resealed for it, are left out (the digest of
// the one the host sent stays); both are sealed, and a read has no use for
// them.
type backupRequestView struct {
ID string `json:"id"`
State string `json:"state"`
Stamp string `json:"stamp"`
Pairing string `json:"pairing"`
HostNumber int64 `json:"host_number"`
HostLabel string `json:"host_label"`
To string `json:"to"`
ToFP string `json:"to_fp"`
SidecarSHA string `json:"sidecar_sha256"`
At string `json:"at"`
DecidedAt string `json:"decided_at,omitempty"`
Error string `json:"error,omitempty"`
Words string `json:"words"`
}
type backupRequestsRead struct {
Host string `json:"host"`
Requests []backupRequestView `json:"requests"`
// SetAside are request files found unreadable and kept, not read.
SetAside []string `json:"set_aside,omitempty"`
}
func readBackupRequests(o readOpts) (backupRequestsRead, error) {
cfg, err := stepupPairing(o.Host)
if err != nil {
return err
return backupRequestsRead{}, err
}
dir := cfg.dcnDir()
v := backupRequestsRead{Host: cfg.HostName, Requests: []backupRequestView{}}
// Request files set aside as unreadable (resealLoad) are named here, so
// the person learns of them whichever process found them.
if aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*"); len(aside) > 0 {
fmt.Println("set aside as unreadable (kept, not read):")
for _, a := range aside {
fmt.Println(" " + a)
}
fmt.Println()
}
v.SetAside, _ = filepath.Glob(resealPath(dir) + resealAsideSuffix + "*")
m, err := resealRead(dir)
if err != nil {
return err
}
if len(m) == 0 {
fmt.Println("no backup requests from", cfg.HostName)
return nil
return v, err
}
ids := make([]string, 0, len(m))
for id := range m {
@@ -608,12 +627,44 @@ func cmdBackupRequests(host string) error {
}
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
for _, id := range ids {
fmt.Println(describeReseal(m[id]))
fmt.Println()
r := m[id]
v.Requests = append(v.Requests, backupRequestView{ID: r.ID, State: r.State, Stamp: r.Stamp,
Pairing: r.Pairing, HostNumber: r.HostNumber, HostLabel: r.HostLabel, To: r.To, ToFP: r.ToFP,
SidecarSHA: r.SidecarSH, At: r.At, DecidedAt: r.DecidedAt, Error: r.Error, Words: describeReseal(r)})
}
return v, nil
}
func cmdBackupRequests(host string, asJSON bool) error {
v, err := readMachine("backup-requests", readOpts{Host: host}, readBackupRequests)
if err != nil {
return laneRefusalError("backup requests", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printBackupRequests(os.Stdout, v)
return nil
}
func printBackupRequests(w io.Writer, v backupRequestsRead) {
if len(v.SetAside) > 0 {
fmt.Fprintln(w, "set aside as unreadable (kept, not read):")
for _, a := range v.SetAside {
fmt.Fprintln(w, " "+a)
}
fmt.Fprintln(w)
}
if len(v.Requests) == 0 {
fmt.Fprintln(w, "no backup requests from", v.Host)
return
}
for _, r := range v.Requests {
fmt.Fprintln(w, r.Words)
fmt.Fprintln(w)
}
}
func cmdBackupDecide(host, prefix string, approve bool) error {
cfg, err := stepupPairing(host)
if err != nil {
+155 -43
View File
@@ -8,22 +8,32 @@ package main
import (
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"time"
)
// cmdStepupDrop revokes one unused approval this box issued for a pairing.
// From the owner's account beside the machine's bastion it asks that
// bastion (owneracts.go): dropping only takes reach away, so no code.
func cmdStepupDrop(host string, machine bool, prefix string) error {
return printOrAct(agentAskRequest{Act: "approvals-drop", Host: host, Machine: machine, ID: prefix},
stepupCommand("approvals drop", host, machine, prefix),
func() (string, error) { return stepupDropText(host, machine, prefix) })
}
func stepupDropText(host string, machine bool, prefix string) (string, error) {
t, err := resolveStepupTarget(host, machine)
if err != nil {
return err
return "", err
}
dir := t.dir
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return errors.New("which approval? (the id `approve` printed)")
return "", errors.New("which approval? (the id `approve` printed)")
}
stepupMu.Lock()
defer stepupMu.Unlock()
@@ -36,42 +46,45 @@ func cmdStepupDrop(host string, machine bool, prefix string) error {
}
switch len(hits) {
case 0:
return fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
return "", fmt.Errorf("no unused approval starts with %q %s", prefix, t.forWhom())
case 1:
default:
return fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
return "", fmt.Errorf("%d approvals start with %q — give more of the id", len(hits), prefix)
}
a := m[hits[0]]
a.Used = true // burned without ever having run: the same end state a use reaches
m[hits[0]] = a
if err := approvalsSave(dir, m); err != nil {
return err
return "", err
}
fmt.Printf("dropped approval %s (%s) — nothing can run on it now\n", hits[0][:8], a.Purpose)
return nil
return fmt.Sprintf("dropped approval %s (%s) — nothing can run on it now", hits[0][:8], a.Purpose), nil
}
// cmdGrantsDrop removes a held store grant from this box.
// cmdGrantsDrop removes a held store grant from this box (through the
// owner's lane beside the machine's bastion, as cmdStepupDrop).
func cmdGrantsDrop(host, store string) error {
return printOrAct(agentAskRequest{Act: "grants-drop", Host: host, ID: store},
"grants drop "+store+hostFlagWords(host), func() (string, error) { return grantsDropText(host, store) })
}
func grantsDropText(host, store string) (string, error) {
cfg, err := stepupPairing(host)
if err != nil {
return err
return "", err
}
id, err := strconv.ParseInt(strings.TrimSpace(store), 10, 64)
if err != nil {
return errors.New("store id must be a number (the id on the host's store card)")
return "", errors.New("store id must be a number (the id on the host's store card)")
}
dir := cfg.dcnDir()
present, err := dcnDropStore(dir, id, "dropped at this machine's console")
if err != nil {
return err
return "", err
}
if !present {
fmt.Printf("store %d was not held here for %s\n", id, cfg.HostName)
return nil
return fmt.Sprintf("store %d was not held here for %s", id, cfg.HostName), nil
}
fmt.Printf("store %d dropped for %s — the host's reads of it stop now and it learns on the next check-in\n", id, cfg.HostName)
return nil
return fmt.Sprintf("store %d dropped for %s — the host's reads of it stop now and it learns on the next check-in", id, cfg.HostName), nil
}
// cmdQuarantineClear lifts this box's hold on a pairing's write lanes.
@@ -90,65 +103,140 @@ func cmdQuarantineClear(host string) error {
}
// cmdOpsList prints the ops and op grants a pairing holds here (ops.go).
func cmdOpsList(host string) error {
cfg, err := stepupPairing(host)
// opRow is one op a Host holds here, verified against its signature or not.
type opRow struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Privilege string `json:"privilege,omitempty"`
Summary string `json:"summary,omitempty"`
NotVerified string `json:"not_verified,omitempty"`
}
// opGrantRow is one op grant this machine holds and has not dropped.
type opGrantRow struct {
ID string `json:"id"`
Op string `json:"op,omitempty"`
Version string `json:"version,omitempty"`
RunsLeft int `json:"runs_left"`
Until string `json:"until,omitempty"`
Within string `json:"within,omitempty"`
NotVerified string `json:"not_verified,omitempty"`
}
// opsRead is what ops and op grants one pairing holds on this machine.
type opsRead struct {
Host string `json:"host"`
Sandbox bool `json:"sandbox"`
SandboxUnavailable string `json:"sandbox_unavailable,omitempty"`
Ops []opRow `json:"ops"`
Grants []opGrantRow `json:"grants"`
}
func readOps(o readOpts) (opsRead, error) {
cfg, err := stepupPairing(o.Host)
if err != nil {
return err
return opsRead{}, err
}
dir := cfg.dcnDir()
ok, why := opSandboxAvailable()
if ok {
fmt.Println("sandbox: available")
} else {
fmt.Println("sandbox: NOT available —", why)
}
n := 0
v := opsRead{Host: cfg.HostName, Ops: []opRow{}, Grants: []opGrantRow{}}
v.Sandbox, v.SandboxUnavailable = opSandboxAvailable()
if entries, err := os.ReadDir(opsDir(dir)); err == nil {
for _, e := range entries {
if !e.IsDir() || e.Name() == opsRunsDirName || strings.HasSuffix(e.Name(), ".tmp") {
continue
}
n++
r := opRow{ID: e.Name()[:12]}
if m, err := opLoad(dir, e.Name()); err == nil {
fmt.Printf("op %s %s as %s [%s]\n %s\n", m.Name, m.Version, m.Privilege, e.Name()[:12], m.Summary)
r.Name, r.Version, r.Privilege, r.Summary = m.Name, m.Version, m.Privilege, m.Summary
} else {
fmt.Printf("op [%s] NOT VERIFIED: %v\n", e.Name()[:12], err)
r.NotVerified = err.Error()
}
v.Ops = append(v.Ops, r)
}
}
f := opsLoad(dir)
nonces := make([]string, 0, len(f.Grants))
for nonce, rec := range f.Grants {
if rec.Dropped != "" {
continue
if rec.Dropped == "" {
nonces = append(nonces, nonce)
}
t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant)
}
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
}
v.Grants = append(v.Grants, r)
}
return v, nil
}
func cmdOpsList(host string, asJSON bool) error {
v, err := readMachine("ops", readOpts{Host: host}, readOps)
if err != nil {
fmt.Printf("grant [%s] NOT VERIFIED: %v\n", nonce[:8], err)
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.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)
fmt.Fprintf(w, "op %s %s as %s [%s]\n %s\n", r.Name, r.Version, r.Privilege, r.ID, r.Summary)
}
if n == 0 && len(f.Grants) == 0 {
fmt.Println("no ops held for", cfg.HostName)
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)
}
return nil
}
// 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
}
+58 -22
View File
@@ -1,7 +1,8 @@
package main
// consolelane.go — the machine owner's own account opens the console of the
// bastion installed for the whole machine, without sudo (Linux and macOS).
// bastion installed for the whole machine, without sudo on Linux and macOS
// and without an Administrator terminal on Windows.
//
// THE PROBLEM. Installed for the whole machine, the bastion runs as root and
// its console signs in with root's ui.secret. `privatae-bastion ui` run by the
@@ -9,7 +10,8 @@ package main
// account's key, so the person reached their own machine's console only
// through sudo, and a browser started by sudo is root's, not theirs.
//
// THE LANE. ask.sock (agentask.go) already tells the daemon, from the kernel,
// THE LANE. ask.sock (agentask.go), and on Windows the service's ask pipe
// (asklane_windows.go), already tells the daemon, from the operating system,
// which account connected. Asked for "console" by the account recorded as the
// machine's owner (owner_account.go), the daemon mints a one-use ticket of a
// LIMITED kind and answers with the link; `ui` opens it in the person's own
@@ -21,17 +23,18 @@ package main
// read on this install. It is refused what widens access with no code today:
// pairing a Host, setting up or rotating the owner's code, choosing the Host
// in charge, proposing a folder, and the approval device's approvals. Those
// stay with the administrator (`sudo -H privatae-bastion ui`). Codes typed for
// stay with the administrator (`sudo -H privatae-bastion ui`, or `ui` from an
// Administrator terminal on Windows). Codes typed for
// a Host's requests in a limited session are throttled as the owner's are
// (machineCodeSpend), because guessing is the only attack left there.
import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"sync/atomic"
)
@@ -45,21 +48,27 @@ var consoleLaneServer atomic.Pointer[uiServer]
// consoleLaneRefusal says why account uid may not open the console through
// the lane, or "" when it may.
func consoleLaneRefusal(uid uint32, who string) string {
owner, ownerName, present, err := ownerAccountLoad()
return consoleLaneRefusalFor(askPeer{UID: uid, Name: who})
}
// consoleLaneRefusalFor is consoleLaneRefusal for a peer of either platform.
func consoleLaneRefusalFor(p askPeer) string {
admin := adminCommandFor(p.windows(), "ui")
match, ownerName, present, err := laneOwnerMatch(p)
switch {
case err != nil:
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " +
"the console with: sudo -H privatae-bastion ui"
"the console with: " + admin
case !present:
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " +
"sudo -H privatae-bastion ui. To open it from your own account, the administrator records it once: " +
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
case owner != uid:
name := ownerName
if name == "" {
name = "uid " + strconv.FormatUint(uint64(owner), 10)
admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p)
case !match:
without := "without sudo"
if p.windows() {
without = "without an Administrator terminal"
}
return "this machine's console opens without sudo only for its owner's account (" + name + "), not " + who
return "this machine's console opens " + without + " only for its owner's account (" + ownerName + "), not " +
p.Name + ". The administrator opens it with: " + admin
}
return ""
}
@@ -74,7 +83,11 @@ func accountForHint(who string) string {
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
// the link that redeems a fresh limited ticket.
func consoleLaneEntry(uid uint32, who string) (string, error) {
if why := consoleLaneRefusal(uid, who); why != "" {
return consoleLaneEntryFor(askPeer{UID: uid, Name: who})
}
func consoleLaneEntryFor(p askPeer) (string, error) {
if why := consoleLaneRefusalFor(p); why != "" {
return "", errors.New(why)
}
u := consoleLaneServer.Load()
@@ -104,8 +117,9 @@ func (u *uiServer) signedIn(r *http.Request) bool {
return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok))
}
const consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
"with no code, or reads beyond your own account. The machine's administrator does it: sudo -H privatae-bastion ui"
var consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
"with no code, or reads beyond your own account. The machine's administrator does it: " +
adminCommandFor(runtime.GOOS == "windows", "ui")
// adminOnly wraps a route a limited session may not use.
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
@@ -120,17 +134,42 @@ func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
// consoleEntryForThisAccount is the link `ui` opens. An account with a
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary
// account beside the machine's root bastion asks the lane, and is told why
// not when it is not the owner's.
// account beside the machine's bastion asks the lane, and is told why not
// when it is not the owner's.
func consoleEntryForThisAccount() (entry, addr string, err error) {
if runtime.GOOS != "windows" && os.Geteuid() != 0 && !uiDaemonListening() {
if runtime.GOOS == "windows" {
if systemService.installed && !machineAuthorityHeld() {
return consoleEntryThroughLane()
}
return localUIEntryURL()
}
if os.Geteuid() != 0 && !uiDaemonListening() {
p := askSockPath()
if why := contextSockDialable(p); why != "" {
return "", "", errors.New(why)
}
if _, statErr := os.Stat(p); statErr == nil {
return consoleEntryThroughLane()
}
}
return localUIEntryURL()
}
// consoleEntryThroughLane asks the machine's bastion for the owner's link.
func consoleEntryThroughLane() (entry, addr string, err error) {
p := askLaneAddr(askSockPath())
r, err := askViaSock(agentAskRequest{Op: "console"})
if err != nil {
if runtime.GOOS == "windows" {
// The service is there (systemService.installed) but its pipe is
// not: a service older than this command, or one not running.
return "", "", fmt.Errorf("this machine's bastion runs as the %s service, and it did not answer your "+
"account's request for its console on %s (%v).\n"+
" If the service is not running, start it: %s\n"+
" If it is older than this command, update it by running the installer again.\n"+
" Either way the administrator can open the console now: %s",
winServiceName, p, err, serviceRunHintFor("windows"), adminCommandFor(true, "ui"))
}
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
}
if !r.OK {
@@ -141,9 +180,6 @@ func consoleEntryForThisAccount() (entry, addr string, err error) {
return "", "", errors.New("this machine's bastion answered with a link that is not its own console")
}
return r.URL, u.Host, nil
}
}
return localUIEntryURL()
}
// uiLimitedWriter marks a response as drawn for a limited session, so a page
+5 -3
View File
@@ -680,7 +680,7 @@ func handleDcnGrantSync(ws *websocket.Conn, dir string, args []string) {
if dcnHasMask(terms, "search") {
faEnsureStore(dir, payload.StoreID, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient)
} else {
faStop(payload.StoreID)
faStop(dir, payload.StoreID)
}
dcnResult(ws, dir, "grant_sync", payload.StoreID, "", map[string]any{
"state": "ok", "root": terms.Root, "mask": terms.Mask,
@@ -708,14 +708,16 @@ func dcnDropStoreLocked(dir string, storeID int64, why string) (bool, error) {
}
key := strconv.FormatInt(storeID, 10)
if _, ok := f.Stores[key]; !ok {
faStop(storeID) // nothing on disk, but never leave an orphan instance running
// Nothing on disk, but never leave an orphan instance running: this
// pairing's own (faKey), never another Host's for the same store id.
faStop(dir, storeID)
return false, nil
}
delete(f.Stores, key)
if err := dcnSaveFile(dir, f); err != nil {
return true, err
}
faStop(storeID)
faStop(dir, storeID)
dcnAudit(dir, "grant_drop", storeID, "", why)
return true, nil
}
+70 -12
View File
@@ -33,6 +33,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
@@ -847,31 +848,82 @@ func deviceShownPath() (string, error) {
return filepath.Join(d, "device_shown.json"), nil
}
func cmdDeviceApprovals() error {
// deviceAsk is one ask waiting on this approval device.
type deviceAsk struct {
ID string `json:"id"`
Server string `json:"server"`
At string `json:"at"`
Words string `json:"words"`
}
type deviceApprovalsRead struct {
Asks []deviceAsk `json:"asks"`
}
func readDeviceApprovals(readOpts) (deviceApprovalsRead, error) {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
shown := []string{}
v := deviceApprovalsRead{Asks: []deviceAsk{}}
for _, id := range sortedDeviceRequests(m) {
r := m[id]
if r.State != "pending" {
continue
}
shown = append(shown, id)
fmt.Printf("[%s] on %s, asked %s\n%s\n\n", id[:8], r.Server, r.At, r.Words)
v.Asks = append(v.Asks, deviceAsk{ID: id, Server: r.Server, At: r.At, Words: r.Words})
}
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
}
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)
}
if len(shown) == 0 {
fmt.Println("nothing is waiting for your approval")
return nil
}
fmt.Printf("approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all\n")
return nil
}
// printDeviceApprovals prints the asks; lane: they were read from the
// machine's bastion, so they are answered as its administrator.
func printDeviceApprovals(w io.Writer, v deviceApprovalsRead, lane bool) {
for _, a := range v.Asks {
fmt.Fprintf(w, "[%s] on %s, asked %s\n%s\n\n", a.ID[:min(8, len(a.ID))], a.Server, a.At, a.Words)
}
switch {
case len(v.Asks) == 0:
fmt.Fprintln(w, "nothing is waiting for your approval")
case lane:
fmt.Fprintln(w, "These wait on the machine's bastion. As the administrator: sudo -H privatae-bastion device-approvals, "+
"then device-approve <id> or device-approve all")
default:
fmt.Fprintln(w, "approve one: privatae-bastion device-approve <id> all of the above: privatae-bastion device-approve all")
}
}
func cmdDeviceApprove(prefix string) error { return cmdDeviceApproveCode(prefix, "") }
func cmdDeviceApproveCode(prefix, code string) error {
@@ -947,19 +999,25 @@ func cmdDeviceApproveCode(prefix, code string) error {
return nil
}
// cmdDeviceDeny refuses one ask waiting on this approval device (through the
// owner's lane beside the machine's bastion: denying takes nothing).
func cmdDeviceDeny(prefix string) error {
return printOrAct(agentAskRequest{Act: "device-deny", ID: prefix}, "device-deny "+prefix,
func() (string, error) { return deviceDenyText(prefix) })
}
func deviceDenyText(prefix string) (string, error) {
deviceMu.Lock()
m := deviceRequestsLoad()
deviceMu.Unlock()
id, err := findDeviceRequest(m, prefix)
if err != nil {
return err
return "", err
}
if err := deviceDeny(id); err != nil {
return err
return "", err
}
fmt.Printf("denied %s\n", id)
return nil
return "denied " + id, nil
}
func findDeviceRequest(m map[string]deviceRequest, prefix string) (string, error) {
+360
View File
@@ -0,0 +1,360 @@
package main
// doctor.go — what is wrong with this install, and the fix for each one
// (plan Addendum 6, slice 3).
//
// privatae-bastion doctor [--json]
//
// Each check is a fact the bastion already holds, said as a problem and the
// command that fixes it. The machine's checks are built where its state is:
// in the daemon for the owner's account, through the lane (reads.go), or
// directly for the administrator and a per-account install. The one thing
// only the asking side can find out is whether the lane answers it at all,
// and that becomes the check when it does not. Exit status 1 when any check
// finds a problem, so a script can branch on it.
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
const doctorUsage = "usage: privatae-bastion doctor [--json]"
type doctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Detail string `json:"detail,omitempty"` // what was found, when all is well
Problem string `json:"problem,omitempty"`
Fix string `json:"fix,omitempty"` // the command, or what to do
}
type doctorRead struct {
Version string `json:"version"`
Checks []doctorCheck `json:"checks"`
}
func (d doctorRead) problems() int {
n := 0
for _, c := range d.Checks {
if !c.OK {
n++
}
}
return n
}
// systemInstalled: the bastion is installed for the whole machine.
var systemInstalled = func() bool {
if runtime.GOOS == "linux" {
return linuxSystemUnitPresent()
}
return systemService.installed
}
// doctorSandbox is opSandboxAvailable, replaceable in a test: its probe
// re-executes this binary.
var doctorSandbox = opSandboxAvailable
// asAdmin is a bastion command said the way it has to be typed here: the
// machine's bastion keeps its state as the administrator when it is
// installed for the whole machine.
func asAdmin(cmd string) string {
switch {
case !systemInstalled():
return "privatae-bastion " + cmd
case runtime.GOOS == "windows":
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
}
return "sudo -H privatae-bastion " + cmd
}
// installerCommand is how this platform installs the bastion, and so how a
// machine no Host updates moves to a newer version.
func installerCommand() string {
if runtime.GOOS == "windows" {
return "irm https://privatae.ai/bastion/install.ps1 | iex"
}
return "curl -fsSL https://privatae.ai/bastion/install.sh | sh"
}
// doctorLatest is the newest published version, or "" when it cannot be
// known: the daemon's last answer, else the last time doctor asked
// privatae.ai, if that was within updateCheckEvery, else one fetch that
// waits at most doctorLatestWait. A request to privatae.ai carries this
// machine's address to the platform, so doctor keeps update.go's rate
// (four a day): a run in its own process, or an agent polling it, asks again
// only once that time has passed, whether the last ask was answered or not.
// Running with no route to privatae.ai is supported (update.go), so "" is
// not a problem.
var (
doctorLatestWait = 10 * time.Second
doctorLatest = func() string {
latestMu.Lock()
v := latestPublished
latestMu.Unlock()
if v != "" {
return v
}
if r, ok := doctorAskedLoad(); ok && doctorNow().Sub(r.AskedAt) < updateCheckEvery {
return r.Version
}
got := make(chan string, 1)
go func() {
v, err := doctorFetch()
if err != nil {
v = ""
}
got <- strings.TrimSpace(v)
}()
select {
case v = <-got:
case <-time.After(doctorLatestWait):
}
if v != "" {
recordLatestPublished(v)
} else if r, ok := doctorAskedLoad(); ok {
v = r.Version // no answer this time: the last one stands, as in update.go
}
doctorAskedSave(doctorAsked{Version: v, AskedAt: doctorNow()})
return v
}
doctorNow = time.Now
doctorFetch = func() (string, error) {
m, err := fetchManifest()
if err != nil {
return "", err
}
return m.Version, nil
}
)
// doctorAsked is when doctor last asked privatae.ai, and what it knew after.
type doctorAsked struct {
Version string `json:"version"`
AskedAt time.Time `json:"asked_at"`
}
func doctorAskedPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "doctor_latest.json"), nil
}
func doctorAskedLoad() (doctorAsked, bool) {
var r doctorAsked
p, err := doctorAskedPath()
if err != nil {
return r, false
}
raw, err := os.ReadFile(p)
if err != nil || json.Unmarshal(raw, &r) != nil || r.AskedAt.IsZero() {
return doctorAsked{}, false
}
return r, true
}
func doctorAskedSave(r doctorAsked) {
p, err := doctorAskedPath()
if err != nil {
return
}
if raw, err := json.Marshal(r); err == nil {
_ = atomicWrite(p, raw)
}
}
// logHint is where the bastion's own log is read on this platform.
func logHint() string {
switch runtime.GOOS {
case "linux":
if systemInstalled() {
return "journalctl -u privatae-bastion"
}
return "journalctl --user -u privatae-bastion"
case "windows":
return "the bastion's window, or Event Viewer when it runs as a service"
}
return "Console.app, process privatae-bastion"
}
func readDoctor(readOpts) (doctorRead, error) {
d := doctorRead{Version: clientVersion, Checks: []doctorCheck{}}
add := func(c doctorCheck) { d.Checks = append(d.Checks, c) }
s, err := readStatus(readOpts{})
if err != nil {
add(doctorCheck{Name: "pairings", Problem: sentenceCase(err.Error()) + ".",
Fix: "The bastion's log says why: " + logHint()})
return d, nil
}
paired := len(s.Pairings) > 0
start := runCommandHint()
if runtime.GOOS == "linux" && systemInstalled() {
start = "sudo " + start
}
switch {
case s.Running == "no":
add(doctorCheck{Name: "running", Problem: "The bastion is not running, so nothing on this machine is watched.",
Fix: start})
case !paired:
add(doctorCheck{Name: "running", OK: true, Detail: "Running on its own; no Host is connected."})
default:
add(doctorCheck{Name: "running", OK: true, Detail: fmt.Sprintf("Running, paired with %d Host(s).", len(s.Pairings))})
}
switch latest := doctorLatest(); {
case latest == "":
add(doctorCheck{Name: "version", OK: true, Detail: "This is " + clientVersion +
"; the latest published version could not be read from privatae.ai."})
case versionNewer(latest, clientVersion):
fix := "Run the installer again: " + installerCommand()
if paired {
fix = "Your Host can update it (Network app, Update), or run the installer again: " + installerCommand()
}
add(doctorCheck{Name: "version", Problem: "Version " + latest + " is published; this machine runs " +
clientVersion + ".", Fix: fix})
default:
add(doctorCheck{Name: "version", OK: true, Detail: clientVersion + " is the latest published version."})
}
if systemInstalled() {
_, name, present, err := ownerAccountLoad()
fix := "sudo -H privatae-bastion approvals-owner --account <your account>"
if runtime.GOOS == "windows" {
fix = "privatae-bastion approvals-owner --sid <your account's SID> (from an Administrator terminal)"
}
switch {
case err != nil:
add(doctorCheck{Name: "owner", Problem: "This machine's owner account cannot be read (" + err.Error() + ").",
Fix: strings.Replace(fix, "approvals-owner ", "approvals-owner --replace ", 1)})
case !present:
add(doctorCheck{Name: "owner", Problem: "No account is recorded as this machine's owner, so the console " +
"opens only for the administrator, and agents in your account can neither read the machine nor ask it " +
"to stop an agent.", Fix: fix})
default:
add(doctorCheck{Name: "owner", OK: true, Detail: "The machine's owner account is " + name + "."})
}
}
factor := existingMachineFactorDir()
if stepupEnrolled(factor) {
add(doctorCheck{Name: "second factor", OK: true, Detail: "The machine's owner has a second factor here."})
} else {
add(doctorCheck{Name: "second factor", Problem: "This machine has no second factor of its own, so a stop, " +
"and every ask an agent files, cannot be approved.", Fix: asAdmin("stepup-enroll --machine")})
}
now := time.Now()
var held []string
for account, e := range askQuarantineLoad(factor) {
if quarantinedAt(e, now) {
held = append(held, account)
}
}
sort.Strings(held)
for _, account := range held {
add(doctorCheck{Name: "held asker", Problem: sentenceCase(askHoldRefusal(account, askHeldUntil(factor, account, now))) + ".",
Fix: asAdmin("quarantine clear --asker " + account)})
}
for _, p := range s.Pairings {
switch {
case p.Problem != "":
add(doctorCheck{Name: "host " + p.Host, Problem: "The pairing with " + p.Host + " is not usable: " +
p.Problem + ".", Fix: "The bastion's log says more: " + logHint()})
case !p.Answering && s.Running != "no":
add(doctorCheck{Name: "host " + p.Host, Problem: "The bastion is not serving " + p.Host +
" (nothing answers on " + p.Local + ").", Fix: "The bastion's log says why: " + logHint()})
}
}
if paired {
localDenyMu.Lock()
_, derr := localDenyLoad()
localDenyMu.Unlock()
if derr != nil {
add(doctorCheck{Name: "switches", Problem: localDenyUnreadableText(derr), Fix: asAdmin("switch --reset")})
}
if ok, why := doctorSandbox(); !ok {
c := doctorCheck{Name: "sandbox", Problem: "Ops a Host sends cannot run sandboxed here: " + why + "."}
if !processIsRoot() {
c.Fix = "Install the bastion for the whole machine: run the installer again without --just-me: " +
installerCommand()
}
add(c)
}
}
return d, nil
}
func cmdDoctor(args []string) error {
asJSON, err := jsonOnly(doctorUsage, args)
if err != nil {
return err
}
d, err := readMachine("doctor", readOpts{}, readDoctor)
var le *laneError
if errors.As(err, &le) {
// The one check the machine cannot make for this account.
d = doctorRead{Version: clientVersion, Checks: []doctorCheck{laneCheck(le)}}
err = nil
}
if err != nil {
return err
}
if asJSON {
if err := printJSON(os.Stdout, d); err != nil {
return err
}
} else {
printDoctor(os.Stdout, d)
}
if d.problems() > 0 {
return quietExit{code: 1}
}
return nil
}
// laneCheck is the lane not answering this account, as a check.
func laneCheck(le *laneError) doctorCheck {
if le.down {
fix := "Start it: sudo " + runCommandHintFor(runtime.GOOS) + " (see whether it runs: systemctl status privatae-bastion)"
if runtime.GOOS != "linux" {
fix = "Start it: " + runCommandHint()
}
return doctorCheck{Name: "running", Problem: "The machine's bastion does not answer this account: " + le.why + ".",
Fix: fix}
}
return doctorCheck{Name: "reads", Problem: sentenceCase(le.why) + ".",
Fix: "As the administrator: sudo -H privatae-bastion doctor"}
}
func printDoctor(w io.Writer, d doctorRead) {
switch n := d.problems(); n {
case 0:
fmt.Fprintf(w, "privatae-bastion %s: nothing wrong found.\n", d.Version)
case 1:
fmt.Fprintf(w, "privatae-bastion %s: 1 problem.\n", d.Version)
default:
fmt.Fprintf(w, "privatae-bastion %s: %d problems.\n", d.Version, n)
}
for _, c := range d.Checks {
if c.OK {
fmt.Fprintln(w, " ok "+c.Detail)
continue
}
fmt.Fprintln(w, " PROBLEM "+c.Problem)
if c.Fix != "" {
fmt.Fprintln(w, " Fix: "+c.Fix)
}
}
}
+9
View File
@@ -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)
}
+1 -1
View File
@@ -117,7 +117,7 @@ func buildEnforcingReport(dir string, allowed []string) map[string]any {
b, _ := strconv.ParseInt(ids[j], 10, 64)
return a < b
})
status := faManagedStatus()
status := faManagedStatus(dir)
for _, k := range ids {
id, err := strconv.ParseInt(k, 10, 64)
if err != nil {
+10 -10
View File
@@ -223,8 +223,8 @@ func freeagentDo(c freeagentConfig, method, path string, body any, d time.Durati
// faConfigFor is the client config for a store: the MANAGED instance the
// bastion runs for it (freeagent_managed.go) when there is one, else the
// operator's PRIVATAE_BASTION_AGENT_* env for a hand-run agent.
func faConfigFor(storeID int64) (freeagentConfig, bool) {
if c, ok := faManagedConfig(storeID); ok {
func faConfigFor(dcnDir string, storeID int64) (freeagentConfig, bool) {
if c, ok := faManagedConfig(dcnDir, storeID); ok {
return c, true
}
return freeagentEnv()
@@ -232,8 +232,8 @@ func faConfigFor(storeID int64) (freeagentConfig, bool) {
// faAbsentReason says WHY there is no local agent to ask, in the words the
// host should relay: a missing Python is the member's one manual step.
func faAbsentReason(storeID int64) string {
inst, ok := faSnapshot(storeID)
func faAbsentReason(dcnDir string, storeID int64) string {
inst, ok := faSnapshot(dcnDir, storeID)
if ok {
switch inst.State {
case "missing-python":
@@ -358,9 +358,9 @@ func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
if terms == nil {
return
}
c, on := faConfigFor(storeID)
c, on := faConfigFor(dir, storeID)
if !on {
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(storeID))
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(dir, storeID))
return
}
q := strings.TrimSpace(a.Query)
@@ -419,9 +419,9 @@ func handleDcnCatalog(ws *websocket.Conn, dir string, args []string) {
"catalog needs the 'index' mask on this grant (re-approve the store with index)")
return
}
c, on := faConfigFor(storeID)
c, on := faConfigFor(dir, storeID)
if !on {
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(storeID))
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(dir, storeID))
return
}
path := "/catalog"
@@ -471,9 +471,9 @@ func handleDcnReadRangeDoc(ws *websocket.Conn, dir string, storeID int64, a *dcn
if terms == nil {
return
}
c, on := faConfigFor(storeID)
c, on := faConfigFor(dir, storeID)
if !on {
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, faAbsentReason(storeID))
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, faAbsentReason(dir, storeID))
return
}
start := a.StartByte
+48 -25
View File
@@ -123,20 +123,29 @@ type faInstance struct {
}
// faSnapshot copies the fields a reader needs, under the lock.
func faSnapshot(storeID int64) (faInstance, bool) {
func faSnapshot(dcnDir string, storeID int64) (faInstance, bool) {
faMgr.Lock()
defer faMgr.Unlock()
inst, ok := faMgr.inst[storeID]
inst, ok := faMgr.inst[faKey{dcnDir, storeID}]
if !ok {
return faInstance{}, false
}
return *inst, true
}
// faKey is one pairing's store: store ids are each Host's own, so the same id
// from two Hosts is two stores. Keyed by store id alone, a machine paired with
// two Hosts ran one agent for both, and a search from one Host for its store
// was answered by the agent over the other Host's folder.
type faKey struct {
Dir string // the pairing's DCN state dir (Config.dcnDir)
ID int64
}
var faMgr = struct {
sync.Mutex
inst map[int64]*faInstance
}{inst: map[int64]*faInstance{}}
inst map[faKey]*faInstance
}{inst: map[faKey]*faInstance{}}
// ── locations ───────────────────────────────────────────────────────────────
@@ -664,8 +673,8 @@ func faRecipientOf(inst *faInstance) string {
// faManagedConfig is the client config for a store's managed instance, or
// false when none is running (port file absent).
func faManagedConfig(storeID int64) (freeagentConfig, bool) {
inst, ok := faSnapshot(storeID)
func faManagedConfig(dcnDir string, storeID int64) (freeagentConfig, bool) {
inst, ok := faSnapshot(dcnDir, storeID)
if !ok {
return freeagentConfig{}, false
}
@@ -697,10 +706,11 @@ func faEnsureStore(dcnDir string, storeID int64, terms *dcnTerms, sweep, recipie
}
cn := "store-" + strconv.FormatInt(storeID, 10)
faMgr.Lock()
inst, ok := faMgr.inst[storeID]
key := faKey{dcnDir, storeID}
inst, ok := faMgr.inst[key]
if !ok {
inst = &faInstance{StoreID: storeID, Dir: dir, PrivDir: priv, PubDir: pub, CN: cn, State: "installing"}
faMgr.inst[storeID] = inst
faMgr.inst[key] = inst
} else if inst.State == "installing" || inst.ensuring {
faMgr.Unlock()
return // one ensure at a time per store — see faInstance.ensuring
@@ -993,22 +1003,27 @@ func sweepFromCanonical(termsB64 string) string {
return ""
}
// faManagedProbe reports (anyManaged, allServing) and refreshes each
// instance's Serving flag by probing its /health over mTLS.
func faManagedProbe() (bool, bool) {
// faManagedProbe reports (anyManaged, allServing) for ONE pairing's instances
// and refreshes their Serving flags by probing each /health over mTLS. Another
// pairing's agents are never counted: its health is not this Host's to hear,
// and this Host reads the capability as "my stores here are served" when its
// own freeagent_stores is empty (faManagedStatus). "" probes none.
func faManagedProbe(dcnDir string) (bool, bool) {
faMgr.Lock()
ids := make([]int64, 0, len(faMgr.inst))
for id := range faMgr.inst {
ids = append(ids, id)
keys := make([]faKey, 0, len(faMgr.inst))
for k := range faMgr.inst {
if dcnDir != "" && k.Dir == dcnDir {
keys = append(keys, k)
}
}
faMgr.Unlock()
if len(ids) == 0 {
if len(keys) == 0 {
return false, false
}
all := true
for _, id := range ids {
for _, k := range keys {
serving := false
if c, ok := faManagedConfig(id); ok {
if c, ok := faManagedConfig(k.Dir, k.ID); ok {
status, body, err := freeagentCallTimeout(c, http.MethodGet, "/health", nil, freeagentProbeTimeout)
if err == nil && status == http.StatusOK {
var h struct {
@@ -1019,7 +1034,7 @@ func faManagedProbe() (bool, bool) {
}
}
faMgr.Lock()
if inst, ok := faMgr.inst[id]; ok {
if inst, ok := faMgr.inst[k]; ok {
inst.Serving = serving
}
faMgr.Unlock()
@@ -1030,13 +1045,20 @@ func faManagedProbe() (bool, bool) {
return true, all
}
// faManagedStatus is the heartbeat's per-store detail.
func faManagedStatus() map[string]any {
// faManagedStatus is one pairing's per-store detail for its heartbeat: only
// its own stores, never another Host's.
func faManagedStatus(dcnDir string) map[string]any {
faMgr.Lock()
defer faMgr.Unlock()
out := map[string]any{}
for id, inst := range faMgr.inst {
out[strconv.FormatInt(id, 10)] = map[string]any{
if dcnDir == "" {
return out
}
for k, inst := range faMgr.inst {
if k.Dir != dcnDir {
continue
}
out[strconv.FormatInt(k.ID, 10)] = map[string]any{
"state": inst.State, "serving": inst.Serving, "version": inst.Version, "detail": inst.Detail,
}
}
@@ -1045,14 +1067,15 @@ func faManagedStatus() map[string]any {
// faStop tears down a store's managed instance (grant revoked / re-synced
// without search).
func faStop(storeID int64) {
func faStop(dcnDir string, storeID int64) {
faMgr.Lock()
defer faMgr.Unlock()
if inst, ok := faMgr.inst[storeID]; ok {
key := faKey{dcnDir, storeID}
if inst, ok := faMgr.inst[key]; ok {
if inst.cancel != nil {
inst.cancel()
}
delete(faMgr.inst, storeID)
delete(faMgr.inst, key)
}
}
+56 -21
View File
@@ -182,29 +182,64 @@ func TestFaManifestText(t *testing.T) {
func TestFaManagedConfigFromPortFile(t *testing.T) {
dir := t.TempDir()
faMgr.Lock()
faMgr.inst[4242] = &faInstance{StoreID: 4242, Dir: dir, Cert: "c", Key: "k"}
faMgr.inst[faKey{"dcn-test", 4242}] = &faInstance{StoreID: 4242, Dir: dir, Cert: "c", Key: "k"}
faMgr.Unlock()
t.Cleanup(func() { faStop(4242) })
if _, ok := faManagedConfig(4242); ok {
t.Cleanup(func() { faStop("dcn-test", 4242) })
if _, ok := faManagedConfig("dcn-test", 4242); ok {
t.Fatal("no port file → not configured")
}
os.WriteFile(filepath.Join(dir, "port"), []byte("18473\n"), 0o600)
c, ok := faManagedConfig(4242)
c, ok := faManagedConfig("dcn-test", 4242)
if !ok || c.Addr != "127.0.0.1:18473" || c.CA != "c" {
t.Fatalf("port file → config, got %+v %v", c, ok)
}
if _, ok := faConfigFor(4242); !ok {
if _, ok := faConfigFor("dcn-test", 4242); !ok {
t.Fatal("faConfigFor must prefer the managed instance")
}
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "")
if _, ok := faConfigFor(1); ok {
if _, ok := faConfigFor("dcn-test", 1); ok {
t.Fatal("unknown store with no env → absent")
}
if !strings.Contains(faAbsentReason(1), "no local agent") {
if !strings.Contains(faAbsentReason("dcn-test", 1), "no local agent") {
t.Fatal("absent reason for an unknown store")
}
}
// A beat's freeagent capability and freeagent_ok are its own pairing's agents
// only. Probed across every pairing, Host B's beat carried Host A's agent's
// health, and with B's freeagent_stores now empty the Host reads the
// capability alone (the Host's store-card check: `"freeagent"
// in caps and not stores`), so A's serving agent marked every one of B's
// stores on this machine as served.
func TestHeartbeatProbesOnlyItsOwnPairingsAgents(t *testing.T) {
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "") // no hand-run agent
dcnA, dcnB := t.TempDir(), t.TempDir()
faMgr.Lock()
// no port file: A's agent is managed and not serving
faMgr.inst[faKey{dcnA, 7}] = &faInstance{StoreID: 7, Dir: t.TempDir(), Cert: "a", Key: "a"}
faMgr.Unlock()
t.Cleanup(func() { faStop(dcnA, 7) })
beat := func(dir string) map[string]any {
var body map[string]any
raw := buildHeartbeatBodyOn(beatLanes{dcnDir: dir}, "", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatal(err)
}
return body
}
// MUTATION: probe every pairing's instances (no k.Dir filter in
// faManagedProbe). RESULT: red, B's beat carries freeagent_ok false.
if b, ok := beat(dcnB)["freeagent_ok"]; ok {
t.Errorf("Host B holds no agent here, yet its beat says freeagent_ok %v", b)
}
if b, ok := beat(dcnA)["freeagent_ok"]; !ok || b != false {
t.Errorf("Host A's beat must carry its own agent's health: %v %v", b, ok)
}
if b, ok := beat("")["freeagent_ok"]; ok {
t.Errorf("a beat with no DCN state probes no managed agent: %v", b)
}
}
// End to end with the real artifact: FREEAGENT_TEST_PYZ=/path/to/freeagent-x.pyz
func TestFaManagedEndToEnd(t *testing.T) {
pyzPath := os.Getenv("FREEAGENT_TEST_PYZ")
@@ -264,7 +299,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
}
faReconcile(dcnDir) // what the daemon does at start
t.Cleanup(func() { faStop(1) })
t.Cleanup(func() { faStop(dcnDir, 1) })
stateDir, err := faStoreDir(dcnDir, 1)
if err != nil {
t.Fatal(err)
@@ -274,7 +309,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
var c freeagentConfig
for time.Now().Before(deadline) {
faMgr.Lock()
inst := faMgr.inst[1]
inst := faMgr.inst[faKey{dcnDir, 1}]
state, detail := "", ""
if inst != nil {
state, detail = inst.State, inst.Detail
@@ -283,7 +318,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
if state == "failed" || state == "missing-python" {
t.Fatalf("managed install %s: %s", state, detail)
}
if cfg, ok := faManagedConfig(1); ok {
if cfg, ok := faManagedConfig(dcnDir, 1); ok {
c = cfg
break
}
@@ -395,11 +430,11 @@ func TestFaManagedEndToEnd(t *testing.T) {
}
}
// heartbeat sees it
adv, allOK := faManagedProbe()
adv, allOK := faManagedProbe(dcnDir)
if !adv || !allOK {
t.Fatalf("managed probe: %v %v", adv, allOK)
}
st := faManagedStatus()["1"].(map[string]any)
st := faManagedStatus(dcnDir)["1"].(map[string]any)
if st["state"] != "running" || st["serving"] != true || st["version"] != "9.9.9" {
t.Fatalf("status: %v", st)
}
@@ -417,7 +452,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
// a re-approved grant with narrower terms restarts
// the instance under the new manifest (new excludes, new serve port)
before, _ := faSnapshot(1)
before, _ := faSnapshot(dcnDir, 1)
terms2 := map[string]any{}
for k, v := range terms {
terms2[k] = v
@@ -432,9 +467,9 @@ func TestFaManagedEndToEnd(t *testing.T) {
deadline = time.Now().Add(60 * time.Second)
var after faInstance
for time.Now().Before(deadline) {
after, _ = faSnapshot(1)
after, _ = faSnapshot(dcnDir, 1)
if after.State == "running" && after.Text != before.Text {
if c2, ok := faManagedConfig(1); ok && c2.Addr != c.Addr {
if c2, ok := faManagedConfig(dcnDir, 1); ok && c2.Addr != c.Addr {
break
}
}
@@ -448,10 +483,10 @@ func TestFaManagedEndToEnd(t *testing.T) {
t.Fatal("re-rendered manifest must carry the new excludes and be re-signed")
}
// unchanged terms on the next reconcile: no restart (same port)
c3, _ := faManagedConfig(1)
c3, _ := faManagedConfig(dcnDir, 1)
faReconcile(dcnDir)
time.Sleep(time.Second)
c4, _ := faManagedConfig(1)
c4, _ := faManagedConfig(dcnDir, 1)
if c3.Addr != c4.Addr {
t.Fatal("identical terms must not restart the instance")
}
@@ -459,7 +494,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
// A fresh identity with a surviving manifest (new layout, wiped base, a
// re-pair): the manifest must be re-signed under the new cert, or the
// gate refuses it and serve answers 503 forever.
faStop(1)
faStop(dcnDir, 1)
_, privDir, _, _ := faStoreDirs(dcnDir, 1)
if err := os.RemoveAll(filepath.Join(privDir, "host.key")); err != nil {
t.Fatal(err)
@@ -470,15 +505,15 @@ func TestFaManagedEndToEnd(t *testing.T) {
deadline = time.Now().Add(60 * time.Second)
var c9 freeagentConfig
for time.Now().Before(deadline) {
if cfg, ok := faManagedConfig(1); ok && cfg.Addr != "" {
if st, _ := faSnapshot(1); st.State == "running" && st.Cert != "" {
if cfg, ok := faManagedConfig(dcnDir, 1); ok && cfg.Addr != "" {
if st, _ := faSnapshot(dcnDir, 1); st.State == "running" && st.Cert != "" {
if _, body, err := freeagentCall(cfg, http.MethodGet, "/health", nil); err == nil && strings.Contains(string(body), `"serving": true`) {
c9 = cfg
break
}
}
}
if st, _ := faSnapshot(1); st.State == "failed" {
if st, _ := faSnapshot(dcnDir, 1); st.State == "failed" {
t.Fatalf("re-identity: %s", st.Detail)
}
time.Sleep(500 * time.Millisecond)
+1 -1
View File
@@ -149,7 +149,7 @@ func cmdGitCredential(host string, action string) error {
case len(cfgs) == 1:
cfg = cfgs[0]
case len(cfgs) == 0:
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
return fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
default:
return fmt.Errorf("%d hosts are paired — configure the helper with --host: "+
"credential.https://git.privatae.ai.helper \"!privatae-bastion git-credential --host <host>\"", len(cfgs))
+8
View File
@@ -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)
}
}
}()
}
+1 -1
View File
@@ -915,7 +915,7 @@ func TestDeviceApproveAllSignsOnlyWhatWasShown(t *testing.T) {
if err := cmdDeviceApprove("all"); err == nil {
t.Fatal("nothing shown yet: all must refuse")
}
if err := cmdDeviceApprovals(); err != nil {
if err := cmdDeviceApprovals(nil); err != nil {
t.Fatal(err)
}
b := file("harness:run") // filed AFTER the person looked
+4
View File
@@ -377,6 +377,10 @@ exit 0
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
"curl": `for a; do o=$a; done; case "$2" in */version.json) cp "$STAND/version.json" "$o" ;; *) cp "$STAND/bastion.bin" "$o" ;; esac`,
"printf": `exec /usr/bin/printf "$@"`,
// No openssl that does Ed25519 here, so the release signature is
// noted as not checked (install_sig_sh_test.go tests the check): this
// stand-in manifest is signed by no release key.
"openssl": `exit 1`,
}
for name, body := range stand {
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n")
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// ed25519SPKIPrefix is the DER that wraps a raw Ed25519 public key in the
// SubjectPublicKeyInfo openssl reads (RFC 8410).
var ed25519SPKIPrefix = []byte{0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00}
func installShVar(t *testing.T, name string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
m := regexp.MustCompile(`(?m)^` + name + `='([^']*)'$`).FindSubmatch(raw)
if m == nil {
t.Fatalf("install.sh sets no %s", name)
}
return string(m[1])
}
func spkiToRaw(t *testing.T, b64 string) []byte {
t.Helper()
der, err := base64.StdEncoding.DecodeString(b64)
if err != nil || len(der) != len(ed25519SPKIPrefix)+ed25519.PublicKeySize || !bytes.HasPrefix(der, ed25519SPKIPrefix) {
t.Fatalf("not an Ed25519 public key: %q (%v)", b64, err)
}
return der[len(ed25519SPKIPrefix):]
}
// The installer checks a release with exactly the keys a bastion checks its
// updates with: a key rotated in release_sign.go and not here would make
// every install refuse, or accept a key the bastions no longer do.
func TestInstallShPinsTheReleaseKeysTheBastionChecks(t *testing.T) {
keys := strings.Fields(installShVar(t, "RELEASE_KEYS"))
if len(keys) != len(productionReleaseKeys) {
t.Fatalf("install.sh pins %d release keys, release_sign.go %d", len(keys), len(productionReleaseKeys))
}
for i, k := range keys {
if got := base64.StdEncoding.EncodeToString(spkiToRaw(t, k)); got != productionReleaseKeys[i] {
t.Errorf("install.sh key %d is %s, release_sign.go has %s", i, got, productionReleaseKeys[i])
}
}
}
// The probe is what tells "this openssl cannot check" from "the signature is
// wrong". A probe that did not verify would make every system read as unable,
// and every install skip the signature with a note.
func TestInstallShProbeSignatureVerifies(t *testing.T) {
pub := spkiToRaw(t, installShVar(t, "SIG_PROBE_KEY"))
sig, err := base64.StdEncoding.DecodeString(installShVar(t, "SIG_PROBE_SIG"))
if err != nil {
t.Fatal(err)
}
if !ed25519.Verify(ed25519.PublicKey(pub), []byte("privatae-installer-probe-v1\n"), sig) {
t.Fatal("the probe signature does not verify under the probe key")
}
}
// sigHarness is release_signature_check from install.sh, with the release
// keys replaced by key and the download host by base, run on manifest.
func sigHarness(t *testing.T, pub ed25519.PublicKey, base string, manifest []byte, onlyPATH ...bool) (string, error) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
s := string(raw)
vars := regexp.MustCompile(`(?ms)^RELEASE_KEYS=.*?^SIG_PROBE_SIG='[^']*'$`).FindString(s)
fn := regexp.MustCompile(`(?ms)^release_signature_check\(\) \{.*?^\}$`).FindString(s)
if vars == "" || fn == "" {
t.Fatal("install.sh's release_signature_check or its keys moved")
}
if len(onlyPATH) > 0 && onlyPATH[0] {
// Only the openssl on PATH: a Mac with Homebrew's openssl@3 would
// otherwise find a real one at its fixed path.
loop := regexp.MustCompile(`(?m)^ for c in openssl [^;]*; do$`)
if !loop.MatchString(fn) {
t.Fatal("install.sh's openssl candidates moved")
}
fn = loop.ReplaceAllString(fn, " for c in openssl; do")
}
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
vars = regexp.MustCompile(`(?m)^RELEASE_KEYS='[^']*'$`).ReplaceAllString(vars,
"RELEASE_KEYS='"+base64.StdEncoding.EncodeToString(der)+"'")
d := t.TempDir()
if err := os.WriteFile(filepath.Join(d, "man.json"), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(d, "v"), 0o700); err != nil {
t.Fatal(err)
}
script := "die() { echo \"ERROR: $*\" >&2; exit 1; }\nSHACMD=sha256sum\nsha_of() { $SHACMD \"$1\" | cut -d' ' -f1; }\n" +
"base='" + base + "'\n" + vars + "\n" + fn + "\nrelease_signature_check \"$1\" \"$2\"\necho RETURNED\n"
sh := filepath.Join(d, "h.sh")
if err := os.WriteFile(sh, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
out, err := exec.Command("sh", sh, filepath.Join(d, "man.json"), filepath.Join(d, "v")).CombinedOutput()
return string(out), err
}
func releaseSigFor(priv ed25519.PrivateKey, manifest []byte, version string) (name, b64 string) {
sum := sha256.Sum256(manifest)
stmt := releaseStatement("bastion", version, hex.EncodeToString(sum[:]))
return "/version.json." + hex.EncodeToString(sum[:8]) + ".sig",
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, stmt))
}
func TestInstallShRefusesAReleaseWhoseSignatureDoesNotVerify(t *testing.T) {
requireTools(t, "sh", "openssl", "sha256sum", "curl")
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
_, other, _ := ed25519.GenerateKey(rand.Reader)
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
name, good := releaseSigFor(priv, manifest, "9.9.9")
_, wrongKey := releaseSigFor(other, manifest, "9.9.9")
_, wrongVersion := releaseSigFor(priv, manifest, "9.9.8")
serve := map[string]string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v, ok := serve[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(v))
}))
defer srv.Close()
// The check needs an openssl that does Ed25519; without one it only
// notes it, which TestInstallShSaysWhenItCannotCheckTheSignature covers.
// Whether this one can is asked of openssl here, never read off the
// installer's note: a probe broken in install.sh would print that note on
// every machine, and this test would skip while every install skipped the
// signature.
// MUTATION: break the probe's pkeyutl flags in install.sh. RESULT: red here
// (it skipped before).
if !opensslChecksEd25519(t) {
skipOrFail(t, "the openssl here does not check Ed25519 signatures")
}
serve[name] = good
out, err := sigHarness(t, pub, srv.URL, manifest)
if err != nil || strings.Contains(out, "no openssl here can check") ||
!strings.Contains(out, "Release signature verified (version 9.9.9)") || !strings.Contains(out, "RETURNED") {
t.Fatalf("a good signature: %v\n%s", err, out)
}
for what, sig := range map[string]string{"another key's": wrongKey, "another version's": wrongVersion} {
serve[name] = sig
// MUTATION: return 0 after the key loop. RESULT: red, these install.
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || strings.Contains(out, "RETURNED") ||
!strings.Contains(out, "does not verify") {
t.Errorf("%s signature: %v\n%s", what, err, out)
}
}
serve[name] = "not base64 at all !"
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "malformed") {
t.Errorf("a malformed signature: %v\n%s", err, out)
}
delete(serve, name)
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "cannot fetch the release's signature") {
t.Errorf("no signature: %v\n%s", err, out)
}
}
// opensslChecksEd25519 says, without install.sh, whether the openssl on PATH
// verifies an Ed25519 signature made here, and refuses one over another
// message.
func opensslChecksEd25519(t *testing.T) bool {
t.Helper()
if _, err := exec.LookPath("openssl"); err != nil {
return false
}
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
d := t.TempDir()
files := map[string][]byte{
"k.pem": []byte("-----BEGIN PUBLIC KEY-----\n" + base64.StdEncoding.EncodeToString(der) + "\n-----END PUBLIC KEY-----\n"),
"m": []byte("capability\n"),
"other": []byte("capability?\n"),
"s": ed25519.Sign(priv, []byte("capability\n")),
}
for n, b := range files {
if err := os.WriteFile(filepath.Join(d, n), b, 0o600); err != nil {
t.Fatal(err)
}
}
verify := func(msg string) bool {
return exec.Command("openssl", "pkeyutl", "-verify", "-pubin", "-inkey", filepath.Join(d, "k.pem"), "-rawin",
"-in", filepath.Join(d, msg), "-sigfile", filepath.Join(d, "s")).Run() == nil
}
return verify("m") && !verify("other")
}
// An openssl that passes every signature is no check: the installer must not
// take it for one and print "verified" over a signature no pinned key made.
// It is set aside like one that cannot check; a later candidate that can
// still checks.
func TestInstallShTakesNoOpensslThatPassesEverySignature(t *testing.T) {
requireTools(t, "sh", "openssl", "sha256sum", "curl")
systemSSL, _ := exec.LookPath("openssl")
fake := t.TempDir()
if err := os.WriteFile(filepath.Join(fake, "openssl"),
[]byte("#!/bin/sh\ncase \"$1\" in pkeyutl) exit 0 ;; esac\nexec '"+systemSSL+"' \"$@\"\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
pub, _, _ := ed25519.GenerateKey(rand.Reader)
_, other, _ := ed25519.GenerateKey(rand.Reader)
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
name, forged := releaseSigFor(other, manifest, "9.9.9")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != name {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(forged))
}))
defer srv.Close()
// MUTATION: drop the probe's second verify (the one that must fail).
// RESULT: red, "Release signature verified" over the forged signature.
out, _ := sigHarness(t, pub, srv.URL, manifest)
if strings.Contains(out, "Release signature verified") ||
!(strings.Contains(out, "no openssl here can check") || strings.Contains(out, "does not verify")) {
t.Errorf("an openssl that passes everything was taken for a check:\n%s", out)
}
}
// SECURITY.md sends a reader who does not trust our website to a copy of
// install.sh read from this repository. Run without sudo, that copy installs
// for the whole machine through a root step that fetches the installer from
// the website again (whole_machine_script) and runs that, so the copy read
// checks nothing itself. The page has to say how the copy must be run.
func TestSecurityMdSaysHowAReadCopyOfTheInstallerMustRun(t *testing.T) {
sh, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(sh), `whole_machine_script="https://privatae.ai/bastion/install.sh"`) {
t.Fatal("install.sh's root step no longer fetches the website's installer: re-read SECURITY.md's first-install paragraph, then this test")
}
raw, err := os.ReadFile(filepath.Join("..", "SECURITY.md"))
if err != nil {
t.Fatal(err)
}
sec := strings.Join(strings.Fields(string(raw)), " ")
for _, want := range []string{
"`sudo sh install.sh`",
"`sh install.sh --just-me`",
"fetches the installer from our website again",
"an older release we signed",
} {
if !strings.Contains(sec, want) {
t.Errorf("SECURITY.md does not say %q", want)
}
}
}
// With no openssl that can check Ed25519, the check says so and returns, so
// the digest check still runs; it never reads as verified.
func TestInstallShSaysWhenItCannotCheckTheSignature(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("no sh")
}
fake := t.TempDir()
if err := os.WriteFile(filepath.Join(fake, "openssl"), []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
pub, _, _ := ed25519.GenerateKey(rand.Reader)
out, err := sigHarness(t, pub, "http://127.0.0.1:1", []byte("{\"version\": \"9.9.9\"}\n"), true)
if err != nil || !strings.Contains(out, "no openssl here can check") || strings.Contains(out, "verified") ||
!strings.Contains(out, "RETURNED") {
t.Errorf("no capable openssl: %v\n%s", err, out)
}
}
// The check runs on every install, after the manifest is fetched and before
// any digest is read out of it: a digest from an unchecked manifest is the
// thing the signature exists to stop.
func TestInstallShChecksTheSignatureBeforeReadingTheDigest(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
if err != nil {
t.Fatal(err)
}
s := string(raw)
fetch := strings.Index(s, `curl -fsSL "$base/version.json" -o "$man"`)
check := strings.Index(s, "\nrelease_signature_check \"$man\" \"$vdir\"\n")
digest := strings.Index(s, "expected=$(awk")
if fetch < 0 || check < 0 || digest < 0 || !(fetch < check && check < digest) {
t.Fatalf("install.sh must fetch the manifest, check its signature, then read its digest (at %d, %d, %d)",
fetch, check, digest)
}
}
// requireTools skips without one of these tools, unless CI says the check
// must run (PRIVATAE_REQUIRE_OPENSSL=1, .gitea/workflows/ci.yml): there a
// missing tool is a failure, so the signature check cannot quietly go untested.
func requireTools(t *testing.T, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
skipOrFail(t, tool+" is not installed here")
}
}
}
func skipOrFail(t *testing.T, why string) {
t.Helper()
if os.Getenv("PRIVATAE_REQUIRE_OPENSSL") == "1" {
t.Fatalf("%s, and PRIVATAE_REQUIRE_OPENSSL=1 says this check must run", why)
}
t.Skip(why)
}
+157
View File
@@ -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
}
+223
View File
@@ -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
View File
@@ -111,22 +111,25 @@ Usage:
anchor anywhere.
privatae-bastion approvals [--host <host> | --machine] [--json]
show what your host is asking to do on this machine, in plain words
privatae-bastion approve [--host <host>] <id>
privatae-bastion approve [--host <host> | --machine] <id>
approve one request with a code from the authenticator enrolled here
privatae-bastion deny [--host <host>] <id>
refuse one request
privatae-bastion deny [--host <host> | --machine] <id>
refuse one request. From the machine's owner account, beside the
bastion installed for the whole machine, both act on that bastion,
and so do switch (off with nothing, on with the owner's code) and,
with nothing, approvals drop, grants drop, ops drop and device-deny
privatae-bastion approvals drop [--host <host>] <id>
take back an approval this machine issued and has not used yet
privatae-bastion grants drop <store id> [--host <host>]
remove a folder grant held on this machine; the host is told
privatae-bastion device-init make THIS machine your approval device: from then on a write a
host asks of any machine you govern is approved with a tap here
privatae-bastion device-approvals what your machines are asking you to approve, in their words
privatae-bastion device-approvals [--json] what your machines are asking you to approve, in their words
privatae-bastion device-approve <id>|all / device-deny <id>
sudo -H privatae-bastion pin-device <key>
on a machine you govern: accept approvals signed by your device
(the key device-init printed). unpin-device <key> withdraws it
privatae-bastion ops [--host <host>] the ops your host wrote that this machine holds, and the
privatae-bastion ops [--host <host>] [--json] the ops your host wrote that this machine holds, and the
standing op grants; ops drop <grant> takes one back
sudo -H privatae-bastion root-exec grant [--host <host>] --for <30d>
let the anchor host run commands AS ROOT here, one at a time,
@@ -145,11 +148,18 @@ Usage:
switch one capability family off for a host on this machine,
or clear that refusal (the console page's switch). Off only
removes reach; on grants nothing past the host's signed grant
privatae-bastion switch --list every paired host's families, on or off, one per line
privatae-bastion switch --list [--json]
every paired host's families, on or off, one per line
privatae-bastion agents [--json] [--prompts]
every AI agent on this machine and what it is doing: its
session name and title, whether it waits for you, its folder
and branch, what it is running now (--prompts: its last prompt)
privatae-bastion agents show <pid or session> [--json] [--prompts]
one agent with everything known about it: what it is, the file
it runs, how it was recognised, what it says about itself
privatae-bastion doctor [--json]
what is wrong with this install, and the fix for each one.
Exit 1 when something is
privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]
write what this bastion knows as one Markdown file: agents,
scheduled agents, MCP tool-server problems, the baseline, activity.
@@ -173,8 +183,10 @@ Usage:
denied. Exit 0 stopped (or asked), 1 not, 3 still waiting
From the machine's owner account, beside the bastion installed for
the whole machine, the reads (status, agents, schedules,
tool-servers, posture, baseline, activity, report, approvals)
ask that bastion and show the whole machine
tool-servers, posture, baseline, activity, report, approvals,
switch --list, ops, device-approvals, backup key show,
backup requests, backup shares, doctor) ask that bastion and
show the whole machine
privatae-bastion help this text
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan h[:p],...]
[--image-prefix <registry/path/>|default]
@@ -613,7 +625,7 @@ func main() {
case "device-init":
err = cmdDeviceInit()
case "device-approvals":
err = cmdDeviceApprovals()
err = cmdDeviceApprovals(os.Args[2:])
case "device-approve", "device-deny":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: privatae-bastion "+os.Args[1]+" <request id> [--code <code>]")
@@ -685,7 +697,7 @@ func main() {
}
os.Exit(opActionChildMain(os.Args[2]))
case "ops":
// ops [--host <host>] what ops and op grants this machine holds
// ops [--host <host>] [--json] what ops and op grants this machine holds
// ops drop <grant> [--host <host>] take back a held op grant (the host is told)
// ops drop --all [--host <host>] take back every op grant the host holds here
host, args := takeHostFlag(os.Args[2:])
@@ -693,7 +705,12 @@ func main() {
err = cmdOpsDrop(host, args[1])
break
}
err = cmdOpsList(host)
asJSON, jerr := jsonOnly("usage: privatae-bastion ops [--host <host>] [--json] | ops drop <grant>|--all [--host <host>]", args)
if jerr != nil {
fmt.Fprintln(os.Stderr, jerr)
os.Exit(2)
}
err = cmdOpsList(host, asJSON)
case "root-exec":
// root-exec [--host <host>] [grant --for <d> | revoke] (rootexec.go): the
// standing grant for the anchor host's root commands, given and taken
@@ -752,6 +769,10 @@ func main() {
// baseline show|diff [--json], baseline record [--force]
// (machine_cli.go): observe:baseline* under the words people use.
err = cmdBaseline(os.Args[2:])
case "doctor":
// doctor [--json] (doctor.go): what is wrong with this install, and
// the fix for each one.
err = cmdDoctor(os.Args[2:])
case "help", "-h", "--help":
usage()
case "switch":
+8
View File
@@ -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)
}
}
}()
}
-23
View File
@@ -362,26 +362,3 @@ func utf16Len(s string) int {
}
return n
}
// secondRunNotice is runguard_windows.go's refusal, written for a dialog box
// rather than for a terminal.
//
// It is deliberately NOT the same string as the error that goes to stderr. That
// one is laid out for a console — indented continuation lines, "you can close
// this window" meaning the console window — and the two media want different
// words for the same fact. They live next to each other (runguard_windows.go) so
// they are maintained together.
//
// The length caps above do NOT apply here: MessageBoxW takes an ordinary
// null-terminated string with no fixed buffer, unlike the notification-area
// struct.
func secondRunNotice() uiNotice {
return uiNotice{
Title: "Privatae bastion is already running",
Body: "This machine is already serving your hosts.\r\n\r\n" +
"A second copy would fight the first one for the same local ports, so this " +
"one is stopping. Nothing is wrong and nothing has stopped working.\r\n\r\n" +
"If you believe the running one is dead, end privatae-bastion.exe in Task " +
"Manager and start this again.",
}
}
+7 -1
View File
@@ -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
}
+38
View File
@@ -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{
+319
View File
@@ -0,0 +1,319 @@
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"
"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) {
return serveActFor(askPeer{UID: uid, Name: user}, req)
}
func serveActFor(peer askPeer, req agentAskRequest) (r agentActReply) {
user := peer.Name
r.User = user
if why := readLaneRefusalFor(peer); 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, peer.windows())
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, pipe bool) {
dir, err := machineFactorDir()
if err != nil {
uilogf("owner act not audited (%v): %s, from %s\n", err, what, user)
return
}
lane := "its ask socket"
if pipe {
lane = "its ask pipe"
}
dcnAuditWith(dir, "owner_lane_act", 0, "",
what+", from the owner's account "+user+" through "+lane, nil)
}
// ── the asking side ─────────────────────────────────────────────────────────
func actViaSock(req agentAskRequest) (string, error) {
p := askLaneAddr(readSockPath())
c, err := dialAskLane(readSockPath())
if err != nil {
return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
}
defer c.Close()
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
req.Op = "act"
if err := json.NewEncoder(c).Encode(req); err != nil {
return "", &laneError{down: true, why: "the act could not be sent (" + err.Error() + ")"}
}
var r agentActReply
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&r); err != nil {
return "", &laneError{down: true, why: "the machine's bastion did not answer (" + err.Error() + ")"}
}
if !r.OK && r.Failed {
return "", errors.New(r.Why)
}
if !r.OK {
return "", &laneError{why: r.Why}
}
return r.Done, nil
}
// ownerApproveViaLane shows the request the machine's bastion holds, takes
// the owner's code at this terminal, and asks the bastion to approve it.
func ownerApproveViaLane(host string, machine bool, prefix string) error {
v, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
if err != nil {
// Refused, the read is refused as the approve it was read for.
var le *laneError
if errors.As(err, &le) && !le.down {
err = &laneError{why: actWording(le.why)}
}
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
}
if !v.CodeSetUp {
return fmt.Errorf("no second factor is enrolled %s, so nothing can be approved yet", v.For)
}
var hit *approvalWaiting
for i := range v.Waiting {
if strings.HasPrefix(v.Waiting[i].ID, prefix) || strings.HasPrefix(prefix, v.Waiting[i].ID) {
if hit != nil {
return fmt.Errorf("more than one waiting request starts with %q; give more of its id", prefix)
}
hit = &v.Waiting[i]
}
}
if hit == nil {
return fmt.Errorf("nothing waiting %s starts with %q; `privatae-bastion approvals%s` lists them",
v.For, prefix, map[bool]string{true: " --machine", false: ""}[machine])
}
fmt.Println(hit.Text)
fmt.Println()
code, ok := promptOwnerCode("Approve this? Enter the code from your authenticator (or leave empty to cancel): ")
if !ok {
return nil
}
// The bastion finds the request again by this id: the one shown, never
// fewer characters of it than shown. Sent as typed, a short prefix would
// approve whatever request matched it by then (one an agent filed while
// the person typed, say), which is not what they read.
id := hit.ID
if len(prefix) > len(id) {
id = prefix
}
done, err := actViaSock(agentAskRequest{Act: "approve", Host: host, Machine: machine, ID: id, Code: code})
if err != nil {
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
}
fmt.Println(done)
return nil
}
// actWording is the reads' refusal said of an act.
func actWording(why string) string {
return strings.Replace(why, "answers reads only for", "acts only for", 1)
}
// stepupCommand is approve or deny as the administrator would type it here.
func stepupCommand(verb, host string, machine bool, id string) string {
switch {
case machine:
return verb + " --machine " + id
case host != "":
return verb + " --host " + host + " " + id
}
return verb + " " + id
}
// promptOwnerCode reads a code from this terminal; ok is false when the
// person left it empty, and then nothing is done (said here).
var promptOwnerCode = func(prompt string) (string, bool) {
fmt.Print(prompt)
code, _ := bufio.NewReader(os.Stdin).ReadString('\n')
code = strings.TrimSpace(code)
if code == "" {
fmt.Println("cancelled — nothing done")
return "", false
}
return code, true
}
+504
View File
@@ -0,0 +1,504 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// stubCode answers the owner's code prompt with code ("" = left empty).
func stubCode(t *testing.T, code string) *int {
t.Helper()
asked := 0
prev := promptOwnerCode
t.Cleanup(func() { promptOwnerCode = prev })
promptOwnerCode = func(string) (string, bool) { asked++; return code, code != "" }
return &asked
}
// From the owner's account, beside the machine's bastion, approve answers a
// stop ask the machine holds, with the owner's code: before, it looked in
// that account's own empty state and said the request did not exist.
func TestTheOwnerApprovesTheMachinesAskThroughTheLane(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatalf("ask refused: %s", r.Why)
}
laneOwner(t, laneTestUID)
laneSock(t)
out := captureStdout(t)
stubCode(t, codeFor(t, machineTestSecret))
if err := cmdStepupApprove("", true, r.Request); err != nil {
t.Fatalf("approve through the lane: %v", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "approved" {
t.Errorf("the machine's request is %q after the owner approved it", got)
}
if got := printedSince(out, 0, "approved."); !strings.Contains(got, "approved.") {
t.Errorf("printed %q", got)
}
// The kernel's name for this account: the harness maps only its uid to
// the owner's, so the name is the test process's own.
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
if !strings.Contains(string(raw), `"op":"owner_lane_act"`) ||
!strings.Contains(string(raw), "from the owner's account ") {
t.Errorf("the act is not in the audit log with the account that acted:\n%s", raw)
}
}
// A wrong code approves nothing and counts against the owner's code, as the
// console's does; an empty one sends nothing.
func TestAWrongCodeThroughTheLaneApprovesNothing(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
stubCode(t, "000000")
err := cmdStepupApprove("", true, r.Request)
var le *laneError
if err == nil || errors.As(err, &le) {
t.Errorf("a wrong code: %v (want the bastion's refusal, not a lane refusal)", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "pending" {
t.Errorf("a wrong code left the request %q", got)
}
asked := stubCode(t, "")
if err := cmdStepupApprove("", true, r.Request); err != nil || *asked != 1 {
t.Errorf("an empty code: %v, asked %d", err, *asked)
}
// MUTATION: spend the code without machineCodeSpend. RESULT: red, a wrong
// code no longer counts.
stepupMu.Lock()
n := pipeThrottleLoad(d).Failures
stepupMu.Unlock()
if n < 1 {
t.Errorf("the wrong code was not counted (%d)", n)
}
}
// Deny takes no code: refusing only takes reach away.
func TestTheOwnerDeniesThroughTheLaneWithNoCode(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
asked := stubCode(t, "")
if err := cmdStepupDeny("", true, r.Request); err != nil {
t.Fatalf("deny through the lane: %v", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "denied" || *asked != 0 {
t.Errorf("deny left %q, asked for a code %d times", got, *asked)
}
}
// The local switch from the owner's account: off with nothing, on only with
// the owner's code (the console's switch rule).
func TestTheOwnerSwitchesThroughTheLane(t *testing.T) {
askFlowWorld(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
if err := cmdSwitch([]string{host, "ops", "off"}); err != nil {
t.Fatalf("switch off: %v", err)
}
if !localDenied(host, "ops") {
t.Fatal("ops is not off after the owner switched it off")
}
stubCode(t, "000000")
if err := cmdSwitch([]string{host, "ops", "on"}); err == nil || !localDenied(host, "ops") {
t.Errorf("switch on with a wrong code: %v, still off %v", err, localDenied(host, "ops"))
}
stubCode(t, codeFor(t, machineTestSecret))
if err := cmdSwitch([]string{host, "ops", "on"}); err != nil || localDenied(host, "ops") {
t.Errorf("switch on with the owner's code: %v, still off %v", err, localDenied(host, "ops"))
}
}
// Any other account is refused every act, with the administrator's command.
func TestAnotherAccountIsRefusedEveryAct(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID+1)
laneSock(t)
captureStdout(t)
stubCode(t, codeFor(t, machineTestSecret))
for name, run := range map[string]func() error{
"approve": func() error { return cmdStepupApprove("", true, r.Request) },
"deny": func() error { return cmdStepupDeny("", true, r.Request) },
"switch off": func() error { return cmdSwitch([]string{"box-one.example", "ops", "off"}) },
} {
err := run()
if err == nil || !strings.Contains(err.Error(), "acts only for its owner's account") || !strings.Contains(err.Error(), "sudo -H") {
t.Errorf("%s from another account: %v", name, err)
}
if (name == "approve" || name == "deny") && !strings.Contains(err.Error(), "sudo -H privatae-bastion "+name+" --machine "+r.Request) {
t.Errorf("the administrator's command does not name the request: %v", err)
}
}
// The list, refused, is given back as the administrator types it for the
// same requests: the machine's own, not a Host's.
if err := cmdStepupApprovals("", true, false); err == nil || !strings.Contains(err.Error(), "answers reads only for its owner's account") ||
!strings.HasSuffix(err.Error(), "sudo -H privatae-bastion approvals --machine") {
t.Errorf("approvals --machine from another account: %v", err)
}
if got := requestsLoad(d)[fullRequestID(t, d, r.Request)].State; got != "pending" {
t.Errorf("another account's deny left the request %q", got)
}
if rep := serveAct(laneTestUID, "lee", agentAskRequest{Act: "approve", Machine: true, ID: r.Request,
Code: codeFor(t, machineTestSecret)}); rep.OK {
t.Error("another account approved the machine's request")
}
}
func TestAnActTheBastionDoesNotHaveFails(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "uninstall"}); rep.OK || !rep.Failed ||
!strings.Contains(rep.Why, "no act called") {
t.Errorf("an unknown act: %+v", rep)
}
}
// Approve through the lane approves the request it showed, or nothing: a
// short prefix sent as typed was found again by the bastion after the code was
// entered, so a request that came to match it meanwhile (the shown one denied,
// another filed) was approved in its place.
func TestTheLaneApprovesOnlyTheRequestItShowed(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatalf("ask refused: %s", r.Why)
}
shown := fullRequestID(t, d, r.Request)
// Same first character, different from the second on.
other := shown[:1] + string("0123456789abcdef"[(strings.IndexByte("0123456789abcdef", shown[1])+1)%16]) + shown[2:]
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
code := codeFor(t, machineTestSecret)
prev := promptOwnerCode
t.Cleanup(func() { promptOwnerCode = prev })
promptOwnerCode = func(string) (string, bool) {
// While the person types: the shown request is answered, and another
// matching the typed prefix is waiting.
stepupMu.Lock()
m := requestsLoad(d)
rec := m[shown]
m[other] = rec
_ = requestsSave(d, m)
stepupMu.Unlock()
if err := stepupDenyRequest(d, shown); err != nil {
t.Fatal(err)
}
return code, true
}
// MUTATION: send the typed prefix (ID: prefix). RESULT: red, the other
// request is approved.
err := cmdStepupApprove("", true, shown[:1])
if got := requestsLoad(d)[other].State; got != "pending" {
t.Fatalf("a request the person was never shown is %q after they approved the one they were shown (%v)", got, err)
}
if err == nil {
t.Error("approving a request answered meanwhile said nothing")
}
}
// A Host's request is approved through the lane only for a pairing this
// machine serves, as the console's approve (approvalTarget) refuses the rest.
func TestTheLaneDoesNotApproveForAPairingThisMachineDoesNotServe(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
const host, id = "box-two.example", "c0ffee00c0ffee00c0ffee00c0ffee00"
waiting := func(state string) string {
dcn := givenPairing(t, host, state)
enrolFactorAt(t, dcn, hostTestSecret)
if err := requestsSave(dcn, map[string]stepupRequest{id: {Purpose: "test|host", State: "pending",
At: time.Now().UTC().Format(time.RFC3339)}}); err != nil {
t.Fatal(err)
}
return dcn
}
dcn := waiting(pairingPending)
// MUTATION: drop the approvalTarget check. RESULT: red, approved.
rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "approve", Host: host, ID: id[:8], Code: codeFor(t, hostTestSecret)})
if rep.OK || !strings.Contains(rep.Why, "does not serve") {
t.Errorf("approve for a pairing the anchor has not approved: %+v", rep)
}
if got := requestsLoad(dcn)[id].State; got != "pending" {
t.Errorf("the request of a pairing this machine does not serve is %q", got)
}
// The same act for an active pairing is answered: the refusal above is
// the pairing's state, not the test's setup.
dcn = waiting(pairingActive)
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "approve", Host: host, ID: id[:8],
Code: codeFor(t, hostTestSecret)}); !rep.OK {
t.Errorf("approve for an active pairing: %+v", rep)
}
if got := requestsLoad(dcn)[id].State; got != "approved" {
t.Errorf("the active pairing's request is %q", got)
}
}
// The act's audit line lands in the machine's log, and says what was acted
// on as the bastion resolved it. With no owner's code set up (a switch off
// needs none) it was written to audit.jsonl in the daemon's working
// directory; and a request reached with no --machine, the machine's because
// no Host is paired, was logged as a Host's.
func TestAnOwnerActIsAuditedInTheMachinesLogAsResolved(t *testing.T) {
uiTestConfigDir(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
laneOwner(t, laneTestUID)
cwd := t.TempDir()
t.Chdir(cwd)
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "switch", Host: host, Family: "ops", Off: true}); !rep.OK {
t.Fatalf("switch off: %+v", rep)
}
// MUTATION: audit to existingMachineFactorDir(). RESULT: red, the line is
// in the working directory.
if _, err := os.Stat(filepath.Join(cwd, "audit.jsonl")); err == nil {
t.Error("the act's audit line was written into the daemon's working directory")
}
dir, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(dir, "audit.jsonl"))
if !strings.Contains(string(raw), `"op":"owner_lane_act"`) || !strings.Contains(string(raw), "switch "+host+" ops off") {
t.Errorf("the switch is not in the machine's audit log:\n%s", raw)
}
}
func TestAnOwnerActOnTheMachinesRequestIsAuditedAsTheMachines(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
laneOwner(t, laneTestUID)
// No --machine, and no Host paired: the machine's request.
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", ID: r.Request}); !rep.OK {
t.Fatalf("deny: %+v", rep)
}
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
// MUTATION: name the target from req.Machine. RESULT: red, "a Host's ()".
if want := "deny the machine's request " + fullRequestID(t, d, r.Request); !strings.Contains(string(raw), want) {
t.Errorf("the audit line does not say %q:\n%s", want, raw)
}
}
// Deny for a Host's request is refused, as the console refuses it, for a
// pairing this machine does not serve.
func TestTheLaneDoesNotDenyForAPairingThisMachineDoesNotServe(t *testing.T) {
askFlowWorld(t)
const host = "box-pending.example"
givenPairing(t, host, pairingPending)
laneOwner(t, laneTestUID)
rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", Host: host, ID: "abcd1234"})
if rep.OK || !strings.Contains(rep.Why, "does not serve") {
t.Errorf("deny for a pending pairing: %+v", rep)
}
}
// An act that panics inside the daemon leaves the request lock free: the
// next approval does not hang.
func TestAPanickingActLeavesTheRequestLockFree(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
prev := requestsLoadHook
t.Cleanup(func() { requestsLoadHook = prev })
requestsLoadHook = func() { panic("boom") }
if rep := serveAct(laneTestUID, "sam", agentAskRequest{Act: "deny", Machine: true, ID: "abcd1234"}); rep.OK || !rep.Failed {
t.Fatalf("a panicking act: %+v", rep)
}
requestsLoadHook = func() {}
done := make(chan struct{})
go func() { lockedRequests(existingMachineFactorDir()); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the request lock was left held by the panicking act")
}
}
// The narrowing acts go through the lane from the owner's account with no
// code: an unused approval is dropped, a device ask denied, a store grant
// dropped, every op grant dropped.
func TestTheOwnerTakesReachBackThroughTheLane(t *testing.T) {
d := askFlowWorld(t)
const host = "box-one.example"
dcn := givenPairing(t, host, pairingActive)
givenStoreGrants(t, dcn, map[string]map[string]any{"7": givenMachineStore("/srv/data", nil, nil, "")})
approval, err := stepupIssueApproval(d, codeFor(t, machineTestSecret), "local_stop|101|aider")
if err != nil {
t.Fatal(err)
}
if _, err := deviceInit(); err != nil {
t.Fatal(err)
}
ask, err := deviceNewRequest(map[string]any{"kind": "mgmt_op", "op": "ctx_del", "server": "box-7", "args": []any{}})
if err != nil {
t.Fatal(err)
}
laneOwner(t, laneTestUID)
laneSock(t)
out := captureStdout(t)
asked := stubCode(t, "")
if err := cmdStepupDrop("", true, approval[:8]); err != nil {
t.Fatalf("approvals drop: %v", err)
}
if !approvalsLoad(d)[approval].Used {
t.Error("the machine's approval is still usable after the owner dropped it")
}
if err := cmdDeviceDeny(ask[:8]); err != nil {
t.Fatalf("device-deny: %v", err)
}
deviceMu.Lock()
state := deviceRequestsLoad()[ask].State
deviceMu.Unlock()
if state != "denied" {
t.Errorf("the device ask is %q after the owner denied it", state)
}
if err := cmdGrantsDrop(host, "7"); err != nil {
t.Fatalf("grants drop: %v", err)
}
if err := cmdOpsDrop(host, "--all"); err != nil {
t.Fatalf("ops drop --all: %v", err)
}
got := printedSince(out, 0, "op grant(s) dropped")
for _, want := range []string{"dropped approval " + approval[:8], "denied " + ask, "store 7 dropped for " + host,
"0 op grant(s) dropped"} {
if !strings.Contains(got, want) {
t.Errorf("printed no %q:\n%s", want, got)
}
}
if *asked != 0 {
t.Errorf("a narrowing act asked for a code %d times", *asked)
}
// MUTATION: route grants-drop to the account's own state. RESULT: red,
// a second drop through the lane still finds the store.
if err := cmdGrantsDrop(host, "7"); err != nil || !strings.Contains(printedSince(out, len(got), "not held"), "was not held here") {
t.Errorf("a second drop of the same store: %v", err)
}
raw, _ := os.ReadFile(filepath.Join(d, "audit.jsonl"))
for _, act := range []string{"approvals-drop", "device-deny", "grants-drop 7 for " + host, "ops-drop --all for " + host} {
if !strings.Contains(string(raw), act) {
t.Errorf("no audit row for %s", act)
}
}
}
// Any other account is refused the narrowing acts too, with the
// administrator's command.
func TestAnotherAccountIsRefusedTheNarrowingActs(t *testing.T) {
askFlowWorld(t)
givenPairing(t, "box-one.example", pairingActive)
laneOwner(t, laneTestUID+1)
laneSock(t)
captureStdout(t)
for name, run := range map[string]func() error{
"approvals drop": func() error { return cmdStepupDrop("", true, "abcd1234") },
"device-deny": func() error { return cmdDeviceDeny("abcd1234") },
"grants drop": func() error { return cmdGrantsDrop("box-one.example", "7") },
"ops drop": func() error { return cmdOpsDrop("box-one.example", "--all") },
} {
err := run()
if err == nil || !strings.Contains(err.Error(), "acts only for its owner's account") ||
!strings.Contains(err.Error(), "sudo -H privatae-bastion ") {
t.Errorf("%s from another account: %v", name, err)
}
}
}
// `ops drop ""` (an unset variable in a script, say) names no grant: it is
// refused, as an empty id is for approvals drop and device-deny, and the one
// grant held is kept. It used to match every nonce, so with one grant held
// it dropped that one.
//
// MUTATION: take the empty-id refusal out of opsDropText. RESULT: red, the
// held grant is dropped.
func TestAnEmptyOpGrantIDDropsNothing(t *testing.T) {
askFlowWorld(t)
const host = "box-one.example"
dcn := givenPairing(t, host, pairingActive)
const nonce = "0123456789abcdef0123"
f := opsLoad(dcn)
f.Grants[nonce] = opGrantRec{UsesLeft: 1}
if err := opsSave(dcn, f); err != nil {
t.Fatal(err)
}
laneOwner(t, laneTestUID)
laneSock(t)
captureStdout(t)
for _, id := range []string{"", " "} {
err := cmdOpsDrop(host, id)
if err == nil || !strings.Contains(err.Error(), "which op grant") {
t.Errorf("ops drop %q: %v", id, err)
}
}
if rec := opsLoad(dcn).Grants[nonce]; rec.Dropped != "" || rec.UsesLeft != 1 {
t.Errorf("an empty id dropped the one grant held: %+v", rec)
}
}
// Store ids are each Host's own: two Hosts' store 7 are two stores, with two
// agents. Kept by store id alone, a machine paired with both ran one agent,
// so a search from one Host for its store 7 was answered by the agent over the
// other Host's folder, and dropping one Host's store stopped the other's.
func TestTwoHostsWithTheSameStoreIDGetTheirOwnAgents(t *testing.T) {
uiTestConfigDir(t)
dcnA := givenPairing(t, "box-a.example", pairingActive)
dcnB := givenPairing(t, "box-b.example", pairingActive)
givenStoreGrants(t, dcnB, map[string]map[string]any{"7": givenMachineStore("/srv/b", nil, nil, "")})
dirA, dirB := t.TempDir(), t.TempDir()
for _, d := range []string{dirA, dirB} {
if err := os.WriteFile(filepath.Join(d, "port"), []byte(map[string]string{dirA: "41001", dirB: "41002"}[d]), 0o600); err != nil {
t.Fatal(err)
}
}
stopped := map[string]int{}
faMgr.Lock()
faMgr.inst[faKey{dcnA, 7}] = &faInstance{StoreID: 7, Dir: dirA, Cert: "a", Key: "a", cancel: func() { stopped["a"]++ }}
faMgr.inst[faKey{dcnB, 7}] = &faInstance{StoreID: 7, Dir: dirB, Cert: "b", Key: "b", cancel: func() { stopped["b"]++ }}
faMgr.inst[faKey{dcnB, 9}] = &faInstance{StoreID: 9, Dir: dirB, State: "running"}
faMgr.Unlock()
t.Cleanup(func() {
faMgr.Lock()
delete(faMgr.inst, faKey{dcnA, 7})
delete(faMgr.inst, faKey{dcnB, 7})
delete(faMgr.inst, faKey{dcnB, 9})
faMgr.Unlock()
})
// A search on each pairing's lane goes to that pairing's own agent.
if c, ok := faConfigFor(dcnA, 7); !ok || c.Addr != "127.0.0.1:41001" {
t.Errorf("Host A's store 7 resolves to %+v", c)
}
if c, ok := faConfigFor(dcnB, 7); !ok || c.Addr != "127.0.0.1:41002" {
t.Errorf("Host B's store 7 resolves to %+v", c)
}
// Each beat reports its own pairing's stores only: B's store 9 is not
// A's to hear about.
// MUTATION: drop the k.Dir filter in faManagedStatus. RESULT: red.
if st := faManagedStatus(dcnA); len(st) != 1 || st["9"] != nil {
t.Errorf("Host A's beat reports %v", st)
}
if st := faManagedStatus(dcnB); len(st) != 2 {
t.Errorf("Host B's beat reports %v", st)
}
if st := faManagedStatus(""); len(st) != 0 {
t.Errorf("a beat with no DCN state reports %d stores", len(st))
}
// A's drop of a store it does not hold stops A's own orphan, never B's.
if present, err := dcnDropStore(dcnA, 7, "test"); err != nil || present || stopped["a"] != 1 || stopped["b"] != 0 {
t.Fatalf("A's drop: present %v err %v, stopped %v", present, err, stopped)
}
if present, err := dcnDropStore(dcnB, 7, "test"); err != nil || !present || stopped["b"] != 1 {
t.Fatalf("B's drop of its own store: present %v err %v, stopped %v", present, err, stopped)
}
}
+49 -23
View File
@@ -11,9 +11,12 @@ package main
// kernel which account connected. Asked for a read by the account recorded as
// the machine's owner (owner_account.go), the daemon builds it with the
// machine's own view and answers with it. That is what the owner's console
// lane (consolelane.go) already shows that account, with one difference:
// posture, which the console has no page for, adds the machine's firewall
// checks, read as the administrator. The delegation tag an agent carries,
// lane (consolelane.go) already shows that account, and more that the console
// has no page for: posture (the machine's firewall checks, read as the
// administrator); the ops and op grants each Host holds here, its backup
// requests and the folders shared with it; this bastion's backup keys (public
// halves only); and doctor. All of it is about the owner's own machine, and
// the lane answers no other account. The delegation tag an agent carries,
// which the console never shows, is in no read at all (readAgents).
// Any other account is refused; where a read means something with its own
// permissions (the agents, schedules and tool servers it can see), it gets
@@ -28,9 +31,9 @@ import (
"errors"
"fmt"
"io"
"net"
"os"
"runtime"
"runtime/debug"
"strconv"
"sync"
"time"
@@ -83,13 +86,20 @@ var machineReads = map[string]machineRead{
"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
// does not administer, so its state is not this process's to read.
var readsGoThroughLane = func() bool {
if runtime.GOOS == "windows" || machineAuthorityHeld() {
return false // no ask pipe on Windows yet; the administrator reads directly
if machineAuthorityHeld() {
return false // the administrator reads the service's state directly
}
if runtime.GOOS == "linux" {
return linuxSystemUnitPresent()
@@ -145,7 +155,7 @@ func laneRefusalError(cmd string, err error) error {
if !errors.As(err, &le) {
return err
}
return fmt.Errorf("%s\n As the administrator: sudo -H privatae-bastion %s", le.why, cmd)
return fmt.Errorf("%s\n As the administrator: %s", le.why, adminCommandFor(runtime.GOOS == "windows", cmd))
}
// ── the asking side ─────────────────────────────────────────────────────────
@@ -155,8 +165,8 @@ func laneRefusalError(cmd string, err error) error {
var readSockPath = askSockPath
func readViaSock(name string, o readOpts, v any) error {
p := readSockPath()
c, err := net.DialTimeout("unix", p, 3*time.Second)
p := askLaneAddr(readSockPath())
c, err := dialAskLane(readSockPath())
if err != nil {
return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
}
@@ -170,6 +180,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}
}
@@ -185,28 +198,28 @@ func readViaSock(name string, o readOpts, v any) error {
type agentReadReply struct {
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 "".
// readLaneRefusalFor says why peer may not read through the lane, or "".
// The owner's account only: the agents, folders and prompts of every
// account's sessions are the owner's to see, not another account's.
func readLaneRefusal(uid uint32, who string) string {
owner, ownerName, present, err := ownerAccountLoad()
func readLaneRefusalFor(p askPeer) string {
match, ownerName, present, err := laneOwnerMatch(p)
switch {
case err != nil:
return "this machine's owner account cannot be read (" + err.Error() + ")"
case !present:
return "no account is recorded as this machine's owner, so its bastion answers reads only for the " +
"administrator. To read from your own account, the administrator records it once: " +
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
case owner != uid:
name := ownerName
if name == "" {
name = "uid " + strconv.FormatUint(uint64(owner), 10)
}
return "this machine's bastion answers reads only for its owner's account (" + name + "), not " + who
ownerRecordCommand(p)
case !match:
return "this machine's bastion answers reads only for its owner's account (" + ownerName + "), not " + p.Name
}
return ""
}
@@ -231,8 +244,12 @@ var readNow = time.Now
var readSlots = make(chan struct{}, 4)
func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
r := agentReadReply{User: user}
if why := readLaneRefusal(uid, user); why != "" {
return serveReadFor(askPeer{UID: uid, Name: user}, req)
}
func serveReadFor(p askPeer, req agentAskRequest) agentReadReply {
r := agentReadReply{User: p.Name}
if why := readLaneRefusalFor(p); why != "" {
r.Why = why
return r
}
@@ -252,7 +269,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 +284,16 @@ func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
}
func runMachineRead(name string, spec machineRead, o readOpts) ([]byte, error) {
build := func() ([]byte, error) {
build := func() (b []byte, err error) {
// A read runs inside the daemon that serves every pairing, from code
// written for a command's own process: one that panics is that read's
// failure, never the daemon's end.
defer func() {
if p := recover(); p != nil {
uilogf("read %s panicked: %v\n%s", name, p, debug.Stack())
b, err = nil, fmt.Errorf("the read %s failed inside this bastion (%v); its log has the trace", name, p)
}
}()
v, err := spec.build(o)
if err != nil {
return nil, err
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"encoding/json"
"errors"
"os"
"strings"
"testing"
)
// The owner's agent reads the switch list, the doctor and the device's asks
// from the machine's bastion over its socket; a read the bastion answers but
// cannot build comes back as that failure, not as a refusal with the
// administrator's command (the administrator would be told the same).
func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
askFlowWorld(t)
laneOwner(t, laneTestUID)
laneSock(t)
prevSys, prevLatest, prevSandbox := systemInstalled, doctorLatest, doctorSandbox
t.Cleanup(func() { systemInstalled, doctorLatest, doctorSandbox = prevSys, prevLatest, prevSandbox })
systemInstalled = func() bool { return true }
doctorLatest = func() string { return "" }
doctorSandbox = func() (bool, string) { return true, "" }
out := captureStdout(t)
if err := cmdSwitch([]string{"--list", "--json"}); err != nil {
t.Fatalf("switch --list --json: %v", err)
}
var sw switchesRead
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &sw); err != nil {
t.Fatalf("switch --list --json printed %q (%v)", out(), err)
}
before := len(out())
err := cmdDoctor([]string{"--json"})
var q quietExit
if err != nil && !errors.As(err, &q) {
t.Fatalf("doctor through the lane: %v", err)
}
var d doctorRead
if err := json.Unmarshal([]byte(printedSince(out, before, "}\n")), &d); err != nil || len(d.Checks) == 0 {
t.Fatalf("doctor --json printed %q (%v)", out()[before:], err)
}
if c, ok := doctorCheckNamed(d, "owner"); !ok || !c.OK {
t.Errorf("the machine's doctor, read by its owner, does not find the owner: %+v", d.Checks)
}
p, err := deviceShownPath()
if err != nil {
t.Fatal(err)
}
if err := cmdDeviceApprovals(nil); err != nil {
t.Fatalf("device-approvals through the lane: %v", err)
}
if _, err := os.Stat(p); err == nil {
t.Error("device-approvals read through the lane recorded what it printed as shown")
}
// MUTATION: drop `r.Failed = true` in serveRead. RESULT: red, the error
// carries the administrator's command.
err = cmdOpsList("", true)
var le *laneError
if err == nil || errors.As(err, &le) || strings.Contains(err.Error(), "As the administrator") {
t.Errorf("ops with nothing paired, through the lane: %v", err)
}
}
// `switch --list` passes the guard beside the system unit, as status does;
// `switch` does too since the owner's account switches through the lane
// (owneracts.go), and on Windows as well since the ask pipe.
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.Errorf("switch from the owner's account beside a Windows service, before the ask pipe could decide: %v", err)
}
}
+520
View File
@@ -0,0 +1,520 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
)
// The switch list as JSON has every row the words have, each family of each
// paired host, and an unreadable list reads as every family off.
func TestTheSwitchListReadHasEveryRowTheWordsHave(t *testing.T) {
uiTestConfigDir(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
if err := localDenySet(host, "ops", true); err != nil {
t.Fatal(err)
}
v, err := readSwitches(readOpts{})
if err != nil {
t.Fatal(err)
}
if len(v.Switches) != len(localSwitchFamilies()) {
t.Fatalf("want one row per family (%d), got %+v", len(localSwitchFamilies()), v.Switches)
}
var buf bytes.Buffer
printSwitches(&buf, v)
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != len(v.Switches) {
t.Fatalf("%d rows, %d lines:\n%s", len(v.Switches), len(lines), buf.String())
}
for i, r := range v.Switches {
f := strings.Fields(lines[i])
if f[0] != r.Host || f[1] != r.Family || (f[2] == "off") != r.Off {
t.Errorf("row %+v printed as %q", r, lines[i])
}
if r.Family == "ops" && (!r.Off || r.Since == "") {
t.Errorf("ops was switched off: %+v", r)
}
}
// MUTATION: build rows from f.Off only, ignoring readErr. RESULT: red,
// the families read on while the gate refuses them all.
path, err := localDenyPath()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
v, err = readSwitches(readOpts{})
if err != nil {
t.Fatal(err)
}
if v.Unreadable == "" {
t.Error("an unreadable list is not said")
}
for _, r := range v.Switches {
if !r.Off {
t.Errorf("with the list unreadable, %s %s reads on", r.Host, r.Family)
}
}
}
// Read as JSON, the device's asks are a program's, not a person's: nothing is
// recorded as shown, so `device-approve all` signs nothing an agent read.
func TestDeviceApprovalsAsJSONRecordsNothingShown(t *testing.T) {
uiTestConfigDir(t)
p, err := deviceShownPath()
if err != nil {
t.Fatal(err)
}
if err := cmdDeviceApprovals([]string{"--json"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err == nil {
t.Error("device-approvals --json recorded what it printed as shown")
}
if err := cmdDeviceApprovals(nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err != nil {
t.Errorf("the words listing recorded nothing as shown: %v", err)
}
if err := cmdDeviceApprovals([]string{"--jsno"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("a stray flag: %v", err)
}
}
// A restore request read leaves the sealed key the host sent, and the one
// resealed for it, out of the answer; their digest and the words stay.
func TestTheBackupRequestsReadLeavesTheSealedKeysOut(t *testing.T) {
uiTestConfigDir(t)
const host = "box-one.example"
givenPairing(t, host, pairingActive)
cfg, err := stepupPairing(host)
if err != nil {
t.Fatal(err)
}
const sealed, resealed = "-----BEGIN AGE ENCRYPTED FILE-----SIDECAR", "-----BEGIN AGE ENCRYPTED FILE-----RESEALED"
at := time.Now().UTC().Format(time.RFC3339)
// r1 waits, so it still carries the sidecar it came with; r2 was
// answered and carries the resealed key.
if err := resealSave(cfg.dcnDir(), map[string]*resealRequest{
"r1": {ID: "r1", Stamp: "20261101T000000Z", Pairing: host, HostNumber: 7, HostLabel: "home", To: "age1xyz",
ToFP: "fp", Sidecar: sealed, SidecarSH: "abc123", At: at, State: "pending"},
"r2": {ID: "r2", Stamp: "20261101T000000Z", Pairing: host, HostNumber: 7, HostLabel: "home", To: "age1xyz",
ToFP: "fp", SidecarSH: "def456", At: at, State: "done", Resealed: resealed},
}); err != nil {
t.Fatal(err)
}
stored, err := resealRead(cfg.dcnDir())
if err != nil || stored["r1"] == nil || stored["r1"].Sidecar != sealed || stored["r2"] == nil || stored["r2"].Resealed != resealed {
t.Fatalf("the fixture does not hold both sealed keys: %+v %v", stored, err)
}
// MUTATION: build the view from *resealRequest itself. RESULT: red, both
// sealed keys are in the answer.
v, err := readBackupRequests(readOpts{Host: host})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(v)
if strings.Contains(string(b), "SIDECAR") || strings.Contains(string(b), "RESEALED") {
t.Errorf("the read carries a sealed key: %s", b)
}
if len(v.Requests) != 2 || v.Requests[0].SidecarSHA == "" || !strings.Contains(v.Requests[0].Words, "home") {
t.Errorf("the requests as read: %+v", v.Requests)
}
}
// The backup key read carries the public halves only.
func TestTheBackupKeysReadHasNoSecretHalf(t *testing.T) {
b, err := json.Marshal(backupKeysRead{Keys: []*backupKeyFile{{Identity: "AGE-SECRET-KEY-1SECRET", Recipient: "age1pub",
Fingerprint: "fp"}}, Current: "fp"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "AGE-SECRET-KEY") {
t.Errorf("the backup keys read carries the secret: %s", b)
}
}
// agents show takes a pid, or a session name that matches exactly one, the
// rule stop keeps.
func TestAgentsShowPicksExactlyOneAgent(t *testing.T) {
views := []agentView{
{agentObs: agentObs{PID: 101, Signature: "claude-code"}, Self: &agentSelf{Name: "shop"}},
{agentObs: agentObs{PID: 102, Signature: "claude-code"}, Self: &agentSelf{Name: "twin"}},
{agentObs: agentObs{PID: 103, Signature: "aider"}, Self: &agentSelf{Name: "twin"}},
}
if v, err := pickAgentView(views, "101"); err != nil || v.PID != 101 {
t.Errorf("by pid: %+v %v", v, err)
}
if v, err := pickAgentView(views, "shop"); err != nil || v.PID != 101 {
t.Errorf("by name: %+v %v", v, err)
}
if _, err := pickAgentView(views, "twin"); err == nil || !strings.Contains(err.Error(), "give the pid") {
t.Errorf("two sessions with one name: %v", err)
}
if _, err := pickAgentView(views, "999"); err == nil {
t.Error("a pid no agent has was shown")
}
if _, err := pickAgentView(views, "nobody"); err == nil {
t.Error("a name no session has was shown")
}
if err := cmdAgents([]string{"show"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("show with no agent: %v", err)
}
if err := cmdAgents([]string{"show", "--json"}); err == nil || !strings.Contains(err.Error(), "usage:") {
t.Errorf("show --json with no agent: %v", err)
}
}
// The detail view names the file the kernel says the agent runs, and keeps
// its command line (which can carry a key) out of the words.
func TestAgentsShowKeepsTheCommandLineOutOfTheWords(t *testing.T) {
v := agentView{agentObs: anObservedAgent("aider", "/usr/local/bin/aider --api-key sk-THEKEY", 4242)}
var b bytes.Buffer
printAgentDetail(&b, v, time.Now())
out := b.String()
if strings.Contains(out, "sk-THEKEY") {
t.Errorf("the words carry the command line:\n%s", out)
}
if !strings.Contains(out, "/usr/local/bin/aider") || !strings.Contains(out, "4242") {
t.Errorf("the words do not name the program and pid:\n%s", out)
}
}
// The file an agent runs is a path whoever placed it chose, so a path built
// of terminal control sequences is shown as such, never sent to the terminal.
//
// MUTATION: row("program", v.Exe). RESULT: red, the escape reaches the words.
func TestAgentsShowNeverPrintsControlCharactersFromTheProgramPath(t *testing.T) {
v := agentView{agentObs: anObservedAgent("claude-code", "claude", 4242)}
v.Exe = "/tmp/\x1b]52;c;cHduZWQ=\x07/\x1b[2Jclaude"
var b bytes.Buffer
printAgentDetail(&b, v, time.Now())
out := b.String()
if strings.ContainsAny(out, "\x1b\x07") {
t.Errorf("the words carry the program path's control characters: %q", out)
}
if !strings.Contains(out, `\x1b]52`) {
t.Errorf("the program path is not shown as what it is: %q", out)
}
v.Exe = "/usr/local/bin/claude"
b.Reset()
printAgentDetail(&b, v, time.Now())
if !strings.Contains(b.String(), "program: /usr/local/bin/claude\n") {
t.Errorf("a plain path is not shown as is: %q", b.String())
}
}
// A read is built in the root daemon now, from code that ran only in a
// command's own process before (readOps slices names it finds on disk). One
// that panics is that read's failure, and the daemon serving every pairing
// stays up.
//
// MUTATION: drop the recover in runMachineRead. RESULT: red, the suite
// itself panics.
func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) {
uiTestConfigDir(t)
laneOwner(t, 1000)
for _, heavy := range []bool{false, true} {
machineReads["test-panics"] = machineRead{heavy: heavy, build: func(readOpts) (any, error) {
var s []string
return s[3], nil
}}
r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "test-panics"})
if r.OK || !r.Failed || !strings.Contains(r.Why, "test-panics") {
t.Errorf("a read that panics (heavy %v): %+v", heavy, r)
}
}
delete(machineReads, "test-panics")
if r := serveRead(1000, "sam", agentAskRequest{Op: "read", Read: "status"}); !r.OK {
t.Errorf("after a read panicked, status: %+v", r)
}
}
// doctor from an ordinary account beside a system service would, run
// directly, check that account's own empty bastion and report it as the
// machine's (not running, no owner, no factor). So it is a lane read, as
// status is: through ask.sock on Linux and macOS, the ask pipe on Windows.
//
// MUTATION: drop "doctor" from serviceLaneReads. RESULT: red on every system.
func TestDoctorBesideAWindowsServiceIsRefusedAsStatusIs(t *testing.T) {
if !serviceGuardedCommands["doctor"] || !serviceLaneReads["doctor"] {
t.Error("doctor is not both guarded and a lane read, so it would check an ordinary account's own bastion")
}
for _, goos := range []string{"linux", "darwin", "windows"} {
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())
}
}
// The slice-3 reads from an ordinary account beside the service ask the
// machine's bastion over its lane on every system (the ask pipe on Windows),
// so the guard lets them by. 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 !serviceGuardedCommands[key] || !serviceLaneReads[key] {
t.Errorf("%v is not both guarded and a lane read", line[1:])
}
for _, goos := range []string{"linux", "darwin", "windows"} {
if err := serviceGuard(goos, key, true, false); err != nil {
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
}
}
}
for _, line := range [][]string{
{"privatae-bastion", "backup", "reseal", "--sidecar", "f", "--to", "k"},
{"privatae-bastion", "backup", "restore-plan", "--dir", "d"},
} {
if err := serviceGuard("windows", serviceGuardKey(line[1], line), true, false); err != nil {
t.Errorf("windows: %v works offline and was refused: %v", line[1:], err)
}
}
}
+41
View File
@@ -134,6 +134,26 @@ func TestEveryReadPrintsTheSameAfterTheLane(t *testing.T) {
func(w *bytes.Buffer, v any) { _ = printDelta(w, v.(deltaReport)) })
rep := buildReport(now, now.Add(-7*24*time.Hour))
add("report", rep, func(w *bytes.Buffer, v any) { renderReportMarkdown(w, v.(machineReport)) })
add("switches", switchesRead{Switches: []switchRow{{Host: "home", Family: "ops", Off: true, Since: "2026-11-01T00:00:00Z"},
{Host: "home", Family: "observe"}}, Unreadable: "the list cannot be read"},
func(w *bytes.Buffer, v any) { printSwitches(w, v.(switchesRead)) })
add("ops", opsRead{Host: "home", SandboxUnavailable: "the kernel offers no overlay filesystem",
Ops: []opRow{{ID: "0123456789ab", Name: "restart-web", Version: "1", Privilege: "root", Summary: "restart nginx"}, {ID: "ba9876543210", NotVerified: "bad signature"}},
Grants: []opGrantRow{{ID: "abcd1234", Op: "restart-web", Version: "1", RunsLeft: 2, Until: "2026-11-02", Within: "nginx only"}}},
func(w *bytes.Buffer, v any) { printOps(w, v.(opsRead)) })
add("device-approvals", deviceApprovalsRead{Asks: []deviceAsk{{ID: "0123456789abcdef", Server: "web-1", At: "2026-11-01", Words: "Restart nginx."}}},
func(w *bytes.Buffer, v any) { printDeviceApprovals(w, v.(deviceApprovalsRead), true) })
add("backup-keys", backupKeysRead{Keys: []*backupKeyFile{{Recipient: "age1pub", PubB64: "cHVi", Fingerprint: "fp1",
CreatedAt: "2026-10-01", Path: "/x/key"}, {Fingerprint: "fp0"}}, Current: "fp1"},
func(w *bytes.Buffer, v any) { printBackupKeys(w, v.(backupKeysRead)) })
add("backup-requests", backupRequestsRead{Host: "home", SetAside: []string{"/x/reseal.json.aside-1"},
Requests: []backupRequestView{{ID: "r1", State: "pending", Words: "r1 [pending]\n Sent by the pairing home."}}},
func(w *bytes.Buffer, v any) { printBackupRequests(w, v.(backupRequestsRead)) })
add("backup-shares", backupSharesRead{With: "new-home", Shares: []backupShare{{Slug: "old", Dir: "/srv/backups/old", Added: "2026-10-01"}}},
func(w *bytes.Buffer, v any) { printBackupShares(w, v.(backupSharesRead)) })
add("doctor", doctorRead{Version: "9.9.9", Checks: []doctorCheck{{Name: "running", OK: true, Detail: "Running on its own."},
{Name: "second factor", Problem: "No second factor.", Fix: "privatae-bastion stepup-enroll --machine"}}},
func(w *bytes.Buffer, v any) { printDoctor(w, v.(doctorRead)) })
for name := range machineReads {
if name == "baseline" {
@@ -223,6 +243,27 @@ func zeroLike(v any) decoded {
case machineReport:
var x machineReport
return decoded{&x, func() any { return x }}
case switchesRead:
var x switchesRead
return decoded{&x, func() any { return x }}
case opsRead:
var x opsRead
return decoded{&x, func() any { return x }}
case deviceApprovalsRead:
var x deviceApprovalsRead
return decoded{&x, func() any { return x }}
case backupKeysRead:
var x backupKeysRead
return decoded{&x, func() any { return x }}
case backupRequestsRead:
var x backupRequestsRead
return decoded{&x, func() any { return x }}
case backupSharesRead:
var x backupSharesRead
return decoded{&x, func() any { return x }}
case doctorRead:
var x doctorRead
return decoded{&x, func() any { return x }}
}
panic("no case")
}
+5
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"os/exec"
"strconv"
"golang.org/x/sys/windows"
)
@@ -53,6 +54,10 @@ func relaunchAfterUpdate() {
return
}
cmd := exec.Command(self, os.Args[1:]...)
// The new bastion waits for this one to exit before it takes the run
// lock (runholder.go takeRunLock); without this it could find the lock
// still held, refuse as a second copy, and leave the machine with none.
cmd.Env = append(os.Environ(), relaunchOfEnv+"="+strconv.Itoa(os.Getpid()))
cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
}
+28 -3
View File
@@ -606,7 +606,13 @@ func runPairing(ctx context.Context, cfg *Config) error {
// (posture). The device fingerprint is NOT sent here: it is bound at
// pairing from the operator CSR; the reverse lanes are admitted on the bearer's
// binding to that cert, which the host enforces. Authed by the bearer.
// Each beat's request carries the run's context, and a stopped run waits
// for the heartbeat before it returns, so no beat of this run records a
// time, reconciles grants into its folder or acts on a directive after
// the pairing has stopped (an unpair or revoke removes those next).
beatDone := make(chan struct{})
go func() {
defer close(beatDone)
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
hostname, _ := os.Hostname()
wgPub := ""
@@ -616,8 +622,11 @@ func runPairing(ctx context.Context, cfg *Config) error {
}
}
beat := func() {
if ctx.Err() != nil {
return
}
payload := heartbeatPayload(cfg, dev, hostname, wgPub)
req, err := http.NewRequest(http.MethodPost, hbURL, strings.NewReader(payload))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hbURL, strings.NewReader(payload))
if err != nil {
return
}
@@ -629,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
+58
View File
@@ -0,0 +1,58 @@
//go:build linux
// A pairing's run and its heartbeat stop together (run.go): uses the serve
// lane's fixtures (servelane_linux_test.go), so it carries the same build tag.
package main
import (
"context"
"os"
"testing"
"time"
)
// A pairing stopped while a beat is out acts on nothing that beat brings
// back. The run waits for its heartbeat and the beat's request ends with the
// run, so its last-OK time, and anything else the reply would have set off
// (a grant reconcile into the pairing's folder, a contain, an update), never
// lands after the stop, when an unpair or a revoke removes what is left.
func TestAStoppedPairingsLastBeatWritesNothingAfterTheStop(t *testing.T) {
h, cfg := serveLaneBox(t, "beat-stop.example.test")
arrived, release := make(chan struct{}, 1), make(chan struct{})
h.mu.Lock()
h.beatArrived, h.beatRelease = arrived, release
h.mu.Unlock()
released := false
t.Cleanup(func() {
if !released {
close(release)
}
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- runPairing(ctx, cfg) }()
select {
case <-arrived:
case <-time.After(10 * time.Second):
t.Fatal("no beat reached the Host")
}
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the pairing did not stop while its beat was out")
}
// The Host answers the beat only now, after the run has returned.
close(release)
released = true
time.Sleep(500 * time.Millisecond)
p, err := pairingStatePath()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(p); err == nil {
t.Fatal("the stopped pairing's last beat recorded its time after the stop")
}
}
+32 -12
View File
@@ -8,6 +8,9 @@ import (
"os"
"path/filepath"
"sync/atomic"
"time"
"golang.org/x/sys/windows"
)
// runGuardFile holds the "one running bastion" lock for the life of this
@@ -72,8 +75,8 @@ func refuseVisibly(shown uiNotice, err error) error {
}
// cannotStart is the wording for the two I/O failures below. They have no
// prepared words the way the already-running case does (secondRunNotice,
// notify.go), and the error itself is the most accurate thing there is to say:
// prepared words the way the already-running case does (secondRunWords,
// runholder.go), and the error itself is the most accurate thing there is to say:
// both mean the config directory is unusable, which is not a state anyone can be
// given advice about from here.
func cannotStart(err error) uiNotice {
@@ -90,19 +93,36 @@ func guardSingleRun() error {
err = fmt.Errorf("cannot open the run lock: %w", err)
return refuseVisibly(cannotStart(err), err)
}
if err := flockFileNB(f); err != nil {
// Relaunched by the self-update of the bastion this one replaces, which
// holds the lock until it has exited (runholder.go).
if err := takeRunLock(lockTry(f), relaunchOf(), waitProcessExit,
30*time.Second, time.Now, time.Sleep); err != nil {
_ = f.Close()
// The dialog and the console get DIFFERENT words for the same fact, and
// that is on purpose: this text is laid out for a terminal — indented
// continuation lines, and "you can close this window" meaning the console
// window it is being printed into. secondRunNotice says it for a dialog.
return refuseVisibly(secondRunNotice(), errors.New(
"a bastion is already running on this machine — that one is serving "+
"your hosts, and a second would fight it for the same local ports.\n"+
" Nothing is wrong: you can close this window.\n"+
" If you believe the running one is dead, stop it (Task Manager → "+
"privatae-bastion.exe) and run this again"))
// that is on purpose: the console's are laid out for a terminal, with
// indented continuation lines (secondRunWords).
h, known := readRunHolder(d)
text, notice := secondRunWords(h, known)
return refuseVisibly(notice, errors.New(text))
}
runGuardFile.Store(f)
writeRunHolder(d, stdinIsTerminal())
return nil
}
// lockTry is one attempt at f's lock. Outside guardSingleRun, whose every
// return is a refusal shown to the person (TestEveryRunGuardRefusalIsVisible).
func lockTry(f *os.File) func() error {
return func() error { return flockFileNB(f) }
}
// waitProcessExit waits up to d for process pid to exit. A process that
// cannot be opened has exited already, or is not ours to wait on.
func waitProcessExit(pid int, d time.Duration) {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
return
}
defer windows.CloseHandle(h)
_, _ = windows.WaitForSingleObject(h, uint32(d/time.Millisecond))
}
+141
View File
@@ -0,0 +1,141 @@
package main
// runholder.go — what a second start on Windows is told about the first.
//
// A bastion that finds its run lock held (runguard_windows.go) used to say
// only that one was running, and that if it seemed dead it could be ended in
// Task Manager. On a machine where `privatae-bastion ui` then failed, that
// was the one thing to try, so a running bastion was killed for a symptom
// that was not its. The bastion that holds the lock now writes who it is
// beside it, and the refusal names it: its process, its program, how it was
// started, and the commands that open, check and restart it.
//
// And the relaunch after a self-update (relaunch_windows.go) starts the new
// binary while the old one still holds the lock, a few milliseconds from
// exiting. The new one waits for it (takeRunLock) rather than refusing and
// leaving the machine with no bastion at all.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
)
type runHolder struct {
PID int `json:"pid"`
Program string `json:"program,omitempty"`
Started string `json:"started,omitempty"` // RFC3339
Service bool `json:"service,omitempty"` // the Windows service
Console bool `json:"console,omitempty"` // started from a terminal
}
func runHolderPath(dir string) string { return filepath.Join(dir, "run.holder.json") }
// writeRunHolder records this process as the lock's holder. Best effort: a
// refusal without it is only less specific.
func writeRunHolder(dir string, console bool) {
h := runHolder{PID: os.Getpid(), Started: time.Now().UTC().Format(time.RFC3339),
Service: runningAsService, Console: console}
if exe, err := os.Executable(); err == nil {
h.Program = exe
}
if raw, err := json.Marshal(h); err == nil {
_ = atomicWrite(runHolderPath(dir), raw)
}
}
func readRunHolder(dir string) (runHolder, bool) {
var h runHolder
raw, err := os.ReadFile(runHolderPath(dir))
if err != nil || json.Unmarshal(raw, &h) != nil || h.PID <= 0 {
return runHolder{}, false
}
return h, true
}
// secondRunWords is the refusal of a second start, for the terminal and for
// the dialog a start with no console shows. known is false
// when the running bastion left no record (an older version holds the lock).
func secondRunWords(h runHolder, known bool) (console string, dialog uiNotice) {
what := "A bastion is already running on this machine"
restart := "Get-Process privatae-bastion | Format-List Id, Path, StartTime shows it; " +
"Stop-Process -Id <its Id>, then Start-ScheduledTask -TaskName " + winServiceName
if known {
how := "in the background (its sign-in task, or itself after an update)"
switch {
case h.Service:
how = "as the " + winServiceName + " service"
case h.Console:
how = "from a terminal"
}
what += " (process " + strconv.Itoa(h.PID)
if h.Program != "" {
what += ", " + h.Program
}
if t, err := time.Parse(time.RFC3339, h.Started); err == nil {
what += ", started " + t.Local().Format("2 Jan 15:04")
}
what += ", " + how + ")"
switch {
case h.Service:
restart = "Restart-Service " + winServiceName + " (from an Administrator terminal)"
default:
restart = "Stop-Process -Id " + strconv.Itoa(h.PID) + ", then start it again: Start-ScheduledTask -TaskName " +
winServiceName + " (or privatae-bastion run)"
}
}
console = fmt.Sprintf("%s.\n"+
" It is the one serving your Hosts, and a second copy would fight it for the same local ports,\n"+
" so this one is stopping. Nothing is wrong, and nothing needs ending.\n"+
" To open it: privatae-bastion ui\n"+
" To check it: privatae-bastion status\n"+
" To restart it: %s", what, restart)
dialog = uiNotice{
Title: "Privatae bastion is already running",
Body: what + ".\r\n\r\n" +
"It is the one serving your Hosts. A second copy would fight it for the same local ports, so this one " +
"is stopping. Nothing is wrong, and nothing needs ending.\r\n\r\n" +
"To open it, run: privatae-bastion ui\r\n" +
"To restart it: " + restart,
}
return console, dialog
}
// relaunchOfEnv names, in the environment of a bastion started by its
// predecessor's self-update, the predecessor's process id.
const relaunchOfEnv = "PRIVATAE_BASTION_RELAUNCH_OF"
// takeRunLock takes the run lock with try. A bastion relaunched by its
// predecessor (replaces > 0) first waits up to patience for that process to
// exit (waitExit), then tries again until patience has passed: the lock is
// released as the predecessor's handles close, a moment after it exits.
func takeRunLock(try func() error, replaces int, waitExit func(pid int, d time.Duration), patience time.Duration,
now func() time.Time, sleep func(time.Duration)) error {
if replaces <= 0 {
return try()
}
deadline := now().Add(patience)
waitExit(replaces, patience)
for {
err := try()
if err == nil || !now().Before(deadline) {
return err
}
sleep(200 * time.Millisecond)
}
}
// relaunchOf reads relaunchOfEnv once and clears it, so nothing this process
// starts inherits it.
func relaunchOf() int {
v := os.Getenv(relaunchOfEnv)
_ = os.Unsetenv(relaunchOfEnv)
pid, err := strconv.Atoi(v)
if err != nil || pid <= 0 {
return 0
}
return pid
}
+9
View File
@@ -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.
+12 -1
View File
@@ -40,12 +40,15 @@ import (
// serveHost is a Host's lane port as the serve lane meets it: the lane
// port's mutual TLS, every worker route kept parked, each beat recorded
// whole, and each /bastion-serve worker handed to the test once it has sent
// its hello.
// its hello. With beatRelease set, each beat's reply waits for it to close,
// and the first beat to arrive says so on beatArrived.
type serveHost struct {
mu sync.Mutex
beats []map[string]any
hellos []string
workers chan *websocket.Conn
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) {
+387
View File
@@ -0,0 +1,387 @@
package main
// servicecarry.go — an install that moves a machine's bastion from an account
// to the system service brings that bastion's state with it.
//
// THE PROBLEM. A bastion installed for one account keeps its pairings, keys,
// grants and audit under that account's profile. The Windows service keeps
// its own under LocalSystem's (sysservice.go). Until this, the switch started
// the service with empty state: the per-user bastion was stopped and removed
// by the installer, the service had no pairing, and the machine went silent to
// every Host it served until someone paired it again by hand. The installer's
// header said so; nothing else did.
//
// THE CARRY. `service install` (elevated) moves the account's state folder
// into the service's before the service starts, when the service has no
// pairing of its own yet:
//
// - only then: a service that already serves a Host is never merged into;
// - not while a bastion still runs from that folder (its run lock is held,
// waited for a moment), and the lock is held through the copy, the check
// and the emptying, so none starts in the middle;
// - a file the service already has is kept, the service's own; where the
// account's version differs, it is kept beside it (carryAsideSuffix);
// - every path in the carried JSON that names the old folder is rewritten
// to the new one (a pairing's context, DCN and backup folders);
// - every file written is read back and compared, and every pairing must
// be in the service's folder, before anything is removed; a copy that
// fails takes back what it wrote, so a later install carries it again;
// - then the account's folder is removed, so the pairing's keys and bearer
// no longer sit where any program running as that account can read them.
// One that will not go is said, and the service still starts.
//
// The decisions are here, untagged, so the suite runs them on every platform;
// finding the account's folder is in sysservice_windows.go.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// The carry's file operations, so the suite can fail one part way.
var (
carryWriteFile = os.WriteFile
carryRemoveAll = os.RemoveAll
)
// carryAsideSuffix names the account's version of a file the service already
// had, kept beside the service's own: the account's folder is removed after
// the carry, and its version would go with it.
const carryAsideSuffix = ".from-account"
// carryLockPatience is how long the carry waits for the account's run lock.
// The installer stops the account's bastion just before (Stop-Process), and
// Windows releases a killed process's file locks a moment after it is gone.
var carryLockPatience = 10 * time.Second
// carrySkip are the files that belong to a running bastion, not to its state.
var carrySkip = map[string]bool{"run.lock": true, "run.holder.json": true, "pairings.lock": true,
"ui.addr": true, "bastion.log": true, "bastion.log.1": true}
// carryResult is what a carry did.
type carryResult struct {
Hosts []string // the pairings carried, by Host
Kept int // files the service already had, left as they were
// Aside are the files the service already had whose account version
// differed; that version is beside each, named with carryAsideSuffix.
Aside []string
Note string // why nothing was carried, when nothing was
// Left: the pairings are in the service, but the account's folder could
// not be removed, and still holds copies of their keys.
Left string
}
// pairingHosts lists the Hosts dir/pairings holds a pairing for. A Host's
// signed grant beside it (pairingGrantSuffix) is not a pairing (loadPairings).
func pairingHosts(dir string) ([]string, error) {
ents, err := os.ReadDir(filepath.Join(dir, "pairings"))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var hosts []string
for _, e := range ents {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") && !strings.HasSuffix(e.Name(), pairingGrantSuffix) {
hosts = append(hosts, strings.TrimSuffix(e.Name(), ".json"))
}
}
sort.Strings(hosts)
return hosts, nil
}
// carryLock takes the account's run lock, waiting up to carryLockPatience for
// a bastion that is exiting to let it go.
func carryLock(f *os.File) error {
deadline := time.Now().Add(carryLockPatience)
for {
err := flockFileNB(f)
if err == nil || !time.Now().Before(deadline) {
return err
}
time.Sleep(200 * time.Millisecond)
}
}
// carryStateInto moves the bastion state at from into to (see the header).
func carryStateInto(from, to string) (carryResult, error) {
var res carryResult
if from == "" || samePath(from, to) {
return res, nil
}
// Only ever a bastion's own folder: from is removed at the end.
if filepath.Base(filepath.Clean(from)) != "privatae-bastion" {
return res, fmt.Errorf("%s is not a bastion's state folder; nothing was moved", from)
}
hosts, err := pairingHosts(from)
if err != nil {
return res, fmt.Errorf("cannot read the pairings under %s: %w", from, err)
}
if len(hosts) == 0 {
return res, nil
}
have, err := pairingHosts(to)
if err != nil {
return res, fmt.Errorf("cannot read the service's pairings under %s: %w", to, err)
}
if len(have) > 0 {
res.Note = fmt.Sprintf("the service already serves %s, so the pairings under %s (%s) were left where they are",
strings.Join(have, ", "), from, strings.Join(hosts, ", "))
return res, nil
}
lock, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return res, fmt.Errorf("cannot open the run lock under %s: %w", from, err)
}
if err := carryLock(lock); err != nil {
_ = lock.Close()
return res, fmt.Errorf("a bastion is still running from %s (it holds that folder's run lock), so its pairings "+
"were not moved into the service. Stop it, then run `privatae-bastion service install` again", from)
}
// Held until only the lock itself is left to remove, so no bastion starts
// from the folder while it is copied, checked and emptied.
locked := true
unlock := func() {
if locked {
_ = lock.Close()
locked = false
}
}
defer unlock()
// A copy that fails takes back every file it wrote: a service left with
// some of the pairings serves a Host, so no later install would carry the
// rest, and the service would serve only those few.
written, kept, aside, err := copyStateTree(from, to)
undo := func() {
for p := range written {
_ = os.Remove(p)
}
}
if err != nil {
undo()
return res, fmt.Errorf("could not copy %s into the service's %s: %w. Nothing was removed from %s", from, to, err, from)
}
for p, want := range written {
got, err := os.ReadFile(p)
if err != nil || !bytes.Equal(got, want) {
undo()
return res, fmt.Errorf("%s did not read back as written; nothing was removed from %s", p, from)
}
}
// Every pairing counted above is in the service's folder, or nothing is
// removed: a source the walk could not enter (a link) copies nothing.
got, err := pairingHosts(to)
if err != nil || strings.Join(got, "\x00") != strings.Join(hosts, "\x00") {
undo()
return res, fmt.Errorf("the service's folder %s holds the pairings %v after the copy, not %v; nothing was "+
"removed from %s", to, got, hosts, from)
}
res.Hosts, res.Kept, res.Aside = hosts, kept, aside
// From here the service has everything, so a folder that will not go is
// said, not a failure: failing would leave the service stopped.
var leftErr error
ents, err := os.ReadDir(from)
if err != nil {
leftErr = err
}
for _, e := range ents {
if e.Name() == "run.lock" {
continue // open here; Windows will not delete an open file
}
if err := carryRemoveAll(filepath.Join(from, e.Name())); err != nil && leftErr == nil {
leftErr = err
}
}
unlock()
if err := carryRemoveAll(from); err != nil && leftErr == nil {
leftErr = err
}
if leftErr != nil {
res.Left = fmt.Sprintf("%s could not be removed (%v). It still holds copies of the pairings' keys: delete that folder",
from, leftErr)
}
return res, nil
}
// copyStateTree copies from into to, file by file, and returns what it wrote
// (path to content, so far when it fails), how many files to already had,
// and which of those differed from the account's (see carryAsideSuffix).
// JSON files have every string that names a path under from rewritten to the
// same path under to. The pairing files go last: a service that starts
// meanwhile serves a pairing only once its file is there, and by then
// everything it reads is too.
func copyStateTree(from, to string) (map[string][]byte, int, []string, error) {
type item struct {
src, dst string
name string
}
var first, last []item
written := map[string][]byte{}
err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(from, p)
if err != nil {
return err
}
dst := filepath.Join(to, rel)
switch {
case d.IsDir():
return os.MkdirAll(dst, 0o700)
case !d.Type().IsRegular():
return nil // a link or a device is never part of the state
case rel == d.Name() && carrySkip[d.Name()]:
return nil
}
it := item{p, dst, d.Name()}
if filepath.Dir(rel) == "pairings" {
last = append(last, it)
} else {
first = append(first, it)
}
return nil
})
if err != nil {
return written, 0, nil, err
}
kept := 0
var aside []string
write := func(p string, data []byte) error {
if err := carryWriteFile(p, data, 0o600); err != nil {
_ = os.Remove(p)
return err
}
written[p] = data
return nil
}
for _, it := range append(first, last...) {
data, err := os.ReadFile(it.src)
if err != nil {
return written, 0, nil, err
}
if strings.HasSuffix(it.name, ".json") {
data = rebaseJSONPaths(data, from, to)
}
if _, err := os.Lstat(it.dst); err == nil {
kept++
if have, err := os.ReadFile(it.dst); err == nil && bytes.Equal(have, data) {
continue
}
p := it.dst + carryAsideSuffix
if _, err := os.Lstat(p); err == nil {
return written, 0, nil, fmt.Errorf("%s is already there; the account's %s was not put beside the "+
"service's", p, it.name)
}
if err := write(p, data); err != nil {
return written, 0, nil, err
}
aside = append(aside, p)
continue
} else if !errors.Is(err, fs.ErrNotExist) {
return written, 0, nil, err
}
if err := write(it.dst, data); err != nil {
return written, 0, nil, err
}
}
return written, kept, aside, nil
}
// rebaseJSONPaths rewrites every string in a JSON document that is from, or a
// path under it, to the same path under to. A document that does not parse is
// returned as it was.
func rebaseJSONPaths(data []byte, from, to string) []byte {
var v any
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return data
}
changed := false
var walk func(any) any
walk = func(x any) any {
switch t := x.(type) {
case string:
if r, ok := rebasePath(t, from, to); ok {
changed = true
return r
}
case []any:
for i := range t {
t[i] = walk(t[i])
}
case map[string]any:
for k := range t {
t[k] = walk(t[k])
}
}
return x
}
v = walk(v)
if !changed {
return data
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return data
}
return out
}
// rebasePath is p moved from under from to under to, and whether p was there.
func rebasePath(p, from, to string) (string, bool) {
sep := string(filepath.Separator)
f := strings.TrimRight(from, `\/`)
switch {
case samePath(p, f):
return to, true
case len(p) > len(f) && samePath(p[:len(f)], f) && (p[len(f)] == '\\' || p[len(f)] == '/'):
return strings.TrimRight(to, `\/`) + sep + strings.TrimLeft(p[len(f):], `\/`), true
}
return p, false
}
// samePath compares two paths as this platform's filesystem does: without
// case on Windows.
func samePath(a, b string) bool {
a, b = filepath.Clean(a), filepath.Clean(b)
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
// carryReport is what `service install` says of a carry.
func carryReport(r carryResult, from string) string {
switch {
case r.Note != "":
return " " + sentenceCase(r.Note) + "."
case len(r.Hosts) == 0:
return ""
}
s := " Moved this machine's pairings into the service from " + from + ": " + strings.Join(r.Hosts, ", ") +
". They go on serving as they were; nothing needs pairing again."
if r.Kept > 0 {
s += fmt.Sprintf(" %d file(s) the service already had were kept as the service's.", r.Kept)
}
if len(r.Aside) > 0 {
s += " Your account's own version of " + strconv.Itoa(len(r.Aside)) + " of them differed, and is kept " +
"beside it in the service's folder as: " + strings.Join(r.Aside, ", ") + "."
}
if r.Left != "" {
s += "\n " + sentenceCase(r.Left) + "."
}
return s
}
+353
View File
@@ -0,0 +1,353 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// carryTree is a per-user bastion's state folder as an install finds it.
func carryTree(t *testing.T) (from, to string) {
t.Helper()
root := t.TempDir()
from = filepath.Join(root, "account", "privatae-bastion")
to = filepath.Join(root, "service", "privatae-bastion")
files := map[string]string{
"pairings/box-one.example.test.json": `{"host_name":"box-one.example.test","bearer":"mcp_test",` +
`"context_root":"` + filepath.Join(from, "context", "box-one.example.test") + `",` +
`"dcn_dir":"` + filepath.Join(from, "dcn", "box-one.example.test") + `",` +
`"backup_root":"/mnt/nas/backups"}`,
"dcn/box-one.example.test/grants.json": `{"folders":["/home/someone/notes"]}`,
"context/box-one.example.test/blob": "context bytes",
"ui.secret": "account secret",
"run.lock": "",
"run.holder.json": `{"pid":1}`,
"ui.addr": "127.0.0.1:7880",
"bastion.log": "a log line",
}
for rel, body := range files {
p := filepath.Join(from, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return from, to
}
// An install that moves the bastion from an account to the service brings
// its pairings with it: the service serves the same Hosts with the same keys,
// the paths that named the account's folder name the service's, nothing that
// belongs to a running bastion comes along, and the account's copy is gone.
//
// MUTATION: return before copyStateTree in carryStateInto. RESULT: red (the
// service has no pairing). Drop the rebaseJSONPaths call. RESULT: red (the
// pairing's context folder is still the account's).
func TestAServiceInstallCarriesTheAccountsPairingsIntoTheService(t *testing.T) {
from, to := carryTree(t)
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if strings.Join(res.Hosts, ",") != "box-one.example.test" {
t.Fatalf("carried %v", res.Hosts)
}
raw, err := os.ReadFile(filepath.Join(to, "pairings", "box-one.example.test.json"))
if err != nil {
t.Fatalf("the service has no pairing: %v", err)
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
if c.Bearer != "mcp_test" || c.HostName != "box-one.example.test" {
t.Errorf("the pairing changed on the way: %+v", c)
}
if c.ContextRoot != filepath.Join(to, "context", "box-one.example.test") || c.DCNDir != filepath.Join(to, "dcn", "box-one.example.test") {
t.Errorf("the pairing's folders still name the account's state: context %q, dcn %q", c.ContextRoot, c.DCNDir)
}
if c.BackupRoot != "/mnt/nas/backups" {
t.Errorf("a path outside the state folder was rewritten: %q", c.BackupRoot)
}
if b, _ := os.ReadFile(filepath.Join(to, "dcn", "box-one.example.test", "grants.json")); !strings.Contains(string(b), "/home/someone/notes") {
t.Errorf("the grants did not come along unchanged: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(to, "context", "box-one.example.test", "blob")); string(b) != "context bytes" {
t.Errorf("the context store did not come along: %q", b)
}
for _, n := range []string{"run.lock", "run.holder.json", "ui.addr", "bastion.log"} {
if _, err := os.Stat(filepath.Join(to, n)); err == nil {
t.Errorf("%s belongs to the running bastion it was carried from, not to its state", n)
}
}
if _, err := os.Stat(from); !os.IsNotExist(err) {
t.Errorf("the account's copy, keys included, is still there: %v", err)
}
if line := carryReport(res, from); !strings.Contains(line, "box-one.example.test") || !strings.Contains(line, "nothing needs pairing again") {
t.Errorf("the install does not say what it carried: %q", line)
}
}
// A service that already serves a Host is never merged into, and the
// account's state stays where it was.
func TestACarryNeverMergesIntoAServiceThatServesAHost(t *testing.T) {
from, to := carryTree(t)
p := filepath.Join(to, "pairings", "box-two.example.test.json")
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil {
t.Fatal(err)
}
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if len(res.Hosts) != 0 || !strings.Contains(res.Note, "box-two.example.test") {
t.Fatalf("carried into a serving service: %+v", res)
}
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
t.Errorf("the account's pairing was touched: %v", err)
}
if _, err := os.Stat(filepath.Join(to, "pairings", "box-one.example.test.json")); err == nil {
t.Error("the account's pairing was merged in")
}
}
// Not while a bastion still runs from the folder: its next write would be
// lost, and two bastions would hold the same keys.
func TestACarryWaitsForTheAccountsBastionToStop(t *testing.T) {
prev := carryLockPatience
t.Cleanup(func() { carryLockPatience = prev })
carryLockPatience = 300 * time.Millisecond
from, to := carryTree(t)
f, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_RDWR, 0o600)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := flockFileNB(f); err != nil {
t.Fatal(err)
}
if _, err := carryStateInto(from, to); err == nil || !strings.Contains(err.Error(), "still running") {
t.Fatalf("carried while the account's bastion ran: %v", err)
}
if _, err := os.Stat(filepath.Join(to, "pairings")); err == nil {
t.Error("the service got a pairing from a running bastion")
}
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
t.Errorf("the account's state was touched: %v", err)
}
}
// A file the service already has is the service's: kept, and counted.
func TestACarryKeepsWhatTheServiceAlreadyHas(t *testing.T) {
from, to := carryTree(t)
if err := os.MkdirAll(to, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(to, "ui.secret"), []byte("service secret"), 0o600); err != nil {
t.Fatal(err)
}
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" {
t.Errorf("the service's own file was replaced: %q", b)
}
if res.Kept != 1 {
t.Errorf("kept %d files, want 1", res.Kept)
}
}
// The carry removes the folder it carried from, so it refuses anything but a
// bastion's own state folder, and does nothing for a folder with no pairing.
func TestACarryTouchesOnlyABastionsStateFolder(t *testing.T) {
root := t.TempDir()
other := filepath.Join(root, "Documents")
if err := os.MkdirAll(filepath.Join(other, "pairings"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(other, "pairings", "x.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := carryStateInto(other, filepath.Join(root, "service", "privatae-bastion")); err == nil {
t.Fatal("a folder that is not a bastion's was carried, and would have been removed")
}
if _, err := os.Stat(other); err != nil {
t.Fatalf("the folder was touched: %v", err)
}
empty := filepath.Join(root, "account", "privatae-bastion")
if err := os.MkdirAll(empty, 0o700); err != nil {
t.Fatal(err)
}
if res, err := carryStateInto(empty, filepath.Join(root, "service", "privatae-bastion")); err != nil || len(res.Hosts) != 0 {
t.Fatalf("a folder with no pairing: %+v, %v", res, err)
}
if _, err := os.Stat(empty); err != nil {
t.Errorf("a folder with nothing to carry was removed: %v", err)
}
}
func TestRebasePathMovesOnlyPathsUnderTheFolder(t *testing.T) {
from, to := "/a/privatae-bastion", "/b/privatae-bastion"
for in, want := range map[string]string{
"/a/privatae-bastion": "/b/privatae-bastion",
"/a/privatae-bastion/dcn/x": "/b/privatae-bastion/dcn/x",
"/a/privatae-bastion-old/dcn": "/a/privatae-bastion-old/dcn",
"/a/other": "/a/other",
"mcp_/a/privatae-bastion/fake": "mcp_/a/privatae-bastion/fake",
} {
if got, _ := rebasePath(in, from, to); got != want {
t.Errorf("rebasePath(%q) = %q, want %q", in, got, want)
}
}
}
// A grant file beside a pairing (pairings/<host>.grant.json, anchor.go) is
// not a pairing: it names no Host the service serves, and a service holding
// only one is not one that serves a Host.
func TestACarryCountsOnlyPairingsAsHosts(t *testing.T) {
from, _ := carryTree(t)
if err := os.WriteFile(filepath.Join(from, "pairings", "box-one.example.test"+pairingGrantSuffix), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
hosts, err := pairingHosts(from)
if err != nil {
t.Fatal(err)
}
if strings.Join(hosts, ",") != "box-one.example.test" {
t.Fatalf("hosts %v, want only box-one.example.test", hosts)
}
}
// A bastion killed a moment ago (the installer's Stop-Process) can hold its
// run lock for a moment after it is gone; the carry waits for it rather than
// failing the install.
func TestACarryWaitsAMomentForAJustStoppedBastionsLock(t *testing.T) {
from, to := carryTree(t)
f, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_RDWR, 0o600)
if err != nil {
t.Fatal(err)
}
if err := flockFileNB(f); err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(300 * time.Millisecond)
_ = f.Close()
}()
res, err := carryStateInto(from, to)
if err != nil {
t.Fatalf("a lock released 300 ms in failed the carry: %v", err)
}
if strings.Join(res.Hosts, ",") != "box-one.example.test" {
t.Fatalf("carried %v", res.Hosts)
}
}
// The account's version of a file the service already has is not lost when
// the account's folder is removed: it is kept beside the service's.
func TestACarryKeepsTheAccountsVersionOfAFileTheServiceHas(t *testing.T) {
from, to := carryTree(t)
if err := os.MkdirAll(to, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(to, "ui.secret"), []byte("service secret"), 0o600); err != nil {
t.Fatal(err)
}
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" {
t.Errorf("the service's own file was replaced: %q", b)
}
if b, err := os.ReadFile(filepath.Join(to, "ui.secret"+carryAsideSuffix)); err != nil || string(b) != "account secret" {
t.Errorf("the account's version is gone with its folder: %q, %v", b, err)
}
if line := carryReport(res, from); !strings.Contains(line, carryAsideSuffix) {
t.Errorf("the install does not say where the account's version went: %q", line)
}
}
// A state folder that is a link to elsewhere is walked as nothing at all; the
// carry must not report the pairings moved, nor remove anything, when the
// service did not get them.
func TestACarryRemovesNothingUnlessTheServiceHasEveryPairing(t *testing.T) {
from, to := carryTree(t)
link := filepath.Join(filepath.Dir(filepath.Dir(from)), "linked", "privatae-bastion")
if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(from, link); err != nil {
t.Skipf("no symlinks here: %v", err)
}
if _, err := carryStateInto(link, to); err == nil {
t.Fatal("the carry reported success with no pairing in the service")
}
if _, err := os.Lstat(link); err != nil {
t.Errorf("the account's state folder was removed: %v", err)
}
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
t.Errorf("the account's pairing was touched: %v", err)
}
}
// A copy that fails part way takes back what it wrote: a service left with
// some of the pairings would never be carried into again (it serves a Host),
// and would serve those few.
func TestACarryThatFailsPartWayLeavesTheServiceAsItWas(t *testing.T) {
from, to := carryTree(t)
second := filepath.Join(from, "pairings", "box-two.example.test.json")
if err := os.WriteFile(second, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil {
t.Fatal(err)
}
prev := carryWriteFile
t.Cleanup(func() { carryWriteFile = prev })
carryWriteFile = func(p string, b []byte, m os.FileMode) error {
if filepath.Base(p) == "box-two.example.test.json" {
return errors.New("disk full")
}
return prev(p, b, m)
}
if _, err := carryStateInto(from, to); err == nil {
t.Fatal("a failed copy reported success")
}
if hosts, _ := pairingHosts(to); len(hosts) != 0 {
t.Errorf("the service was left serving %v of the account's pairings", hosts)
}
if b, err := os.ReadFile(filepath.Join(to, "ui.secret")); err == nil {
t.Errorf("a file the failed copy wrote was left in the service: %q", b)
}
if _, err := os.Stat(second); err != nil {
t.Errorf("the account's state was touched: %v", err)
}
}
// Once the service holds every pairing, a source folder that cannot be
// removed is said, not a failure: failing would leave the service stopped,
// and the machine as silent as the empty start this carry replaces.
func TestACarryWhoseSourceCannotBeRemovedStillSucceeds(t *testing.T) {
from, to := carryTree(t)
prev := carryRemoveAll
t.Cleanup(func() { carryRemoveAll = prev })
carryRemoveAll = func(string) error { return errors.New("in use by another process") }
res, err := carryStateInto(from, to)
if err != nil {
t.Fatalf("a carry that moved every pairing failed the install: %v", err)
}
if hosts, _ := pairingHosts(to); strings.Join(hosts, ",") != "box-one.example.test" {
t.Errorf("the service holds %v", hosts)
}
if line := carryReport(res, from); !strings.Contains(line, "delete that folder") {
t.Errorf("the install does not say the account's copy is still there: %q", line)
}
}
+8
View File
@@ -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
View File
@@ -1162,7 +1162,7 @@ func stepupPairing(host string) (*Config, error) {
return nil, err
}
if len(cfgs) == 0 {
return nil, fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
return nil, fmt.Errorf("not paired yet — connect a Host first: %s", asAdmin("pair <token>"))
}
switch {
case host != "":
@@ -1341,7 +1341,7 @@ func readApprovals(o readOpts) (approvalsRead, error) {
func cmdStepupApprovals(host string, machine, asJSON bool) error {
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
if err != nil {
return laneRefusalError("approvals", err)
return laneRefusalError(strings.TrimSpace(stepupCommand("approvals", host, machine, "")), err)
}
if asJSON {
return printJSON(os.Stdout, r)
@@ -1388,6 +1388,9 @@ func findRequest(m map[string]stepupRequest, prefix string) (string, error) {
// privatae-bastion approve [--host <host> | --machine] <request id>
func cmdStepupApprove(host string, machine bool, prefix string) error {
if readsGoThroughLane() {
return ownerApproveViaLane(host, machine, prefix)
}
t, err := resolveStepupTarget(host, machine)
if err != nil {
return err
@@ -1425,6 +1428,14 @@ func cmdStepupApprove(host string, machine bool, prefix string) error {
// privatae-bastion deny [--host <host> | --machine] <request id>
func cmdStepupDeny(host string, machine bool, prefix string) error {
if readsGoThroughLane() {
done, err := actViaSock(agentAskRequest{Act: "deny", Host: host, Machine: machine, ID: prefix})
if err != nil {
return laneRefusalError(stepupCommand("deny", host, machine, prefix), err)
}
fmt.Println(done)
return nil
}
t, err := resolveStepupTarget(host, machine)
if err != nil {
return err
+111 -42
View File
@@ -3,7 +3,7 @@ package main
// switch_cli.go — the local switch (localdeny.go) from a terminal.
//
// privatae-bastion switch <host> <family> on|off
// privatae-bastion switch --list
// privatae-bastion switch --list [--json]
//
// The console page has had this switch since localdeny.go was written; a
// machine with no browser and no person at it (a server the bastion guards
@@ -14,10 +14,11 @@ package main
// MACHINE AUTHORITY, for both directions. On a machine that serves a Host it
// does not belong to the person using that Host, the switch is part of how
// the machine was set up, and the one who stands behind that is whoever
// administers the box. --list only reads, and needs no authority of its own;
// beside a system service on macOS or Windows an ordinary account is refused
// it (serviceGuard, sysservice.go), because it would read that account's own
// state rather than the machine's.
// administers the box. --list only reads, and needs no authority of its own:
// beside a system service on Linux or macOS the owner's account reads the
// machine's list through the lane (reads.go); on Windows an ordinary account
// is refused it (serviceGuard, sysservice.go), because it would read that
// account's own state rather than the machine's.
import (
"errors"
@@ -29,14 +30,26 @@ import (
)
const switchUsage = "usage: sudo -H privatae-bastion switch <host> <family> on|off\n" +
" privatae-bastion switch --list\n" +
" privatae-bastion switch --list [--json]\n" +
" sudo -H privatae-bastion switch --reset (only when the list cannot be read)"
// cmdSwitch parses the arguments and runs one switch, the listing, or the
// reset of an unreadable list.
func cmdSwitch(args []string) error {
if len(args) == 1 && args[0] == "--list" {
return switchList(os.Stdout)
if len(args) >= 1 && args[0] == "--list" {
asJSON, err := jsonOnly(switchUsage, args[1:])
if err != nil {
return err
}
v, err := readMachine("switches", readOpts{}, readSwitches)
if err != nil {
return laneRefusalError("switch --list", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printSwitches(os.Stdout, v)
return nil
}
if len(args) == 1 && args[0] == "--reset" {
// The same authority as switching anything back on: a reset leaves
@@ -64,6 +77,25 @@ func cmdSwitch(args []string) error {
default:
return fmt.Errorf("the last word is on or off, not %q\n%s", args[2], switchUsage)
}
if readsGoThroughLane() {
// The owner's account, beside the machine's bastion: the console's
// rule, through its ask socket (owneracts.go). Off takes reach away and
// needs nothing; on takes the owner's code.
req := agentAskRequest{Act: "switch", Host: args[0], Family: args[1], Off: off}
if !off {
code, ok := promptOwnerCode("Switch " + args[0] + " " + args[1] + " back on? Enter the code from your authenticator (or leave empty to cancel): ")
if !ok {
return nil
}
req.Code = code
}
done, err := actViaSock(req)
if err != nil {
return laneRefusalError("switch "+strings.Join(args, " "), err)
}
fmt.Println(done)
return nil
}
if err := requireMachineAuthority("switch"); err != nil {
return err
}
@@ -75,11 +107,7 @@ func cmdSwitch(args []string) error {
if err := localDenySet(host, family, off); err != nil {
return err
}
if off {
fmt.Printf("%s %s off: this machine now refuses it, and tells the host why. Its grant is untouched.\n", host, family)
} else {
fmt.Printf("%s %s on: the refusal is cleared, up to whatever its signed grant already allowed.\n", host, family)
}
fmt.Println(switchDoneText(host, family, off))
return nil
}
@@ -106,56 +134,97 @@ func switchResolveHost(key string) (string, error) {
return c.HostName, nil
}
// switchList prints one line per paired host and family:
//
// <host> <family> on|off [since <time>]
//
// Plain columns so a setup script can read it as well as a person.
func switchList(w io.Writer) error {
// switchRow is one family of one paired host: off (refused here) or on.
type switchRow struct {
Host string `json:"host"`
Family string `json:"family"`
Off bool `json:"off"`
Since string `json:"since,omitempty"`
}
// switchesRead is the local switch as it stands. Unreadable: the list cannot
// be read, so the gate refuses everything (localDenied) and every row is off.
type switchesRead struct {
Switches []switchRow `json:"switches"`
Unreadable string `json:"unreadable,omitempty"`
// StateDir is the bastion state that was read, named when nothing is
// paired in it: an ordinary account's own state is not the machine's.
StateDir string `json:"state_dir,omitempty"`
}
func readSwitches(readOpts) (switchesRead, error) {
v := switchesRead{Switches: []switchRow{}}
cfgs, err := loadPairings()
if err != nil {
return err
return v, err
}
if len(cfgs) == 0 {
// Name the state that was read. On Linux the root service keeps its
// state under /root, and an ordinary account's --list reads its own,
// so "no host is paired with this machine" would be untrue there.
dir, _ := configDirPath()
fmt.Fprintf(w, "no host is paired in this account's bastion state (%s); "+
"the machine's bastion may keep its state under another account (%s)\n", dir, adminHowTo())
return nil
v.StateDir, _ = configDirPath()
return v, nil
}
localDenyMu.Lock()
f, readErr := localDenyLoad()
localDenyMu.Unlock()
if readErr != nil {
// The gate refuses everything while the list cannot be read
// (localDenied), so every line says off, and the warning says why.
fmt.Fprintf(w, "warning: %s\n", localDenyUnreadableText(readErr))
v.Unreadable = localDenyUnreadableText(readErr)
}
names := hostNames(cfgs)
sort.Strings(names)
fams := localSwitchFamilies()
for _, host := range names {
off := map[string]bool{}
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)
}
}
+80 -15
View File
@@ -28,9 +28,11 @@ package main
// installed and the caller administers the machine, the process points
// that one variable at the service's own before anything reads state.
// - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed,
// `pair`, `run` and `status` from an account that does not administer the
// machine would act on an empty bastion of that account's own, beside the
// machine's. They are refused with a sentence saying what to do instead. As
// `pair` and `run` from an account that does not administer the machine
// would act on an empty bastion of that account's own, beside the
// machine's. They are refused with a sentence saying what to do instead.
// The reads and the owner's acts (`status` among them) ask the machine's
// bastion over its lane instead (serviceLaneReads). As
// with the machine marker (singleton.go), this binds a cooperating binary
// only; an older copy will not refuse. What stops the person from removing
// the service is that they do not administer the machine.
@@ -46,6 +48,11 @@ import (
"time"
)
// serviceCarryFromSID is the account `service install --from-account` names:
// the bastion state carried into the service is that account's
// (servicecarry.go). "" carries the state of the account running the install.
var serviceCarryFromSID string
const (
launchdLabel = "ai.privatae.bastion"
launchdPlistPath = "/Library/LaunchDaemons/" + launchdLabel + ".plist"
@@ -98,17 +105,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
// lane (reads.go, owneracts.go): ask.sock on Linux and macOS, the ask pipe
// on Windows. Run directly, 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}
// serviceLaneReads are the guarded commands that read the machine's bastion
// itself over its lane (reads.go: ask.sock on Linux and macOS, the ask pipe on
// Windows) 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, "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.
@@ -122,7 +145,7 @@ func serviceGuard(goos, cmd string, installed, authority bool) error {
if !installed || authority || !serviceGuardedCommands[cmd] {
return nil
}
if serviceLaneReads[cmd] && goos != "windows" {
if serviceLaneReads[cmd] {
return nil
}
return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+
@@ -339,6 +362,10 @@ var systemService struct {
installed bool
authority bool
err error
// callerAppData is APPDATA as this process was started with, before an
// administrator's command is pointed at the service's: where a bastion of
// this account's own keeps its state (servicecarry.go).
callerAppData string
}
// adoptSystemServiceState runs before any command. It records whether the
@@ -356,6 +383,7 @@ var systemService struct {
// authority only after an installed service left root on Linux looking like
// an ordinary account, and enrolServiceStateRefusal refused it.
func adoptSystemServiceState() {
systemService.callerAppData = os.Getenv("APPDATA")
installed, err := systemServiceInstalled()
systemService.err = err
systemService.installed = installed || err != nil
@@ -372,8 +400,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,10 +412,44 @@ 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 {
const usage = "usage: privatae-bastion service install [--from-account <SID>] | uninstall | status"
if len(args) == 3 && args[0] == "install" && args[1] == "--from-account" {
if !approvalsOwnerSIDOK(args[2]) {
return fmt.Errorf("%q is not the SID of a person's account (S-1-5-21-… or S-1-12-1-…) — nothing was installed", args[2])
}
serviceCarryFromSID = args[2]
args = args[:1]
}
if len(args) != 1 {
return errors.New("usage: privatae-bastion service install|uninstall|status")
return errors.New(usage)
}
switch args[0] {
case "install":
+78 -5
View File
@@ -165,11 +165,12 @@ 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" {
// These read the machine's bastion over its socket (reads.go),
// which answers the owner's account and refuses any other.
if cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report" {
// These read the machine's bastion over its lane (reads.go:
// ask.sock, or the ask pipe on Windows), which answers the
// owner's account and refuses any other.
if err != nil {
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, cmd, err)
}
@@ -199,7 +200,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 +445,74 @@ 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 go through the owner's lane
// (owneracts.go) on every system, ask.sock or the Windows ask pipe, so the
// guard lets them by; each must be both guarded and a lane command, or from
// an ordinary account it would act on that account's own, empty bastion.
// 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 serviceLaneReads. RESULT: red on every
// system. Take "device-approve" out of serviceGuardedCommands. 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 !serviceGuardedCommands[want] || !serviceLaneReads[want] {
t.Errorf("`%s` is guarded=%v, a lane command=%v: from an ordinary account beside the service it "+
"would act on that account's own, empty bastion", want, serviceGuardedCommands[want], serviceLaneReads[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 {
if err := serviceGuard(goos, key, true, false); 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 go through the owner's
// lane on every platform: ask.sock on Linux and macOS, the ask pipe on
// Windows, which answers the owner's account and refuses any other.
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLane(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)
for _, goos := range []string{"linux", "darwin", "windows"} {
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)
}
}
}
}
+50
View File
@@ -24,6 +24,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
@@ -236,6 +237,20 @@ func installSystemService() error {
}
defer s.Close()
// Before the service first starts on this state: the pairings of the
// bastion this machine ran for an account until now (servicecarry.go).
// A carry that fails leaves the service stopped rather than starting it
// empty, which would leave the machine silent to every Host it serves.
to := filepath.Join(windowsSystemAppData(os.Getenv("SystemRoot")), "privatae-bastion")
from, ferr := serviceCarrySource(serviceCarryFromSID)
if ferr != nil {
fmt.Printf(" No bastion state of an account was looked for (%v).\n", ferr)
} else if res, err := carryStateInto(from, to); err != nil {
return fmt.Errorf("the %s service is registered but was not started: %w", winServiceName, err)
} else if line := carryReport(res, from); line != "" {
fmt.Println(line)
}
restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second}
if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil {
return fmt.Errorf("cannot set the %s service to restart after a failure: %w", winServiceName, err)
@@ -268,6 +283,41 @@ func installSystemService() error {
return nil
}
// serviceCarrySource is the state folder of the bastion an account ran: the
// named account's (its profile from the registry's ProfileList, which an
// elevated step run as another administrator still finds), or else the
// account running this install, as it was before its APPDATA was pointed at
// the service's.
func serviceCarrySource(sid string) (string, error) {
// The account running this install, as the installer's elevated step
// usually is: its own APPDATA, which is where its bastion kept its state
// even when that folder is redirected away from the profile.
if u, err := windows.GetCurrentProcessToken().GetTokenUser(); err == nil && strings.EqualFold(u.User.Sid.String(), sid) {
sid = ""
}
if sid == "" {
d := systemService.callerAppData
if d == "" || samePath(d, windowsSystemAppData(os.Getenv("SystemRoot"))) {
return "", nil
}
return filepath.Join(d, "privatae-bastion"), nil
}
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE)
if err != nil {
return "", fmt.Errorf("no profile is recorded for %s: %w", sid, err)
}
defer k.Close()
prof, _, err := k.GetStringValue("ProfileImagePath")
if err != nil {
return "", fmt.Errorf("the profile of %s has no path: %w", sid, err)
}
if prof, err = registry.ExpandString(prof); err != nil {
return "", err
}
return filepath.Join(prof, "AppData", "Roaming", "privatae-bastion"), nil
}
// stopServiceAndWait asks the service to stop and waits up to thirty seconds
// for it to say it has. A service that is not running is already stopped.
func stopServiceAndWait(s *mgr.Service) {
+51 -7
View File
@@ -130,6 +130,10 @@ const uiEventRecentFor = 7 * 24 * time.Hour
// line of the page where the day is the point.
const uiEventTime = "2 Jan 2006, 15:04"
// Admin is `privatae-bastion <cmd>` as this machine's administrator types it,
// for what a page tells a limited session to ask of them.
func (uiStatus) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) }
type uiStatus struct {
// Limited: drawn for the owner's lane (consolelane.go), which may not
// answer the approval device's requests.
@@ -499,8 +503,7 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
return
}
if !u.signedIn(r) {
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
http.StatusUnauthorized)
http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
return
}
if u.limitedSession(r) {
@@ -510,6 +513,31 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
}
}
// notSignedIn is what a page says to a browser with no session: the command
// that opens it on this machine. The console of a bastion installed for the
// whole machine opens from its owner's own account through the lane
// (consolelane.go), and from the administrator's; telling either one only
// "privatae-bastion ui" sent the person back to the command that, from the
// wrong account, could not open it.
func notSignedIn(verb, goos string, machines bool) string {
cmd := "privatae-bastion " + verb
switch {
case !machines:
return "not signed in — open this page with `" + cmd + "`"
case goos == "windows":
return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd +
"` from the account that owns this machine, or with `" + cmd + "` in an Administrator terminal"
}
return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd +
"` from the account that owns this machine, or with `sudo -H " + cmd + "`"
}
// consoleIsTheMachines: this console is the bastion installed for the whole
// machine (the Windows service, or root's on Linux and macOS).
func consoleIsTheMachines() bool {
return runningAsService || (runtime.GOOS != "windows" && os.Geteuid() == 0)
}
// root serves the page, and is also where a ticket is redeemed.
func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
if why := uiRequestAllowed(r, u.port, u.verb()); why != "" {
@@ -548,8 +576,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
return
}
if !u.signedIn(r) {
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
http.StatusUnauthorized)
http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
return
}
if u.contextUI {
@@ -937,9 +964,7 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", addr, fmt.Errorf("the local UI refused this machine's own key (%s) — "+
"if the bastion was reinstalled, restart it so it and this command agree",
resp.Status)
return "", addr, uiKeyRefused(addr, resp.Status, runtime.GOOS, systemService.installed, machineAuthorityHeld())
}
var out struct {
Ticket string `json:"ticket"`
@@ -954,6 +979,25 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
return entry, addr, nil
}
// uiKeyRefused is the console refusing this account's own key: the console
// on that port belongs to another bastion than this account's.
func uiKeyRefused(addr, status, goos string, service, admin bool) error {
switch {
case goos == "windows" && service && !admin:
return fmt.Errorf("the console at %s did not take this account's key (%s): it belongs to the %s service, "+
"which this account opens through the service, not with a key of its own. Update the bastion "+
"(run the installer again), or open the console from an Administrator terminal: privatae-bastion ui",
addr, status, winServiceName)
case goos == "windows":
return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that "+
"port. Get-Process privatae-bastion | Format-List Id, Path, StartTime shows which; `privatae-bastion "+
"status` says whether this account's own is running", addr, status)
}
return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that port, "+
"most likely the one installed for the whole machine. Open it from the account that owns this machine "+
"with `privatae-bastion ui`, or with `sudo -H privatae-bastion ui`", addr, status)
}
// cmdUI opens the local UI in a browser. It does NOT start a server: the UI
// belongs to the running daemon, because that is the process that knows what is
// live.
+1 -1
View File
@@ -58,7 +58,7 @@
</form>
{{if $.Limited}}
<p class="approval-line">Approving signs with this device's key, which is the administrator's:
<code>sudo -H privatae-bastion ui</code></p>
<code>{{$.Admin "ui"}}</code></p>
{{else}}
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}">
+6 -6
View File
@@ -89,7 +89,7 @@
{{else if .OwnerCode}}
<p><span class="flag ok">set up</span> Your code is set up on this machine.</p>
{{if .Limited}}
<p class="sub">To replace it, at a terminal: <code>sudo -H privatae-bastion stepup-enroll --machine --rotate</code></p>
<p class="sub">To replace it, at a terminal: <code>{{$.Admin "stepup-enroll --machine --rotate"}}</code></p>
{{else}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="target" value="machine">
@@ -99,7 +99,7 @@
{{end}}
{{else if .Limited}}
<p class="sub">Set it up once, at a terminal, as the machine's administrator, so no program running as you
can set it up first: <code>sudo -H privatae-bastion stepup-enroll --machine</code></p>
can set it up first: <code>{{$.Admin "stepup-enroll --machine"}}</code></p>
{{else}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="target" value="machine">
@@ -146,7 +146,7 @@
<h3>Connect a Host</h3>
<div class="host">
<p class="sub">The token comes from your host's “Set up a bastion”. It is used once.</p>
{{if .Limited}}<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>{{else}}
{{if .Limited}}<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>{{else}}
<form class="setup-form" hx-post="/setup/pair" hx-target="#setup" hx-swap="innerHTML"
autocomplete="off">
<label for="pair-token">Token</label>
@@ -199,7 +199,7 @@
</div>
</div>
{{else if and .Servable .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>
{{else if .Servable}}
{{/* .Servable, not .Hosts: a pending pairing gets no state on this machine
at all, not even a directory (run.go:311), and a factor for it would
@@ -231,7 +231,7 @@
<h3>Add a folder to your network</h3>
<div class="host">
{{if and .Servable .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>
{{else if .Servable}}
<p class="sub">Your files stay on this machine. Your host keeps only the index, and
the draft does nothing until you approve it there.</p>
@@ -275,7 +275,7 @@
<p class="sub">The anchor host is the one that approves every other host paired here.
It is a choice among the hosts this machine already serves.</p>
{{if and .Hosts .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>
{{else if .Hosts}}
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
{{range .Hosts}}
+3
View File
@@ -263,6 +263,9 @@ type uiCommand struct {
Class string // ok | warn | bad — bad means "this cannot be run yet"
}
// Admin is uiStatus.Admin for the setup page.
func (uiSetupView) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) }
type uiSetupView struct {
Version string
Mode string
+1 -1
View File
@@ -1 +1 @@
0.60.0
0.62.0
+41
View File
@@ -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}"
+17 -9
View File
@@ -23,10 +23,13 @@
# that form errors without installing anything.)
# On an install that already exists, `privatae-bastion set-mode networked` (from
# an Administrator terminal) changes the mode but not how it runs: it stays a
# logon task. Re-run this installer with -Networked to make it a service. The
# service starts with its own state: pairings made by the earlier per-user
# install stay in that user's profile and are not carried over, so the machine
# is paired again (claim-anchor with a token from the host).
# logon task. Re-run this installer with -Networked to make it a service. When
# an install moves the bastion from your account to the service, the service
# takes over your account's bastion state before it first starts: its
# pairings, keys and grants, so every Host it served goes on being served and
# nothing is paired again (`service install --from-account`, servicecarry.go).
# The copy in your profile is then removed, so the keys no longer sit where
# programs running as you can read them.
#
# ELEVATE AS YOURSELF, not as a different administrator account. The state
# directory is per-user (a folder under %APPDATA%), and UAC elevation of your
@@ -248,9 +251,16 @@ if (-not $elevatedDone) {
Where-Object { $_.Path -and ($_.Path -ne $exe) } |
ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
# Stop-Process returns before the process is gone, and its run
# lock goes with it; the carry below needs that lock free.
Wait-Process -Id $_.Id -Timeout 10 -ErrorAction SilentlyContinue
Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)."
}
& $exe service install
# The account that asked for this install, or this one when run elevated
# by hand: its bastion's pairings go into the service, and it is recorded
# as the machine's owner below.
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
& $exe service install --from-account $owner
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
} else {
try {
@@ -326,10 +336,8 @@ if (-not $elevatedDone) {
& $exe set-mode networked
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
}
# The machine's owner: the account that asked for this install, or this
# one when run elevated by hand. A second administrator's reinstall leaves
# the first owner in place; the bastion says so.
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
# The machine's owner ($owner, above). A second administrator's
# reinstall leaves the first owner in place; the bastion says so.
& $exe approvals-owner --sid $owner
if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." }
Write-Host ""
+85 -2
View File
@@ -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
+11 -4
View File
@@ -11,6 +11,8 @@ it shares with Linux is tested there, but it has not run on that system yet.
|---|---|---|---|
| Install for the whole machine from your own account | run ([gate](verification/m0-gate-linux-2026-10-31.md)) | built | not run |
| `--just-me`, and the fall back where sudo cannot be used | run | built | not run |
| Moving from an install for your account to one for the whole machine keeps its pairings | not needed (one state folder) | not built | built |
| An update restarts the bastion | run | built | built (the service restarts it; a sign-in task's copy waits for the old one to exit) |
| Every AI agent, by its program or its connection to a model | run | built | built |
| Who started each one (terminal, remote login, schedule, service, another agent) | run | built | built |
| Claude Code: session title, permission mode, what it is running now | tested | read, not checked against the process's start time | read, not checked against the process's start time |
@@ -19,8 +21,11 @@ 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) |
| The console from the owner's own account, no sudo | run | built | the tray's approvals window 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 | built (the ask pipe) |
| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | built (the ask pipe) |
| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | built (the 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 or Administrator terminal | run | built | built (the ask pipe); the tray's approvals window |
| Activity, report | run | built | built |
| `uninstall`, keeping the audit log | run | built | built |
| A notice when something is waiting | none yet | none yet | the tray |
@@ -46,7 +51,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)).
+2
View File
@@ -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
View File
@@ -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.
+4 -2
View File
@@ -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
View File
@@ -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
```