Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2043fa5e9e | |||
| 79646bda13 | |||
| d4594d18b7 | |||
| 1176f7fd0b |
@@ -40,11 +40,13 @@ jobs:
|
|||||||
# The backup tests check this code against the age command itself: a
|
# The backup tests check this code against the age command itself: a
|
||||||
# pinned age release, its archive checked against the SHA-256 below,
|
# pinned age release, its archive checked against the SHA-256 below,
|
||||||
# and PRIVATAE_REQUIRE_AGE makes those tests fail rather than skip if
|
# 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: |
|
run: |
|
||||||
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/client-go \
|
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 '
|
-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
|
apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon openssl
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;;
|
x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;;
|
||||||
aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;;
|
aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;;
|
||||||
|
|||||||
@@ -243,6 +243,6 @@ jobs:
|
|||||||
sudo env PATH="/opt/go-pinned/bin:$PATH" HOME="$HOME" AGE_BIN="$AGE_BIN" PRIVATAE_REQUIRE_AGE=1 GOTOOLCHAIN=local CGO_ENABLED=0 \
|
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" \
|
GOFLAGS=-mod=readonly GOMODCACHE="$HOME/go/pkg/mod" GOCACHE="$HOME/.cache/go-build" \
|
||||||
go test -count=1 -json -run "^($tests)\$" . > "$RUNNER_TEMP/claims.json" || {
|
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
|
- name: none of them skipped
|
||||||
run: bash deploy/check-claims.sh --skipped "$RUNNER_TEMP/claims.json"
|
run: bash deploy/check-claims.sh --skipped "$RUNNER_TEMP/claims.json"
|
||||||
|
|||||||
@@ -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
|
that is settled. The newest entries may not be published yet; the manifest
|
||||||
says which version is. Older entries are not marked.
|
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
|
## 0.60.0
|
||||||
|
|
||||||
- **An agent can follow its own stop request to the end.** `request <id>
|
- **An agent can follow its own stop request to the end.** `request <id>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](client-go/go.mod)
|
[](client-go/go.mod)
|
||||||
[](#supported-platforms)
|
[](#supported-platforms)
|
||||||
[](https://git.privatae.ai/privatae/privatae-bastion/actions)
|
[](https://github.com/Privatae/bastion/actions/workflows/ci.yml)
|
||||||
|
|
||||||
**See every AI agent running on your computer, what each one is doing and what it can reach, and stop any of them. One program, no account, no AI of its own.**
|
**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))
|
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
|
- **Verified releases.** The self-updater checks the manifest's signature and
|
||||||
the binary's SHA-256 before swapping; the release key never reaches the
|
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)
|
build machine. The installer checks the SHA-256, and `install.sh` checks the
|
||||||
says what that proves). ([self-update](docs/updates.md))
|
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
|
- **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
|
every auth and stores nothing; which other helpers are kept out depends on
|
||||||
the git version. ([git lane](docs/lanes.md#git-lane))
|
the git version. ([git lane](docs/lanes.md#git-lane))
|
||||||
|
|||||||
+16
-3
@@ -74,9 +74,22 @@ it was reinstalled by hand.
|
|||||||
|
|
||||||
**The first install.** `curl … | sudo sh` and `irm … | iex` run a script from
|
**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
|
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
|
the binary's digest against the manifest. `install.sh` also checks the
|
||||||
come from the same place. To avoid trusting that one download, build from
|
manifest's signature against the two pinned release keys, the ones a bastion
|
||||||
source (see the README) or run `deploy/verify-release.sh` before installing.
|
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
|
**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
|
to ask, and what it can ask for is limited to what we signed: the update goes
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func cmdAddFolder(host, path, name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(cfgs) == 0 {
|
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
|
// 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.
|
// ends up proposed to the wrong entity. One pairing needs no flag.
|
||||||
|
|||||||
+49
-24
@@ -8,7 +8,8 @@ package main
|
|||||||
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
|
// `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).
|
// 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
|
// 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
|
// 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
|
// 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.
|
// for you and answered with the owner's code. Nothing here approves anything.
|
||||||
@@ -39,7 +40,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type agentAskRequest struct {
|
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"`
|
PID int `json:"pid"`
|
||||||
Request string `json:"request,omitempty"` // "status": the id `stop` printed
|
Request string `json:"request,omitempty"` // "status": the id `stop` printed
|
||||||
// "read": which one, and its flags (readOpts).
|
// "read": which one, and its flags (readOpts).
|
||||||
@@ -48,6 +49,12 @@ type agentAskRequest struct {
|
|||||||
Prompts bool `json:"prompts,omitempty"`
|
Prompts bool `json:"prompts,omitempty"`
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
Machine bool `json:"machine,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 {
|
type agentAskReply struct {
|
||||||
@@ -75,7 +82,13 @@ var askPeerIdentityFn = contextPeerIdentity
|
|||||||
|
|
||||||
func startAskSock(done <-chan struct{}) {
|
func startAskSock(done <-chan struct{}) {
|
||||||
if runtime.GOOS == "windows" {
|
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()
|
p := askSockPath()
|
||||||
ln, err := contextSockListen(p)
|
ln, err := contextSockListen(p)
|
||||||
@@ -117,8 +130,7 @@ func askSockServe(c net.Conn) {
|
|||||||
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
|
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
uid, user, err := askPeerIdentityFn(c)
|
uid, user, err := askPeerIdentityFn(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" +
|
_ = json.NewEncoder(c).Encode(askUnattributable(err))
|
||||||
err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req agentAskRequest
|
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"})
|
_ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Op == "console" {
|
extend := func(d time.Duration) { _ = c.SetDeadline(time.Now().Add(d)) }
|
||||||
r := agentAskReply{User: user}
|
_ = json.NewEncoder(c).Encode(askAnswer(askPeer{UID: uid, Name: user}, req, extend,
|
||||||
if link, err := consoleLaneEntry(uid, user); err != nil {
|
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()
|
r.Why = err.Error()
|
||||||
} else {
|
} else {
|
||||||
r.OK, r.URL = true, link
|
r.OK, r.URL = true, link
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(c).Encode(r)
|
return r
|
||||||
return
|
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" {
|
return fileAsk(req, peer.Name, pid())
|
||||||
_ = 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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileAsk turns one ask into a pending request of the machine, or says why not.
|
// 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
|
// askViaSock is the asking side: `stop` run where it cannot write the
|
||||||
// machine's state itself.
|
// machine's state itself.
|
||||||
func askViaSock(req agentAskRequest) (agentAskReply, error) {
|
func askViaSock(req agentAskRequest) (agentAskReply, error) {
|
||||||
if runtime.GOOS == "windows" {
|
c, err := dialAskLane(askSockPath())
|
||||||
return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet")
|
|
||||||
}
|
|
||||||
c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return agentAskReply{}, err
|
return agentAskReply{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -528,10 +528,13 @@ type beatLanes struct {
|
|||||||
// noCoders: a tcp pairing, whose coder runs have no control plane to use
|
// noCoders: a tcp pairing, whose coder runs have no control plane to use
|
||||||
// (pair_tcp.go coderTransportRefusal), so it is never offered one.
|
// (pair_tcp.go coderTransportRefusal), so it is never offered one.
|
||||||
noCoders bool
|
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 {
|
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.
|
// 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()
|
faAdvertised, faOK := freeagentProbe()
|
||||||
// managed instances (freeagent_managed.go) count the same way: any
|
// managed instances (freeagent_managed.go) count the same way: any
|
||||||
// serving instance advertises the capability; all must serve for ok
|
// serving instance advertises the capability; all must serve for ok
|
||||||
if mAdv, mOK := faManagedProbe(); mAdv {
|
if mAdv, mOK := faManagedProbe(lanes.dcnDir); mAdv {
|
||||||
if !faAdvertised {
|
if !faAdvertised {
|
||||||
faAdvertised, faOK = true, mOK
|
faAdvertised, faOK = true, mOK
|
||||||
} else {
|
} else {
|
||||||
@@ -1067,7 +1070,7 @@ func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wg
|
|||||||
}
|
}
|
||||||
if faAdvertised {
|
if faAdvertised {
|
||||||
body["freeagent_ok"] = faOK
|
body["freeagent_ok"] = faOK
|
||||||
if st := faManagedStatus(); len(st) > 0 {
|
if st := faManagedStatus(lanes.dcnDir); len(st) > 0 {
|
||||||
body["freeagent_stores"] = st
|
body["freeagent_stores"] = st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-3
@@ -18,11 +18,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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{
|
var familyLabels = map[string]string{
|
||||||
"claude-code": "Claude Code", "aider": "aider", "goose": "goose", "cline": "Cline",
|
"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 {
|
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
|
asJSON, prompts := false, false
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
switch a {
|
switch a {
|
||||||
@@ -90,6 +100,17 @@ func cmdAgents(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if asJSON {
|
||||||
return printJSON(os.Stdout, v)
|
return printJSON(os.Stdout, v)
|
||||||
}
|
}
|
||||||
@@ -97,6 +118,104 @@ func cmdAgents(args []string) error {
|
|||||||
return nil
|
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
|
// printAgents writes the views grouped by agent, sessions waiting for the
|
||||||
// person first, apps last.
|
// person first, apps last.
|
||||||
func printAgents(w io.Writer, views []agentView, now time.Time) {
|
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.
|
// No session record: name it by what it is and where it works.
|
||||||
l.Head = familyLabel(v.Signature)
|
l.Head = familyLabel(v.Signature)
|
||||||
if v.Folder != "" {
|
if v.Folder != "" {
|
||||||
l.Head += " in " + filepath.Base(v.Folder)
|
l.Head += " in " + shownPath(filepath.Base(v.Folder))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.Kind == kindApp {
|
if v.Kind == kindApp {
|
||||||
@@ -255,7 +374,7 @@ func agentLinesOf(v agentView, now time.Time) agentLines {
|
|||||||
l.State = strings.Join(state, " · ")
|
l.State = strings.Join(state, " · ")
|
||||||
var where []string
|
var where []string
|
||||||
if v.Folder != "" {
|
if v.Folder != "" {
|
||||||
f := "in " + v.Folder
|
f := "in " + shownPath(v.Folder)
|
||||||
if v.GitBranch != "" {
|
if v.GitBranch != "" {
|
||||||
f += " (" + v.GitBranch + ")"
|
f += " (" + v.GitBranch + ")"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ func gitBranchOf(dir string) string {
|
|||||||
return cleanText(b, 80)
|
return cleanText(b, 80)
|
||||||
}
|
}
|
||||||
if len(h) >= 12 {
|
if len(h) >= 12 {
|
||||||
return "detached at " + h[:12]
|
return "detached at " + cleanText(h[:12], 12)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,15 @@ func winPersonSID(sid string) bool {
|
|||||||
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-")
|
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 ""
|
// 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
|
// when it may: it must run as the person signed in to its own session, in a
|
||||||
// session someone is in front of.
|
// session someone is in front of.
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ type approvalPipeServer struct {
|
|||||||
open int
|
open int
|
||||||
sa *windows.SecurityAttributes
|
sa *windows.SecurityAttributes
|
||||||
name *uint16
|
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 is set when the service stops; it ends a connect that is waiting.
|
||||||
stopEv windows.Handle
|
stopEv windows.Handle
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ func (s *approvalPipeServer) create() (*pipeConn, error) {
|
|||||||
windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa)
|
windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.open == 0 {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -230,11 +231,17 @@ func startApprovalPipe(stop <-chan struct{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot build the pipe's permissions: %w", err)
|
return fmt.Errorf("cannot build the pipe's permissions: %w", err)
|
||||||
}
|
}
|
||||||
name, err := windows.UTF16PtrFromString(approvalPipeName)
|
name, err := windows.UTF16PtrFromString(pipeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -249,11 +256,12 @@ func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
|
|||||||
s := &approvalPipeServer{
|
s := &approvalPipeServer{
|
||||||
sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd},
|
sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd},
|
||||||
name: name,
|
name: name,
|
||||||
|
label: pipeName,
|
||||||
stopEv: stopEv,
|
stopEv: stopEv,
|
||||||
}
|
}
|
||||||
// The loop and its no-gap rule are in approvalpipe.go, where the suite runs them.
|
// The loop and its no-gap rule are in approvalpipe.go, where the suite runs them.
|
||||||
return pipeAcceptLoop[*pipeConn](s, stop, pipeMaxClients, time.Second,
|
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
|
// 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
|
// (GetNamedPipeClientProcessId), its user and session from that process's
|
||||||
// token, and the person signed in to that session from the session's own token
|
// token, and the person signed in to that session from the session's own token
|
||||||
// (WTSQueryUserToken, which LocalSystem may call).
|
// (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) {
|
func pipeClientOf(h windows.Handle) (pipeClient, error) {
|
||||||
|
connected := time.Now()
|
||||||
var c pipeClient
|
var c pipeClient
|
||||||
if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil {
|
if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil {
|
||||||
return c, fmt.Errorf("cannot tell which process connected: %w", err)
|
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)
|
return c, fmt.Errorf("cannot open the connecting process (pid %d): %w", c.PID, err)
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(p)
|
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
|
var tok windows.Token
|
||||||
if err := windows.OpenProcessToken(p, windows.TOKEN_QUERY, &tok); err != nil {
|
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)
|
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
|
// (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.
|
// would ask for), and lets the server identify this process but not act as it.
|
||||||
func dialApprovalPipe() (*pipeConn, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -401,7 +426,7 @@ func dialApprovalPipe() (*pipeConn, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
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)
|
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 {
|
func pipeServerIsService(h windows.Handle) error {
|
||||||
var pid uint32
|
var pid uint32
|
||||||
if err := windows.GetNamedPipeServerProcessId(h, &pid); err != nil {
|
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()
|
svcPID, err := bastionServicePID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if svcPID == 0 || pid != svcPID {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}) {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
)
|
||||||
@@ -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
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -22,13 +23,13 @@ const backupUsage = `usage:
|
|||||||
make this bastion's backup key here and print what to register on your
|
make this bastion's backup key here and print what to register on your
|
||||||
host (Settings > Backup > keys: kind, label, public key). --replace makes
|
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
|
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
|
the key this bastion holds, its fingerprint, and any older keys it keeps
|
||||||
privatae-bastion backup reseal --sidecar <file> --to <host key> [--stamp <stamp>]
|
privatae-bastion backup reseal --sidecar <file> --to <host key> [--stamp <stamp>]
|
||||||
[--out <file>] [--identity <key file>]
|
[--out <file>] [--identity <key file>]
|
||||||
seal one backup's key to a host's own key (age1... or base64), so that
|
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
|
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
|
restore requests your host sent this machine
|
||||||
privatae-bastion backup approve [--host <host>] <id> | deny [--host <host>] <id>
|
privatae-bastion backup approve [--host <host>] <id> | deny [--host <host>] <id>
|
||||||
answer one
|
answer one
|
||||||
@@ -39,7 +40,7 @@ const backupUsage = `usage:
|
|||||||
let the host paired as <host> list and send the backups in another
|
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
|
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
|
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`
|
the folders shared with a host, and taking one back`
|
||||||
|
|
||||||
func cmdBackup(args []string) error {
|
func cmdBackup(args []string) error {
|
||||||
@@ -65,7 +66,11 @@ func cmdBackup(args []string) error {
|
|||||||
return cmdBackupKeyInit(replace, flags["--label"])
|
return cmdBackupKeyInit(replace, flags["--label"])
|
||||||
}
|
}
|
||||||
if len(args) >= 2 && args[1] == "show" {
|
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":
|
case "reseal":
|
||||||
flags, rest := backupFlags(args[1:], "--sidecar", "--to", "--stamp", "--out", "--identity")
|
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"])
|
return cmdBackupReseal(flags["--sidecar"], flags["--to"], flags["--stamp"], flags["--out"], flags["--identity"])
|
||||||
case "requests":
|
case "requests":
|
||||||
host, rest := takeHostFlag(args[1:])
|
host, rest := takeHostFlag(args[1:])
|
||||||
if len(rest) != 0 {
|
asJSON, err := jsonOnly(backupUsage, rest)
|
||||||
return errors.New(backupUsage)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return cmdBackupRequests(host)
|
return cmdBackupRequests(host, asJSON)
|
||||||
case "approve", "deny":
|
case "approve", "deny":
|
||||||
host, rest := takeHostFlag(args[1:])
|
host, rest := takeHostFlag(args[1:])
|
||||||
if len(rest) != 1 {
|
if len(rest) != 1 {
|
||||||
@@ -93,10 +99,11 @@ func cmdBackup(args []string) error {
|
|||||||
return cmdBackupShare(flags["--with"], flags["--from"], flags["--dir"])
|
return cmdBackupShare(flags["--with"], flags["--from"], flags["--dir"])
|
||||||
case "shares":
|
case "shares":
|
||||||
flags, rest := backupFlags(args[1:], "--with")
|
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 errors.New(backupUsage)
|
||||||
}
|
}
|
||||||
return cmdBackupShares(flags["--with"])
|
return cmdBackupShares(flags["--with"], asJSON)
|
||||||
case "unshare":
|
case "unshare":
|
||||||
flags, rest := backupFlags(args[1:], "--with")
|
flags, rest := backupFlags(args[1:], "--with")
|
||||||
if len(rest) != 1 || flags["--with"] == "" {
|
if len(rest) != 1 || flags["--with"] == "" {
|
||||||
@@ -163,24 +170,53 @@ func cmdBackupKeyInit(replace bool, label string) error {
|
|||||||
return nil
|
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()
|
active, all, err := backupKeysLoad()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return backupKeysRead{}, err
|
||||||
}
|
}
|
||||||
if len(all) == 0 {
|
v := backupKeysRead{Keys: all}
|
||||||
fmt.Println("no backup key on this machine — make one with `privatae-bastion backup key init`")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
for _, k := range all {
|
|
||||||
|
func printBackupKeys(w io.Writer, v backupKeysRead) {
|
||||||
|
if len(v.Keys) == 0 {
|
||||||
|
fmt.Fprintln(w, "no backup key on this machine — make one with `privatae-bastion backup key init`")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, k := range v.Keys {
|
||||||
state := "older key (still opens the backups made with it)"
|
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"
|
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)
|
k.Fingerprint, state, k.PubB64, k.Recipient, k.CreatedAt, k.Path)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupIDs(identityFile string) ([]age.Identity, error) {
|
func backupIDs(identityFile string) ([]age.Identity, error) {
|
||||||
@@ -339,6 +375,9 @@ func pairingByName(name string) (*Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if cfg == nil {
|
||||||
return nil, fmt.Errorf("no pairing matches '%s' — paired hosts:\n %s", name, strings.Join(hostNames(cfgs), "\n "))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdBackupShares(with string) error {
|
// backupSharesRead is the folders shared with one pairing.
|
||||||
reader, err := pairingByName(with)
|
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 {
|
if err != nil {
|
||||||
return err
|
return backupSharesRead{}, err
|
||||||
}
|
}
|
||||||
list := sharesFor(reader.HostName)
|
v := backupSharesRead{With: reader.HostName, Shares: sharesFor(reader.HostName)}
|
||||||
if len(list) == 0 {
|
if v.Shares == nil {
|
||||||
fmt.Println("no folders are shared with", reader.HostName)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
for _, s := range list {
|
|
||||||
fmt.Printf("shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
|
func printBackupShares(w io.Writer, v backupSharesRead) {
|
||||||
|
if len(v.Shares) == 0 {
|
||||||
|
fmt.Fprintln(w, "no folders are shared with", v.With)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, s := range v.Shares {
|
||||||
|
fmt.Fprintf(w, "shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdBackupUnshare(with, slug string) error {
|
func cmdBackupUnshare(with, slug string) error {
|
||||||
|
|||||||
+68
-17
@@ -45,6 +45,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -579,28 +580,46 @@ func describeReseal(r *resealRequest) string {
|
|||||||
|
|
||||||
// ── the person's side, on this machine ──────────────────────────────────────
|
// ── the person's side, on this machine ──────────────────────────────────────
|
||||||
|
|
||||||
func cmdBackupRequests(host string) error {
|
// backupRequestView is one restore request as a read shows it: the armored
|
||||||
cfg, err := stepupPairing(host)
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return backupRequestsRead{}, err
|
||||||
}
|
}
|
||||||
dir := cfg.dcnDir()
|
dir := cfg.dcnDir()
|
||||||
|
v := backupRequestsRead{Host: cfg.HostName, Requests: []backupRequestView{}}
|
||||||
// Request files set aside as unreadable (resealLoad) are named here, so
|
// Request files set aside as unreadable (resealLoad) are named here, so
|
||||||
// the person learns of them whichever process found them.
|
// the person learns of them whichever process found them.
|
||||||
if aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*"); len(aside) > 0 {
|
v.SetAside, _ = filepath.Glob(resealPath(dir) + resealAsideSuffix + "*")
|
||||||
fmt.Println("set aside as unreadable (kept, not read):")
|
|
||||||
for _, a := range aside {
|
|
||||||
fmt.Println(" " + a)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
m, err := resealRead(dir)
|
m, err := resealRead(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return v, err
|
||||||
}
|
|
||||||
if len(m) == 0 {
|
|
||||||
fmt.Println("no backup requests from", cfg.HostName)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
ids := make([]string, 0, len(m))
|
ids := make([]string, 0, len(m))
|
||||||
for id := range 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 })
|
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
fmt.Println(describeReseal(m[id]))
|
r := m[id]
|
||||||
fmt.Println()
|
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
|
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 {
|
func cmdBackupDecide(host, prefix string, approve bool) error {
|
||||||
cfg, err := stepupPairing(host)
|
cfg, err := stepupPairing(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+155
-43
@@ -8,22 +8,32 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdStepupDrop revokes one unused approval this box issued for a pairing.
|
// 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 {
|
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)
|
t, err := resolveStepupTarget(host, machine)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
dir := t.dir
|
dir := t.dir
|
||||||
prefix = strings.TrimSpace(prefix)
|
prefix = strings.TrimSpace(prefix)
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
return errors.New("which approval? (the id `approve` printed)")
|
return "", errors.New("which approval? (the id `approve` printed)")
|
||||||
}
|
}
|
||||||
stepupMu.Lock()
|
stepupMu.Lock()
|
||||||
defer stepupMu.Unlock()
|
defer stepupMu.Unlock()
|
||||||
@@ -36,42 +46,45 @@ func cmdStepupDrop(host string, machine bool, prefix string) error {
|
|||||||
}
|
}
|
||||||
switch len(hits) {
|
switch len(hits) {
|
||||||
case 0:
|
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:
|
case 1:
|
||||||
default:
|
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 := m[hits[0]]
|
||||||
a.Used = true // burned without ever having run: the same end state a use reaches
|
a.Used = true // burned without ever having run: the same end state a use reaches
|
||||||
m[hits[0]] = a
|
m[hits[0]] = a
|
||||||
if err := approvalsSave(dir, m); err != nil {
|
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 fmt.Sprintf("dropped approval %s (%s) — nothing can run on it now", hits[0][:8], a.Purpose), nil
|
||||||
return 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 {
|
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)
|
cfg, err := stepupPairing(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
id, err := strconv.ParseInt(strings.TrimSpace(store), 10, 64)
|
id, err := strconv.ParseInt(strings.TrimSpace(store), 10, 64)
|
||||||
if err != nil {
|
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()
|
dir := cfg.dcnDir()
|
||||||
present, err := dcnDropStore(dir, id, "dropped at this machine's console")
|
present, err := dcnDropStore(dir, id, "dropped at this machine's console")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if !present {
|
if !present {
|
||||||
fmt.Printf("store %d was not held here for %s\n", id, cfg.HostName)
|
return fmt.Sprintf("store %d was not held here for %s", id, cfg.HostName), nil
|
||||||
return 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 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
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdQuarantineClear lifts this box's hold on a pairing's write lanes.
|
// 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).
|
// cmdOpsList prints the ops and op grants a pairing holds here (ops.go).
|
||||||
func cmdOpsList(host string) error {
|
// opRow is one op a Host holds here, verified against its signature or not.
|
||||||
cfg, err := stepupPairing(host)
|
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 {
|
if err != nil {
|
||||||
return err
|
return opsRead{}, err
|
||||||
}
|
}
|
||||||
dir := cfg.dcnDir()
|
dir := cfg.dcnDir()
|
||||||
ok, why := opSandboxAvailable()
|
v := opsRead{Host: cfg.HostName, Ops: []opRow{}, Grants: []opGrantRow{}}
|
||||||
if ok {
|
v.Sandbox, v.SandboxUnavailable = opSandboxAvailable()
|
||||||
fmt.Println("sandbox: available")
|
|
||||||
} else {
|
|
||||||
fmt.Println("sandbox: NOT available —", why)
|
|
||||||
}
|
|
||||||
n := 0
|
|
||||||
if entries, err := os.ReadDir(opsDir(dir)); err == nil {
|
if entries, err := os.ReadDir(opsDir(dir)); err == nil {
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.IsDir() || e.Name() == opsRunsDirName || strings.HasSuffix(e.Name(), ".tmp") {
|
if !e.IsDir() || e.Name() == opsRunsDirName || strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n++
|
r := opRow{ID: e.Name()[:12]}
|
||||||
if m, err := opLoad(dir, e.Name()); err == nil {
|
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 {
|
} 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)
|
f := opsLoad(dir)
|
||||||
|
nonces := make([]string, 0, len(f.Grants))
|
||||||
for nonce, rec := range f.Grants {
|
for nonce, rec := range f.Grants {
|
||||||
if rec.Dropped != "" {
|
if rec.Dropped == "" {
|
||||||
continue
|
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 {
|
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
|
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 {
|
for _, r := range v.Grants {
|
||||||
fmt.Println("no ops held for", cfg.HostName)
|
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.
|
// cmdOpsDrop takes a held op grant back at the console.
|
||||||
func cmdOpsDrop(host, prefix string) error {
|
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)
|
cfg, err := stepupPairing(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
dir := cfg.dcnDir()
|
dir := cfg.dcnDir()
|
||||||
prefix = strings.TrimSpace(prefix)
|
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" {
|
if prefix == "--all" {
|
||||||
dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true })
|
dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true })
|
||||||
if err != nil {
|
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 fmt.Sprintf("%d op grant(s) dropped — the host cannot run under any of them again", len(dropped)), nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
var hits []string
|
var hits []string
|
||||||
for nonce, rec := range opsLoad(dir).Grants {
|
for nonce, rec := range opsLoad(dir).Grants {
|
||||||
@@ -158,14 +246,38 @@ func cmdOpsDrop(host, prefix string) error {
|
|||||||
}
|
}
|
||||||
switch len(hits) {
|
switch len(hits) {
|
||||||
case 0:
|
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:
|
case 1:
|
||||||
default:
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("op grant %s dropped — the host cannot run under it again\n", hits[0][:8])
|
fmt.Println(done)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostFlagWords is " --host <host>" when one was named.
|
||||||
|
func hostFlagWords(host string) string {
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " --host " + host
|
||||||
|
}
|
||||||
|
|||||||
+58
-22
@@ -1,7 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
// consolelane.go — the machine owner's own account opens the console of the
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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:
|
// 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
|
// 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
|
// 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
|
// a Host's requests in a limited session are throttled as the owner's are
|
||||||
// (machineCodeSpend), because guessing is the only attack left there.
|
// (machineCodeSpend), because guessing is the only attack left there.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,21 +48,27 @@ var consoleLaneServer atomic.Pointer[uiServer]
|
|||||||
// consoleLaneRefusal says why account uid may not open the console through
|
// consoleLaneRefusal says why account uid may not open the console through
|
||||||
// the lane, or "" when it may.
|
// the lane, or "" when it may.
|
||||||
func consoleLaneRefusal(uid uint32, who string) string {
|
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 {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " +
|
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:
|
case !present:
|
||||||
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " +
|
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: " +
|
admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p)
|
||||||
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
|
case !match:
|
||||||
case owner != uid:
|
without := "without sudo"
|
||||||
name := ownerName
|
if p.windows() {
|
||||||
if name == "" {
|
without = "without an Administrator terminal"
|
||||||
name = "uid " + strconv.FormatUint(uint64(owner), 10)
|
|
||||||
}
|
}
|
||||||
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -74,7 +83,11 @@ func accountForHint(who string) string {
|
|||||||
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
|
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
|
||||||
// the link that redeems a fresh limited ticket.
|
// the link that redeems a fresh limited ticket.
|
||||||
func consoleLaneEntry(uid uint32, who string) (string, error) {
|
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)
|
return "", errors.New(why)
|
||||||
}
|
}
|
||||||
u := consoleLaneServer.Load()
|
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))
|
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 " +
|
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: sudo -H privatae-bastion ui"
|
"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.
|
// adminOnly wraps a route a limited session may not use.
|
||||||
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
|
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
|
// consoleEntryForThisAccount is the link `ui` opens. An account with a
|
||||||
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary
|
// 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
|
// account beside the machine's bastion asks the lane, and is told why not
|
||||||
// not when it is not the owner's.
|
// when it is not the owner's.
|
||||||
func consoleEntryForThisAccount() (entry, addr string, err error) {
|
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()
|
p := askSockPath()
|
||||||
if why := contextSockDialable(p); why != "" {
|
if why := contextSockDialable(p); why != "" {
|
||||||
return "", "", errors.New(why)
|
return "", "", errors.New(why)
|
||||||
}
|
}
|
||||||
if _, statErr := os.Stat(p); statErr == nil {
|
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"})
|
r, err := askViaSock(agentAskRequest{Op: "console"})
|
||||||
if err != nil {
|
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())
|
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
|
||||||
}
|
}
|
||||||
if !r.OK {
|
if !r.OK {
|
||||||
@@ -142,9 +181,6 @@ func consoleEntryForThisAccount() (entry, addr string, err error) {
|
|||||||
}
|
}
|
||||||
return r.URL, u.Host, nil
|
return r.URL, u.Host, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return localUIEntryURL()
|
|
||||||
}
|
|
||||||
|
|
||||||
// uiLimitedWriter marks a response as drawn for a limited session, so a page
|
// uiLimitedWriter marks a response as drawn for a limited session, so a page
|
||||||
// shows the administrator's command in place of a form the session would be
|
// shows the administrator's command in place of a form the session would be
|
||||||
|
|||||||
+5
-3
@@ -680,7 +680,7 @@ func handleDcnGrantSync(ws *websocket.Conn, dir string, args []string) {
|
|||||||
if dcnHasMask(terms, "search") {
|
if dcnHasMask(terms, "search") {
|
||||||
faEnsureStore(dir, payload.StoreID, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient)
|
faEnsureStore(dir, payload.StoreID, terms, sweepFromCanonical(rec.TermsCanonical), rec.SealRecipient)
|
||||||
} else {
|
} else {
|
||||||
faStop(payload.StoreID)
|
faStop(dir, payload.StoreID)
|
||||||
}
|
}
|
||||||
dcnResult(ws, dir, "grant_sync", payload.StoreID, "", map[string]any{
|
dcnResult(ws, dir, "grant_sync", payload.StoreID, "", map[string]any{
|
||||||
"state": "ok", "root": terms.Root, "mask": terms.Mask,
|
"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)
|
key := strconv.FormatInt(storeID, 10)
|
||||||
if _, ok := f.Stores[key]; !ok {
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
delete(f.Stores, key)
|
delete(f.Stores, key)
|
||||||
if err := dcnSaveFile(dir, f); err != nil {
|
if err := dcnSaveFile(dir, f); err != nil {
|
||||||
return true, err
|
return true, err
|
||||||
}
|
}
|
||||||
faStop(storeID)
|
faStop(dir, storeID)
|
||||||
dcnAudit(dir, "grant_drop", storeID, "", why)
|
dcnAudit(dir, "grant_drop", storeID, "", why)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-12
@@ -33,6 +33,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -847,29 +848,80 @@ func deviceShownPath() (string, error) {
|
|||||||
return filepath.Join(d, "device_shown.json"), nil
|
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()
|
deviceMu.Lock()
|
||||||
m := deviceRequestsLoad()
|
m := deviceRequestsLoad()
|
||||||
deviceMu.Unlock()
|
deviceMu.Unlock()
|
||||||
shown := []string{}
|
v := deviceApprovalsRead{Asks: []deviceAsk{}}
|
||||||
for _, id := range sortedDeviceRequests(m) {
|
for _, id := range sortedDeviceRequests(m) {
|
||||||
r := m[id]
|
r := m[id]
|
||||||
if r.State != "pending" {
|
if r.State != "pending" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
shown = append(shown, id)
|
v.Asks = append(v.Asks, deviceAsk{ID: id, Server: r.Server, At: r.At, Words: r.Words})
|
||||||
fmt.Printf("[%s] on %s, asked %s\n%s\n\n", id[:8], r.Server, r.At, 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 {
|
if p, err := deviceShownPath(); err == nil {
|
||||||
raw, _ := json.Marshal(shown)
|
raw, _ := json.Marshal(shown)
|
||||||
_ = atomicWrite(p, raw)
|
_ = atomicWrite(p, raw)
|
||||||
}
|
}
|
||||||
if len(shown) == 0 {
|
}
|
||||||
fmt.Println("nothing is waiting for your approval")
|
|
||||||
return nil
|
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 cmdDeviceApprove(prefix string) error { return cmdDeviceApproveCode(prefix, "") }
|
||||||
@@ -947,19 +999,25 @@ func cmdDeviceApproveCode(prefix, code string) error {
|
|||||||
return nil
|
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 {
|
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()
|
deviceMu.Lock()
|
||||||
m := deviceRequestsLoad()
|
m := deviceRequestsLoad()
|
||||||
deviceMu.Unlock()
|
deviceMu.Unlock()
|
||||||
id, err := findDeviceRequest(m, prefix)
|
id, err := findDeviceRequest(m, prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := deviceDeny(id); err != nil {
|
if err := deviceDeny(id); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
fmt.Printf("denied %s\n", id)
|
return "denied " + id, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findDeviceRequest(m map[string]deviceRequest, prefix string) (string, error) {
|
func findDeviceRequest(m map[string]deviceRequest, prefix string) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// doctor.go — what is wrong with this install, and the fix for each one
|
||||||
|
// (plan Addendum 6, slice 3).
|
||||||
|
//
|
||||||
|
// privatae-bastion doctor [--json]
|
||||||
|
//
|
||||||
|
// Each check is a fact the bastion already holds, said as a problem and the
|
||||||
|
// command that fixes it. The machine's checks are built where its state is:
|
||||||
|
// in the daemon for the owner's account, through the lane (reads.go), or
|
||||||
|
// directly for the administrator and a per-account install. The one thing
|
||||||
|
// only the asking side can find out is whether the lane answers it at all,
|
||||||
|
// and that becomes the check when it does not. Exit status 1 when any check
|
||||||
|
// finds a problem, so a script can branch on it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const doctorUsage = "usage: privatae-bastion doctor [--json]"
|
||||||
|
|
||||||
|
type doctorCheck struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Detail string `json:"detail,omitempty"` // what was found, when all is well
|
||||||
|
Problem string `json:"problem,omitempty"`
|
||||||
|
Fix string `json:"fix,omitempty"` // the command, or what to do
|
||||||
|
}
|
||||||
|
|
||||||
|
type doctorRead struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Checks []doctorCheck `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d doctorRead) problems() int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range d.Checks {
|
||||||
|
if !c.OK {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemInstalled: the bastion is installed for the whole machine.
|
||||||
|
var systemInstalled = func() bool {
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
return linuxSystemUnitPresent()
|
||||||
|
}
|
||||||
|
return systemService.installed
|
||||||
|
}
|
||||||
|
|
||||||
|
// doctorSandbox is opSandboxAvailable, replaceable in a test: its probe
|
||||||
|
// re-executes this binary.
|
||||||
|
var doctorSandbox = opSandboxAvailable
|
||||||
|
|
||||||
|
// asAdmin is a bastion command said the way it has to be typed here: the
|
||||||
|
// machine's bastion keeps its state as the administrator when it is
|
||||||
|
// installed for the whole machine.
|
||||||
|
func asAdmin(cmd string) string {
|
||||||
|
switch {
|
||||||
|
case !systemInstalled():
|
||||||
|
return "privatae-bastion " + cmd
|
||||||
|
case runtime.GOOS == "windows":
|
||||||
|
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
|
||||||
|
}
|
||||||
|
return "sudo -H privatae-bastion " + cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// installerCommand is how this platform installs the bastion, and so how a
|
||||||
|
// machine no Host updates moves to a newer version.
|
||||||
|
func installerCommand() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "irm https://privatae.ai/bastion/install.ps1 | iex"
|
||||||
|
}
|
||||||
|
return "curl -fsSL https://privatae.ai/bastion/install.sh | sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
// doctorLatest is the newest published version, or "" when it cannot be
|
||||||
|
// known: the daemon's last answer, else the last time doctor asked
|
||||||
|
// privatae.ai, if that was within updateCheckEvery, else one fetch that
|
||||||
|
// waits at most doctorLatestWait. A request to privatae.ai carries this
|
||||||
|
// machine's address to the platform, so doctor keeps update.go's rate
|
||||||
|
// (four a day): a run in its own process, or an agent polling it, asks again
|
||||||
|
// only once that time has passed, whether the last ask was answered or not.
|
||||||
|
// Running with no route to privatae.ai is supported (update.go), so "" is
|
||||||
|
// not a problem.
|
||||||
|
var (
|
||||||
|
doctorLatestWait = 10 * time.Second
|
||||||
|
doctorLatest = func() string {
|
||||||
|
latestMu.Lock()
|
||||||
|
v := latestPublished
|
||||||
|
latestMu.Unlock()
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if r, ok := doctorAskedLoad(); ok && doctorNow().Sub(r.AskedAt) < updateCheckEvery {
|
||||||
|
return r.Version
|
||||||
|
}
|
||||||
|
got := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
v, err := doctorFetch()
|
||||||
|
if err != nil {
|
||||||
|
v = ""
|
||||||
|
}
|
||||||
|
got <- strings.TrimSpace(v)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case v = <-got:
|
||||||
|
case <-time.After(doctorLatestWait):
|
||||||
|
}
|
||||||
|
if v != "" {
|
||||||
|
recordLatestPublished(v)
|
||||||
|
} else if r, ok := doctorAskedLoad(); ok {
|
||||||
|
v = r.Version // no answer this time: the last one stands, as in update.go
|
||||||
|
}
|
||||||
|
doctorAskedSave(doctorAsked{Version: v, AskedAt: doctorNow()})
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
doctorNow = time.Now
|
||||||
|
doctorFetch = func() (string, error) {
|
||||||
|
m, err := fetchManifest()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return m.Version, nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// doctorAsked is when doctor last asked privatae.ai, and what it knew after.
|
||||||
|
type doctorAsked struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
AskedAt time.Time `json:"asked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func doctorAskedPath() (string, error) {
|
||||||
|
d, err := configDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(d, "doctor_latest.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doctorAskedLoad() (doctorAsked, bool) {
|
||||||
|
var r doctorAsked
|
||||||
|
p, err := doctorAskedPath()
|
||||||
|
if err != nil {
|
||||||
|
return r, false
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil || json.Unmarshal(raw, &r) != nil || r.AskedAt.IsZero() {
|
||||||
|
return doctorAsked{}, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func doctorAskedSave(r doctorAsked) {
|
||||||
|
p, err := doctorAskedPath()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw, err := json.Marshal(r); err == nil {
|
||||||
|
_ = atomicWrite(p, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logHint is where the bastion's own log is read on this platform.
|
||||||
|
func logHint() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
if systemInstalled() {
|
||||||
|
return "journalctl -u privatae-bastion"
|
||||||
|
}
|
||||||
|
return "journalctl --user -u privatae-bastion"
|
||||||
|
case "windows":
|
||||||
|
return "the bastion's window, or Event Viewer when it runs as a service"
|
||||||
|
}
|
||||||
|
return "Console.app, process privatae-bastion"
|
||||||
|
}
|
||||||
|
|
||||||
|
func readDoctor(readOpts) (doctorRead, error) {
|
||||||
|
d := doctorRead{Version: clientVersion, Checks: []doctorCheck{}}
|
||||||
|
add := func(c doctorCheck) { d.Checks = append(d.Checks, c) }
|
||||||
|
|
||||||
|
s, err := readStatus(readOpts{})
|
||||||
|
if err != nil {
|
||||||
|
add(doctorCheck{Name: "pairings", Problem: sentenceCase(err.Error()) + ".",
|
||||||
|
Fix: "The bastion's log says why: " + logHint()})
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
paired := len(s.Pairings) > 0
|
||||||
|
start := runCommandHint()
|
||||||
|
if runtime.GOOS == "linux" && systemInstalled() {
|
||||||
|
start = "sudo " + start
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case s.Running == "no":
|
||||||
|
add(doctorCheck{Name: "running", Problem: "The bastion is not running, so nothing on this machine is watched.",
|
||||||
|
Fix: start})
|
||||||
|
case !paired:
|
||||||
|
add(doctorCheck{Name: "running", OK: true, Detail: "Running on its own; no Host is connected."})
|
||||||
|
default:
|
||||||
|
add(doctorCheck{Name: "running", OK: true, Detail: fmt.Sprintf("Running, paired with %d Host(s).", len(s.Pairings))})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch latest := doctorLatest(); {
|
||||||
|
case latest == "":
|
||||||
|
add(doctorCheck{Name: "version", OK: true, Detail: "This is " + clientVersion +
|
||||||
|
"; the latest published version could not be read from privatae.ai."})
|
||||||
|
case versionNewer(latest, clientVersion):
|
||||||
|
fix := "Run the installer again: " + installerCommand()
|
||||||
|
if paired {
|
||||||
|
fix = "Your Host can update it (Network app, Update), or run the installer again: " + installerCommand()
|
||||||
|
}
|
||||||
|
add(doctorCheck{Name: "version", Problem: "Version " + latest + " is published; this machine runs " +
|
||||||
|
clientVersion + ".", Fix: fix})
|
||||||
|
default:
|
||||||
|
add(doctorCheck{Name: "version", OK: true, Detail: clientVersion + " is the latest published version."})
|
||||||
|
}
|
||||||
|
|
||||||
|
if systemInstalled() {
|
||||||
|
_, name, present, err := ownerAccountLoad()
|
||||||
|
fix := "sudo -H privatae-bastion approvals-owner --account <your account>"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
fix = "privatae-bastion approvals-owner --sid <your account's SID> (from an Administrator terminal)"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
add(doctorCheck{Name: "owner", Problem: "This machine's owner account cannot be read (" + err.Error() + ").",
|
||||||
|
Fix: strings.Replace(fix, "approvals-owner ", "approvals-owner --replace ", 1)})
|
||||||
|
case !present:
|
||||||
|
add(doctorCheck{Name: "owner", Problem: "No account is recorded as this machine's owner, so the console " +
|
||||||
|
"opens only for the administrator, and agents in your account can neither read the machine nor ask it " +
|
||||||
|
"to stop an agent.", Fix: fix})
|
||||||
|
default:
|
||||||
|
add(doctorCheck{Name: "owner", OK: true, Detail: "The machine's owner account is " + name + "."})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
factor := existingMachineFactorDir()
|
||||||
|
if stepupEnrolled(factor) {
|
||||||
|
add(doctorCheck{Name: "second factor", OK: true, Detail: "The machine's owner has a second factor here."})
|
||||||
|
} else {
|
||||||
|
add(doctorCheck{Name: "second factor", Problem: "This machine has no second factor of its own, so a stop, " +
|
||||||
|
"and every ask an agent files, cannot be approved.", Fix: asAdmin("stepup-enroll --machine")})
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var held []string
|
||||||
|
for account, e := range askQuarantineLoad(factor) {
|
||||||
|
if quarantinedAt(e, now) {
|
||||||
|
held = append(held, account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(held)
|
||||||
|
for _, account := range held {
|
||||||
|
add(doctorCheck{Name: "held asker", Problem: sentenceCase(askHoldRefusal(account, askHeldUntil(factor, account, now))) + ".",
|
||||||
|
Fix: asAdmin("quarantine clear --asker " + account)})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range s.Pairings {
|
||||||
|
switch {
|
||||||
|
case p.Problem != "":
|
||||||
|
add(doctorCheck{Name: "host " + p.Host, Problem: "The pairing with " + p.Host + " is not usable: " +
|
||||||
|
p.Problem + ".", Fix: "The bastion's log says more: " + logHint()})
|
||||||
|
case !p.Answering && s.Running != "no":
|
||||||
|
add(doctorCheck{Name: "host " + p.Host, Problem: "The bastion is not serving " + p.Host +
|
||||||
|
" (nothing answers on " + p.Local + ").", Fix: "The bastion's log says why: " + logHint()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if paired {
|
||||||
|
localDenyMu.Lock()
|
||||||
|
_, derr := localDenyLoad()
|
||||||
|
localDenyMu.Unlock()
|
||||||
|
if derr != nil {
|
||||||
|
add(doctorCheck{Name: "switches", Problem: localDenyUnreadableText(derr), Fix: asAdmin("switch --reset")})
|
||||||
|
}
|
||||||
|
if ok, why := doctorSandbox(); !ok {
|
||||||
|
c := doctorCheck{Name: "sandbox", Problem: "Ops a Host sends cannot run sandboxed here: " + why + "."}
|
||||||
|
if !processIsRoot() {
|
||||||
|
c.Fix = "Install the bastion for the whole machine: run the installer again without --just-me: " +
|
||||||
|
installerCommand()
|
||||||
|
}
|
||||||
|
add(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdDoctor(args []string) error {
|
||||||
|
asJSON, err := jsonOnly(doctorUsage, args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d, err := readMachine("doctor", readOpts{}, readDoctor)
|
||||||
|
var le *laneError
|
||||||
|
if errors.As(err, &le) {
|
||||||
|
// The one check the machine cannot make for this account.
|
||||||
|
d = doctorRead{Version: clientVersion, Checks: []doctorCheck{laneCheck(le)}}
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if asJSON {
|
||||||
|
if err := printJSON(os.Stdout, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printDoctor(os.Stdout, d)
|
||||||
|
}
|
||||||
|
if d.problems() > 0 {
|
||||||
|
return quietExit{code: 1}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// laneCheck is the lane not answering this account, as a check.
|
||||||
|
func laneCheck(le *laneError) doctorCheck {
|
||||||
|
if le.down {
|
||||||
|
fix := "Start it: sudo " + runCommandHintFor(runtime.GOOS) + " (see whether it runs: systemctl status privatae-bastion)"
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
fix = "Start it: " + runCommandHint()
|
||||||
|
}
|
||||||
|
return doctorCheck{Name: "running", Problem: "The machine's bastion does not answer this account: " + le.why + ".",
|
||||||
|
Fix: fix}
|
||||||
|
}
|
||||||
|
return doctorCheck{Name: "reads", Problem: sentenceCase(le.why) + ".",
|
||||||
|
Fix: "As the administrator: sudo -H privatae-bastion doctor"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printDoctor(w io.Writer, d doctorRead) {
|
||||||
|
switch n := d.problems(); n {
|
||||||
|
case 0:
|
||||||
|
fmt.Fprintf(w, "privatae-bastion %s: nothing wrong found.\n", d.Version)
|
||||||
|
case 1:
|
||||||
|
fmt.Fprintf(w, "privatae-bastion %s: 1 problem.\n", d.Version)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(w, "privatae-bastion %s: %d problems.\n", d.Version, n)
|
||||||
|
}
|
||||||
|
for _, c := range d.Checks {
|
||||||
|
if c.OK {
|
||||||
|
fmt.Fprintln(w, " ok "+c.Detail)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, " PROBLEM "+c.Problem)
|
||||||
|
if c.Fix != "" {
|
||||||
|
fmt.Fprintln(w, " Fix: "+c.Fix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,11 @@ func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *t
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
hold := laneHoldFor(self)
|
||||||
for pctx.Err() == nil {
|
for pctx.Err() == nil {
|
||||||
|
if !hold.waitClear(pctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||||
cancel()
|
cancel()
|
||||||
@@ -137,12 +141,17 @@ func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *t
|
|||||||
sleepCtx(pctx, time.Second)
|
sleepCtx(pctx, time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
held := watchHeldClose(ws)
|
||||||
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
||||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||||
took := handleEgressConn(&wsConn{ws: ws}, dialOut, self) // blocks until a job, serves it, then returns
|
took := handleEgressConn(&wsConn{ws: ws}, dialOut, self) // blocks until a job, serves it, then returns
|
||||||
unpark()
|
unpark()
|
||||||
stopKA()
|
stopKA()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
|
if held() {
|
||||||
|
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !took {
|
if !took {
|
||||||
sleepCtx(pctx, egressRefusedWait)
|
sleepCtx(pctx, egressRefusedWait)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func buildEnforcingReport(dir string, allowed []string) map[string]any {
|
|||||||
b, _ := strconv.ParseInt(ids[j], 10, 64)
|
b, _ := strconv.ParseInt(ids[j], 10, 64)
|
||||||
return a < b
|
return a < b
|
||||||
})
|
})
|
||||||
status := faManagedStatus()
|
status := faManagedStatus(dir)
|
||||||
for _, k := range ids {
|
for _, k := range ids {
|
||||||
id, err := strconv.ParseInt(k, 10, 64)
|
id, err := strconv.ParseInt(k, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+10
-10
@@ -223,8 +223,8 @@ func freeagentDo(c freeagentConfig, method, path string, body any, d time.Durati
|
|||||||
// faConfigFor is the client config for a store: the MANAGED instance the
|
// 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
|
// bastion runs for it (freeagent_managed.go) when there is one, else the
|
||||||
// operator's PRIVATAE_BASTION_AGENT_* env for a hand-run agent.
|
// operator's PRIVATAE_BASTION_AGENT_* env for a hand-run agent.
|
||||||
func faConfigFor(storeID int64) (freeagentConfig, bool) {
|
func faConfigFor(dcnDir string, storeID int64) (freeagentConfig, bool) {
|
||||||
if c, ok := faManagedConfig(storeID); ok {
|
if c, ok := faManagedConfig(dcnDir, storeID); ok {
|
||||||
return c, true
|
return c, true
|
||||||
}
|
}
|
||||||
return freeagentEnv()
|
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
|
// 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.
|
// host should relay: a missing Python is the member's one manual step.
|
||||||
func faAbsentReason(storeID int64) string {
|
func faAbsentReason(dcnDir string, storeID int64) string {
|
||||||
inst, ok := faSnapshot(storeID)
|
inst, ok := faSnapshot(dcnDir, storeID)
|
||||||
if ok {
|
if ok {
|
||||||
switch inst.State {
|
switch inst.State {
|
||||||
case "missing-python":
|
case "missing-python":
|
||||||
@@ -358,9 +358,9 @@ func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
|
|||||||
if terms == nil {
|
if terms == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c, on := faConfigFor(storeID)
|
c, on := faConfigFor(dir, storeID)
|
||||||
if !on {
|
if !on {
|
||||||
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(storeID))
|
dcnRefuse(ws, dir, "search", storeID, "", faAbsentReason(dir, storeID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := strings.TrimSpace(a.Query)
|
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)")
|
"catalog needs the 'index' mask on this grant (re-approve the store with index)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c, on := faConfigFor(storeID)
|
c, on := faConfigFor(dir, storeID)
|
||||||
if !on {
|
if !on {
|
||||||
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(storeID))
|
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, faAbsentReason(dir, storeID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := "/catalog"
|
path := "/catalog"
|
||||||
@@ -471,9 +471,9 @@ func handleDcnReadRangeDoc(ws *websocket.Conn, dir string, storeID int64, a *dcn
|
|||||||
if terms == nil {
|
if terms == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c, on := faConfigFor(storeID)
|
c, on := faConfigFor(dir, storeID)
|
||||||
if !on {
|
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
|
return
|
||||||
}
|
}
|
||||||
start := a.StartByte
|
start := a.StartByte
|
||||||
|
|||||||
@@ -123,20 +123,29 @@ type faInstance struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// faSnapshot copies the fields a reader needs, under the lock.
|
// 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()
|
faMgr.Lock()
|
||||||
defer faMgr.Unlock()
|
defer faMgr.Unlock()
|
||||||
inst, ok := faMgr.inst[storeID]
|
inst, ok := faMgr.inst[faKey{dcnDir, storeID}]
|
||||||
if !ok {
|
if !ok {
|
||||||
return faInstance{}, false
|
return faInstance{}, false
|
||||||
}
|
}
|
||||||
return *inst, true
|
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 {
|
var faMgr = struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
inst map[int64]*faInstance
|
inst map[faKey]*faInstance
|
||||||
}{inst: map[int64]*faInstance{}}
|
}{inst: map[faKey]*faInstance{}}
|
||||||
|
|
||||||
// ── locations ───────────────────────────────────────────────────────────────
|
// ── locations ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -664,8 +673,8 @@ func faRecipientOf(inst *faInstance) string {
|
|||||||
|
|
||||||
// faManagedConfig is the client config for a store's managed instance, or
|
// faManagedConfig is the client config for a store's managed instance, or
|
||||||
// false when none is running (port file absent).
|
// false when none is running (port file absent).
|
||||||
func faManagedConfig(storeID int64) (freeagentConfig, bool) {
|
func faManagedConfig(dcnDir string, storeID int64) (freeagentConfig, bool) {
|
||||||
inst, ok := faSnapshot(storeID)
|
inst, ok := faSnapshot(dcnDir, storeID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return freeagentConfig{}, false
|
return freeagentConfig{}, false
|
||||||
}
|
}
|
||||||
@@ -697,10 +706,11 @@ func faEnsureStore(dcnDir string, storeID int64, terms *dcnTerms, sweep, recipie
|
|||||||
}
|
}
|
||||||
cn := "store-" + strconv.FormatInt(storeID, 10)
|
cn := "store-" + strconv.FormatInt(storeID, 10)
|
||||||
faMgr.Lock()
|
faMgr.Lock()
|
||||||
inst, ok := faMgr.inst[storeID]
|
key := faKey{dcnDir, storeID}
|
||||||
|
inst, ok := faMgr.inst[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
inst = &faInstance{StoreID: storeID, Dir: dir, PrivDir: priv, PubDir: pub, CN: cn, State: "installing"}
|
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 {
|
} else if inst.State == "installing" || inst.ensuring {
|
||||||
faMgr.Unlock()
|
faMgr.Unlock()
|
||||||
return // one ensure at a time per store — see faInstance.ensuring
|
return // one ensure at a time per store — see faInstance.ensuring
|
||||||
@@ -993,22 +1003,27 @@ func sweepFromCanonical(termsB64 string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// faManagedProbe reports (anyManaged, allServing) and refreshes each
|
// faManagedProbe reports (anyManaged, allServing) for ONE pairing's instances
|
||||||
// instance's Serving flag by probing its /health over mTLS.
|
// and refreshes their Serving flags by probing each /health over mTLS. Another
|
||||||
func faManagedProbe() (bool, bool) {
|
// 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()
|
faMgr.Lock()
|
||||||
ids := make([]int64, 0, len(faMgr.inst))
|
keys := make([]faKey, 0, len(faMgr.inst))
|
||||||
for id := range faMgr.inst {
|
for k := range faMgr.inst {
|
||||||
ids = append(ids, id)
|
if dcnDir != "" && k.Dir == dcnDir {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
faMgr.Unlock()
|
faMgr.Unlock()
|
||||||
if len(ids) == 0 {
|
if len(keys) == 0 {
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
all := true
|
all := true
|
||||||
for _, id := range ids {
|
for _, k := range keys {
|
||||||
serving := false
|
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)
|
status, body, err := freeagentCallTimeout(c, http.MethodGet, "/health", nil, freeagentProbeTimeout)
|
||||||
if err == nil && status == http.StatusOK {
|
if err == nil && status == http.StatusOK {
|
||||||
var h struct {
|
var h struct {
|
||||||
@@ -1019,7 +1034,7 @@ func faManagedProbe() (bool, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
faMgr.Lock()
|
faMgr.Lock()
|
||||||
if inst, ok := faMgr.inst[id]; ok {
|
if inst, ok := faMgr.inst[k]; ok {
|
||||||
inst.Serving = serving
|
inst.Serving = serving
|
||||||
}
|
}
|
||||||
faMgr.Unlock()
|
faMgr.Unlock()
|
||||||
@@ -1030,13 +1045,20 @@ func faManagedProbe() (bool, bool) {
|
|||||||
return true, all
|
return true, all
|
||||||
}
|
}
|
||||||
|
|
||||||
// faManagedStatus is the heartbeat's per-store detail.
|
// faManagedStatus is one pairing's per-store detail for its heartbeat: only
|
||||||
func faManagedStatus() map[string]any {
|
// its own stores, never another Host's.
|
||||||
|
func faManagedStatus(dcnDir string) map[string]any {
|
||||||
faMgr.Lock()
|
faMgr.Lock()
|
||||||
defer faMgr.Unlock()
|
defer faMgr.Unlock()
|
||||||
out := map[string]any{}
|
out := map[string]any{}
|
||||||
for id, inst := range faMgr.inst {
|
if dcnDir == "" {
|
||||||
out[strconv.FormatInt(id, 10)] = map[string]any{
|
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,
|
"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
|
// faStop tears down a store's managed instance (grant revoked / re-synced
|
||||||
// without search).
|
// without search).
|
||||||
func faStop(storeID int64) {
|
func faStop(dcnDir string, storeID int64) {
|
||||||
faMgr.Lock()
|
faMgr.Lock()
|
||||||
defer faMgr.Unlock()
|
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 {
|
if inst.cancel != nil {
|
||||||
inst.cancel()
|
inst.cancel()
|
||||||
}
|
}
|
||||||
delete(faMgr.inst, storeID)
|
delete(faMgr.inst, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,29 +182,64 @@ func TestFaManifestText(t *testing.T) {
|
|||||||
func TestFaManagedConfigFromPortFile(t *testing.T) {
|
func TestFaManagedConfigFromPortFile(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
faMgr.Lock()
|
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()
|
faMgr.Unlock()
|
||||||
t.Cleanup(func() { faStop(4242) })
|
t.Cleanup(func() { faStop("dcn-test", 4242) })
|
||||||
if _, ok := faManagedConfig(4242); ok {
|
if _, ok := faManagedConfig("dcn-test", 4242); ok {
|
||||||
t.Fatal("no port file → not configured")
|
t.Fatal("no port file → not configured")
|
||||||
}
|
}
|
||||||
os.WriteFile(filepath.Join(dir, "port"), []byte("18473\n"), 0o600)
|
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" {
|
if !ok || c.Addr != "127.0.0.1:18473" || c.CA != "c" {
|
||||||
t.Fatalf("port file → config, got %+v %v", c, ok)
|
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.Fatal("faConfigFor must prefer the managed instance")
|
||||||
}
|
}
|
||||||
t.Setenv("PRIVATAE_BASTION_AGENT_ADDR", "")
|
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")
|
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")
|
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
|
// End to end with the real artifact: FREEAGENT_TEST_PYZ=/path/to/freeagent-x.pyz
|
||||||
func TestFaManagedEndToEnd(t *testing.T) {
|
func TestFaManagedEndToEnd(t *testing.T) {
|
||||||
pyzPath := os.Getenv("FREEAGENT_TEST_PYZ")
|
pyzPath := os.Getenv("FREEAGENT_TEST_PYZ")
|
||||||
@@ -264,7 +299,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
faReconcile(dcnDir) // what the daemon does at start
|
faReconcile(dcnDir) // what the daemon does at start
|
||||||
t.Cleanup(func() { faStop(1) })
|
t.Cleanup(func() { faStop(dcnDir, 1) })
|
||||||
stateDir, err := faStoreDir(dcnDir, 1)
|
stateDir, err := faStoreDir(dcnDir, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -274,7 +309,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
var c freeagentConfig
|
var c freeagentConfig
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
faMgr.Lock()
|
faMgr.Lock()
|
||||||
inst := faMgr.inst[1]
|
inst := faMgr.inst[faKey{dcnDir, 1}]
|
||||||
state, detail := "", ""
|
state, detail := "", ""
|
||||||
if inst != nil {
|
if inst != nil {
|
||||||
state, detail = inst.State, inst.Detail
|
state, detail = inst.State, inst.Detail
|
||||||
@@ -283,7 +318,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
if state == "failed" || state == "missing-python" {
|
if state == "failed" || state == "missing-python" {
|
||||||
t.Fatalf("managed install %s: %s", state, detail)
|
t.Fatalf("managed install %s: %s", state, detail)
|
||||||
}
|
}
|
||||||
if cfg, ok := faManagedConfig(1); ok {
|
if cfg, ok := faManagedConfig(dcnDir, 1); ok {
|
||||||
c = cfg
|
c = cfg
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -395,11 +430,11 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// heartbeat sees it
|
// heartbeat sees it
|
||||||
adv, allOK := faManagedProbe()
|
adv, allOK := faManagedProbe(dcnDir)
|
||||||
if !adv || !allOK {
|
if !adv || !allOK {
|
||||||
t.Fatalf("managed probe: %v %v", 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" {
|
if st["state"] != "running" || st["serving"] != true || st["version"] != "9.9.9" {
|
||||||
t.Fatalf("status: %v", st)
|
t.Fatalf("status: %v", st)
|
||||||
}
|
}
|
||||||
@@ -417,7 +452,7 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
|
|
||||||
// a re-approved grant with narrower terms restarts
|
// a re-approved grant with narrower terms restarts
|
||||||
// the instance under the new manifest (new excludes, new serve port)
|
// the instance under the new manifest (new excludes, new serve port)
|
||||||
before, _ := faSnapshot(1)
|
before, _ := faSnapshot(dcnDir, 1)
|
||||||
terms2 := map[string]any{}
|
terms2 := map[string]any{}
|
||||||
for k, v := range terms {
|
for k, v := range terms {
|
||||||
terms2[k] = v
|
terms2[k] = v
|
||||||
@@ -432,9 +467,9 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
deadline = time.Now().Add(60 * time.Second)
|
deadline = time.Now().Add(60 * time.Second)
|
||||||
var after faInstance
|
var after faInstance
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
after, _ = faSnapshot(1)
|
after, _ = faSnapshot(dcnDir, 1)
|
||||||
if after.State == "running" && after.Text != before.Text {
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,10 +483,10 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
t.Fatal("re-rendered manifest must carry the new excludes and be re-signed")
|
t.Fatal("re-rendered manifest must carry the new excludes and be re-signed")
|
||||||
}
|
}
|
||||||
// unchanged terms on the next reconcile: no restart (same port)
|
// unchanged terms on the next reconcile: no restart (same port)
|
||||||
c3, _ := faManagedConfig(1)
|
c3, _ := faManagedConfig(dcnDir, 1)
|
||||||
faReconcile(dcnDir)
|
faReconcile(dcnDir)
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
c4, _ := faManagedConfig(1)
|
c4, _ := faManagedConfig(dcnDir, 1)
|
||||||
if c3.Addr != c4.Addr {
|
if c3.Addr != c4.Addr {
|
||||||
t.Fatal("identical terms must not restart the instance")
|
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
|
// 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
|
// re-pair): the manifest must be re-signed under the new cert, or the
|
||||||
// gate refuses it and serve answers 503 forever.
|
// gate refuses it and serve answers 503 forever.
|
||||||
faStop(1)
|
faStop(dcnDir, 1)
|
||||||
_, privDir, _, _ := faStoreDirs(dcnDir, 1)
|
_, privDir, _, _ := faStoreDirs(dcnDir, 1)
|
||||||
if err := os.RemoveAll(filepath.Join(privDir, "host.key")); err != nil {
|
if err := os.RemoveAll(filepath.Join(privDir, "host.key")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -470,15 +505,15 @@ func TestFaManagedEndToEnd(t *testing.T) {
|
|||||||
deadline = time.Now().Add(60 * time.Second)
|
deadline = time.Now().Add(60 * time.Second)
|
||||||
var c9 freeagentConfig
|
var c9 freeagentConfig
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
if cfg, ok := faManagedConfig(1); ok && cfg.Addr != "" {
|
if cfg, ok := faManagedConfig(dcnDir, 1); ok && cfg.Addr != "" {
|
||||||
if st, _ := faSnapshot(1); st.State == "running" && st.Cert != "" {
|
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`) {
|
if _, body, err := freeagentCall(cfg, http.MethodGet, "/health", nil); err == nil && strings.Contains(string(body), `"serving": true`) {
|
||||||
c9 = cfg
|
c9 = cfg
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if st, _ := faSnapshot(1); st.State == "failed" {
|
if st, _ := faSnapshot(dcnDir, 1); st.State == "failed" {
|
||||||
t.Fatalf("re-identity: %s", st.Detail)
|
t.Fatalf("re-identity: %s", st.Detail)
|
||||||
}
|
}
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func cmdGitCredential(host string, action string) error {
|
|||||||
case len(cfgs) == 1:
|
case len(cfgs) == 1:
|
||||||
cfg = cfgs[0]
|
cfg = cfgs[0]
|
||||||
case len(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:
|
default:
|
||||||
return fmt.Errorf("%d hosts are paired — configure the helper with --host: "+
|
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))
|
"credential.https://git.privatae.ai.helper \"!privatae-bastion git-credential --host <host>\"", len(cfgs))
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string,
|
|||||||
}
|
}
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
|
hold := laneHoldFor(self)
|
||||||
for pctx.Err() == nil {
|
for pctx.Err() == nil {
|
||||||
|
if !hold.waitClear(pctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||||
cancel()
|
cancel()
|
||||||
@@ -127,12 +131,16 @@ func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string,
|
|||||||
sleepCtx(pctx, time.Second)
|
sleepCtx(pctx, time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
held := watchHeldClose(ws)
|
||||||
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
||||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||||
handleHarnessConn(ws, self, dcnDir) // blocks until a job, serves it, then returns
|
handleHarnessConn(ws, self, dcnDir) // blocks until a job, serves it, then returns
|
||||||
unpark()
|
unpark()
|
||||||
stopKA()
|
stopKA()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
|
if held() {
|
||||||
|
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -915,7 +915,7 @@ func TestDeviceApproveAllSignsOnlyWhatWasShown(t *testing.T) {
|
|||||||
if err := cmdDeviceApprove("all"); err == nil {
|
if err := cmdDeviceApprove("all"); err == nil {
|
||||||
t.Fatal("nothing shown yet: all must refuse")
|
t.Fatal("nothing shown yet: all must refuse")
|
||||||
}
|
}
|
||||||
if err := cmdDeviceApprovals(); err != nil {
|
if err := cmdDeviceApprovals(nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
b := file("harness:run") // filed AFTER the person looked
|
b := file("harness:run") // filed AFTER the person looked
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ exit 0
|
|||||||
"id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`,
|
"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`,
|
"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 "$@"`,
|
"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 {
|
for name, body := range stand {
|
||||||
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n")
|
s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n")
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ed25519SPKIPrefix is the DER that wraps a raw Ed25519 public key in the
|
||||||
|
// SubjectPublicKeyInfo openssl reads (RFC 8410).
|
||||||
|
var ed25519SPKIPrefix = []byte{0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00}
|
||||||
|
|
||||||
|
func installShVar(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := regexp.MustCompile(`(?m)^` + name + `='([^']*)'$`).FindSubmatch(raw)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("install.sh sets no %s", name)
|
||||||
|
}
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func spkiToRaw(t *testing.T, b64 string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
der, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil || len(der) != len(ed25519SPKIPrefix)+ed25519.PublicKeySize || !bytes.HasPrefix(der, ed25519SPKIPrefix) {
|
||||||
|
t.Fatalf("not an Ed25519 public key: %q (%v)", b64, err)
|
||||||
|
}
|
||||||
|
return der[len(ed25519SPKIPrefix):]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The installer checks a release with exactly the keys a bastion checks its
|
||||||
|
// updates with: a key rotated in release_sign.go and not here would make
|
||||||
|
// every install refuse, or accept a key the bastions no longer do.
|
||||||
|
func TestInstallShPinsTheReleaseKeysTheBastionChecks(t *testing.T) {
|
||||||
|
keys := strings.Fields(installShVar(t, "RELEASE_KEYS"))
|
||||||
|
if len(keys) != len(productionReleaseKeys) {
|
||||||
|
t.Fatalf("install.sh pins %d release keys, release_sign.go %d", len(keys), len(productionReleaseKeys))
|
||||||
|
}
|
||||||
|
for i, k := range keys {
|
||||||
|
if got := base64.StdEncoding.EncodeToString(spkiToRaw(t, k)); got != productionReleaseKeys[i] {
|
||||||
|
t.Errorf("install.sh key %d is %s, release_sign.go has %s", i, got, productionReleaseKeys[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The probe is what tells "this openssl cannot check" from "the signature is
|
||||||
|
// wrong". A probe that did not verify would make every system read as unable,
|
||||||
|
// and every install skip the signature with a note.
|
||||||
|
func TestInstallShProbeSignatureVerifies(t *testing.T) {
|
||||||
|
pub := spkiToRaw(t, installShVar(t, "SIG_PROBE_KEY"))
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(installShVar(t, "SIG_PROBE_SIG"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(ed25519.PublicKey(pub), []byte("privatae-installer-probe-v1\n"), sig) {
|
||||||
|
t.Fatal("the probe signature does not verify under the probe key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sigHarness is release_signature_check from install.sh, with the release
|
||||||
|
// keys replaced by key and the download host by base, run on manifest.
|
||||||
|
func sigHarness(t *testing.T, pub ed25519.PublicKey, base string, manifest []byte, onlyPATH ...bool) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(raw)
|
||||||
|
vars := regexp.MustCompile(`(?ms)^RELEASE_KEYS=.*?^SIG_PROBE_SIG='[^']*'$`).FindString(s)
|
||||||
|
fn := regexp.MustCompile(`(?ms)^release_signature_check\(\) \{.*?^\}$`).FindString(s)
|
||||||
|
if vars == "" || fn == "" {
|
||||||
|
t.Fatal("install.sh's release_signature_check or its keys moved")
|
||||||
|
}
|
||||||
|
if len(onlyPATH) > 0 && onlyPATH[0] {
|
||||||
|
// Only the openssl on PATH: a Mac with Homebrew's openssl@3 would
|
||||||
|
// otherwise find a real one at its fixed path.
|
||||||
|
loop := regexp.MustCompile(`(?m)^ for c in openssl [^;]*; do$`)
|
||||||
|
if !loop.MatchString(fn) {
|
||||||
|
t.Fatal("install.sh's openssl candidates moved")
|
||||||
|
}
|
||||||
|
fn = loop.ReplaceAllString(fn, " for c in openssl; do")
|
||||||
|
}
|
||||||
|
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
|
||||||
|
vars = regexp.MustCompile(`(?m)^RELEASE_KEYS='[^']*'$`).ReplaceAllString(vars,
|
||||||
|
"RELEASE_KEYS='"+base64.StdEncoding.EncodeToString(der)+"'")
|
||||||
|
d := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(d, "man.json"), manifest, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(d, "v"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
script := "die() { echo \"ERROR: $*\" >&2; exit 1; }\nSHACMD=sha256sum\nsha_of() { $SHACMD \"$1\" | cut -d' ' -f1; }\n" +
|
||||||
|
"base='" + base + "'\n" + vars + "\n" + fn + "\nrelease_signature_check \"$1\" \"$2\"\necho RETURNED\n"
|
||||||
|
sh := filepath.Join(d, "h.sh")
|
||||||
|
if err := os.WriteFile(sh, []byte(script), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := exec.Command("sh", sh, filepath.Join(d, "man.json"), filepath.Join(d, "v")).CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseSigFor(priv ed25519.PrivateKey, manifest []byte, version string) (name, b64 string) {
|
||||||
|
sum := sha256.Sum256(manifest)
|
||||||
|
stmt := releaseStatement("bastion", version, hex.EncodeToString(sum[:]))
|
||||||
|
return "/version.json." + hex.EncodeToString(sum[:8]) + ".sig",
|
||||||
|
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, stmt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallShRefusesAReleaseWhoseSignatureDoesNotVerify(t *testing.T) {
|
||||||
|
requireTools(t, "sh", "openssl", "sha256sum", "curl")
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
|
||||||
|
name, good := releaseSigFor(priv, manifest, "9.9.9")
|
||||||
|
_, wrongKey := releaseSigFor(other, manifest, "9.9.9")
|
||||||
|
_, wrongVersion := releaseSigFor(priv, manifest, "9.9.8")
|
||||||
|
serve := map[string]string{}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v, ok := serve[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(v))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// The check needs an openssl that does Ed25519; without one it only
|
||||||
|
// notes it, which TestInstallShSaysWhenItCannotCheckTheSignature covers.
|
||||||
|
// Whether this one can is asked of openssl here, never read off the
|
||||||
|
// installer's note: a probe broken in install.sh would print that note on
|
||||||
|
// every machine, and this test would skip while every install skipped the
|
||||||
|
// signature.
|
||||||
|
// MUTATION: break the probe's pkeyutl flags in install.sh. RESULT: red here
|
||||||
|
// (it skipped before).
|
||||||
|
if !opensslChecksEd25519(t) {
|
||||||
|
skipOrFail(t, "the openssl here does not check Ed25519 signatures")
|
||||||
|
}
|
||||||
|
serve[name] = good
|
||||||
|
out, err := sigHarness(t, pub, srv.URL, manifest)
|
||||||
|
if err != nil || strings.Contains(out, "no openssl here can check") ||
|
||||||
|
!strings.Contains(out, "Release signature verified (version 9.9.9)") || !strings.Contains(out, "RETURNED") {
|
||||||
|
t.Fatalf("a good signature: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for what, sig := range map[string]string{"another key's": wrongKey, "another version's": wrongVersion} {
|
||||||
|
serve[name] = sig
|
||||||
|
// MUTATION: return 0 after the key loop. RESULT: red, these install.
|
||||||
|
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || strings.Contains(out, "RETURNED") ||
|
||||||
|
!strings.Contains(out, "does not verify") {
|
||||||
|
t.Errorf("%s signature: %v\n%s", what, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serve[name] = "not base64 at all !"
|
||||||
|
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "malformed") {
|
||||||
|
t.Errorf("a malformed signature: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
delete(serve, name)
|
||||||
|
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "cannot fetch the release's signature") {
|
||||||
|
t.Errorf("no signature: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// opensslChecksEd25519 says, without install.sh, whether the openssl on PATH
|
||||||
|
// verifies an Ed25519 signature made here, and refuses one over another
|
||||||
|
// message.
|
||||||
|
func opensslChecksEd25519(t *testing.T) bool {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("openssl"); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
|
||||||
|
d := t.TempDir()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"k.pem": []byte("-----BEGIN PUBLIC KEY-----\n" + base64.StdEncoding.EncodeToString(der) + "\n-----END PUBLIC KEY-----\n"),
|
||||||
|
"m": []byte("capability\n"),
|
||||||
|
"other": []byte("capability?\n"),
|
||||||
|
"s": ed25519.Sign(priv, []byte("capability\n")),
|
||||||
|
}
|
||||||
|
for n, b := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(d, n), b, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verify := func(msg string) bool {
|
||||||
|
return exec.Command("openssl", "pkeyutl", "-verify", "-pubin", "-inkey", filepath.Join(d, "k.pem"), "-rawin",
|
||||||
|
"-in", filepath.Join(d, msg), "-sigfile", filepath.Join(d, "s")).Run() == nil
|
||||||
|
}
|
||||||
|
return verify("m") && !verify("other")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An openssl that passes every signature is no check: the installer must not
|
||||||
|
// take it for one and print "verified" over a signature no pinned key made.
|
||||||
|
// It is set aside like one that cannot check; a later candidate that can
|
||||||
|
// still checks.
|
||||||
|
func TestInstallShTakesNoOpensslThatPassesEverySignature(t *testing.T) {
|
||||||
|
requireTools(t, "sh", "openssl", "sha256sum", "curl")
|
||||||
|
systemSSL, _ := exec.LookPath("openssl")
|
||||||
|
fake := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(fake, "openssl"),
|
||||||
|
[]byte("#!/bin/sh\ncase \"$1\" in pkeyutl) exit 0 ;; esac\nexec '"+systemSSL+"' \"$@\"\n"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
|
||||||
|
name, forged := releaseSigFor(other, manifest, "9.9.9")
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != name {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(forged))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
// MUTATION: drop the probe's second verify (the one that must fail).
|
||||||
|
// RESULT: red, "Release signature verified" over the forged signature.
|
||||||
|
out, _ := sigHarness(t, pub, srv.URL, manifest)
|
||||||
|
if strings.Contains(out, "Release signature verified") ||
|
||||||
|
!(strings.Contains(out, "no openssl here can check") || strings.Contains(out, "does not verify")) {
|
||||||
|
t.Errorf("an openssl that passes everything was taken for a check:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY.md sends a reader who does not trust our website to a copy of
|
||||||
|
// install.sh read from this repository. Run without sudo, that copy installs
|
||||||
|
// for the whole machine through a root step that fetches the installer from
|
||||||
|
// the website again (whole_machine_script) and runs that, so the copy read
|
||||||
|
// checks nothing itself. The page has to say how the copy must be run.
|
||||||
|
func TestSecurityMdSaysHowAReadCopyOfTheInstallerMustRun(t *testing.T) {
|
||||||
|
sh, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(sh), `whole_machine_script="https://privatae.ai/bastion/install.sh"`) {
|
||||||
|
t.Fatal("install.sh's root step no longer fetches the website's installer: re-read SECURITY.md's first-install paragraph, then this test")
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "SECURITY.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sec := strings.Join(strings.Fields(string(raw)), " ")
|
||||||
|
for _, want := range []string{
|
||||||
|
"`sudo sh install.sh`",
|
||||||
|
"`sh install.sh --just-me`",
|
||||||
|
"fetches the installer from our website again",
|
||||||
|
"an older release we signed",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(sec, want) {
|
||||||
|
t.Errorf("SECURITY.md does not say %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no openssl that can check Ed25519, the check says so and returns, so
|
||||||
|
// the digest check still runs; it never reads as verified.
|
||||||
|
func TestInstallShSaysWhenItCannotCheckTheSignature(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("sh"); err != nil {
|
||||||
|
t.Skip("no sh")
|
||||||
|
}
|
||||||
|
fake := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(fake, "openssl"), []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
out, err := sigHarness(t, pub, "http://127.0.0.1:1", []byte("{\"version\": \"9.9.9\"}\n"), true)
|
||||||
|
if err != nil || !strings.Contains(out, "no openssl here can check") || strings.Contains(out, "verified") ||
|
||||||
|
!strings.Contains(out, "RETURNED") {
|
||||||
|
t.Errorf("no capable openssl: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check runs on every install, after the manifest is fetched and before
|
||||||
|
// any digest is read out of it: a digest from an unchecked manifest is the
|
||||||
|
// thing the signature exists to stop.
|
||||||
|
func TestInstallShChecksTheSignatureBeforeReadingTheDigest(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(raw)
|
||||||
|
fetch := strings.Index(s, `curl -fsSL "$base/version.json" -o "$man"`)
|
||||||
|
check := strings.Index(s, "\nrelease_signature_check \"$man\" \"$vdir\"\n")
|
||||||
|
digest := strings.Index(s, "expected=$(awk")
|
||||||
|
if fetch < 0 || check < 0 || digest < 0 || !(fetch < check && check < digest) {
|
||||||
|
t.Fatalf("install.sh must fetch the manifest, check its signature, then read its digest (at %d, %d, %d)",
|
||||||
|
fetch, check, digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireTools skips without one of these tools, unless CI says the check
|
||||||
|
// must run (PRIVATAE_REQUIRE_OPENSSL=1, .gitea/workflows/ci.yml): there a
|
||||||
|
// missing tool is a failure, so the signature check cannot quietly go untested.
|
||||||
|
func requireTools(t *testing.T, tools ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, tool := range tools {
|
||||||
|
if _, err := exec.LookPath(tool); err != nil {
|
||||||
|
skipOrFail(t, tool+" is not installed here")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipOrFail(t *testing.T, why string) {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("PRIVATAE_REQUIRE_OPENSSL") == "1" {
|
||||||
|
t.Fatalf("%s, and PRIVATAE_REQUIRE_OPENSSL=1 says this check must run", why)
|
||||||
|
}
|
||||||
|
t.Skip(why)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// When a Host holds this machine (its owner quarantined it there), the Host
|
||||||
|
// refuses every worker socket of every lane with close code 4403, and says
|
||||||
|
// "quarantined": true on each beat it answers. The lane workers park a pool of
|
||||||
|
// sockets per lane and used to dial again at once (mgmt, harness, session) or
|
||||||
|
// a second after a close (egress, serve), so one held machine whose tunnel
|
||||||
|
// stayed up dialled the Host 30 to 80 times a second, each one a TLS
|
||||||
|
// handshake. A pairing's workers now share one hold:
|
||||||
|
//
|
||||||
|
// - a socket the Host closes with 4403 holds every worker of that pairing for
|
||||||
|
// laneHoldFirst, doubling with each hold up to laneHoldMax;
|
||||||
|
// - a beat that says quarantined stops them dialling until a beat no longer
|
||||||
|
// does, which is how the owner's release reaches this machine.
|
||||||
|
//
|
||||||
|
// The hold is the pairing's, so another Host this machine is paired with is
|
||||||
|
// never slowed by it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// laneHeldCloseCode is the close code a Host gives a worker socket of a
|
||||||
|
// machine it holds (the Host's quarantine gate).
|
||||||
|
const laneHeldCloseCode = 4403
|
||||||
|
|
||||||
|
// The first hold, and the longest. Variables so a test can shorten them.
|
||||||
|
var (
|
||||||
|
laneHoldFirst = 5 * time.Second
|
||||||
|
laneHoldMax = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type laneHold struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
wait time.Duration // the last hold's length; the next one doubles it
|
||||||
|
until time.Time // no worker of the pairing dials before this
|
||||||
|
last time.Time // when the last hold began
|
||||||
|
quarantined bool // what the last beat said
|
||||||
|
wake chan struct{} // closed when the beat lifts a quarantine
|
||||||
|
}
|
||||||
|
|
||||||
|
var laneHolds sync.Map // host name -> *laneHold
|
||||||
|
|
||||||
|
func laneHoldFor(host string) *laneHold {
|
||||||
|
if h, ok := laneHolds.Load(host); ok {
|
||||||
|
return h.(*laneHold)
|
||||||
|
}
|
||||||
|
h, _ := laneHolds.LoadOrStore(host, &laneHold{wake: make(chan struct{})})
|
||||||
|
return h.(*laneHold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// refused is a worker socket the Host closed with 4403. The workers of a pool
|
||||||
|
// are refused together, so a refusal while a hold is on is that hold's and
|
||||||
|
// does not lengthen it. A refusal long after the last hold starts again from
|
||||||
|
// laneHoldFirst.
|
||||||
|
func (h *laneHold) refused(host string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if now.Before(h.until) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case h.wait == 0 || now.Sub(h.last) > 2*laneHoldMax:
|
||||||
|
h.wait = laneHoldFirst
|
||||||
|
default:
|
||||||
|
h.wait = min(2*h.wait, laneHoldMax)
|
||||||
|
}
|
||||||
|
h.last = now
|
||||||
|
h.until = now.Add(h.wait)
|
||||||
|
fmt.Printf("[%s] the Host refused a lane: it holds this machine; its lanes dial again in %s\n", host, h.wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromBeat is what a beat's reply said. A release ends the hold at once and
|
||||||
|
// starts the next one from laneHoldFirst.
|
||||||
|
func (h *laneHold) fromBeat(host string, quarantined bool) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case quarantined && !h.quarantined:
|
||||||
|
h.quarantined = true
|
||||||
|
fmt.Printf("[%s] the Host holds this machine (quarantined there): its lanes wait until its owner releases it\n", host)
|
||||||
|
case !quarantined && h.quarantined:
|
||||||
|
h.quarantined = false
|
||||||
|
h.wait, h.until = 0, time.Time{}
|
||||||
|
close(h.wake)
|
||||||
|
h.wake = make(chan struct{})
|
||||||
|
fmt.Printf("[%s] the Host released this machine: its lanes are back\n", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitClear blocks a worker until it may dial: the hold is over and the last
|
||||||
|
// beat did not say quarantined. A worker waits a little past the hold's end,
|
||||||
|
// a different little for each, so a pool does not dial in one burst. False
|
||||||
|
// when ctx ends first.
|
||||||
|
func (h *laneHold) waitClear(ctx context.Context) bool {
|
||||||
|
for {
|
||||||
|
h.mu.Lock()
|
||||||
|
quarantined, until, wake := h.quarantined, h.until, h.wake
|
||||||
|
h.mu.Unlock()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var t *time.Timer
|
||||||
|
var timer <-chan time.Time
|
||||||
|
switch {
|
||||||
|
case quarantined:
|
||||||
|
// Only a beat lifts it.
|
||||||
|
case time.Now().Before(until):
|
||||||
|
d := time.Until(until)
|
||||||
|
t = time.NewTimer(d + rand.N(d/10+time.Millisecond))
|
||||||
|
timer = t.C
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
case <-wake:
|
||||||
|
case <-timer:
|
||||||
|
}
|
||||||
|
if t != nil {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchHeldClose reports, once the socket has been read to its end, whether
|
||||||
|
// the Host closed it with laneHeldCloseCode.
|
||||||
|
func watchHeldClose(ws *websocket.Conn) (held func() bool) {
|
||||||
|
var got atomic.Bool
|
||||||
|
def := ws.CloseHandler()
|
||||||
|
ws.SetCloseHandler(func(code int, text string) error {
|
||||||
|
if code == laneHeldCloseCode {
|
||||||
|
got.Store(true)
|
||||||
|
}
|
||||||
|
return def(code, text)
|
||||||
|
})
|
||||||
|
return got.Load
|
||||||
|
}
|
||||||
|
|
||||||
|
// beatSaysQuarantined reads the "quarantined" a Host puts on the beat's reply
|
||||||
|
// for a machine it holds. A reply without it, or one that cannot be read, is
|
||||||
|
// not a hold.
|
||||||
|
func beatSaysQuarantined(body []byte) bool {
|
||||||
|
var r struct {
|
||||||
|
Quarantined bool `json:"quarantined"`
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body, &r) == nil && r.Quarantined
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// heldHost stands in for a Host's lane port: it counts each worker's hello,
|
||||||
|
// then either closes the socket with the code a Host gives a machine it holds,
|
||||||
|
// or parks it.
|
||||||
|
type heldHost struct {
|
||||||
|
hellos atomic.Int32
|
||||||
|
hold atomic.Bool
|
||||||
|
tls *tls.Config
|
||||||
|
dial func(ctx context.Context) (net.Conn, error)
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHeldHost(t *testing.T) *heldHost {
|
||||||
|
t.Helper()
|
||||||
|
h := &heldHost{}
|
||||||
|
h.hold.Store(true)
|
||||||
|
up := websocket.Upgrader{}
|
||||||
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := up.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if _, _, err := c.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.hellos.Add(1)
|
||||||
|
if h.hold.Load() {
|
||||||
|
_ = c.WriteControl(websocket.CloseMessage,
|
||||||
|
websocket.FormatCloseMessage(laneHeldCloseCode, "this bastion is quarantined"), time.Now().Add(time.Second))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if _, _, err := c.ReadMessage(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
srv.StartTLS()
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(srv.Certificate())
|
||||||
|
h.tls = &tls.Config{RootCAs: pool, ServerName: "example.com"}
|
||||||
|
addr := srv.Listener.Addr().String()
|
||||||
|
h.dial = func(ctx context.Context) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "tcp", addr)
|
||||||
|
}
|
||||||
|
h.url = "wss://example.com/lane"
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortHolds makes the holds short enough for a test. Set before any worker
|
||||||
|
// runs, and never from a parallel subtest.
|
||||||
|
func shortHolds(t *testing.T, first, max time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
f, m := laneHoldFirst, laneHoldMax
|
||||||
|
laneHoldFirst, laneHoldMax = first, max
|
||||||
|
t.Cleanup(func() { laneHoldFirst, laneHoldMax = f, m })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownHold gives a test its own pairing's hold, and forgets it after.
|
||||||
|
func ownHold(t *testing.T, host string) {
|
||||||
|
t.Helper()
|
||||||
|
laneHolds.Delete(host)
|
||||||
|
t.Cleanup(func() { laneHolds.Delete(host) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every lane's pool, so a lane added later without the hold is caught.
|
||||||
|
func lanePools(dir string) map[string]func(ctx context.Context, host string, h *heldHost, n int) {
|
||||||
|
return map[string]func(ctx context.Context, host string, h *heldHost, n int){
|
||||||
|
"egress": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||||
|
var d net.Dialer
|
||||||
|
runEgressWorkers(ctx, host, h.url, h.tls, h.dial, "b", d.DialContext, n)
|
||||||
|
},
|
||||||
|
"serve": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||||
|
runServeWorkers(ctx, host, dir, h.url, h.tls, h.dial, "b", n)
|
||||||
|
},
|
||||||
|
"mgmt": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||||
|
runMgmtWorkers(ctx, host, h.url, h.tls, h.dial, "b", dir, dir, dir, n)
|
||||||
|
},
|
||||||
|
"harness": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||||
|
runHarnessWorkers(ctx, host, dir, h.url, h.tls, h.dial, "b", n)
|
||||||
|
},
|
||||||
|
"session": func(ctx context.Context, host string, h *heldHost, n int) {
|
||||||
|
runSessionWorkers(ctx, host, h.url, h.tls, h.dial, "b", n,
|
||||||
|
sessionEnv{CtxRoot: dir, DcnDir: dir, HostName: host, Server: "test"})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Host that holds this machine closes every worker socket with 4403. No
|
||||||
|
// worker of any lane dials it again before the hold is over: one hello per
|
||||||
|
// worker, where a pool used to dial again at once or a second later, 30 to 80
|
||||||
|
// times a second from one held machine.
|
||||||
|
func TestEveryLanePoolWaitsOutAHostThatHoldsThisMachine(t *testing.T) {
|
||||||
|
shortHolds(t, 3*time.Second, time.Minute)
|
||||||
|
for lane, run := range lanePools(t.TempDir()) {
|
||||||
|
t.Run(lane, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
host := "held-" + lane + ".example.test"
|
||||||
|
ownHold(t, host)
|
||||||
|
h := startHeldHost(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
const n = 2
|
||||||
|
go run(ctx, host, h, n)
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
if got := h.hellos.Load(); got != n {
|
||||||
|
t.Fatalf("%d dials within the hold from %d workers; want one each", got, n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A beat whose reply says quarantined stops the lanes before any of them
|
||||||
|
// dials, and the first beat that no longer says it brings them back at once.
|
||||||
|
func TestABeatThatSaysQuarantinedStopsTheLanesUntilOneDoesNot(t *testing.T) {
|
||||||
|
host := "quarantined.example.test"
|
||||||
|
shortHolds(t, time.Hour, time.Hour)
|
||||||
|
ownHold(t, host)
|
||||||
|
h := startHeldHost(t)
|
||||||
|
h.hold.Store(false)
|
||||||
|
laneHoldFor(host).fromBeat(host, beatSaysQuarantined([]byte(`{"ok":true,"quarantined":true}`)))
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
const n = 2
|
||||||
|
go lanePools(t.TempDir())["mgmt"](ctx, host, h, n)
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
if got := h.hellos.Load(); got != 0 {
|
||||||
|
t.Fatalf("%d dials while the Host says it holds this machine", got)
|
||||||
|
}
|
||||||
|
laneHoldFor(host).fromBeat(host, beatSaysQuarantined([]byte(`{"ok":true}`)))
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for h.hellos.Load() < n && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if got := h.hellos.Load(); got != n {
|
||||||
|
t.Fatalf("after the release, %d of %d workers parked again", got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pool's workers are refused together, so a refusal inside a hold is that
|
||||||
|
// hold's; each hold after one runs out is twice as long, up to the most; a
|
||||||
|
// refusal long after the last starts again from the first; a release ends a
|
||||||
|
// hold at once.
|
||||||
|
func TestAHoldDoublesOncePerHoldUpToItsMost(t *testing.T) {
|
||||||
|
host := "doubling.example.test"
|
||||||
|
shortHolds(t, time.Second, 4*time.Second)
|
||||||
|
ownHold(t, host)
|
||||||
|
h := laneHoldFor(host)
|
||||||
|
expire := func() {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.until = time.Now().Add(-time.Millisecond)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
wait := func() time.Duration {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
return h.wait
|
||||||
|
}
|
||||||
|
h.refused(host)
|
||||||
|
h.refused(host)
|
||||||
|
h.refused(host)
|
||||||
|
if got := wait(); got != time.Second {
|
||||||
|
t.Fatalf("three refusals in one hold: %s, want the first hold", got)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for range 3 {
|
||||||
|
expire()
|
||||||
|
h.refused(host)
|
||||||
|
got = append(got, wait().String())
|
||||||
|
}
|
||||||
|
if strings.Join(got, " ") != "2s 4s 4s" {
|
||||||
|
t.Fatalf("holds after the first: %v, want 2s 4s 4s", got)
|
||||||
|
}
|
||||||
|
expire()
|
||||||
|
h.mu.Lock()
|
||||||
|
h.last = time.Now().Add(-9 * time.Second)
|
||||||
|
h.mu.Unlock()
|
||||||
|
h.refused(host)
|
||||||
|
if got := wait(); got != time.Second {
|
||||||
|
t.Fatalf("a refusal after a long quiet: %s, want the first hold again", got)
|
||||||
|
}
|
||||||
|
h.fromBeat(host, true)
|
||||||
|
h.fromBeat(host, false)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if !h.waitClear(ctx) {
|
||||||
|
t.Fatal("a release must end the hold at once")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeatSaysQuarantinedReadsOnlyTheHostsTrue(t *testing.T) {
|
||||||
|
for body, want := range map[string]bool{
|
||||||
|
`{"ok":true,"quarantined":true}`: true,
|
||||||
|
`{"ok":true,"quarantined":false}`: false,
|
||||||
|
`{"ok":true}`: false,
|
||||||
|
`{"quarantined":"yes"}`: false,
|
||||||
|
`not json`: false,
|
||||||
|
``: false,
|
||||||
|
} {
|
||||||
|
if got := beatSaysQuarantined([]byte(body)); got != want {
|
||||||
|
t.Errorf("%q: %v, want %v", body, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-11
@@ -111,22 +111,25 @@ Usage:
|
|||||||
anchor anywhere.
|
anchor anywhere.
|
||||||
privatae-bastion approvals [--host <host> | --machine] [--json]
|
privatae-bastion approvals [--host <host> | --machine] [--json]
|
||||||
show what your host is asking to do on this machine, in plain words
|
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
|
approve one request with a code from the authenticator enrolled here
|
||||||
privatae-bastion deny [--host <host>] <id>
|
privatae-bastion deny [--host <host> | --machine] <id>
|
||||||
refuse one request
|
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>
|
privatae-bastion approvals drop [--host <host>] <id>
|
||||||
take back an approval this machine issued and has not used yet
|
take back an approval this machine issued and has not used yet
|
||||||
privatae-bastion grants drop <store id> [--host <host>]
|
privatae-bastion grants drop <store id> [--host <host>]
|
||||||
remove a folder grant held on this machine; the host is told
|
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
|
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
|
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>
|
privatae-bastion device-approve <id>|all / device-deny <id>
|
||||||
sudo -H privatae-bastion pin-device <key>
|
sudo -H privatae-bastion pin-device <key>
|
||||||
on a machine you govern: accept approvals signed by your device
|
on a machine you govern: accept approvals signed by your device
|
||||||
(the key device-init printed). unpin-device <key> withdraws it
|
(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
|
standing op grants; ops drop <grant> takes one back
|
||||||
sudo -H privatae-bastion root-exec grant [--host <host>] --for <30d>
|
sudo -H privatae-bastion root-exec grant [--host <host>] --for <30d>
|
||||||
let the anchor host run commands AS ROOT here, one at a time,
|
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,
|
switch one capability family off for a host on this machine,
|
||||||
or clear that refusal (the console page's switch). Off only
|
or clear that refusal (the console page's switch). Off only
|
||||||
removes reach; on grants nothing past the host's signed grant
|
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]
|
privatae-bastion agents [--json] [--prompts]
|
||||||
every AI agent on this machine and what it is doing: its
|
every AI agent on this machine and what it is doing: its
|
||||||
session name and title, whether it waits for you, its folder
|
session name and title, whether it waits for you, its folder
|
||||||
and branch, what it is running now (--prompts: its last prompt)
|
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 -]
|
privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]
|
||||||
write what this bastion knows as one Markdown file: agents,
|
write what this bastion knows as one Markdown file: agents,
|
||||||
scheduled agents, MCP tool-server problems, the baseline, activity.
|
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
|
denied. Exit 0 stopped (or asked), 1 not, 3 still waiting
|
||||||
From the machine's owner account, beside the bastion installed for
|
From the machine's owner account, beside the bastion installed for
|
||||||
the whole machine, the reads (status, agents, schedules,
|
the whole machine, the reads (status, agents, schedules,
|
||||||
tool-servers, posture, baseline, activity, report, approvals)
|
tool-servers, posture, baseline, activity, report, approvals,
|
||||||
ask that bastion and show the whole machine
|
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 help this text
|
||||||
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan h[:p],...]
|
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan h[:p],...]
|
||||||
[--image-prefix <registry/path/>|default]
|
[--image-prefix <registry/path/>|default]
|
||||||
@@ -613,7 +625,7 @@ func main() {
|
|||||||
case "device-init":
|
case "device-init":
|
||||||
err = cmdDeviceInit()
|
err = cmdDeviceInit()
|
||||||
case "device-approvals":
|
case "device-approvals":
|
||||||
err = cmdDeviceApprovals()
|
err = cmdDeviceApprovals(os.Args[2:])
|
||||||
case "device-approve", "device-deny":
|
case "device-approve", "device-deny":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: privatae-bastion "+os.Args[1]+" <request id> [--code <code>]")
|
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]))
|
os.Exit(opActionChildMain(os.Args[2]))
|
||||||
case "ops":
|
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 <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
|
// ops drop --all [--host <host>] take back every op grant the host holds here
|
||||||
host, args := takeHostFlag(os.Args[2:])
|
host, args := takeHostFlag(os.Args[2:])
|
||||||
@@ -693,7 +705,12 @@ func main() {
|
|||||||
err = cmdOpsDrop(host, args[1])
|
err = cmdOpsDrop(host, args[1])
|
||||||
break
|
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":
|
case "root-exec":
|
||||||
// root-exec [--host <host>] [grant --for <d> | revoke] (rootexec.go): the
|
// root-exec [--host <host>] [grant --for <d> | revoke] (rootexec.go): the
|
||||||
// standing grant for the anchor host's root commands, given and taken
|
// 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]
|
// baseline show|diff [--json], baseline record [--force]
|
||||||
// (machine_cli.go): observe:baseline* under the words people use.
|
// (machine_cli.go): observe:baseline* under the words people use.
|
||||||
err = cmdBaseline(os.Args[2:])
|
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":
|
case "help", "-h", "--help":
|
||||||
usage()
|
usage()
|
||||||
case "switch":
|
case "switch":
|
||||||
|
|||||||
@@ -347,7 +347,11 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
|
|||||||
}
|
}
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
|
hold := laneHoldFor(self)
|
||||||
for pctx.Err() == nil {
|
for pctx.Err() == nil {
|
||||||
|
if !hold.waitClear(pctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||||
cancel()
|
cancel()
|
||||||
@@ -360,6 +364,7 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
|
|||||||
sleepCtx(pctx, time.Second)
|
sleepCtx(pctx, time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
held := watchHeldClose(ws)
|
||||||
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
|
||||||
// A revoke while parked: closing the socket is what unblocks the read.
|
// A revoke while parked: closing the socket is what unblocks the read.
|
||||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||||
@@ -367,6 +372,9 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
|
|||||||
unpark()
|
unpark()
|
||||||
stopKA()
|
stopKA()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
|
if held() {
|
||||||
|
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,26 +362,3 @@ func utf16Len(s string) int {
|
|||||||
}
|
}
|
||||||
return n
|
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.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
@@ -648,7 +649,12 @@ func opUpperChanges(upper, hostRoot string) ([]opChange, error) {
|
|||||||
out = append(out, opChange{Path: real, Kind: "special"})
|
out = append(out, opChange{Path: real, Kind: "special"})
|
||||||
return nil
|
return nil
|
||||||
case mode.IsDir():
|
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"})
|
out = append(out, opChange{Path: real, Kind: "opaque"})
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── the upper-dir reader (no sandbox needed) ────────────────────────────────
|
// ── 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) {
|
func TestHostMountsAndEffectPrefix(t *testing.T) {
|
||||||
mounts := []opHostMount{{"/", "ext4"}, {"/srv", "xfs"}, {"/srv/share", "nfs4"}, {"/proc", "proc"}, {"/home", "ext4"}}
|
mounts := []opHostMount{{"/", "ext4"}, {"/srv", "xfs"}, {"/srv/share", "nfs4"}, {"/proc", "proc"}, {"/home", "ext4"}}
|
||||||
for p, want := range map[string]string{
|
for p, want := range map[string]string{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -11,9 +11,12 @@ package main
|
|||||||
// kernel which account connected. Asked for a read by the account recorded as
|
// 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
|
// 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
|
// 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:
|
// lane (consolelane.go) already shows that account, and more that the console
|
||||||
// posture, which the console has no page for, adds the machine's firewall
|
// has no page for: posture (the machine's firewall checks, read as the
|
||||||
// checks, read as the administrator. The delegation tag an agent carries,
|
// 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).
|
// 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
|
// 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
|
// permissions (the agents, schedules and tool servers it can see), it gets
|
||||||
@@ -28,9 +31,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -83,13 +86,20 @@ var machineReads = map[string]machineRead{
|
|||||||
"activity": {build: func(o readOpts) (any, error) { return readActivity(o) }},
|
"activity": {build: func(o readOpts) (any, error) { return readActivity(o) }},
|
||||||
"report": {build: func(o readOpts) (any, error) { return readReport(o) }, heavy: true},
|
"report": {build: func(o readOpts) (any, error) { return readReport(o) }, heavy: true},
|
||||||
"approvals": {build: func(o readOpts) (any, error) { return readApprovals(o) }},
|
"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
|
// readsGoThroughLane: the machine's bastion is a system service this account
|
||||||
// does not administer, so its state is not this process's to read.
|
// does not administer, so its state is not this process's to read.
|
||||||
var readsGoThroughLane = func() bool {
|
var readsGoThroughLane = func() bool {
|
||||||
if runtime.GOOS == "windows" || machineAuthorityHeld() {
|
if machineAuthorityHeld() {
|
||||||
return false // no ask pipe on Windows yet; the administrator reads directly
|
return false // the administrator reads the service's state directly
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
return linuxSystemUnitPresent()
|
return linuxSystemUnitPresent()
|
||||||
@@ -145,7 +155,7 @@ func laneRefusalError(cmd string, err error) error {
|
|||||||
if !errors.As(err, &le) {
|
if !errors.As(err, &le) {
|
||||||
return err
|
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 ─────────────────────────────────────────────────────────
|
// ── the asking side ─────────────────────────────────────────────────────────
|
||||||
@@ -155,8 +165,8 @@ func laneRefusalError(cmd string, err error) error {
|
|||||||
var readSockPath = askSockPath
|
var readSockPath = askSockPath
|
||||||
|
|
||||||
func readViaSock(name string, o readOpts, v any) error {
|
func readViaSock(name string, o readOpts, v any) error {
|
||||||
p := readSockPath()
|
p := askLaneAddr(readSockPath())
|
||||||
c, err := net.DialTimeout("unix", p, 3*time.Second)
|
c, err := dialAskLane(readSockPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
|
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 {
|
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() + ")"}
|
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 {
|
if !r.OK {
|
||||||
return &laneError{why: r.Why}
|
return &laneError{why: r.Why}
|
||||||
}
|
}
|
||||||
@@ -185,28 +198,28 @@ func readViaSock(name string, o readOpts, v any) error {
|
|||||||
type agentReadReply struct {
|
type agentReadReply struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Why string `json:"why,omitempty"`
|
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"`
|
User string `json:"user,omitempty"`
|
||||||
View json.RawMessage `json:"view,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
|
// 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.
|
// account's sessions are the owner's to see, not another account's.
|
||||||
func readLaneRefusal(uid uint32, who string) string {
|
func readLaneRefusalFor(p askPeer) string {
|
||||||
owner, ownerName, present, err := ownerAccountLoad()
|
match, ownerName, present, err := laneOwnerMatch(p)
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "this machine's owner account cannot be read (" + err.Error() + ")"
|
return "this machine's owner account cannot be read (" + err.Error() + ")"
|
||||||
case !present:
|
case !present:
|
||||||
return "no account is recorded as this machine's owner, so its bastion answers reads only for the " +
|
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: " +
|
"administrator. To read from your own account, the administrator records it once: " +
|
||||||
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
|
ownerRecordCommand(p)
|
||||||
case owner != uid:
|
case !match:
|
||||||
name := ownerName
|
return "this machine's bastion answers reads only for its owner's account (" + ownerName + "), not " + p.Name
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -231,8 +244,12 @@ var readNow = time.Now
|
|||||||
var readSlots = make(chan struct{}, 4)
|
var readSlots = make(chan struct{}, 4)
|
||||||
|
|
||||||
func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
|
func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
|
||||||
r := agentReadReply{User: user}
|
return serveReadFor(askPeer{UID: uid, Name: user}, req)
|
||||||
if why := readLaneRefusal(uid, user); why != "" {
|
}
|
||||||
|
|
||||||
|
func serveReadFor(p askPeer, req agentAskRequest) agentReadReply {
|
||||||
|
r := agentReadReply{User: p.Name}
|
||||||
|
if why := readLaneRefusalFor(p); why != "" {
|
||||||
r.Why = why
|
r.Why = why
|
||||||
return r
|
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}
|
o := readOpts{Since: req.Since, Prompts: req.Prompts, Host: req.Host, Machine: req.Machine}
|
||||||
view, err := runMachineRead(req.Read, spec, o)
|
view, err := runMachineRead(req.Read, spec, o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Why = err.Error()
|
r.Why, r.Failed = err.Error(), true
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
if len(view) > readMaxBytes-readEnvelope {
|
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) {
|
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)
|
v, err := spec.build(o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The owner's agent reads the switch list, the doctor and the device's asks
|
||||||
|
// from the machine's bastion over its socket; a read the bastion answers but
|
||||||
|
// cannot build comes back as that failure, not as a refusal with the
|
||||||
|
// administrator's command (the administrator would be told the same).
|
||||||
|
func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
|
||||||
|
askFlowWorld(t)
|
||||||
|
laneOwner(t, laneTestUID)
|
||||||
|
laneSock(t)
|
||||||
|
prevSys, prevLatest, prevSandbox := systemInstalled, doctorLatest, doctorSandbox
|
||||||
|
t.Cleanup(func() { systemInstalled, doctorLatest, doctorSandbox = prevSys, prevLatest, prevSandbox })
|
||||||
|
systemInstalled = func() bool { return true }
|
||||||
|
doctorLatest = func() string { return "" }
|
||||||
|
doctorSandbox = func() (bool, string) { return true, "" }
|
||||||
|
out := captureStdout(t)
|
||||||
|
|
||||||
|
if err := cmdSwitch([]string{"--list", "--json"}); err != nil {
|
||||||
|
t.Fatalf("switch --list --json: %v", err)
|
||||||
|
}
|
||||||
|
var sw switchesRead
|
||||||
|
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &sw); err != nil {
|
||||||
|
t.Fatalf("switch --list --json printed %q (%v)", out(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := len(out())
|
||||||
|
err := cmdDoctor([]string{"--json"})
|
||||||
|
var q quietExit
|
||||||
|
if err != nil && !errors.As(err, &q) {
|
||||||
|
t.Fatalf("doctor through the lane: %v", err)
|
||||||
|
}
|
||||||
|
var d doctorRead
|
||||||
|
if err := json.Unmarshal([]byte(printedSince(out, before, "}\n")), &d); err != nil || len(d.Checks) == 0 {
|
||||||
|
t.Fatalf("doctor --json printed %q (%v)", out()[before:], err)
|
||||||
|
}
|
||||||
|
if c, ok := doctorCheckNamed(d, "owner"); !ok || !c.OK {
|
||||||
|
t.Errorf("the machine's doctor, read by its owner, does not find the owner: %+v", d.Checks)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := deviceShownPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := cmdDeviceApprovals(nil); err != nil {
|
||||||
|
t.Fatalf("device-approvals through the lane: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
t.Error("device-approvals read through the lane recorded what it printed as shown")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MUTATION: drop `r.Failed = true` in serveRead. RESULT: red, the error
|
||||||
|
// carries the administrator's command.
|
||||||
|
err = cmdOpsList("", true)
|
||||||
|
var le *laneError
|
||||||
|
if err == nil || errors.As(err, &le) || strings.Contains(err.Error(), "As the administrator") {
|
||||||
|
t.Errorf("ops with nothing paired, through the lane: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `switch --list` passes the guard beside the system unit, as status does;
|
||||||
|
// `switch` does too since the owner's account switches through the lane
|
||||||
|
// (owneracts.go), and 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,6 +134,26 @@ func TestEveryReadPrintsTheSameAfterTheLane(t *testing.T) {
|
|||||||
func(w *bytes.Buffer, v any) { _ = printDelta(w, v.(deltaReport)) })
|
func(w *bytes.Buffer, v any) { _ = printDelta(w, v.(deltaReport)) })
|
||||||
rep := buildReport(now, now.Add(-7*24*time.Hour))
|
rep := buildReport(now, now.Add(-7*24*time.Hour))
|
||||||
add("report", rep, func(w *bytes.Buffer, v any) { renderReportMarkdown(w, v.(machineReport)) })
|
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 {
|
for name := range machineReads {
|
||||||
if name == "baseline" {
|
if name == "baseline" {
|
||||||
@@ -223,6 +243,27 @@ func zeroLike(v any) decoded {
|
|||||||
case machineReport:
|
case machineReport:
|
||||||
var x machineReport
|
var x machineReport
|
||||||
return decoded{&x, func() any { return x }}
|
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")
|
panic("no case")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,10 @@ func relaunchAfterUpdate() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := exec.Command(self, os.Args[1:]...)
|
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{
|
cmd.SysProcAttr = &windows.SysProcAttr{
|
||||||
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -606,7 +606,13 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
|||||||
// (posture). The device fingerprint is NOT sent here: it is bound at
|
// (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
|
// 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.
|
// 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() {
|
go func() {
|
||||||
|
defer close(beatDone)
|
||||||
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
|
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
wgPub := ""
|
wgPub := ""
|
||||||
@@ -616,8 +622,11 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
beat := func() {
|
beat := func() {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
payload := heartbeatPayload(cfg, dev, hostname, wgPub)
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -629,11 +638,16 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return // a reply past its bound is no answer (lane_bounds.go)
|
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 {
|
if resp.StatusCode < 300 {
|
||||||
// last_ok per pairing (anchor.go): the anchor rotation's
|
// last_ok per pairing (anchor.go): the anchor rotation's
|
||||||
// silence path reads this — 14 days with no 2xx from the
|
// silence path reads this — 14 days with no 2xx from the
|
||||||
// anchor host lets root re-anchor without its consent.
|
// anchor host lets root re-anchor without its consent.
|
||||||
recordPairingOK(cfg.HostName)
|
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
|
// Box-wide directives (update, contain, mirror) are anchor-only
|
||||||
// on an anchored box — each takes this pairing's identity and
|
// on an anchored box — each takes this pairing's identity and
|
||||||
@@ -694,6 +708,7 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = srv.Close()
|
_ = srv.Close()
|
||||||
|
<-beatDone
|
||||||
return nil
|
return nil
|
||||||
case err := <-errc:
|
case err := <-errc:
|
||||||
return err
|
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
|
// The heartbeat: the same body (no WireGuard device, so no allowed IPs
|
||||||
// and no endpoint) and every directive its reply carries except the two
|
// 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() {
|
go func() {
|
||||||
|
defer close(beatDone)
|
||||||
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
|
hbURL := fmt.Sprintf("https://%s:8443/api/network/bastions/heartbeat", cfg.HostName)
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
wgPub := ""
|
wgPub := ""
|
||||||
@@ -853,7 +870,10 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
beat := func() {
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -868,8 +888,12 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return // a reply past its bound is no answer (lane_bounds.go)
|
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 {
|
if resp.StatusCode < 300 {
|
||||||
recordPairingOK(cfg.HostName)
|
recordPairingOK(cfg.HostName)
|
||||||
|
laneHoldFor(cfg.HostName).fromBeat(cfg.HostName, beatSaysQuarantined(body))
|
||||||
}
|
}
|
||||||
maybeSelfUpdate(cfg.HostName, cfg.dcnDir(), body)
|
maybeSelfUpdate(cfg.HostName, cfg.dcnDir(), body)
|
||||||
maybeContain(cfg.HostName, cfg.dcnDir(), body)
|
maybeContain(cfg.HostName, cfg.dcnDir(), body)
|
||||||
@@ -899,6 +923,7 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = srv.Close()
|
_ = srv.Close()
|
||||||
|
<-beatDone
|
||||||
return nil
|
return nil
|
||||||
case err := <-errc:
|
case err := <-errc:
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
// A pairing's run and its heartbeat stop together (run.go): uses the serve
|
||||||
|
// lane's fixtures (servelane_linux_test.go), so it carries the same build tag.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A pairing stopped while a beat is out acts on nothing that beat brings
|
||||||
|
// back. The run waits for its heartbeat and the beat's request ends with the
|
||||||
|
// run, so its last-OK time, and anything else the reply would have set off
|
||||||
|
// (a grant reconcile into the pairing's folder, a contain, an update), never
|
||||||
|
// lands after the stop, when an unpair or a revoke removes what is left.
|
||||||
|
func TestAStoppedPairingsLastBeatWritesNothingAfterTheStop(t *testing.T) {
|
||||||
|
h, cfg := serveLaneBox(t, "beat-stop.example.test")
|
||||||
|
arrived, release := make(chan struct{}, 1), make(chan struct{})
|
||||||
|
h.mu.Lock()
|
||||||
|
h.beatArrived, h.beatRelease = arrived, release
|
||||||
|
h.mu.Unlock()
|
||||||
|
released := false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !released {
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- runPairing(ctx, cfg) }()
|
||||||
|
select {
|
||||||
|
case <-arrived:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("no beat reached the Host")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the pairing did not stop while its beat was out")
|
||||||
|
}
|
||||||
|
// The Host answers the beat only now, after the run has returned.
|
||||||
|
close(release)
|
||||||
|
released = true
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
p, err := pairingStatePath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
t.Fatal("the stopped pairing's last beat recorded its time after the stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runGuardFile holds the "one running bastion" lock for the life of this
|
// 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
|
// cannotStart is the wording for the two I/O failures below. They have no
|
||||||
// prepared words the way the already-running case does (secondRunNotice,
|
// prepared words the way the already-running case does (secondRunWords,
|
||||||
// notify.go), and the error itself is the most accurate thing there is to say:
|
// 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
|
// both mean the config directory is unusable, which is not a state anyone can be
|
||||||
// given advice about from here.
|
// given advice about from here.
|
||||||
func cannotStart(err error) uiNotice {
|
func cannotStart(err error) uiNotice {
|
||||||
@@ -90,19 +93,36 @@ func guardSingleRun() error {
|
|||||||
err = fmt.Errorf("cannot open the run lock: %w", err)
|
err = fmt.Errorf("cannot open the run lock: %w", err)
|
||||||
return refuseVisibly(cannotStart(err), 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()
|
_ = f.Close()
|
||||||
// The dialog and the console get DIFFERENT words for the same fact, and
|
// 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
|
// that is on purpose: the console's are laid out for a terminal, with
|
||||||
// continuation lines, and "you can close this window" meaning the console
|
// indented continuation lines (secondRunWords).
|
||||||
// window it is being printed into. secondRunNotice says it for a dialog.
|
h, known := readRunHolder(d)
|
||||||
return refuseVisibly(secondRunNotice(), errors.New(
|
text, notice := secondRunWords(h, known)
|
||||||
"a bastion is already running on this machine — that one is serving "+
|
return refuseVisibly(notice, errors.New(text))
|
||||||
"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"))
|
|
||||||
}
|
}
|
||||||
runGuardFile.Store(f)
|
runGuardFile.Store(f)
|
||||||
|
writeRunHolder(d, stdinIsTerminal())
|
||||||
return nil
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -616,7 +616,11 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
|
|||||||
spawn = func() {
|
spawn = func() {
|
||||||
go func() {
|
go func() {
|
||||||
wait := 3 * time.Second
|
wait := 3 * time.Second
|
||||||
|
hold := laneHoldFor(self)
|
||||||
for pctx.Err() == nil {
|
for pctx.Err() == nil {
|
||||||
|
if !hold.waitClear(pctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||||
ws, resp, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
ws, resp, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||||
cancel()
|
cancel()
|
||||||
@@ -635,6 +639,7 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
|
|||||||
sleepCtx(pctx, time.Second)
|
sleepCtx(pctx, time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
held := watchHeldClose(ws)
|
||||||
stopKA := armWSKeepalive(ws)
|
stopKA := armWSKeepalive(ws)
|
||||||
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
|
||||||
took := serveJob(ws, self, dcnDir, spawn)
|
took := serveJob(ws, self, dcnDir, spawn)
|
||||||
@@ -644,6 +649,10 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
|
|||||||
if took {
|
if took {
|
||||||
return
|
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
|
// Closed while parked (a Host restart), or the job refused: not
|
||||||
// straight back, so a Host that closes or misuses every worker is
|
// straight back, so a Host that closes or misuses every worker is
|
||||||
// not dialled in a loop.
|
// not dialled in a loop.
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ import (
|
|||||||
// serveHost is a Host's lane port as the serve lane meets it: the lane
|
// 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
|
// 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
|
// 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 {
|
type serveHost struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
beats []map[string]any
|
beats []map[string]any
|
||||||
hellos []string
|
hellos []string
|
||||||
workers chan *websocket.Conn
|
workers chan *websocket.Conn
|
||||||
|
beatArrived chan struct{}
|
||||||
|
beatRelease chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startServeHost(t *testing.T, raw net.Listener, cert tls.Certificate, clientCA *testCA) *serveHost {
|
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)
|
_ = json.NewDecoder(r.Body).Decode(&m)
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.beats = append(h.beats, m)
|
h.beats = append(h.beats, m)
|
||||||
|
arrived, release := h.beatArrived, h.beatRelease
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
if release != nil {
|
||||||
|
select {
|
||||||
|
case arrived <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
<-release
|
||||||
|
}
|
||||||
_, _ = w.Write([]byte("{}"))
|
_, _ = w.Write([]byte("{}"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/bastion-serve", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/bastion-serve", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,11 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
|
|||||||
}
|
}
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
|
hold := laneHoldFor(self)
|
||||||
for pctx.Err() == nil {
|
for pctx.Err() == nil {
|
||||||
|
if !hold.waitClear(pctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
|
||||||
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
|
||||||
cancel()
|
cancel()
|
||||||
@@ -96,6 +100,7 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
|
|||||||
sleepCtx(pctx, time.Second)
|
sleepCtx(pctx, time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
held := watchHeldClose(ws)
|
||||||
stopKA := armWSKeepalive(ws)
|
stopKA := armWSKeepalive(ws)
|
||||||
// A revoked pairing closes the socket under the session, which ends the
|
// A revoked pairing closes the socket under the session, which ends the
|
||||||
// shell through the same path a host disconnect takes.
|
// shell through the same path a host disconnect takes.
|
||||||
@@ -104,6 +109,9 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
|
|||||||
unpark()
|
unpark()
|
||||||
stopKA()
|
stopKA()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
|
if held() {
|
||||||
|
hold.refused(self) // the Host holds this machine (lanehold.go)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -1162,7 +1162,7 @@ func stepupPairing(host string) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(cfgs) == 0 {
|
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 {
|
switch {
|
||||||
case host != "":
|
case host != "":
|
||||||
@@ -1341,7 +1341,7 @@ func readApprovals(o readOpts) (approvalsRead, error) {
|
|||||||
func cmdStepupApprovals(host string, machine, asJSON bool) error {
|
func cmdStepupApprovals(host string, machine, asJSON bool) error {
|
||||||
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
r, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return laneRefusalError("approvals", err)
|
return laneRefusalError(strings.TrimSpace(stepupCommand("approvals", host, machine, "")), err)
|
||||||
}
|
}
|
||||||
if asJSON {
|
if asJSON {
|
||||||
return printJSON(os.Stdout, r)
|
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>
|
// privatae-bastion approve [--host <host> | --machine] <request id>
|
||||||
func cmdStepupApprove(host string, machine bool, prefix string) error {
|
func cmdStepupApprove(host string, machine bool, prefix string) error {
|
||||||
|
if readsGoThroughLane() {
|
||||||
|
return ownerApproveViaLane(host, machine, prefix)
|
||||||
|
}
|
||||||
t, err := resolveStepupTarget(host, machine)
|
t, err := resolveStepupTarget(host, machine)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1425,6 +1428,14 @@ func cmdStepupApprove(host string, machine bool, prefix string) error {
|
|||||||
|
|
||||||
// privatae-bastion deny [--host <host> | --machine] <request id>
|
// privatae-bastion deny [--host <host> | --machine] <request id>
|
||||||
func cmdStepupDeny(host string, machine bool, prefix string) error {
|
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)
|
t, err := resolveStepupTarget(host, machine)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+111
-42
@@ -3,7 +3,7 @@ package main
|
|||||||
// switch_cli.go — the local switch (localdeny.go) from a terminal.
|
// switch_cli.go — the local switch (localdeny.go) from a terminal.
|
||||||
//
|
//
|
||||||
// privatae-bastion switch <host> <family> on|off
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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;
|
// 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
|
// beside a system service on Linux or macOS the owner's account reads the
|
||||||
// it (serviceGuard, sysservice.go), because it would read that account's own
|
// machine's list through the lane (reads.go); on Windows an ordinary account
|
||||||
// state rather than the machine's.
|
// is refused it (serviceGuard, sysservice.go), because it would read that
|
||||||
|
// account's own state rather than the machine's.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -29,14 +30,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const switchUsage = "usage: sudo -H privatae-bastion switch <host> <family> on|off\n" +
|
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)"
|
" 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
|
// cmdSwitch parses the arguments and runs one switch, the listing, or the
|
||||||
// reset of an unreadable list.
|
// reset of an unreadable list.
|
||||||
func cmdSwitch(args []string) error {
|
func cmdSwitch(args []string) error {
|
||||||
if len(args) == 1 && args[0] == "--list" {
|
if len(args) >= 1 && args[0] == "--list" {
|
||||||
return switchList(os.Stdout)
|
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" {
|
if len(args) == 1 && args[0] == "--reset" {
|
||||||
// The same authority as switching anything back on: a reset leaves
|
// The same authority as switching anything back on: a reset leaves
|
||||||
@@ -64,6 +77,25 @@ func cmdSwitch(args []string) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("the last word is on or off, not %q\n%s", args[2], switchUsage)
|
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 {
|
if err := requireMachineAuthority("switch"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -75,11 +107,7 @@ func cmdSwitch(args []string) error {
|
|||||||
if err := localDenySet(host, family, off); err != nil {
|
if err := localDenySet(host, family, off); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if off {
|
fmt.Println(switchDoneText(host, family, 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)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,56 +134,97 @@ func switchResolveHost(key string) (string, error) {
|
|||||||
return c.HostName, nil
|
return c.HostName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchList prints one line per paired host and family:
|
// switchRow is one family of one paired host: off (refused here) or on.
|
||||||
//
|
type switchRow struct {
|
||||||
// <host> <family> on|off [since <time>]
|
Host string `json:"host"`
|
||||||
//
|
Family string `json:"family"`
|
||||||
// Plain columns so a setup script can read it as well as a person.
|
Off bool `json:"off"`
|
||||||
func switchList(w io.Writer) error {
|
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()
|
cfgs, err := loadPairings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return v, err
|
||||||
}
|
}
|
||||||
if len(cfgs) == 0 {
|
if len(cfgs) == 0 {
|
||||||
// Name the state that was read. On Linux the root service keeps its
|
v.StateDir, _ = configDirPath()
|
||||||
// state under /root, and an ordinary account's --list reads its own,
|
return v, nil
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
localDenyMu.Lock()
|
localDenyMu.Lock()
|
||||||
f, readErr := localDenyLoad()
|
f, readErr := localDenyLoad()
|
||||||
localDenyMu.Unlock()
|
localDenyMu.Unlock()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
// The gate refuses everything while the list cannot be read
|
v.Unreadable = localDenyUnreadableText(readErr)
|
||||||
// (localDenied), so every line says off, and the warning says why.
|
|
||||||
fmt.Fprintf(w, "warning: %s\n", localDenyUnreadableText(readErr))
|
|
||||||
}
|
}
|
||||||
names := hostNames(cfgs)
|
names := hostNames(cfgs)
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
fams := localSwitchFamilies()
|
fams := localSwitchFamilies()
|
||||||
for _, host := range names {
|
for _, host := range names {
|
||||||
off := map[string]bool{}
|
off := map[string]bool{}
|
||||||
|
if readErr == nil {
|
||||||
for _, fam := range f.Off[host] {
|
for _, fam := range f.Off[host] {
|
||||||
off[fam] = true
|
off[fam] = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for _, fam := range fams {
|
for _, fam := range fams {
|
||||||
if readErr != nil {
|
r := switchRow{Host: host, Family: fam, Off: readErr != nil || off[fam]}
|
||||||
fmt.Fprintln(w, host+" "+fam+" off")
|
if readErr == nil && off[fam] {
|
||||||
continue
|
r.Since = f.At[host+"/"+fam]
|
||||||
}
|
}
|
||||||
if off[fam] {
|
v.Switches = append(v.Switches, r)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
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
@@ -28,9 +28,11 @@ package main
|
|||||||
// installed and the caller administers the machine, the process points
|
// installed and the caller administers the machine, the process points
|
||||||
// that one variable at the service's own before anything reads state.
|
// that one variable at the service's own before anything reads state.
|
||||||
// - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed,
|
// - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed,
|
||||||
// `pair`, `run` and `status` from an account that does not administer the
|
// `pair` and `run` from an account that does not administer the machine
|
||||||
// machine would act on an empty bastion of that account's own, beside the
|
// 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
|
// 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
|
// with the machine marker (singleton.go), this binds a cooperating binary
|
||||||
// only; an older copy will not refuse. What stops the person from removing
|
// only; an older copy will not refuse. What stops the person from removing
|
||||||
// the service is that they do not administer the machine.
|
// the service is that they do not administer the machine.
|
||||||
@@ -46,6 +48,11 @@ import (
|
|||||||
"time"
|
"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 (
|
const (
|
||||||
launchdLabel = "ai.privatae.bastion"
|
launchdLabel = "ai.privatae.bastion"
|
||||||
launchdPlistPath = "/Library/LaunchDaemons/" + launchdLabel + ".plist"
|
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
|
// 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
|
// profile. Beside a system service that is a second bastion (pair, run), a
|
||||||
// view of an empty one (status, `switch --list`, activity and report, which
|
// switch of its own, or a view of an empty one (status, `switch --list`,
|
||||||
// would print the caller's own records as though they were the machine's).
|
// 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,
|
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
|
// serviceLaneReads are the guarded commands that read the machine's bastion
|
||||||
// the machine's bastion itself over its socket (reads.go) rather than a
|
// itself over its lane (reads.go: ask.sock on Linux and macOS, the ask pipe on
|
||||||
// bastion of the caller's own: not refused here, and refused there for any
|
// Windows) rather than a bastion of the caller's own: not refused here, and
|
||||||
// account but the owner's.
|
// refused there for any account but the owner's.
|
||||||
var serviceLaneReads = map[string]bool{"status": true, "activity": true, "report": true}
|
var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true,
|
||||||
|
"doctor": true, "ops": true, "device-approvals": true, "backup read": true,
|
||||||
|
// Not reads: the owner's account switches, and takes reach back (`ops
|
||||||
|
// drop` is guarded as "ops"), through the same socket, with the console's
|
||||||
|
// rules (owneracts.go), and any other account is refused there.
|
||||||
|
"switch": true, "approvals drop": true, "grants": true, "device-deny": true,
|
||||||
|
"approvals": true, "approve": true, "deny": true}
|
||||||
|
|
||||||
// serviceGuard refuses a guarded command from an account that does not
|
// serviceGuard refuses a guarded command from an account that does not
|
||||||
// administer a machine whose bastion is a system service.
|
// 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] {
|
if !installed || authority || !serviceGuardedCommands[cmd] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if serviceLaneReads[cmd] && goos != "windows" {
|
if serviceLaneReads[cmd] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+
|
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
|
installed bool
|
||||||
authority bool
|
authority bool
|
||||||
err error
|
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
|
// 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
|
// authority only after an installed service left root on Linux looking like
|
||||||
// an ordinary account, and enrolServiceStateRefusal refused it.
|
// an ordinary account, and enrolServiceStateRefusal refused it.
|
||||||
func adoptSystemServiceState() {
|
func adoptSystemServiceState() {
|
||||||
|
systemService.callerAppData = os.Getenv("APPDATA")
|
||||||
installed, err := systemServiceInstalled()
|
installed, err := systemServiceInstalled()
|
||||||
systemService.err = err
|
systemService.err = err
|
||||||
systemService.installed = installed || err != nil
|
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 {
|
func guardServiceFork(cmd string) error {
|
||||||
|
cmd = serviceGuardKey(cmd, os.Args)
|
||||||
installed := systemService.installed
|
installed := systemService.installed
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
installed = linuxSystemUnitPresent()
|
installed = linuxSystemUnitPresent()
|
||||||
@@ -381,10 +412,44 @@ func guardServiceFork(cmd string) error {
|
|||||||
return serviceGuard(runtime.GOOS, cmd, installed, systemService.authority)
|
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`.
|
// cmdService is `privatae-bastion service install|uninstall|status`.
|
||||||
func cmdService(args []string) error {
|
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 {
|
if len(args) != 1 {
|
||||||
return errors.New("usage: privatae-bastion service install|uninstall|status")
|
return errors.New(usage)
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "install":
|
case "install":
|
||||||
|
|||||||
@@ -165,11 +165,12 @@ func TestWindowsSystemAppDataIsLocalSystemsProfile(t *testing.T) {
|
|||||||
|
|
||||||
func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||||
for _, goos := range []string{"darwin", "windows", "linux"} {
|
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)
|
err := serviceGuard(goos, cmd, true, false)
|
||||||
if (cmd == "status" || cmd == "activity" || cmd == "report") && goos != "windows" {
|
if cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report" {
|
||||||
// These read the machine's bastion over its socket (reads.go),
|
// These read the machine's bastion over its lane (reads.go:
|
||||||
// which answers the owner's account and refuses any other.
|
// ask.sock, or the ask pipe on Windows), which answers the
|
||||||
|
// owner's account and refuses any other.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, cmd, err)
|
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.
|
// 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 {
|
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)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -236,6 +237,20 @@ func installSystemService() error {
|
|||||||
}
|
}
|
||||||
defer s.Close()
|
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}
|
restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second}
|
||||||
if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil {
|
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)
|
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
|
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
|
// 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.
|
// for it to say it has. A service that is not running is already stopped.
|
||||||
func stopServiceAndWait(s *mgr.Service) {
|
func stopServiceAndWait(s *mgr.Service) {
|
||||||
|
|||||||
+51
-7
@@ -130,6 +130,10 @@ const uiEventRecentFor = 7 * 24 * time.Hour
|
|||||||
// line of the page where the day is the point.
|
// line of the page where the day is the point.
|
||||||
const uiEventTime = "2 Jan 2006, 15:04"
|
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 {
|
type uiStatus struct {
|
||||||
// Limited: drawn for the owner's lane (consolelane.go), which may not
|
// Limited: drawn for the owner's lane (consolelane.go), which may not
|
||||||
// answer the approval device's requests.
|
// answer the approval device's requests.
|
||||||
@@ -499,8 +503,7 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !u.signedIn(r) {
|
if !u.signedIn(r) {
|
||||||
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
|
http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
|
||||||
http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.limitedSession(r) {
|
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.
|
// root serves the page, and is also where a ticket is redeemed.
|
||||||
func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
||||||
if why := uiRequestAllowed(r, u.port, u.verb()); why != "" {
|
if why := uiRequestAllowed(r, u.port, u.verb()); why != "" {
|
||||||
@@ -548,8 +576,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !u.signedIn(r) {
|
if !u.signedIn(r) {
|
||||||
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
|
http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
|
||||||
http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.contextUI {
|
if u.contextUI {
|
||||||
@@ -937,9 +964,7 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", addr, fmt.Errorf("the local UI refused this machine's own key (%s) — "+
|
return "", addr, uiKeyRefused(addr, resp.Status, runtime.GOOS, systemService.installed, machineAuthorityHeld())
|
||||||
"if the bastion was reinstalled, restart it so it and this command agree",
|
|
||||||
resp.Status)
|
|
||||||
}
|
}
|
||||||
var out struct {
|
var out struct {
|
||||||
Ticket string `json:"ticket"`
|
Ticket string `json:"ticket"`
|
||||||
@@ -954,6 +979,25 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
|
|||||||
return entry, addr, nil
|
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
|
// 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
|
// belongs to the running daemon, because that is the process that knows what is
|
||||||
// live.
|
// live.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{{if $.Limited}}
|
{{if $.Limited}}
|
||||||
<p class="approval-line">Approving signs with this device's key, which is the administrator's:
|
<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}}
|
{{else}}
|
||||||
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
|
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
{{else if .OwnerCode}}
|
{{else if .OwnerCode}}
|
||||||
<p><span class="flag ok">set up</span> Your code is set up on this machine.</p>
|
<p><span class="flag ok">set up</span> Your code is set up on this machine.</p>
|
||||||
{{if .Limited}}
|
{{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}}
|
{{else}}
|
||||||
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
|
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="target" value="machine">
|
<input type="hidden" name="target" value="machine">
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{else if .Limited}}
|
{{else if .Limited}}
|
||||||
<p class="sub">Set it up once, at a terminal, as the machine's administrator, so no program running as you
|
<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}}
|
{{else}}
|
||||||
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
|
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="target" value="machine">
|
<input type="hidden" name="target" value="machine">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<h3>Connect a Host</h3>
|
<h3>Connect a Host</h3>
|
||||||
<div class="host">
|
<div class="host">
|
||||||
<p class="sub">The token comes from your host's “Set up a bastion”. It is used once.</p>
|
<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"
|
<form class="setup-form" hx-post="/setup/pair" hx-target="#setup" hx-swap="innerHTML"
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
<label for="pair-token">Token</label>
|
<label for="pair-token">Token</label>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else if and .Servable .Limited}}
|
{{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}}
|
{{else if .Servable}}
|
||||||
{{/* .Servable, not .Hosts: a pending pairing gets no state on this machine
|
{{/* .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
|
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>
|
<h3>Add a folder to your network</h3>
|
||||||
<div class="host">
|
<div class="host">
|
||||||
{{if and .Servable .Limited}}
|
{{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}}
|
{{else if .Servable}}
|
||||||
<p class="sub">Your files stay on this machine. Your host keeps only the index, and
|
<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>
|
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.
|
<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>
|
It is a choice among the hosts this machine already serves.</p>
|
||||||
{{if and .Hosts .Limited}}
|
{{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}}
|
{{else if .Hosts}}
|
||||||
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
|
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
|
||||||
{{range .Hosts}}
|
{{range .Hosts}}
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ type uiCommand struct {
|
|||||||
Class string // ok | warn | bad — bad means "this cannot be run yet"
|
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 {
|
type uiSetupView struct {
|
||||||
Version string
|
Version string
|
||||||
Mode string
|
Mode string
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.60.0
|
0.62.0
|
||||||
|
|||||||
@@ -24,8 +24,49 @@
|
|||||||
# reads a `go test -json` run instead and names every cited test that
|
# 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
|
# 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.
|
# 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
|
set -euo pipefail
|
||||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
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
|
if [ "${1:-}" = "--skipped" ]; then
|
||||||
RUN="$(cd "$(dirname "${2:?usage: deploy/check-claims.sh --skipped <go test -json output> [repo root]}")" && pwd)/$(basename "$2")"
|
RUN="$(cd "$(dirname "${2:?usage: deploy/check-claims.sh --skipped <go test -json output> [repo root]}")" && pwd)/$(basename "$2")"
|
||||||
ROOT="${3:-$HERE}"
|
ROOT="${3:-$HERE}"
|
||||||
|
|||||||
+17
-9
@@ -23,10 +23,13 @@
|
|||||||
# that form errors without installing anything.)
|
# that form errors without installing anything.)
|
||||||
# On an install that already exists, `privatae-bastion set-mode networked` (from
|
# 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
|
# 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
|
# logon task. Re-run this installer with -Networked to make it a service. When
|
||||||
# service starts with its own state: pairings made by the earlier per-user
|
# an install moves the bastion from your account to the service, the service
|
||||||
# install stay in that user's profile and are not carried over, so the machine
|
# takes over your account's bastion state before it first starts: its
|
||||||
# is paired again (claim-anchor with a token from the host).
|
# 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
|
# ELEVATE AS YOURSELF, not as a different administrator account. The state
|
||||||
# directory is per-user (a folder under %APPDATA%), and UAC elevation of your
|
# 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) } |
|
Where-Object { $_.Path -and ($_.Path -ne $exe) } |
|
||||||
ForEach-Object {
|
ForEach-Object {
|
||||||
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
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)."
|
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)" }
|
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -326,10 +336,8 @@ if (-not $elevatedDone) {
|
|||||||
& $exe set-mode networked
|
& $exe set-mode networked
|
||||||
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
|
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
|
# The machine's owner ($owner, above). A second administrator's
|
||||||
# one when run elevated by hand. A second administrator's reinstall leaves
|
# reinstall leaves the first owner in place; the bastion says so.
|
||||||
# the first owner in place; the bastion says so.
|
|
||||||
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
|
|
||||||
& $exe approvals-owner --sid $owner
|
& $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)." }
|
if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." }
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
+85
-2
@@ -466,11 +466,11 @@ if [ "$networked" = 1 ] && [ "$root" != 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
key="$os-$arch"
|
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:
|
# 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
|
# this script is piped to `sudo sh`, so a partial run is a root-privileged
|
||||||
# partial install.
|
# 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; }
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
@@ -485,8 +485,91 @@ else die "no sha256sum or shasum on this system — cannot verify the download,
|
|||||||
fi
|
fi
|
||||||
sha_of() { $SHACMD "$1" | cut -d' ' -f1; }
|
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..."
|
echo "Fetching manifest..."
|
||||||
curl -fsSL "$base/version.json" -o "$man" || die "cannot fetch $base/version.json — refusing to install unverified"
|
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
|
# 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
|
# python (neither is guaranteed on a clean macOS or a minimal container): find the
|
||||||
|
|||||||
+11
-4
@@ -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 |
|
| 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 |
|
| `--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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| MCP tool servers and their problems | tested | built | built |
|
||||||
| Changes since your baseline | tested | built | built |
|
| Changes since your baseline | tested | built | built |
|
||||||
| Stop an agent with your code, from the console or `stop` | run | built | built |
|
| Stop an agent with your code, from the console or `stop` | run | built | built |
|
||||||
| An agent in another account asks (Waiting for you) | tested, with two accounts | built | not built (the ask socket is Unix only) |
|
| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | built (the ask pipe) |
|
||||||
| The console from the owner's own account, no sudo | run | built | the tray's approvals window only |
|
| 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 |
|
| Activity, report | run | built | built |
|
||||||
| `uninstall`, keeping the audit log | run | built | built |
|
| `uninstall`, keeping the audit log | run | built | built |
|
||||||
| A notice when something is waiting | none yet | none yet | the tray |
|
| 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
|
- 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
|
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, where a sync client can copy it, and two grants over the same
|
||||||
folder. The fix moves it under the bastion's own state.
|
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
|
- There is no MCP endpoint on the bastion, on purpose. An agent on the machine
|
||||||
uses the command line ([for agents](skills/bastion.md)).
|
uses the command line ([for agents](skills/bastion.md)).
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `ops_linux_test.go`: `TestOpDryRunShowsTheChangeAndApplyCommitsExactlyThat` | A dry run commits nothing; an apply must name the dry run's change list and carry an approval bound to that op, its parameters, the machine and that change list. Skipped where the sandbox is unavailable. |
|
| | `ops_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_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`: `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_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. |
|
| | `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. |
|
| | `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. |
|
| | `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_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. |
|
| | `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. |
|
| | 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. |
|
| 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. |
|
| | `claims_git_test.go`: `TestNoHelperOnTheMachineStoresTheForgeToken` | A real git reading the forge through the proxy, with git's `store` helper configured before `git-setup`'s lines, after them, or in the repository, authenticates with the host's token and writes it nowhere. |
|
||||||
|
|||||||
+14
-8
@@ -22,11 +22,11 @@
|
|||||||
| `approvals drop [--host <host> \| --machine] <id>` | Take back an approval this machine issued and has not used yet. |
|
| `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. |
|
| `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-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`. |
|
| `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-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. |
|
| `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. |
|
| `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 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`. |
|
| `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. |
|
| `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. |
|
| `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 [--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. |
|
| `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. |
|
| `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. |
|
| `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 --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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 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 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>]` | Restore requests your host sent this machine. |
|
| `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 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 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 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 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. |
|
| `version` | Print the version. |
|
||||||
|
|
||||||
Run `privatae-bastion` with no arguments for the built-in help.
|
Run `privatae-bastion` with no arguments for the built-in help.
|
||||||
|
|
||||||
## Reads from the owner's account
|
## Reads from the owner's account
|
||||||
|
|
||||||
Installed for the whole machine, the bastion runs as root and keeps its records in root's state. On Linux and macOS, the account recorded as the machine's owner (`approvals-owner --account`, which the installer writes) reads them without sudo: `status`, `agents`, `schedules`, `tool-servers`, `posture`, `baseline show`, `baseline diff`, `activity`, `report` and `approvals` ask the running bastion over its local ask socket, which knows from the kernel which account is asking, and print what it answers. That is what the console already shows the owner, so an agent the owner runs can read it too; `posture`, which the console has no page for, adds the firewall checks, read by the bastion as the administrator. No read prints the delegation tag an agent carries, as the console never shows one. Nothing that changes anything goes this way; those still take the owner's code.
|
Installed for the whole machine, the bastion runs as root and keeps its records in root's state. On Linux and macOS, the account recorded as the machine's owner (`approvals-owner --account`, which the installer writes) reads them without sudo: `status`, `agents` (and `agents show`), `schedules`, `tool-servers`, `posture`, `baseline show`, `baseline diff`, `activity`, `report`, `approvals`, `switch --list`, `ops`, `device-approvals`, `backup key show`, `backup requests`, `backup shares` and `doctor` ask the running bastion over its local ask socket, which knows from the kernel which account is asking, and print what it answers. That is what the console already shows the owner, so an agent the owner runs can read it too, and more that the console has no page for, all of it about the owner's own machine: `posture` adds the firewall checks, read by the bastion as the administrator; `ops` and the backup reads show what each Host holds here; `doctor` checks the install. Read this way, `device-approvals` records nothing as shown, and says that the asks are answered as the administrator. No read prints the delegation tag an agent carries, as the console never shows one. The few acts that go this way follow the console's rules for them ([Acts from the owner's account](#acts-from-the-owners-account)): what widens reach takes the owner's code.
|
||||||
|
|
||||||
Any other account is refused. `agents`, `schedules`, `tool-servers` and `posture` then show what that account can read itself, and say so; the rest print the refusal and the administrator's command. Comparing against the baseline, the posture checks and the report are reused for 30 seconds, and the bastion answers four reads at a time, so a polling agent cannot slow the machine.
|
Any other account is refused. `agents`, `schedules`, `tool-servers` and `posture` then show what that account can read itself, and say so; the rest print the refusal and the administrator's command. Comparing against the baseline, the posture checks, the report and `doctor` are reused for 30 seconds, and the bastion answers four reads at a time, so a polling agent cannot slow the machine.
|
||||||
|
|
||||||
|
## Acts from the owner's account
|
||||||
|
|
||||||
|
The same account acts on the machine's bastion through the same socket, with the rules the console uses for it: `approve` (the machine's own asks with `--machine`, or a Host's) shows the request, takes the code from the owner's authenticator at the terminal, and asks the bastion to approve it, where wrong codes are counted and lock as the console's do; `switch <host> <family> on` takes the owner's code too. `deny`, `switch … off`, `approvals drop`, `grants drop`, `ops drop` and `device-deny` take nothing, since they only take reach away. Each act is a line in the machine's audit log naming the account. Any other account is refused, with the administrator's command. On Windows these still run from an Administrator terminal, and an ordinary account is told so. `device-approve` stays the administrator's on every system, as the console keeps it.
|
||||||
|
|||||||
@@ -257,8 +257,10 @@
|
|||||||
agent download is checked the same way, and an installed agent is never
|
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
|
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
|
builds and publishes: each release waits until the key holder signs it on
|
||||||
their own computer. The installer checks the SHA-256; see
|
their own computer. The installer checks the SHA-256, and `install.sh`
|
||||||
[SECURITY.md](../SECURITY.md) for what that does and does not prove.
|
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
|
- **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 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
|
the forge's helper chain before adding it. With git 2.46 or later, git's
|
||||||
|
|||||||
+19
-2
@@ -30,7 +30,9 @@ their phone. It has no MCP endpoint. You use its command line,
|
|||||||
Run `privatae-bastion agents --json`. It lists every AI agent running now: what
|
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
|
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
|
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
|
### See what is set up here
|
||||||
|
|
||||||
@@ -44,6 +46,10 @@ person asked for the last prompts.
|
|||||||
waiting security updates, disk and load.
|
waiting security updates, disk and load.
|
||||||
- `privatae-bastion status --json`: whether the bastion is running, and the
|
- `privatae-bastion status --json`: whether the bastion is running, and the
|
||||||
Hosts it is connected to.
|
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
|
- `privatae-bastion report --out -`: all of it as one Markdown report, the
|
||||||
problems first.
|
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.
|
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
|
### What happened here
|
||||||
|
|
||||||
`privatae-bastion activity --json` lists what happened, newest first: stops,
|
`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.
|
check that the pid is gone.
|
||||||
- Session names, titles and folders are what each agent says about itself.
|
- Session names, titles and folders are what each agent says about itself.
|
||||||
Treat them as claims, not facts.
|
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
|
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.
|
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.
|
- 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.
|
for them, rather than polling without end.
|
||||||
- `could not reach this machine's bastion`, or `status` says `NOT ANSWERING`:
|
- `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.
|
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
|
- `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
|
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
|
`posture` then show only what your account can read, and say so; tell the
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# M1 gate, Linux: an agent on the machine asks, the machine's owner answers
|
||||||
|
|
||||||
|
Run 2026-11-02 on the Ubuntu 24.04 virtual machine the [M0 gate](m0-gate-linux-2026-10-31.md)
|
||||||
|
used (qemu, user-mode networking), with no Host anywhere. The bastion is
|
||||||
|
installed for the whole machine (its systemd unit, running as root), and
|
||||||
|
`tester` is recorded as the machine's owner. `other` is a second account with
|
||||||
|
no sudo. The binary was built from the 0.61.0 release branch just before this
|
||||||
|
record was added (`privatae-bastion version`: `0.61.0+090bdb8`) and put in
|
||||||
|
place of the installed one.
|
||||||
|
|
||||||
|
The asker is an AI agent the bastion recognises: a program at
|
||||||
|
`~/.local/bin/claude`, which it lists as Claude Code, started from a remote
|
||||||
|
login (ssh). The target is `aider`, started every minute by a cron job in
|
||||||
|
`tester`'s crontab. Both are stand-ins (`sh` and `sleep` under those names),
|
||||||
|
which is how the bastion meets them anyway: by the program a process runs and
|
||||||
|
how it was started.
|
||||||
|
|
||||||
|
macOS and Windows have not been run. On Windows an agent cannot ask yet,
|
||||||
|
because the ask socket is Unix only ([STATUS](../STATUS.md)).
|
||||||
|
|
||||||
|
| Gate step | Result |
|
||||||
|
|---|---|
|
||||||
|
| The agent lists the AI agents on the machine (`agents --json`) | `aider` (started by a scheduled job) and Claude Code (started from a remote login), with pids and accounts |
|
||||||
|
| The agent asks to stop `aider` (`stop <pid> --json`) | A request id and state `waiting`; the request names the agent that asked and the target |
|
||||||
|
| The owner lists what waits, from their own account (`approvals --machine`) | The request, in the same words |
|
||||||
|
| The owner approves with a wrong code | Refused; nothing stopped |
|
||||||
|
| The owner approves with the code from their authenticator (`approve --machine <id>`) | Approved, and the bastion carried it out |
|
||||||
|
| The agent follows its request (`request <id> --wait 30s --json`) | State `stopped`, outcome "You stopped aider"; the process is gone |
|
||||||
|
| The agent asks to stop the next cron'd `aider`; the owner denies it (`deny --machine <id>`, no code) | Denied; the agent's `request` reads `denied` with exit status 1; `aider` keeps running |
|
||||||
|
| The agent asks the same stop again | Refused: the owner refused this same stop within the last 24 hours |
|
||||||
|
| `other` tries `approvals --machine`, `approve`, `deny`, `doctor` | Each refused, naming the owner's account, with the administrator's command as typed (`sudo -H privatae-bastion approve --machine <id>`); the request stays waiting |
|
||||||
|
| `other` reads `agents --json` | Told the machine's bastion answers only its owner, and shown what that account can read itself |
|
||||||
|
| `other` asks after the agent's request (`request <id>`) | `unknown`: no such request was asked from that account |
|
||||||
|
| The machine's audit log | Each ask, approval and denial names the asking account, the asking agent (Claude Code, remote login) and the target (aider, scheduled job); each act from the owner's account is its own line naming that account |
|
||||||
|
|
||||||
|
Found and fixed while running M1 on this machine, before this record:
|
||||||
|
`switch --list` was refused by the M0 guard meant for `switch`; a read the
|
||||||
|
bastion answered but could not build came back as a refusal with the
|
||||||
|
administrator's command; "not paired" named `pair` without `sudo -H`; an act
|
||||||
|
refused for another account said "reads" and gave a bare `deny`; and
|
||||||
|
`approve` and `approvals --machine` from another account were refused as a
|
||||||
|
read and without `--machine`.
|
||||||
|
|
||||||
|
## Transcript (abridged)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ~/.local/bin/claude -c 'privatae-bastion agents --json' # summarised
|
||||||
|
aider pid 5162 origin scheduler user tester
|
||||||
|
claude-code pid 5265 origin remote user tester
|
||||||
|
|
||||||
|
$ ~/.local/bin/claude -c 'privatae-bastion stop 5162 --json'
|
||||||
|
{
|
||||||
|
"request": "857285ef",
|
||||||
|
"state": "waiting",
|
||||||
|
"text": "Stop aider in /home/tester (pid 5162) on this machine, and every process it started. Asked from Claude Code (pid 5324), running as tester.",
|
||||||
|
"asked_as": "tester"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ privatae-bastion approvals --machine
|
||||||
|
[857285ef] asked 2026-11-02T05:23:07Z
|
||||||
|
Stop aider in /home/tester (pid 5162) on this machine, and every process it started. Asked from Claude Code (pid 5324), running as tester.
|
||||||
|
|
||||||
|
$ privatae-bastion approve --machine 857285ef # a wrong code
|
||||||
|
Approve this? Enter the code from your authenticator (or leave empty to cancel): error: code refused
|
||||||
|
$ privatae-bastion approve --machine 857285ef
|
||||||
|
Approve this? Enter the code from your authenticator (or leave empty to cancel): approved.
|
||||||
|
|
||||||
|
$ ~/.local/bin/claude -c 'privatae-bastion request 857285ef --wait 30s --json'
|
||||||
|
{
|
||||||
|
"request": "857285ef",
|
||||||
|
"state": "stopped",
|
||||||
|
"outcome": "You stopped aider (pid 5162) running on this machine.",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
$ ~/.local/bin/claude -c 'privatae-bastion stop 6145 --json' # the next cron'd aider
|
||||||
|
"request": "f090208b", "state": "waiting"
|
||||||
|
|
||||||
|
other$ privatae-bastion approve --machine f090208b
|
||||||
|
error: this machine's bastion acts only for its owner's account (tester), not other
|
||||||
|
As the administrator: sudo -H privatae-bastion approve --machine f090208b
|
||||||
|
other$ privatae-bastion approvals --machine
|
||||||
|
error: this machine's bastion answers reads only for its owner's account (tester), not other
|
||||||
|
As the administrator: sudo -H privatae-bastion approvals --machine
|
||||||
|
|
||||||
|
$ privatae-bastion deny --machine f090208b
|
||||||
|
denied.
|
||||||
|
$ ~/.local/bin/claude -c 'privatae-bastion request f090208b --json'
|
||||||
|
"state": "denied" (exit status 1)
|
||||||
|
$ ~/.local/bin/claude -c 'privatae-bastion stop 6145 --json'
|
||||||
|
"why": "the machine's owner refused this same stop within the last 24 hours, so it cannot be asked again yet"
|
||||||
|
|
||||||
|
# the machine's audit log (machine-factor/audit.jsonl), summarised
|
||||||
|
05:23:07Z local_stop_ask asked to stop aider (pid 5162) asker: tester, claude-code, remote target: aider, scheduler
|
||||||
|
05:23:08Z stepup_approve approved: local_stop|5162|aider asker: tester, claude-code, remote target: aider, scheduler
|
||||||
|
05:23:08Z owner_lane_act approve the machine's request 857285ef…, from the owner's account tester through its ask socket
|
||||||
|
05:24:04Z local_stop_ask asked to stop aider (pid 6145) asker: tester, claude-code, remote target: aider, scheduler
|
||||||
|
05:24:06Z stepup_deny denied: local_stop|6145|aider asker: tester, claude-code, remote target: aider, scheduler
|
||||||
|
05:24:06Z owner_lane_act deny the machine's request f090208b…, from the owner's account tester through its ask socket
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user