13 Commits

Author SHA1 Message Date
Privatae 48899b41a9 bastion 0.63.1
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
2026-11-03 22:56:18 +00:00
Privatae 2043fa5e9e bastion 0.62.0
ci / fuzz (push) Successful in 3m27s
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m39s
2026-11-02 21:31:58 +00:00
Privatae 79646bda13 bastion 0.61.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m56s
ci / fuzz (push) Successful in 3m48s
2026-11-02 15:46:29 +00:00
Privatae d4594d18b7 bastion 0.61.1
ci / test (push) Successful in 5m45s
ci / personal-data (push) Successful in 9s
ci / fuzz (push) Successful in 3m30s
2026-11-02 08:00:14 +00:00
Privatae 1176f7fd0b bastion 0.61.0
ci / personal-data (push) Successful in 8s
ci / test (push) Successful in 7m17s
ci / fuzz (push) Successful in 6m33s
2026-11-02 06:20:42 +00:00
Privatae 4f8285ef18 bastion 0.60.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s
2026-11-02 00:10:58 +00:00
Privatae 4a5c310482 bastion 0.58.1
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 4m6s
ci / fuzz (push) Failing after 2m58s
2026-10-29 21:20:20 +00:00
Privatae 01889e2a48 bastion 0.58.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m5s
ci / fuzz (push) Successful in 4m10s
2026-10-29 15:54:51 +00:00
Privatae 081763a675 bastion 0.57.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m30s
ci / fuzz (push) Successful in 3m25s
2026-10-28 20:35:45 +00:00
Privatae 4012da0b75 bastion 0.56.0
ci / personal-data (push) Successful in 10s
ci / test (push) Successful in 5m30s
ci / fuzz (push) Successful in 3m16s
2026-10-28 02:24:07 +00:00
Privatae 8822d5df85 bastion 0.55.3
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s
2026-10-25 23:16:45 +00:00
Privatae 77b7853ae1 bastion 0.54.1
ci / personal-data (push) Successful in 6s
ci / fuzz (push) Successful in 2m49s
ci / test (push) Successful in 3m7s
2026-10-21 23:39:13 +00:00
Privatae d62da8adb4 bastion 0.54.0
ci / personal-data (push) Successful in 4s
ci / fuzz (push) Successful in 2m44s
ci / test (push) Successful in 2m11s
2026-10-21 22:29:10 +00:00
403 changed files with 58834 additions and 4441 deletions
+3
View File
@@ -0,0 +1,3 @@
# Test tooling for one test lab, built only with -tags lab; it is not part of
# any release build, so it is left out of the published source.
client-go/lab.go export-ignore
+30 -8
View File
@@ -16,7 +16,7 @@ on:
branches: [main] branches: [main]
env: env:
GO_IMAGE: golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 GO_IMAGE: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
# The pull request's own head, or the pushed commit. # The pull request's own head, or the pushed commit.
WANT: ${{ gitea.event.pull_request.head.sha || gitea.sha }} WANT: ${{ gitea.event.pull_request.head.sha || gitea.sha }}
@@ -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 ;;
@@ -58,7 +60,10 @@ jobs:
echo "== gofmt"; out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; } echo "== gofmt"; out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; }
for os in linux darwin windows; do echo "== go vet ($os)"; GOOS=$os go vet ./...; done for os in linux darwin windows; do echo "== go vet ($os)"; GOOS=$os go vet ./...; done
echo "== go build"; go build -o /tmp/privatae-bastion . echo "== go build"; go build -o /tmp/privatae-bastion .
echo "== go test"; go test -count=1 ./...' if [ -f lab.go ]; then echo "== go vet -tags lab"; go vet -tags lab ./...; fi
echo "== go test"; go test -count=1 ./...
echo "== staticcheck"; sh ../deploy/staticcheck.sh
echo "== the GitHub release step, against a fake API"; python3 -I ../deploy/test_github_release.py'
- name: the same for deploy/release-sign - name: the same for deploy/release-sign
run: | run: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/deploy/release-sign \ docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/deploy/release-sign \
@@ -80,9 +85,20 @@ jobs:
test "$(git rev-parse HEAD)" = "$WANT" test "$(git rev-parse HEAD)" = "$WANT"
- name: no person's data in the tree - name: no person's data in the tree
# Reads files, runs nothing from the tree. The term check needs the # Reads files, runs nothing from the tree. The term check needs the
# maintainers' key, which a CI job never holds, so here it is skipped and # maintainers' key and the entity-id check the reserved ids, which a CI
# the run says so; the entity-id, provenance, email and phone checks run. # job never holds, so the job names files that do not exist: both
run: python3 scripts/test_no_personal_data.py # classes are skipped and the run prints a NOTE for each; the
# provenance, email and phone checks run. Left at their defaults, a
# path that exists but cannot be read fails the run instead.
run: |
none="$PWD/.ci-holds-none/none"
NO_PERSONAL_DATA_KEY_FILE="$none" RESERVED_ENTITY_IDS_FILE="$none" python3 scripts/test_no_personal_data.py
- name: every test the claims table cites exists
# docs/claims.md names, for each security claim, the test that fails
# if the claim stops being true. A renamed or deleted test would leave
# the claim looking covered; this fails the run instead. Reads files
# and greps; runs nothing from the tree.
run: bash deploy/check-claims.sh
- name: no Host source file names in the tree - name: no Host source file names in the tree
# The member repository publishes this tree as a release was built, # The member repository publishes this tree as a release was built,
# and its release step refuses a path into the Host's Python # and its release step refuses a path into the Host's Python
@@ -117,8 +133,14 @@ jobs:
- name: every fuzz target, 5 seconds each - name: every fuzz target, 5 seconds each
# A short pass on every commit. Run deploy/fuzz.sh with its default of # A short pass on every commit. Run deploy/fuzz.sh with its default of
# 20 seconds before changing a wall a host's input goes through. # 20 seconds before changing a wall a host's input goes through.
# fuzz.sh does not fail on Go's own -fuzztime report, on the Go
# releases whose fuzzer reports its deadline as a failure, and fails on
# anything else; test_fuzz.sh checks both, with planted failing
# inputs, before the targets run, and fails once the image's Go no
# longer needs the workaround.
run: | run: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src \ docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src \
-e GOTOOLCHAIN=local -e CGO_ENABLED=0 "$GO_IMAGE" sh -euc ' -e GOTOOLCHAIN=local -e CGO_ENABLED=0 "$GO_IMAGE" sh -euc '
apk add -q --no-cache bash apk add -q --no-cache bash
bash deploy/test_fuzz.sh
deploy/fuzz.sh 5' deploy/fuzz.sh 5'
+248
View File
@@ -0,0 +1,248 @@
# The checks of .gitea/workflows/ci.yml, for the copy of this repository on
# GitHub. Gitea reads .gitea/workflows when it exists and then ignores this
# directory, so each forge runs one of the two files, never both.
#
# The jobs, image and commands are the same as there. Every job runs in the Go
# toolchain image deploy/build.sh builds releases with, pinned by the same
# digest, so CI and the release use the same compiler. GitHub does not let a
# job's container name come from env, so the digest is written out in each
# job; it must match GO_IMAGE in deploy/build.sh and .gitea/workflows/ci.yml.
# No action from the marketplace is used: the commit is fetched with git from
# this repository's public URL, so nothing outside this file and the pinned
# image runs. The job token can only read.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
env:
# The pull request's own head, or the pushed commit.
WANT: ${{ github.event.pull_request.head.sha || github.sha }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
GOTOOLCHAIN: local
CGO_ENABLED: "0"
defaults:
run:
shell: sh -eu {0}
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 30
container:
image: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
env:
PRIVATAE_REQUIRE_AGE: "1"
steps:
- name: tools
# pdftotext, python3, setpriv and git are what the extract and
# peer-identity tests call; without them those tests fail rather than
# skip. Alpine's git has no http-backend (it is in git-daemon), which
# the forge-token test serves its repository with.
run: apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon
- name: fetch the commit
# The checkout belongs to the runner's user and the job runs as root,
# so git is told the directory is safe; go build reads it to stamp the
# binary with the commit.
run: |
set -o pipefail
git config --global --add safe.directory "$PWD"
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: age
# The backup tests check this code against the age command itself: a
# pinned age release, its archive checked against the SHA-256 below,
# and PRIVATAE_REQUIRE_AGE makes those tests fail rather than skip if
# it is missing.
run: |
case "$(uname -m)" in
x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;;
aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;;
*) echo "no pinned age release for $(uname -m)"; exit 1 ;;
esac
curl -fsSL -o /tmp/age.tgz "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-$a.tar.gz"
echo "$sum /tmp/age.tgz" | sha256sum -c -
tar -xzf /tmp/age.tgz -C /tmp age/age
/tmp/age/age --version
echo "AGE_BIN=/tmp/age/age" >> "$GITHUB_ENV"
- name: gofmt, go vet on linux/darwin/windows, build, go test
working-directory: client-go
run: |
echo "== gofmt"; out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; }
for os in linux darwin windows; do echo "== go vet ($os)"; GOOS=$os go vet ./...; done
echo "== go build"; go build -o /tmp/privatae-bastion .
echo "== go test"; go test -count=1 ./...
- name: the same for deploy/release-sign
working-directory: deploy/release-sign
run: |
out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; }
for os in linux darwin windows; do GOOS=$os go vet ./...; done
go test -count=1 ./...
- name: staticcheck on linux, darwin and windows
# The pinned staticcheck release, built with this toolchain from the
# module proxy and checked against the checksum database.
run: sh deploy/staticcheck.sh
- name: the release workflow's GitHub step, against a fake API
run: python3 -I deploy/test_github_release.py
personal-data:
runs-on: ubuntu-24.04
timeout-minutes: 10
container:
image: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
steps:
- name: tools
run: apk add -q --no-cache bash git python3
- name: fetch the commit
run: |
set -o pipefail
git config --global --add safe.directory "$PWD"
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: this file's image is the image releases are built with
# Each job here names the digest itself, so a change to GO_IMAGE in
# deploy/build.sh that missed this file fails here.
run: |
want="$(sed -n 's/^GO_IMAGE="\(golang@sha256:[0-9a-f]\{64\}\)"$/\1/p' deploy/build.sh)"
test -n "$want"
test "$(grep -c "^ image: $want\$" .github/workflows/ci.yml)" -eq 3
test "$(grep -c '^ image: ' .github/workflows/ci.yml)" -eq 3
grep -q "^ GO_IMAGE: $want\$" .gitea/workflows/ci.yml
grep -q "^ GO_IMAGE: $want\$" .github/workflows/ci.yml
- name: no person's data in the tree
# Reads files, runs nothing from the tree. The term check needs the
# maintainers' key and the entity-id check the reserved ids, which
# this job never holds, so both are skipped and the run prints a NOTE
# for each; the provenance, email and phone checks run.
run: python3 -I scripts/test_no_personal_data.py
- name: every test the claims table cites exists
# docs/claims.md names, for each security claim, the test that fails
# if the claim stops being true. A renamed or deleted test would leave
# the claim looking covered; this fails the run instead. Reads files
# and greps; runs nothing from the tree.
run: bash deploy/check-claims.sh
- name: no Host source file names in the tree
# The member repository publishes this tree as a release was built,
# and its release step refuses a path into the Host's Python
# (server/... or brain/... .py): this repo has no such files, so the
# path can only name another program's source. git grep exits 1 when
# nothing matches and above 1 when it could not search; only 1 passes.
run: |
rc=0
git grep -nIE 'server/[a-z_/]+\.py|brain/[a-z_/]+\.py' || rc=$?
case "$rc" in
1) ;;
0) echo "these lines name another program's source file; describe it instead" >&2
exit 1 ;;
*) echo "git grep could not search the tree (exit $rc), so it was not checked" >&2
exit 1 ;;
esac
fuzz:
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 20
container:
image: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
steps:
- name: tools
run: apk add -q --no-cache bash git
- name: fetch the commit
run: |
set -o pipefail
git config --global --add safe.directory "$PWD"
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: the fuzz runner fails on a failing input and on nothing else
# fuzz.sh does not fail on Go's own -fuzztime report, on the Go
# releases whose fuzzer reports its deadline as a failure, and fails on
# anything else; this checks both, with planted failing inputs, before
# the targets run, and fails once the image's Go no longer needs the
# workaround.
run: bash deploy/test_fuzz.sh
- name: every fuzz target, 5 seconds each
# A short pass on every commit. Run deploy/fuzz.sh with its default of
# 20 seconds before changing a wall a host's input goes through.
run: bash deploy/fuzz.sh 5
claims-on-a-machine:
# The claim tests a container cannot run: the op and action-op sandboxes
# (root, systemd units, overlayfs, a cgroup with the pids controller),
# the egress tests in private user and network namespaces, and the root
# command watch (nftables). They run here on the runner's own virtual
# machine, as root, because that is what they test: the bastion as a
# networked machine's system service. The machine is GitHub's and is
# discarded after the job. The toolchain is the one in the pinned image.
# Every test docs/claims.md cites is run, and the job fails if any of
# them skips.
needs: test
runs-on: ubuntu-24.04
timeout-minutes: 30
# This job runs on the runner itself, not in the image, and Ubuntu
# 24.04's sh is dash 0.5.12-6ubuntu5, which has no pipefail (Debian added
# it in 0.5.12-7): `set -o pipefail` there is a fatal error. bash has it.
defaults:
run:
shell: bash -eu {0}
env:
GO_IMAGE: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
steps:
- name: fetch the commit
run: |
set -o pipefail
git init -q .
git fetch -q --depth=1 "$REPO_URL" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: tools, and the pinned toolchain on the machine
run: |
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends nftables iproute2 poppler-utils >/dev/null
# The forge-token claim test needs git 2.46 or later; Ubuntu 24.04's
# own git is 2.43.
if ! git version | awk '{split($3, v, "."); exit !(v[1] > 2 || (v[1] == 2 && v[2] >= 46))}'; then
sudo add-apt-repository -y ppa:git-core/ppa >/dev/null
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/null
fi
git version
docker create --name go "$GO_IMAGE" >/dev/null
sudo docker cp go:/usr/local/go /opt/go-pinned
docker rm go >/dev/null
/opt/go-pinned/bin/go version
- name: age
# The same pinned age release as the test job.
run: |
case "$(uname -m)" in
x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;;
aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;;
*) echo "no pinned age release for $(uname -m)"; exit 1 ;;
esac
curl -fsSL -o "$RUNNER_TEMP/age.tgz" "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-$a.tar.gz"
echo "$sum $RUNNER_TEMP/age.tgz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/age.tgz" -C "$RUNNER_TEMP" age/age
echo "AGE_BIN=$RUNNER_TEMP/age/age" >> "$GITHUB_ENV"
- name: every cited test, as root
working-directory: client-go
run: |
set -o pipefail
tests="$(grep -o '_test\.go`: `[A-Za-z0-9_]*`' ../docs/claims.md | sed 's/.*`: `//; s/`$//' | sort -u | paste -sd'|' -)"
test -n "$tests"
sudo env PATH="/opt/go-pinned/bin:$PATH" HOME="$HOME" AGE_BIN="$AGE_BIN" PRIVATAE_REQUIRE_AGE=1 GOTOOLCHAIN=local CGO_ENABLED=0 \
GOFLAGS=-mod=readonly GOMODCACHE="$HOME/go/pkg/mod" GOCACHE="$HOME/.cache/go-build" \
go test -count=1 -json -run "^($tests)\$" . > "$RUNNER_TEMP/claims.json" || {
bash ../deploy/check-claims.sh --failed "$RUNNER_TEMP/claims.json"; exit 1; }
- name: none of them skipped
run: bash deploy/check-claims.sh --skipped "$RUNNER_TEMP/claims.json"
+67
View File
@@ -0,0 +1,67 @@
# Attaches each release's binaries, its signed manifest and the manifest's
# signature to the release on GitHub, for the copy of this repository there.
# Gitea reads .gitea/workflows when it exists and then ignores this directory.
#
# Nothing here is trusted to say what the release is. deploy/release-assets.sh
# rebuilds the binaries from the release tag with the tag's own pinned build,
# writes the release manifest from them, and fetches the signature published
# for that manifest, which only the release key holder can make and which is
# named by the manifest's digest. Unless the rebuilt bytes are exactly the
# signed release, there is no signature by that name and nothing is attached.
# The release key never comes near this workflow.
#
# A tag push runs it once the release is published. Run it by hand
# (workflow_dispatch, from main) to attach the files to an earlier release
# whose tag is here; its signature is still published, so that works for any
# release from 0.53.2 on.
#
# No action from the marketplace is used: the commit is fetched with git from
# this repository's public URL, and the build runs in the Go toolchain image
# deploy/build.sh pins by digest. The job's token can write the release and
# is passed only to the last step.
name: release
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
tag:
description: the release tag, vX.Y.Z
required: true
permissions:
contents: read
defaults:
run:
shell: bash -euo pipefail {0}
jobs:
assets:
runs-on: ubuntu-24.04
timeout-minutes: 40
permissions:
contents: write
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
steps:
- name: fetch this workflow's commit and the release tags
run: |
git init -q .
git fetch -q --tags "$REPO_URL" "$GITHUB_SHA"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
- name: rebuild the release and check it against its signature
run: deploy/release-assets.sh "$TAG" "$RUNNER_TEMP/assets"
- name: attach the files to the release
# The one step with the token. Its shell, git and Python are named by
# path, and Python runs isolated (-I), so nothing an earlier step put
# on PATH or in the environment chooses what runs here.
shell: /usr/bin/bash -euo pipefail {0}
env:
GH_TOKEN: ${{ github.token }}
run: |
/usr/bin/git show "refs/tags/$TAG:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md"
/usr/bin/python3 -I deploy/github-release.py "$GITHUB_REPOSITORY" "$TAG" "$RUNNER_TEMP/assets" "$RUNNER_TEMP/CHANGELOG.md"
+1018 -12
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -7,10 +7,15 @@ their approval on that machine.
## What you can do today ## What you can do today
Sign-in on this forge is not reachable from the internet, so you cannot file Issues and pull requests are welcome on GitHub, at
an issue or open a pull request here. You can read, clone, build and verify a [github.com/Privatae/bastion](https://github.com/Privatae/bastion).
release, and fork under the GPL. Report a bug or a security problem by email Development happens in a private repository, so an accepted change is carried
(see [SECURITY.md](SECURITY.md)). into the development repository and arrives here with the next release, as
its own commit. Sign-in on the git.privatae.ai forge is not reachable from
the internet, so issues and pull requests cannot be opened there. You can
also read, clone, build and verify a release, and fork under the GPL. Report
a security problem by email, not in a public issue (see
[SECURITY.md](SECURITY.md)).
## Checks a change has to pass ## Checks a change has to pass
@@ -20,16 +25,23 @@ gofmt -l . # must print nothing
go vet ./... go vet ./...
CGO_ENABLED=0 go build . CGO_ENABLED=0 go build .
go test ./... go test ./...
cd .. && deploy/staticcheck.sh # must find nothing, on linux, darwin and windows
``` ```
Some claim tests need a real machine and root: the op sandboxes, the
namespace tests and the root command watch skip in a container and as a
user. GitHub's CI runs every test `docs/claims.md` cites as root on its own
virtual machine and fails if one skips; to run them yourself, use a machine
you can throw away.
The backup tests also check this code against the age command itself when The backup tests also check this code against the age command itself when
one is on PATH or named by `AGE_BIN`; CI installs a pinned age release and one is on PATH or named by `AGE_BIN`; CI installs a pinned age release and
sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip. sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip.
The test suite covers all three platforms' parsing and judgement logic from Most parsing and judgement logic is in files with no build tag and runs under
one `go test` on any OS. Platform-specific decisions live in files with no one `go test` on any OS. Some platform parsers (on Linux and macOS) and the
build tag; only the enumerators are tagged. Keep it that way: a new judgement Linux sandbox are in tagged files, and their tests run only on that OS. A new
goes in an untagged file with a table test. Check it with judgement goes in an untagged file with a table test. Check it with
`GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` as well: `go build` `GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` as well: `go build`
does not compile test files, so only vet sees a test that uses a Linux-only does not compile test files, so only vet sees a test that uses a Linux-only
name. name.
+328 -1376
View File
File diff suppressed because it is too large Load Diff
+57 -19
View File
@@ -4,7 +4,7 @@
Please do not open a public issue for a security problem. Please do not open a public issue for a security problem.
Email **info@privatae.ai** with: Email **security@privatae.ai** with:
- the version (`privatae-bastion version`) and platform, - the version (`privatae-bastion version`) and platform,
- what you found and how to reproduce it, - what you found and how to reproduce it,
@@ -22,13 +22,15 @@ notes if you wish.
machine do more than the grant and the local approval allow: reading files machine do more than the grant and the local approval allow: reading files
outside a granted store, deleting where only archiving is allowed, running a outside a granted store, deleting where only archiving is allowed, running a
program that is not in the fixed table, bypassing the second factor, opening program that is not in the fixed table, bypassing the second factor, opening
a listener that is not documented in the README's security model. a listener that is not documented in the security model
([docs/security-model.md](docs/security-model.md)).
## What is out of scope ## What is out of scope
- The Privatae platform the bastion connects to. It is separate software with - The Host the bastion connects to. It is separate software: a C-Series Host
its own reporting path; reports about it sent to the address above are is [Privatae/cseries](https://github.com/Privatae/cseries), and a Host on
forwarded. Privatae's own platform is software that is not open source. Reports about
either sent to the address above are forwarded.
- Findings that require root on the machine already. The bastion's root ops - Findings that require root on the machine already. The bastion's root ops
assume the machine's administrator is trusted. assume the machine's administrator is trusted.
@@ -72,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
@@ -100,22 +115,45 @@ one installed.
**Checking our work.** Release builds are reproducible. **Checking our work.** Release builds are reproducible.
`deploy/verify-release.sh` fetches the published manifest, checks its `deploy/verify-release.sh` fetches the published manifest, checks its
signature, checks out the release's source, rebuilds it, and compares every signature, checks out the release's source, rebuilds it, and compares every
binary's digest. From 0.43.0 on, each public snapshot in this repository binary's digest. From 0.53.2 on, apart from 0.55.0 to 0.55.2, each release in
carries the annotated tag `v<version>`, whose message records the release's this repository carries the annotated tag `v<version>`, whose message records
`built_from`. That field names the commit in the repository the release was the release's `built_from`. A release arrives here after it is published.
built in, which is not a commit here, so the script uses the tag and prints That field names the commit in the repository the release was built in,
which is not a commit here, so the script uses the tag and prints
which source it used. If the digests match, this source builds the binaries we which source it used. If the digests match, this source builds the binaries we
serve. Releases published before build reproducibility was added (0.40.0 and serve. Only a release tagged here can be checked this way, and releases
earlier) cannot be checked this way. published before build reproducibility was added (0.40.0 and earlier) cannot
be checked at all.
**The agent.** The bastion downloads the bastion agent, runs it, and gives it **The agent.** The bastion downloads the bastion agent, runs it, and gives it
the folder you granted. The agent is a separate program in its own repository the folder you granted. The agent is a separate program in its own repository
(`privatae/bastion-agent`), and it reads your files in order to index them. What (`privatae/bastion-agent`), and it reads your files in order to index them. What
leaves your machine is what its manifest and your grant allow. Its extraction leaves your machine is what its manifest and your grant allow. Its extraction
and PII redaction happen inside it, so check that repository, not this one, and PII redaction happen inside it, so check that repository, not this one,
for how well those work. for how well those work. The bastion runs it in `flag` mode, which does not
find names, street addresses or dates of birth, and every search reply says so
([DCN stores](docs/context.md#distributed-context-network-dcn-stores)).
**The platform.** Your host runs on our platform, which is not open source. The **The Host.** The bastion holds your Host to your grant and your approvals.
bastion keeps the host to your grant and your approvals whatever the platform What the Host does with what you share with it is the Host's own software, and
does. What the host does with what you share with it is governed by software which software that is depends on where the Host runs.
you cannot read here.
- **A C-Series Host** runs on your own machine, in its own Linux virtual
machine. Its source is [Privatae/cseries](https://github.com/Privatae/cseries),
under AGPL-3.0-only; the bastion and the bastion agent are GPL-3.0-or-later.
`npx cseries` installs the bastion beside it as the machine's system service
and, on every pairing, runs `set-mode networked`, then
`pair --redeem-url <the Host's https enroll address> --host-ca <its CA> -`
with the token on standard input, then `claim-anchor` and
`stepup-enroll --console-only --host <the Host>`: the machine is anchored to
that Host and you approve its writes here with your own authenticator. No
Privatae account is needed
([A Host in a VM on this machine](docs/host-on-this-machine.md#a-host-in-a-vm-on-this-machine)).
- **Optional and paid, from Privatae:** C-Series Cloud, which runs a Host on a
server of its own that Privatae sets up and keeps patched, and a Privatae
network membership, which joins a Host on your own machine to the Privatae
network.
- **A Host on Privatae's own platform** is the case where the Host's software
is not open source. There, the bastion keeps the Host to your grant and your
approvals whatever the platform does, and what the Host does with what you
share with it is governed by software you cannot read here.
+303
View File
@@ -0,0 +1,303 @@
package main
// activity.go — what happened on this machine, newest first, in one list:
// the console's Activity page and `privatae-bastion activity [--since 7d]
// [--json]` (plan Addendum 4, step 3).
//
// It adds no record of its own. It reads the ones this bastion already keeps,
// each the authority for its own kind of fact:
//
// events.json stops, by the owner or a Host, and stops refused
// machine-factor/ the owner's approvals and the stops they carried
// audit.jsonl out or refused
// <pairing>/audit.jsonl what each Host did here (files, stores, op runs)
// mgmt_audit.jsonl each Host's management ops and the verdict
// pairing_audit.jsonl Hosts paired, granted, revoked
// sanctions programs marked as yours, and when
// local-deny.json capabilities switched off for a Host, and when
//
// Each file is read from its tail (activityTailMax), so a long-lived machine
// shows its recent past without reading years of log on every load.
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const (
activityTailMax = 256 << 10
activityLimit = 300
)
type activityItem struct {
At time.Time `json:"at"`
Kind string `json:"kind"` // stop, approval, host, change
Who string `json:"who,omitempty"`
What string `json:"what"`
Refused bool `json:"refused,omitempty"`
}
// askedBy is ", asked by <who>" for a row about an agent's ask, else "".
func askedBy(row map[string]any) string {
if who := actStr(row, "who"); who != "" {
return ", asked by " + who
}
return ""
}
func collectActivity(since time.Time) []activityItem {
var out []activityItem
add := func(it activityItem) {
if !it.At.Before(since) && it.What != "" {
out = append(out, it)
}
}
// Stops, as the page and the Host were told them.
if p, err := localEventPath(); err == nil {
for _, e := range loadLocalEvents(p) {
add(activityItem{At: e.At, Kind: "stop", What: e.Text,
Refused: strings.Contains(e.Text, "refused") || strings.Contains(e.Text, "It refused")})
}
}
// The owner's approvals, and stops that did not happen (one that did is
// already an event above).
if dir := existingMachineFactorDir(); dir != "" {
for _, row := range activityRows(filepath.Join(dir, "audit.jsonl")) {
op, decision := actStr(row, "op"), actStr(row, "decision")
switch {
case op == localStopKind && strings.HasPrefix(decision, "not stopped"):
target := actStr(row, "path") // "<pid> <signature>", as localStopOnce writes it
if pid, sig, ok := strings.Cut(target, " "); ok {
target = sig + " (pid " + pid + ")"
}
add(activityItem{At: rowTime(row), Kind: "stop", Who: "You", What: "approved stopping " + target +
", but it was not stopped: " + strings.TrimPrefix(decision, "not stopped: "), Refused: true})
case op == "local_baseline":
add(activityItem{At: rowTime(row), Kind: "change", Who: "You", What: "recorded a new baseline"})
case op == "stepup_approve":
add(activityItem{At: rowTime(row), Kind: "approval", Who: "You",
What: "approved " + purposeWords(strings.TrimPrefix(decision, "approved: ")) + askedBy(row)})
case op == "stepup_deny":
add(activityItem{At: rowTime(row), Kind: "approval", Who: "You", Refused: true,
What: "denied " + purposeWords(strings.TrimPrefix(decision, "denied: ")) + askedBy(row)})
case op == localStopAskOp:
// An agent asked (agentask.go). Who is the asker in the words
// the owner read on the request.
add(activityItem{At: rowTime(row), Kind: "approval", Who: actStr(row, "who"), What: decision})
case op == askHoldOp:
who, what := "This machine", "held asks from "+actStr(row, "path")+" ("+decision+")"
if decision == "lifted by the owner" {
who, what = "You", "lifted the hold on asks from "+actStr(row, "path")
}
add(activityItem{At: rowTime(row), Kind: "approval", Who: who, What: what, Refused: who != "You"})
}
}
}
// What each Host did here.
if cfgs, err := loadPairings(); err == nil {
for _, c := range cfgs {
if !c.active() {
continue // nothing is created for a pending pairing (run.go)
}
for _, row := range activityRows(filepath.Join(c.dcnDir(), "audit.jsonl")) {
decision := actStr(row, "decision")
what := opWords(actStr(row, "op"))
if p := actStr(row, "path"); p != "" {
what += " " + p
}
if decision != "" {
what += " (" + decision + ")"
}
add(activityItem{At: rowTime(row), Kind: "host", Who: "Host " + c.HostName, What: what,
Refused: strings.HasPrefix(decision, "refused") || strings.HasPrefix(decision, "denied")})
}
}
}
if p, err := mgmtAuditPath(); err == nil {
for _, row := range activityRows(p) {
verdict := actStr(row, "verdict")
what := "ran " + actStr(row, "op")
if verdict != "" {
what += " (" + verdict
if why := actStr(row, "why"); why != "" && verdict != "ok" {
what += ": " + why
}
what += ")"
}
add(activityItem{At: rowTime(row), Kind: "host", Who: "Host " + actStr(row, "self"), What: what,
Refused: verdict != "" && verdict != "ok" && verdict != "allowed"})
}
}
if d, err := configDir(); err == nil {
for _, row := range activityRows(filepath.Join(d, auditFilePairing)) {
add(activityItem{At: rowTime(row), Kind: "change", Who: "This machine",
What: opWords(actStr(row, "op")) + " " + actStr(row, "subject") + ": " + actStr(row, "decision"),
Refused: strings.HasPrefix(actStr(row, "decision"), "refused")})
}
}
// The owner's own changes.
if recs, err := sanctionRecords(); err == nil {
for _, r := range recs {
add(activityItem{At: r.At, Kind: "change", Who: "You", What: "marked " + r.describeMark() + " as yours"})
}
}
localDenyMu.Lock()
f, err := localDenyLoad()
localDenyMu.Unlock()
if err == nil {
for key, at := range f.At {
host, fam, _ := strings.Cut(key, "/")
if t, err := time.Parse(time.RFC3339, at); err == nil {
add(activityItem{At: t, Kind: "change", Who: "You", What: "switched " + fam + " off for " + host})
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
if len(out) > activityLimit {
out = out[:activityLimit]
}
return out
}
// activityRows is the JSON rows in the tail of a JSONL file.
func activityRows(path string) []map[string]any {
var rows []map[string]any
for _, line := range tailLines(path, activityTailMax) {
var m map[string]any
if json.Unmarshal(line, &m) == nil {
rows = append(rows, m)
}
}
return rows
}
func actStr(m map[string]any, k string) string { s, _ := m[k].(string); return cleanText(s, 300) }
func rowTime(m map[string]any) time.Time {
t, _ := time.Parse(time.RFC3339, actStr(m, "ts"))
return t
}
// opWords names the ops a person meets most in words; anything else keeps its
// name, which is what the audit file and the Host both call it.
func opWords(op string) string {
switch op {
case "dcn_read", "read_range":
return "read"
case "dcn_list", "list":
return "listed"
case "dcn_stat", "stat":
return "looked at"
case "pair", "pairing_add":
return "Host paired"
case "pairing_grant", "pairing_grant_sync":
return "Host granted"
case "pairing_revoke", "unpair":
return "Host removed"
case "quarantine_clear":
return "Host released from quarantine"
case "claim_owner":
return "owner name set to"
case "stepup_approve":
return "approved"
}
return op
}
// purposeWords says an approval's purpose the way the request said it.
func purposeWords(purpose string) string {
if pid, sig, ok := parseLocalStopPurpose(purpose); ok {
return fmt.Sprintf("stopping %s (pid %d)", sig, pid)
}
if kind, _, _ := strings.Cut(purpose, "|"); kind != "" {
return "a request (" + kind + ")"
}
return "a request"
}
// ── the command ─────────────────────────────────────────────────────────────
const activityUsage = "usage: privatae-bastion activity [--since 7d] [--json]"
func cmdActivity(args []string) error {
sinceFlag, asJSON := "", false
for i := 0; i < len(args); i++ {
switch {
case args[i] == "--json":
asJSON = true
case args[i] == "--since" && i+1 < len(args):
if _, err := parseSince(args[i+1]); err != nil {
return err
}
sinceFlag = args[i+1]
i++
default:
return errors.New(activityUsage)
}
}
v, err := readMachine("activity", readOpts{Since: sinceFlag}, readActivity)
if err != nil {
return laneRefusalError("activity", err)
}
if asJSON {
return printJSON(os.Stdout, v)
}
printActivity(os.Stdout, v.Activity)
return nil
}
// activityRead is `activity`: what happened here, newest first.
type activityRead struct {
Activity []activityItem `json:"activity"`
}
func readActivity(o readOpts) (activityRead, error) {
since, err := o.since(7 * 24 * time.Hour)
if err != nil {
return activityRead{}, err
}
items := collectActivity(time.Now().Add(-since))
if items == nil {
items = []activityItem{}
}
return activityRead{Activity: items}, nil
}
// parseSince takes Go durations and whole days: 90m, 12h, 7d.
func parseSince(s string) (time.Duration, error) {
if d, ok := strings.CutSuffix(s, "d"); ok {
var n int
if _, err := fmt.Sscanf(d, "%d", &n); err == nil && n > 0 && n <= 3650 {
return time.Duration(n) * 24 * time.Hour, nil
}
}
if d, err := time.ParseDuration(s); err == nil && d > 0 {
return d, nil
}
return 0, fmt.Errorf("--since %q: give a duration like 12h or 7d", s)
}
func printActivity(w io.Writer, items []activityItem) {
if len(items) == 0 {
fmt.Fprintln(w, "Nothing has happened on this machine in that time.")
return
}
for _, it := range items {
who := it.Who
if who != "" {
who += " "
}
mark := ""
if it.Refused {
mark = " [refused]"
}
fmt.Fprintf(w, "%s %s%s%s\n", it.At.Local().Format("Jan 2 15:04"), who, it.What, mark)
}
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
)
// activityWorld writes the records the bastion keeps, one of each kind, at
// known times. Everything in them is invented.
func activityWorld(t *testing.T) {
t.Helper()
uiTestConfigDir(t)
now := time.Now().UTC()
p, err := localEventPath()
if err != nil {
t.Fatal(err)
}
ev, _ := json.Marshal([]localEvent{
{Kind: eventContain, At: now.Add(-3 * time.Hour), Text: "You stopped aider (pid 27) running on this machine."},
{Kind: eventContain, At: now.Add(-10 * 24 * time.Hour), Text: "You stopped goose (pid 9) running on this machine."},
})
if err := os.WriteFile(p, ev, 0o600); err != nil {
t.Fatal(err)
}
dir, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
dcnAudit(dir, "stepup_approve", 0, "", "approved: local_stop|27|aider")
dcnAudit(dir, localStopKind, 0, "31 claude-code", "not stopped: pid 31 is now aider, not the claude-code that was approved")
givenPairing(t, "h1.host.privatae.ai", pairingActive)
if err := localDenySet("h1.host.privatae.ai", "dcn", true); err != nil {
t.Fatal(err)
}
}
func TestActivityGathersEveryRecordNewestFirstAndWithinTheWindow(t *testing.T) {
activityWorld(t)
items := collectActivity(time.Now().Add(-7 * 24 * time.Hour))
var lines []string
for _, it := range items {
lines = append(lines, it.Who+" "+it.What)
}
all := strings.Join(lines, "\n")
for _, want := range []string{
"You stopped aider (pid 27) running on this machine.",
"You approved stopping aider (pid 27)",
"You approved stopping claude-code (pid 31), but it was not stopped: pid 31 is now aider",
"You switched dcn off for h1.host.privatae.ai",
} {
if !strings.Contains(all, want) {
t.Errorf("activity does not say %q:\n%s", want, all)
}
}
if strings.Contains(all, "goose") {
t.Error("a stop older than the window was listed")
}
for i := 1; i < len(items); i++ {
if items[i].At.After(items[i-1].At) {
t.Fatalf("not newest first: %v after %v", items[i].At, items[i-1].At)
}
}
}
func TestTheActivityPageFiltersByKind(t *testing.T) {
activityWorld(t)
u := uiTestServer(t)
h, cookie := u.handler(), uiSignedIn(t, u)
all := uiGet(t, h, "/activity", cookie).Body.String()
if !strings.Contains(all, "You stopped aider") || !strings.Contains(all, "switched dcn off") {
t.Errorf("the page:\n%s", firstLines(all))
}
stops := uiGet(t, h, "/activity?kind=stop", cookie).Body.String()
if !strings.Contains(stops, "You stopped aider") || strings.Contains(stops, "switched dcn off") {
t.Errorf("the stops filter let other kinds through")
}
}
func TestSinceTakesDaysAndDurations(t *testing.T) {
for in, want := range map[string]time.Duration{"7d": 7 * 24 * time.Hour, "12h": 12 * time.Hour, "90m": 90 * time.Minute} {
if got, err := parseSince(in); err != nil || got != want {
t.Errorf("%s = %v %v", in, got, err)
}
}
for _, bad := range []string{"0d", "-1h", "soon", "99999d"} {
if _, err := parseSince(bad); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
+1 -1
View File
@@ -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.
+290
View File
@@ -0,0 +1,290 @@
package main
// agentask.go — how something running as a person asks the machine's bastion
// to stop an agent, when the bastion is not theirs.
//
// The bastion installed for the whole machine keeps its state in its own
// account. An agent running as the person cannot write a request there, so
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
// managing the bastion for the person must be able to do (2026-10-30).
// This is the context socket's mechanism (contextsock.go) for a second
// question, and on Windows the service's ask pipe (asklane_windows.go), which
// answers through the same askAnswer: any local account may connect; WHO connected comes from the
// kernel, never from the request; it may only ASK. The ask lands in the
// owner's factor as a pending request (stepup_machine.go), shown on Waiting
// for you and answered with the owner's code. Nothing here approves anything.
//
// Who asked is written into the request from the kernel's record of the
// connecting process: its account, and on Linux its pid, walked up to the
// agent it belongs to, so the owner reads "asked from Claude Code session
// notes-b2", not a sentence the asker wrote.
import (
"encoding/json"
"errors"
"io"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
"time"
)
const (
askSockName = "ask.sock"
askMaxBytes = 4 << 10
askSockInFlight = 16
askMaxPendingPer = 5 // waiting requests one account may have at once
)
type agentAskRequest struct {
Op string `json:"op"` // "stop", "status" (askstatus.go), "console" (consolelane.go), "read" (reads.go) or "act" (owneracts.go)
PID int `json:"pid"`
Request string `json:"request,omitempty"` // "status": the id `stop` printed
// "read": which one, and its flags (readOpts).
Read string `json:"read,omitempty"`
Since string `json:"since,omitempty"`
Prompts bool `json:"prompts,omitempty"`
Host string `json:"host,omitempty"`
Machine bool `json:"machine,omitempty"`
// "act": approve, deny or switch (owneracts.go), and what it acts on.
Act string `json:"act,omitempty"`
ID string `json:"id,omitempty"`
Code string `json:"code,omitempty"`
Family string `json:"family,omitempty"`
Off bool `json:"off,omitempty"`
}
type agentAskReply struct {
OK bool `json:"ok"`
Request string `json:"request,omitempty"`
Text string `json:"text,omitempty"`
Why string `json:"why,omitempty"`
User string `json:"user,omitempty"`
URL string `json:"url,omitempty"` // "console": the link that signs the owner in
// "status": where the ask is (askStatus) and, once carried out, what
// happened.
State string `json:"state,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
func askSockPath() string { return filepath.Join(contextSockDir, askSockName) }
// askPeerPIDFn is the kernel's pid for the connecting process: SO_PEERCRED on
// Linux, 0 where the platform does not say (then only the account is named).
var askPeerPIDFn = askPeerPID
// askPeerIdentityFn is the kernel's account for the connecting process; a
// variable so a suite run as root can stand in for the owner's account.
var askPeerIdentityFn = contextPeerIdentity
func startAskSock(done <-chan struct{}) {
if runtime.GOOS == "windows" {
// The ask pipe (asklane_windows.go). Only the service serves it: a
// bastion run by an account is that account's, and nobody else's
// requests are its to answer.
if runningAsService {
startAskPipe(done)
}
return
}
p := askSockPath()
ln, err := contextSockListen(p)
if err != nil {
uilogf("ask socket not available (%v) — an agent running as another account cannot ask "+
"this bastion to stop anything\n", err)
return
}
go func() {
<-done
_ = ln.Close()
_ = os.Remove(p)
}()
go func() {
slots := make(chan struct{}, askSockInFlight)
for {
c, err := ln.Accept()
if err != nil {
return
}
select {
case slots <- struct{}{}:
go func() {
defer func() { <-slots }()
askSockServe(c)
}()
default:
_ = c.Close()
}
}
}()
uilogf("ask socket listening on %s\n", p)
}
var askMu sync.Mutex // one ask filed at a time: the per-account count is read then written
func askSockServe(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
uid, user, err := askPeerIdentityFn(c)
if err != nil {
_ = json.NewEncoder(c).Encode(askUnattributable(err))
return
}
var req agentAskRequest
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&req); err != nil {
_ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
return
}
extend := func(d time.Duration) { _ = c.SetDeadline(time.Now().Add(d)) }
_ = json.NewEncoder(c).Encode(askAnswer(askPeer{UID: uid, Name: user}, req, extend,
func() int { return askPeerPIDFn(c) }))
}
// askUnattributable is the answer to a connection whose account the
// operating system would not name.
func askUnattributable(err error) agentAskReply {
return agentAskReply{Why: "this machine could not establish who is asking (" + err.Error() +
"), so nothing was asked. An ask nobody can attribute is one nobody can answer."}
}
// askAnswer is the lane's answer to one request from peer, on either
// platform: ask.sock here, the ask pipe on Windows. extend gives the answer
// more time where it takes longer (a read, an act); pid is the connecting
// process, asked only for a stop.
func askAnswer(peer askPeer, req agentAskRequest, extend func(time.Duration), pid func() int) any {
switch req.Op {
case "console":
r := agentAskReply{User: peer.Name}
if link, err := consoleLaneEntryFor(peer); err != nil {
r.Why = err.Error()
} else {
r.OK, r.URL = true, link
}
return r
case "status":
return askStatus(existingMachineFactorDir(), req.Request, peer.Name, false)
case "read":
extend(readDeadline)
return serveReadFor(peer, req)
case "act":
extend(30 * time.Second)
return serveActFor(peer, req)
}
return fileAsk(req, peer.Name, pid())
}
// fileAsk turns one ask into a pending request of the machine, or says why not.
func fileAsk(req agentAskRequest, user string, peerPID int) agentAskReply {
r := agentAskReply{User: user}
if req.Op != "stop" {
r.Why = "this bastion is only asked to stop agents"
return r
}
dir := existingMachineFactorDir()
if !stepupEnrolled(dir) {
r.Why = "the owner of this machine has not set up their code, so nothing can be asked of it yet"
return r
}
now := time.Now()
if until := askHeldUntil(dir, user, now); !until.IsZero() {
r.Why = askHoldRefusal(user, until)
return r
}
// A refusal that is the asker's own doing counts toward its hold.
refuse := func(why string) agentAskReply {
askStrike(dir, user, now)
r.Why = why
return r
}
observed := observedAgentMap()
if why := stopRefusal(req.PID, observed); why != "" {
return refuse(why)
}
a := observed[req.PID]
purpose, err := purposeForLocalStop(req.PID, a.Signature)
if err != nil {
r.Why = err.Error()
return r
}
view := buildAgentViews([]agentObs{a}, processTree())[0]
details := map[string]any{"kind": localStopKind, "pid": strconv.Itoa(req.PID), "signature": a.Signature,
"agent": familyLabel(a.Signature)}
if view.Folder != "" {
details["folder"] = view.Folder
}
if view.Self != nil {
details["session"], details["title"] = view.Self.Name, view.Self.Title
}
by := "a program running as " + user
if peerPID > 1 {
if who, ok := askingAgent(peerPID, observed); ok {
by = who + ", running as " + user
}
}
details["asked_by"] = by
details["asker"], details["target"] = askParties(user, peerPID, req.PID, observed)
askMu.Lock()
defer askMu.Unlock()
// Asking again for a stop this account already has waiting is the same
// request (stepupNewRequest hands it back), not a new one over the limit.
if n, same := askPendingBy(dir, user, purpose); n >= askMaxPendingPer && !same {
return refuse("you already have " + strconv.Itoa(askMaxPendingPer) + " requests waiting for the owner; " +
"wait for them to be answered")
}
details["asked_by_account"] = user
id, err := stepupNewRequest(dir, purpose, details)
if errors.Is(err, errRefusedRecently) {
return refuse("the machine's owner refused this same stop within the last 24 hours, so it cannot be " +
"asked again yet")
}
if err != nil {
r.Why = err.Error()
return r
}
r.OK, r.Request, r.Text = true, id[:8], describeRequest(stepupRequest{Details: details})
auditLocalStopAsk(dir, req.PID, a.Signature, details)
return r
}
// auditLocalStopAsk records that an ask was filed, naming who asked for what.
func auditLocalStopAsk(dir string, pid int, signature string, details map[string]any) {
dcnAuditWith(dir, localStopAskOp, 0, strconv.Itoa(pid)+" "+signature,
"asked to stop "+describeStopTarget(details), askPartiesOf(details))
}
const localStopAskOp = "local_stop_ask"
// askPendingBy is how many requests user has waiting, and whether one of them
// is for purpose.
func askPendingBy(dir, user, purpose string) (n int, same bool) {
stepupMu.Lock()
m := requestsLoad(dir)
stepupMu.Unlock()
for _, id := range sortedPending(m) {
if a, _ := m[id].Details["asked_by_account"].(string); a == user {
n++
same = same || m[id].Purpose == purpose
}
}
return n, same
}
// askViaSock is the asking side: `stop` run where it cannot write the
// machine's state itself.
func askViaSock(req agentAskRequest) (agentAskReply, error) {
c, err := dialAskLane(askSockPath())
if err != nil {
return agentAskReply{}, err
}
defer c.Close()
_ = c.SetDeadline(time.Now().Add(15 * time.Second))
if err := json.NewEncoder(c).Encode(req); err != nil {
return agentAskReply{}, err
}
var r agentAskReply
err = json.NewDecoder(io.LimitReader(c, askMaxBytes*4)).Decode(&r)
return r, err
}
+27
View File
@@ -0,0 +1,27 @@
//go:build linux
package main
import (
"net"
"golang.org/x/sys/unix"
)
func askPeerPID(c net.Conn) int {
uc, ok := c.(*net.UnixConn)
if !ok {
return 0
}
raw, err := uc.SyscallConn()
if err != nil {
return 0
}
pid := 0
_ = raw.Control(func(fd uintptr) {
if cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED); err == nil {
pid = int(cred.Pid)
}
})
return pid
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package main
import "net"
// Off Linux only the account is named; the asking agent is not walked to.
func askPeerPID(net.Conn) int { return 0 }
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"strings"
"testing"
)
func askWorld(t *testing.T, enrol bool) string {
t.Helper()
uiTestConfigDir(t)
stubStopWorld(t, []agentObs{
{PID: 101, Kind: kindAgent, Signature: "aider", User: "u"},
{PID: 102, Kind: kindAgent, Signature: "aider", User: "u"},
{PID: 103, Kind: kindAgent, Signature: "aider", User: "u"},
{PID: 104, Kind: kindAgent, Signature: "aider", User: "u"},
{PID: 105, Kind: kindAgent, Signature: "aider", User: "u"},
{PID: 106, Kind: kindAgent, Signature: "aider", User: "u"},
{PID: 300, Kind: kindAgent, Signature: "claude-code", User: "u"},
{PID: 4532, Kind: kindApp, Signature: "claude-desktop", User: "u"},
}, map[int]int{310: 300, 311: 310}) // 311: a shell the claude-code session started
prev := homeForUserFn
t.Cleanup(func() { homeForUserFn = prev })
homeForUserFn = func(string) string { return "" }
d, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
if enrol {
enrolFactorAt(t, d, machineTestSecret)
}
return d
}
// An ask from another account becomes a pending request naming the agent
// that asked, from the kernel's pid, and approves nothing.
func TestAnAskBecomesARequestNamingTheAgentThatAsked(t *testing.T) {
d := askWorld(t, true)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatalf("refused: %s", r.Why)
}
if !strings.Contains(r.Text, "Asked from Claude Code (pid 300), running as u.") {
t.Errorf("the request reads %q", r.Text)
}
pending := sortedPending(requestsLoad(d))
if len(pending) != 1 {
t.Fatalf("pending %v", pending)
}
for _, a := range approvalsLoad(d) {
t.Fatalf("an ask produced an approval: %+v", a)
}
}
func TestAnAskIsRefusedWithoutTheOwnersCodeOrForAnApp(t *testing.T) {
askWorld(t, false)
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 0); r.OK || !strings.Contains(r.Why, "not set up their code") {
t.Errorf("no owner code: %+v", r)
}
askWorld(t, true)
if r := fileAsk(agentAskRequest{Op: "stop", PID: 4532}, "u", 0); r.OK || !strings.Contains(r.Why, "an app you are running") {
t.Errorf("an app: %+v", r)
}
if r := fileAsk(agentAskRequest{Op: "approve", PID: 101}, "u", 0); r.OK {
t.Errorf("an op other than stop was filed: %+v", r)
}
}
// One account cannot bury the owner in requests.
func TestOneAccountHasAtMostFiveAsksWaiting(t *testing.T) {
d := askWorld(t, true)
for i := 0; i < askMaxPendingPer; i++ {
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101 + i}, "u", 0); !r.OK {
t.Fatalf("ask %d refused: %s", i+1, r.Why)
}
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "u", 0); r.OK || !strings.Contains(r.Why, "already have 5") {
t.Errorf("the sixth: %+v", r)
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "other", 0); !r.OK {
t.Errorf("another account was held to the first one's count: %s", r.Why)
}
// Asking again for the same agent is the same request, not another one:
// it is handed back (so the asker can follow it), and nothing new is filed.
before := len(sortedPending(requestsLoad(d)))
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 0); !r.OK ||
requestsLoad(d)[fullRequestID(t, d, r.Request)].Details["pid"] != "101" {
t.Errorf("a repeat ask past the cap: %+v", r)
}
if after := len(sortedPending(requestsLoad(d))); after != before {
t.Errorf("a repeat ask past the cap filed a new request: %d -> %d pending", before, after)
}
}
+433
View File
@@ -0,0 +1,433 @@
package main
import (
"bytes"
"errors"
"os/exec"
"strconv"
"strings"
)
// WHO RUNS AN AGENT, NOT ONLY WHICH ACCOUNT.
//
// The process scan names the account an agent runs as (procUser on every
// platform, winTokenUser on Windows) and says nothing else about it. The
// question an assessment asks is about the account: is it an administrator, is
// it a person's own account or one that exists for a service, and is that
// person logged in right now. "An agent runs as a person, holds administrator
// rights, and nobody is at the keyboard" is a different finding from "an agent
// runs under a service account with no rights", and the account name alone
// cannot tell them apart.
//
// Three facts, each with an honest third value:
//
// owner_class domain_user, local_user, service, system, or unknown
// owner_admin yes, no, or unknown
// owner_interactive yes, no, or unknown
//
// UNKNOWN IS NEVER FOLDED INTO NO. A box that could not read the group list
// has not learned that the account is unprivileged, and saying "no" there is
// the confident-wrong answer this plane refuses everywhere else
// (baseline_identity.go draws the same line for sudo rules it could not read).
//
// Every decision is in this file, with no build tag, so the Linux test run
// exercises the macOS and Windows rules too. The platform files
// (agents_<goos>.go) only read: /proc and /etc on Linux, `id`, `dscl` and
// `who` on macOS, the process token and the session list on Windows.
//
// These ride on agentObs, so they travel only where `agents` does: inside the
// heartbeat's `observing` gate (agents.go).
const (
ownerClassDomain = "domain_user"
ownerClassLocal = "local_user"
ownerClassService = "service"
ownerClassSystem = "system"
ownerUnknown = "unknown"
ownerYes = "yes"
ownerNo = "no"
)
// ownerFacts is the three answers for one account.
type ownerFacts struct {
Class string
Admin string
Interactive string
}
// unknownOwner is the answer when nothing could be read: a process whose
// owner this bastion may not inspect, or a build with no reader.
var unknownOwner = ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
func yesNo(b bool) string {
if b {
return ownerYes
}
return ownerNo
}
// ── Linux and macOS ─────────────────────────────────────────────────────────
// unixOwner is what a Linux or macOS reader collected about one account.
// Every field that can be unreadable carries its own "known" flag.
type unixOwner struct {
UID int // -1 when the uid could not be read
Name string
// LocalRecord: "yes" when the account is in this machine's own account
// database (/etc/passwd; the macOS local directory node), "no" when it is
// not, "unknown" when that could not be read.
LocalRecord string
// DirectorySource: this machine resolves accounts from a directory
// (nsswitch names sss, ldap, winbind or nis; a macOS mobile account was
// copied from one). Only then does "not in the local database" mean a
// domain account rather than a uid with no account at all.
DirectorySource bool
// SystemUIDMax is the highest uid the platform hands to system accounts.
SystemUIDMax int
Groups []string // group names the account or process holds
GroupsKnown bool
AdminGroups map[string]bool
// SudoApplies: on this platform a sudo rule can grant administration to
// any user or group (Linux), so "in no admin group" is only a "no" once
// the rules were read as well. macOS leaves it false and decides on the
// admin group, which is what its stock sudoers grants.
// SudoNamed / SudoKnown: a sudo rule names the account directly or
// through a group (sudoPrincipals), and whether the rules were readable.
SudoApplies bool
SudoNamed bool
SudoKnown bool
// Sessions is the set of account names with a login session now
// (logind, utmp, `who`); SessionsKnown is false when none could be read.
Sessions map[string]bool
SessionsKnown bool
}
// linuxSystemUIDMax is shadow-utils' default SYS_UID_MAX: accounts below
// UID_MIN (1000 by default) are system accounts. login.defs can move both;
// linuxUIDLimits reads it.
const linuxSystemUIDMax = 999
// darwinSystemUIDMax: macOS creates people's accounts from uid 501 up, and
// keeps the ids below for its own daemons (_www, _spotlight and the rest).
const darwinSystemUIDMax = 500
func classifyUnixOwner(o unixOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.UID == 0:
f.Class = ownerClassSystem
case o.UID == -1:
// Unread uid: the class cannot be told from a name alone. Other
// negative ids are real: macOS's directory gives nobody -2.
case o.UID == 65534 || o.UID <= o.SystemUIDMax:
// nobody, and every uid in the system range, is an account that exists
// for a program rather than a person.
f.Class = ownerClassService
case strings.ContainsAny(o.Name, `@\`):
// user@realm (SSSD) and DOMAIN\user (winbind) are directory names.
f.Class = ownerClassDomain
case o.LocalRecord == ownerYes:
f.Class = ownerClassLocal
case o.LocalRecord == ownerNo && o.DirectorySource:
f.Class = ownerClassDomain
}
switch {
case o.UID == 0:
f.Admin = ownerYes
case o.GroupsKnown && hasAnyGroup(o.Groups, o.AdminGroups):
f.Admin = ownerYes
case o.SudoKnown && o.SudoNamed:
f.Admin = ownerYes
case o.GroupsKnown && (o.SudoKnown || !o.SudoApplies):
f.Admin = ownerNo
}
if o.SessionsKnown && o.Name != "" {
f.Interactive = yesNo(o.Sessions[o.Name])
}
return f
}
func hasAnyGroup(groups []string, want map[string]bool) bool {
for _, g := range groups {
if want[g] {
return true
}
}
return false
}
// linuxAdminGroups: the groups a stock distribution's sudoers grants
// administration to (sudo on Debian and Ubuntu, wheel on Fedora, RHEL and
// Arch, admin on older Ubuntu).
var linuxAdminGroups = map[string]bool{"sudo": true, "wheel": true, "admin": true}
// darwinAdminGroups: macOS administrators are members of `admin`.
var darwinAdminGroups = map[string]bool{"admin": true}
// parseStatusIDs reads the real and effective uid and the supplementary gid
// list out of /proc/<pid>/status. ok is false when there is no Uid line.
func parseStatusIDs(status string) (realUID, effUID int, gids []int, ok bool) {
realUID, effUID = -1, -1
for _, line := range strings.Split(status, "\n") {
switch {
case strings.HasPrefix(line, "Uid:"):
f := strings.Fields(line)
if len(f) >= 3 {
r, err1 := strconv.Atoi(f[1])
e, err2 := strconv.Atoi(f[2])
if err1 == nil && err2 == nil {
realUID, effUID, ok = r, e, true
}
}
case strings.HasPrefix(line, "Groups:"):
for _, g := range strings.Fields(strings.TrimPrefix(line, "Groups:")) {
if n, err := strconv.Atoi(g); err == nil {
gids = append(gids, n)
}
}
}
}
return realUID, effUID, gids, ok
}
// parseGroupNames maps gid -> name from an /etc/group body.
func parseGroupNames(body string) map[int]string {
out := map[int]string{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = p[0]
}
}
return out
}
// parsePasswdUIDs is the set of uids /etc/passwd itself lists.
func parsePasswdUIDs(body string) map[int]bool {
out := map[int]bool{}
for _, line := range strings.Split(body, "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 3 || strings.HasPrefix(p[0], "#") {
continue
}
if n, err := strconv.Atoi(p[2]); err == nil {
out[n] = true
}
}
return out
}
// parseLoginDefsSysMax returns the highest system uid login.defs declares:
// SYS_UID_MAX when present, else UID_MIN-1, else the shadow-utils default.
func parseLoginDefsSysMax(body string) int {
sysMax, uidMin := -1, -1
for _, line := range strings.Split(body, "\n") {
f := strings.Fields(strings.TrimSpace(line))
if len(f) < 2 || strings.HasPrefix(f[0], "#") {
continue
}
n, err := strconv.Atoi(f[1])
if err != nil {
continue
}
switch f[0] {
case "SYS_UID_MAX":
sysMax = n
case "UID_MIN":
uidMin = n
}
}
switch {
case sysMax >= 0:
return sysMax
case uidMin > 0:
return uidMin - 1
}
return linuxSystemUIDMax
}
// nsswitchHasDirectory reports whether the passwd line of nsswitch.conf names
// a directory source, so an account missing from /etc/passwd is resolved from
// one rather than simply absent.
func nsswitchHasDirectory(body string) bool {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "passwd:") {
continue
}
for _, src := range strings.Fields(strings.TrimPrefix(line, "passwd:")) {
switch src {
case "sss", "ldap", "winbind", "nis", "nisplus":
return true
}
}
}
return false
}
// parseLogindUserState reads systemd-logind's per-user state file
// (/run/systemd/users/<uid>). active and online mean the user has a session
// now; lingering means services run for them with no session.
func parseLogindUserState(body string) (interactive bool, ok bool) {
for _, line := range strings.Split(body, "\n") {
if v, found := strings.CutPrefix(strings.TrimSpace(line), "STATE="); found {
switch v {
case "active", "online":
return true, true
case "lingering", "closing", "offline":
return false, true
}
return false, false
}
}
return false, false
}
// utmp record layout on glibc Linux (bits/utmp.h): 384 bytes, ut_type a
// 16-bit value at offset 0, ut_user 32 bytes at offset 44. USER_PROCESS (7)
// is a login session.
const (
utmpRecordSize = 384
utmpUserOffset = 44
utmpUserSize = 32
utmpUserProcess = 7
)
// parseUtmpUsers is the set of account names with a USER_PROCESS record.
func parseUtmpUsers(b []byte) map[string]bool {
out := map[string]bool{}
for off := 0; off+utmpRecordSize <= len(b); off += utmpRecordSize {
rec := b[off : off+utmpRecordSize]
if int(rec[0])|int(rec[1])<<8 != utmpUserProcess {
continue
}
name := rec[utmpUserOffset : utmpUserOffset+utmpUserSize]
if i := bytes.IndexByte(name, 0); i >= 0 {
name = name[:i]
}
if len(name) > 0 {
out[string(name)] = true
}
}
return out
}
// parseWhoUsers is the set of account names `who` lists (macOS): the first
// field of every line.
func parseWhoUsers(out string) map[string]bool {
users := map[string]bool{}
for _, line := range strings.Split(out, "\n") {
if f := strings.Fields(line); len(f) > 0 {
users[f[0]] = true
}
}
return users
}
// parseIDGroups reads `id -Gn <user>`: group names separated by spaces.
func parseIDGroups(out string) []string { return strings.Fields(out) }
// dsclRecordMissing: dscl ran to the end and exited non-zero, which is how it
// says the local node has no such record. A dscl the timeout killed has an
// exit code of -1 and has said nothing about the account.
func dsclRecordMissing(err error) bool {
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() > 0
}
// parseDsclUser reads `dscl . -read /Users/<name> UniqueID OriginalNodeName`.
// A mobile account (cached from a directory) carries OriginalNodeName.
func parseDsclUser(out string) (uid int, mobile bool, ok bool) {
uid = -1
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if v, found := strings.CutPrefix(line, "UniqueID:"); found {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
uid, ok = n, true
}
}
if strings.HasPrefix(line, "OriginalNodeName:") {
mobile = true
}
}
return uid, mobile, ok
}
// ── Windows ─────────────────────────────────────────────────────────────────
// Well-known SIDs, from Microsoft's tables:
// https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids and
// https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers
const (
sidLocalSystem = "S-1-5-18"
sidLocalService = "S-1-5-19"
sidNetworkService = "S-1-5-20"
sidAdministrators = "S-1-5-32-544"
sidInteractive = "S-1-5-4"
sidRemoteInteractive = "S-1-5-14"
sidLocalAccount = "S-1-5-113"
sidNTServicePrefix = "S-1-5-80-"
sidVirtualMachinePfx = "S-1-5-83-"
)
// winOwner is what the Windows reader collected from one process token.
type winOwner struct {
SID string
Domain string // the account's domain as LookupAccount names it
Computer string // this machine's NetBIOS name
// Groups are the token's group SIDs, deny-only ones included: an
// administrator running without elevation holds Administrators as a
// deny-only group, and is still an administrator account.
Groups map[string]bool
GroupsKnown bool
// SessionSIDs: accounts with a Remote Desktop Services session now
// (WTSEnumerateSessions + WTSQueryUserToken, which needs LocalSystem).
SessionSIDs map[string]bool
SessionsKnown bool
}
func classifyWinOwner(o winOwner) ownerFacts {
f := ownerFacts{Class: ownerUnknown, Admin: ownerUnknown, Interactive: ownerUnknown}
switch {
case o.SID == "":
case o.SID == sidLocalSystem:
f.Class = ownerClassSystem
case o.SID == sidLocalService || o.SID == sidNetworkService ||
strings.HasPrefix(o.SID, sidNTServicePrefix) || strings.HasPrefix(o.SID, sidVirtualMachinePfx):
f.Class = ownerClassService
case o.GroupsKnown && o.Groups[sidLocalAccount]:
f.Class = ownerClassLocal
case o.Domain == "":
case strings.EqualFold(o.Domain, o.Computer):
f.Class = ownerClassLocal
case strings.HasPrefix(o.SID, "S-1-5-21-") || strings.EqualFold(o.Domain, "AzureAD"):
// A machine or domain account whose domain is not this computer:
// an Active Directory or Entra ID account.
f.Class = ownerClassDomain
}
switch {
case o.SID == sidLocalSystem:
// "System is a hidden member of Administrators" (the second page above).
f.Admin = ownerYes
case o.GroupsKnown:
f.Admin = yesNo(o.Groups[sidAdministrators])
}
switch {
case o.SessionsKnown && o.SID != "":
f.Interactive = yesNo(o.SessionSIDs[o.SID])
case o.GroupsKnown && (o.Groups[sidInteractive] || o.Groups[sidRemoteInteractive]):
// No session list (the bastion is not LocalSystem), but this very
// process was started by an interactive logon, so the account has
// one. The converse proves nothing: a scheduled task's token is not
// interactive while its owner may well be logged in.
f.Interactive = ownerYes
}
return f
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"testing"
)
// The Linux reader end to end over an invented machine: a /proc with two
// processes and an /etc and /run that say who they belong to. Nothing here is
// read from the box the test runs on.
func TestLinuxOwnerFactsReaderOverAFixtureMachine(t *testing.T) {
root := t.TempDir()
write := func(rel, body string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write("etc/passwd", "root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n"+
"kim:x:1002:1002::/home/kim:/bin/bash\nsvcdemo:x:120:120::/nonexistent:/usr/sbin/nologin\n")
write("etc/group", "root:x:0:\nsudo:x:27:pat\npat:x:1001:\nkim:x:1002:\n")
write("etc/sudoers", "root ALL=(ALL:ALL) ALL\n%sudo ALL=(ALL:ALL) ALL\n")
write("etc/nsswitch.conf", "passwd: files\n")
write("run/systemd/users/1001", "UID=1001\nSTATE=active\n")
write("proc/400/status", "Name:\tdemo\nUid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001\n")
write("proc/401/status", "Name:\tdemo\nUid:\t1002\t1002\t1002\t1002\nGroups:\t1002\n")
write("proc/402/status", "Name:\tdemo\nUid:\t120\t120\t120\t120\nGroups:\t\n")
write("proc/403/status", "Name:\tdemo\nUid:\t1002\t0\t1002\t1002\nGroups:\t1002\n")
oldID, oldProc := identityRoot, procRoot
identityRoot, procRoot = root, filepath.Join(root, "proc")
t.Cleanup(func() { identityRoot, procRoot = oldID, oldProc })
read := ownerFactsReader()
for _, c := range []struct {
pid int
user string
want ownerFacts
}{
{400, "pat", ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{401, "kim", ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
{402, "svcdemo", ownerFacts{ownerClassService, ownerNo, ownerNo}},
// Effective root under kim's account: administrator, class unchanged.
{403, "kim", ownerFacts{ownerClassLocal, ownerYes, ownerNo}},
// A pid with no status file: everything unknown, nothing guessed.
{404, "ghost", unknownOwner},
} {
if got := read(c.pid, c.user); got != c.want {
t.Errorf("pid %d (%s): got %+v, want %+v", c.pid, c.user, got, c.want)
}
}
}
// Every observation scanAgents returns carries the owner facts: the wiring,
// not only the classifier. The one process is this test itself, so the facts
// come from the real /proc and are only checked for being filled in.
func TestScanAgentsCarriesOwnerFacts(t *testing.T) {
prev := scanProcessesFn
t.Cleanup(func() { scanProcessesFn = prev })
self := os.Getpid()
scanProcessesFn = func() (map[int]agentObs, error) {
return map[int]agentObs{self: {PID: self, User: procUser(self), Signature: "claude-code",
Cmdline: "claude", raw: "claude"}}, nil
}
got, err := scanAgents()
if err != nil || len(got) == 0 {
t.Fatalf("scan: %d observations, %v", len(got), err)
}
// The network signal may add this box's own model clients; every
// observation, however it was caught, must carry the facts.
for _, a := range got {
if a.OwnerClass == "" || a.OwnerAdmin == "" || a.OwnerInteractive == "" {
t.Fatalf("owner facts missing from pid %d", a.PID)
}
}
}
+209
View File
@@ -0,0 +1,209 @@
package main
import (
"encoding/binary"
"testing"
)
// Every value below is invented. No account, group or SID here belongs to a
// real machine.
func TestClassifyUnixOwner(t *testing.T) {
linux := func(o unixOwner) unixOwner {
o.SystemUIDMax = linuxSystemUIDMax
o.AdminGroups = linuxAdminGroups
o.SudoApplies = true
return o
}
cases := []struct {
name string
in unixOwner
want ownerFacts
}{
{"root is the system and an administrator",
linux(unixOwner{UID: 0, Name: "root", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
Sessions: map[string]bool{}, SessionsKnown: true}),
ownerFacts{ownerClassSystem, ownerYes, ownerNo}},
{"a daemon account in the system range",
linux(unixOwner{UID: 115, Name: "svc-demo", LocalRecord: ownerYes, GroupsKnown: true, SudoKnown: true,
Sessions: map[string]bool{}, SessionsKnown: true}),
ownerFacts{ownerClassService, ownerNo, ownerNo}},
{"nobody is a service account",
linux(unixOwner{UID: 65534, Name: "nobody", LocalRecord: ownerYes}),
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"a person in the sudo group, logged in",
linux(unixOwner{UID: 1001, Name: "pat", LocalRecord: ownerYes, Groups: []string{"pat", "sudo"},
GroupsKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"a person a sudo rule names directly",
linux(unixOwner{UID: 1002, Name: "lee", LocalRecord: ownerYes, Groups: []string{"lee"},
GroupsKnown: true, SudoNamed: true, SudoKnown: true}),
ownerFacts{ownerClassLocal, ownerYes, ownerUnknown}},
{"no admin group and the sudo rules read: no",
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"},
GroupsKnown: true, SudoKnown: true, Sessions: map[string]bool{"pat": true}, SessionsKnown: true}),
ownerFacts{ownerClassLocal, ownerNo, ownerNo}},
{"no admin group but the sudo rules unreadable: unknown, never no",
linux(unixOwner{UID: 1003, Name: "kim", LocalRecord: ownerYes, Groups: []string{"kim"}, GroupsKnown: true}),
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
{"groups unreadable: unknown, never no",
linux(unixOwner{UID: 1004, Name: "ash", LocalRecord: ownerYes, SudoKnown: true}),
ownerFacts{ownerClassLocal, ownerUnknown, ownerUnknown}},
{"not in passwd on a machine that resolves from a directory",
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo, DirectorySource: true}),
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
{"not in passwd and no directory: a uid with no account, not a domain user",
linux(unixOwner{UID: 501234, Name: "501234", LocalRecord: ownerNo}),
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
{"a realm-qualified name is a directory account",
linux(unixOwner{UID: 70001, Name: "pat@corp.example", LocalRecord: ownerUnknown}),
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
{"uid unread",
linux(unixOwner{UID: -1, Name: ""}),
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
{"macOS admin group member",
unixOwner{UID: 501, Name: "sam", LocalRecord: ownerYes, Groups: []string{"staff", "admin"},
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
Sessions: map[string]bool{"sam": true}, SessionsKnown: true},
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"macOS standard user: the admin group decides",
unixOwner{UID: 502, Name: "jo", LocalRecord: ownerYes, Groups: []string{"staff"},
GroupsKnown: true, SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"macOS daemon",
unixOwner{UID: 88, Name: "_demo", LocalRecord: ownerYes, SystemUIDMax: darwinSystemUIDMax,
AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"macOS mobile account",
unixOwner{UID: 1204567, Name: "rio", LocalRecord: ownerNo, DirectorySource: true,
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups},
ownerFacts{ownerClassDomain, ownerUnknown, ownerUnknown}},
}
for _, c := range cases {
if got := classifyUnixOwner(c.in); got != c.want {
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
}
}
}
func TestClassifyWinOwner(t *testing.T) {
const box = "DESK-DEMO"
user := "S-1-5-21-1111111111-2222222222-3333333333-1001"
cases := []struct {
name string
in winOwner
want ownerFacts
}{
{"LocalSystem",
winOwner{SID: sidLocalSystem, Domain: "NT AUTHORITY", Computer: box},
ownerFacts{ownerClassSystem, ownerYes, ownerUnknown}},
{"NetworkService",
winOwner{SID: sidNetworkService, Domain: "NT AUTHORITY", Computer: box,
Groups: map[string]bool{}, GroupsKnown: true},
ownerFacts{ownerClassService, ownerNo, ownerUnknown}},
{"a virtual service account",
winOwner{SID: "S-1-5-80-1-2-3-4-5", Domain: "NT SERVICE", Computer: box},
ownerFacts{ownerClassService, ownerUnknown, ownerUnknown}},
{"local administrator, unelevated, at the desktop",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{sidAdministrators: true, sidInteractive: true},
GroupsKnown: true, SessionSIDs: map[string]bool{user: true}, SessionsKnown: true},
ownerFacts{ownerClassLocal, ownerYes, ownerYes}},
{"domain user, not an administrator, no session now",
winOwner{SID: user, Domain: "CORPDEMO", Computer: box,
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true,
SessionSIDs: map[string]bool{}, SessionsKnown: true},
ownerFacts{ownerClassDomain, ownerNo, ownerNo}},
{"the local-account group decides even with no domain name",
winOwner{SID: user, Groups: map[string]bool{sidLocalAccount: true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"no session list, but this process is an interactive logon",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{sidRemoteInteractive: true, sidInteractive: true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerYes}},
{"no session list and a batch token: unknown, never no",
winOwner{SID: user, Domain: box, Computer: box,
Groups: map[string]bool{"S-1-5-3": true}, GroupsKnown: true},
ownerFacts{ownerClassLocal, ownerNo, ownerUnknown}},
{"token unreadable",
winOwner{},
ownerFacts{ownerUnknown, ownerUnknown, ownerUnknown}},
}
for _, c := range cases {
if got := classifyWinOwner(c.in); got != c.want {
t.Errorf("%s: got %+v, want %+v", c.name, got, c.want)
}
}
}
func TestOwnerFileParsers(t *testing.T) {
status := "Name:\tdemo\nUid:\t1001\t0\t1001\t1001\nGid:\t1001\t1001\t1001\t1001\nGroups:\t27 1001 \n"
r, e, gids, ok := parseStatusIDs(status)
if !ok || r != 1001 || e != 0 || len(gids) != 2 || gids[0] != 27 {
t.Fatalf("status: %d %d %v %v", r, e, gids, ok)
}
if _, _, _, ok := parseStatusIDs("Name:\tdemo\n"); ok {
t.Fatal("a status with no Uid line must not read as uid 0")
}
names := parseGroupNames("root:x:0:\nsudo:x:27:pat\n# comment\nbad\n")
if names[27] != "sudo" || names[0] != "root" || len(names) != 2 {
t.Fatalf("groups: %v", names)
}
uids := parsePasswdUIDs("root:x:0:0::/root:/bin/sh\npat:x:1001:1001::/home/pat:/bin/bash\n")
if !uids[1001] || !uids[0] || uids[5] {
t.Fatalf("passwd: %v", uids)
}
for body, want := range map[string]int{
"": linuxSystemUIDMax,
"UID_MIN 500\n": 499,
"UID_MIN 1000\nSYS_UID_MAX 899\n": 899,
"# SYS_UID_MAX 10\n": linuxSystemUIDMax,
} {
if got := parseLoginDefsSysMax(body); got != want {
t.Errorf("login.defs %q: %d, want %d", body, got, want)
}
}
if !nsswitchHasDirectory("group: files\npasswd: files sss systemd\n") ||
nsswitchHasDirectory("passwd: files systemd\n") {
t.Fatal("nsswitch directory detection")
}
for body, want := range map[string][2]bool{
"UID=1001\nSTATE=active\nSESSIONS=3\n": {true, true},
"STATE=lingering\n": {false, true},
"STATE=something-new\n": {false, false},
"UID=1001\n": {false, false},
} {
got, ok := parseLogindUserState(body)
if got != want[0] || ok != want[1] {
t.Errorf("logind %q: %v %v", body, got, ok)
}
}
if u := parseWhoUsers("sam console Oct 20 09:00\njo ttys001 Oct 20 09:05\n"); !u["sam"] || !u["jo"] || len(u) != 2 {
t.Fatalf("who: %v", u)
}
uid, mobile, ok := parseDsclUser("OriginalNodeName:\n /Active Directory/DEMO\nUniqueID: 1204567\n")
if !ok || uid != 1204567 || !mobile {
t.Fatalf("dscl: %d %v %v", uid, mobile, ok)
}
if g := parseIDGroups("staff admin everyone\n"); len(g) != 3 || g[1] != "admin" {
t.Fatalf("id: %v", g)
}
}
func TestParseUtmpUsersReadsOnlyLoginSessions(t *testing.T) {
rec := func(typ uint16, user string) []byte {
b := make([]byte, utmpRecordSize)
binary.LittleEndian.PutUint16(b, typ)
copy(b[utmpUserOffset:], user)
return b
}
var blob []byte
blob = append(blob, rec(utmpUserProcess, "pat")...)
blob = append(blob, rec(8, "gone")...) // DEAD_PROCESS: a session that ended
blob = append(blob, rec(utmpUserProcess, "lee")...)
blob = append(blob, 1, 2, 3) // a torn tail is ignored, not misread
got := parseUtmpUsers(blob)
if !got["pat"] || !got["lee"] || got["gone"] || len(got) != 2 {
t.Fatalf("utmp: %v", got)
}
}
+226 -7
View File
@@ -214,6 +214,14 @@ type agentObs struct {
// (baseline.go). Empty where the platform does not supply one: darwin and // (baseline.go). Empty where the platform does not supply one: darwin and
// windows never populate procFacts.Unit, so those fall back to the origin. // windows never populate procFacts.Unit, so those fall back to the origin.
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
// The account behind User (agentowner.go): domain_user, local_user,
// service, system or unknown; whether it administers this machine; and
// whether it has a login session now. yes, no or unknown each — unknown
// is what an unreadable token or group list says, never no. Empty only on
// an observation made outside scanAgents.
OwnerClass string `json:"owner_class,omitempty"`
OwnerAdmin string `json:"owner_admin,omitempty"`
OwnerInteractive string `json:"owner_interactive,omitempty"`
Evidence []evidence `json:"evidence,omitempty"` Evidence []evidence `json:"evidence,omitempty"`
raw string // the UNTRUNCATED command line, for classification only; never reported raw string // the UNTRUNCATED command line, for classification only; never reported
// automation: the signature came from the automation tables, so the // automation: the signature came from the automation tables, so the
@@ -304,6 +312,7 @@ func scanAgents() ([]agentObs, error) {
} }
extra := originExtras() extra := originExtras()
facts := procFactsReader() facts := procFactsReader()
owners := ownerFactsReader()
out := make([]agentObs, 0, len(byPid)) out := make([]agentObs, 0, len(byPid))
for pid, a := range byPid { for pid, a := range byPid {
if faIsOwnChild(pid) { if faIsOwnChild(pid) {
@@ -317,6 +326,11 @@ func scanAgents() ([]agentObs, error) {
if f, ok := facts(pid); ok { if f, ok := facts(pid); ok {
a.Unit = f.Unit a.Unit = f.Unit
} }
// Who the owning account is, beside who it is called. Read here
// rather than in each platform's walk so a process the network
// signal alone caught (mergeConnections) gets the same answer.
o := owners(pid, a.User)
a.OwnerClass, a.OwnerAdmin, a.OwnerInteractive = o.Class, o.Admin, o.Interactive
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...) a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
if a.DelegationToken != "" { if a.DelegationToken != "" {
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"}) a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
@@ -327,6 +341,133 @@ func scanAgents() ([]agentObs, error) {
return out, nil return out, nil
} }
// scanMCPProcessesFn is the seam a test uses to hand the correlation a
// process list; production never reassigns it.
var scanMCPProcessesFn = scanMCPProcesses
// mcpServersReport reads the declared tool servers and correlates them with
// the process list. It lives here, not in mcpconfig.go, because reading the
// process list runs `ps` on macOS: the config reader itself starts nothing,
// and the claims test holds it to that.
func mcpServersReport() ([]mcpServerObs, mcpConfigScan) {
declared, scan, read := cachedMCPConfigs()
if !scan.Supported {
scan.ProcessScan = "unsupported"
return []mcpServerObs{}, scan
}
procs, err := scanMCPProcessesFn(mcpWantedBases(declared))
switch {
case err == nil:
scan.ProcessScan = "ok"
case agentScanSupported:
scan.ProcessScan = "failed"
default:
scan.ProcessScan = "unsupported"
}
if !read {
// No read of the files has finished yet (timed_out says so). With
// nothing known to be declared, every tool-server-looking process
// would read as running_not_declared, which nobody has checked.
return []mcpServerObs{}, scan
}
out := correlateMCP(declared, procs, err == nil)
if out == nil {
out = []mcpServerObs{}
}
if len(out) >= mcpMaxServers {
scan.Truncated = true
}
return out, scan
}
// mcpCache holds the last complete read of the config files. The heartbeat
// is every 60 s and the read is up to a thousand files across every account;
// like the scheduler inventory (schedCacheFor) it is redone every ten
// minutes, while the process correlation above runs on every beat. One read
// runs at a time, and the beats wait for it at most mcpReadTimeout IN TOTAL,
// counted from when it started: a home on a network mount that stopped
// answering holds that one goroutine, delays one beat by at most that much,
// never the beats after it, and never starts a second read.
var mcpCache struct {
sync.Mutex
at time.Time
have bool
declared []mcpServerObs
scan mcpConfigScan
inflight chan struct{}
started time.Time // when the read in flight began
gen int
}
const mcpCacheFor = 10 * time.Minute
// mcpReadTimeout is how long one beat waits for a read in progress.
var mcpReadTimeout = 20 * time.Second
// cachedMCPConfigs is the last complete read. read is false when no read has
// finished yet, so the list says nothing about what is declared.
func cachedMCPConfigs() (declared []mcpServerObs, scan mcpConfigScan, read bool) {
mcpCache.Lock()
if mcpCache.have && time.Since(mcpCache.at) < mcpCacheFor {
d, s := mcpCache.declared, mcpCache.scan
mcpCache.Unlock()
return d, s, true
}
done := mcpCache.inflight
if done == nil {
done = make(chan struct{})
mcpCache.inflight, mcpCache.started = done, time.Now()
gen := mcpCache.gen
go func() {
d, s := readMCPConfigs()
mcpCache.Lock()
if mcpCache.gen == gen {
mcpCache.declared, mcpCache.scan, mcpCache.at, mcpCache.have = d, s, time.Now(), true
mcpCache.inflight = nil
}
mcpCache.Unlock()
close(done)
}()
}
wait := mcpReadTimeout - time.Since(mcpCache.started)
mcpCache.Unlock()
timedOut := false
if wait <= 0 {
// An earlier beat already waited the whole time for this read.
select {
case <-done:
default:
timedOut = true
}
} else {
t := time.NewTimer(wait)
select {
case <-done:
case <-t.C:
timedOut = true
}
t.Stop()
}
mcpCache.Lock()
defer mcpCache.Unlock()
if !mcpCache.have {
// Nothing read yet: say so, rather than "no servers".
return nil, mcpConfigScan{Supported: true, TimedOut: true}, false
}
s := mcpCache.scan
s.TimedOut = timedOut
return mcpCache.declared, s, true
}
// resetMCPConfigCache forgets the last read; a read still running is
// ignored when it finishes. Tests use it to start clean.
func resetMCPConfigCache() {
mcpCache.Lock()
defer mcpCache.Unlock()
mcpCache.gen++
mcpCache.have, mcpCache.declared, mcpCache.scan, mcpCache.inflight = false, nil, mcpConfigScan{}, nil
}
var ( var (
llmIPCache map[string]string llmIPCache map[string]string
llmIPCacheAt time.Time llmIPCacheAt time.Time
@@ -371,6 +512,33 @@ func resolveLLMIPs() map[string]string {
// to the anchor host, because approving is the anchor's job. "" = no pairing // to the anchor host, because approving is the anchor's job. "" = no pairing
// identity (tests, tools): mode, anchor and unapproved attempts still travel. // identity (tests, tools): mode, anchor and unapproved attempts still travel.
func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, tunnelIP, endpoint string, func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, tunnelIP, endpoint string,
savePending bool, mirror *mirrorReport, enforcing map[string]any, peers []peerAnnounce) string {
return buildHeartbeatBodyOn(beatLanes{}, self, hostname, platform, version, wgPub, fingerprint, tunnelIP, endpoint,
savePending, mirror, enforcing, peers)
}
// beatLanes is which of a pairing's lanes run, so its beat advertises those
// and no others. The zero value is a tunnel pairing: the forwards listen on
// the tunnel's stack and the service slots are reachable there.
type beatLanes struct {
// local: a Host with no platform (run.go runUnixPairing). The forwards and
// the service slots are absent; the model servers ride the serve lane
// (servelane.go), and the beat says which in lane_serves.
local bool
// noCoders: a tcp pairing, whose coder runs have no control plane to use
// (pair_tcp.go coderTransportRefusal), so it is never offered one.
noCoders bool
// dcnDir: the pairing's DCN state, whose managed agents this beat probes
// and reports (faManagedProbe, faManagedStatus); "" reports none.
dcnDir string
}
func beatLanesFor(cfg *Config) beatLanes {
return beatLanes{local: cfg.localTransport(), noCoders: cfg.tcpTransport(), dcnDir: cfg.dcnDir()}
}
// buildHeartbeatBodyOn is buildHeartbeatBody for a pairing whose lanes are lanes.
func buildHeartbeatBodyOn(lanes beatLanes, self, hostname, platform, version, wgPub, fingerprint, tunnelIP, endpoint string,
savePending bool, mirror *mirrorReport, enforcing map[string]any, peers []peerAnnounce) string { savePending bool, mirror *mirrorReport, enforcing map[string]any, peers []peerAnnounce) string {
caps := []string{} caps := []string{}
// WHY SOME CAPABILITIES ARE WITHHELD HERE AND OTHERS ARE NOT — the asymmetry // WHY SOME CAPABILITIES ARE WITHHELD HERE AND OTHERS ARE NOT — the asymmetry
@@ -408,7 +576,17 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// gets exactly what it got before: scope.filterModels is the identity then. // gets exactly what it got before: scope.filterModels is the identity then.
scope := pairingModelScope(self) scope := pairingModelScope(self)
reachable, models := false, []string(nil) reachable, models := false, []string(nil)
if inference { // A local pairing has no forward to advertise: its model servers ride the
// serve lane, and each lane below is advertised only when lane_serves
// carries its service (the target set and this machine's, the server
// answering, the family held). Its probes are of the address the lane
// dials, never the tunnel's guesses.
var served laneServeReport
if lanes.local {
served = laneServes(self)
reachable = containsStr(served.served, "ollama")
models = served.models
} else if inference {
reachable, models = ollamaModels(tunnelIP) reachable, models = ollamaModels(tunnelIP)
models = scope.filterModels(models) models = scope.filterModels(models)
// Reachable but nothing shared: advertising `embed` would put this box // Reachable but nothing shared: advertising `embed` would put this box
@@ -431,7 +609,15 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// box running both. // box running both.
llmModels := []string{} llmModels := []string{}
llmPort := llmForwardPort llmPort := llmForwardPort
if !inference { if lanes.local {
// No port listens for it here: the Host reaches the server through the
// serve lane, so llm_port is the "no port" value.
llmPort = 0
if containsStr(served.served, "llm") {
caps = append(caps, "llm-openai")
llmModels = served.llmModels
}
} else if !inference {
// The host stores 0 as NULL; with llm-openai withheld and // The host stores 0 as NULL; with llm-openai withheld and
// llm_models empty nothing on the host resolves a port for this row. // llm_models empty nothing on the host resolves a port for this row.
llmPort = 0 llmPort = 0
@@ -457,7 +643,9 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// probe: an up forwarder with a dead upstream is a distinct state the // probe: an up forwarder with a dead upstream is a distinct state the
// host wants before committing a fail-closed write path to it. // host wants before committing a fail-closed write path to it.
piiAdvertised, piiOK := false, false piiAdvertised, piiOK := false, false
if inference && scope.allowsPII() { if lanes.local {
piiAdvertised, piiOK = containsStr(served.served, "pii"), served.piiOK
} else if inference && scope.allowsPII() {
piiAdvertised, piiOK = piiProbe() piiAdvertised, piiOK = piiProbe()
} }
if piiAdvertised { if piiAdvertised {
@@ -473,7 +661,7 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
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 {
@@ -497,7 +685,7 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// "build a server on this machine" on it, and an offer the box would refuse // "build a server on this machine" on it, and an offer the box would refuse
// is a control that fails after the user chose it. The slot list rides with // is a control that fails after the user chose it. The slot list rides with
// it, withheld (absent, not empty) from a pairing outside the family. // it, withheld (absent, not empty) from a pairing outside the family.
servicesOn := servicesCapable(self) servicesOn := !lanes.local && servicesCapable(self)
if servicesOn { if servicesOn {
caps = append(caps, serviceCapability) caps = append(caps, serviceCapability)
} }
@@ -505,7 +693,10 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// only when this machine would accept a run-up from this pairing — Linux, // only when this machine would accept a run-up from this pairing — Linux,
// root, coders on, a runtime, the family, and (anchored) a standing coder // root, coders on, a runtime, the family, and (anchored) a standing coder
// grant. Absent, never empty, otherwise. // grant. Absent, never empty, otherwise.
coders := coderHeartbeat(self) var coders map[string]any
if !lanes.noCoders {
coders = coderHeartbeat(self)
}
if coders != nil { if coders != nil {
caps = append(caps, coderCapability) caps = append(caps, coderCapability)
} }
@@ -554,11 +745,21 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// done for nothing, on the user's own hardware. // done for nothing, on the user's own hardware.
var schedEntries []schedEntry var schedEntries []schedEntry
var schedScan schedScanReport var schedScan schedScanReport
// Declared tool servers (mcpconfig.go): the agent clients' own config
// files, correlated with the process list. Same gate and same
// quiet-vs-blind rule as the schedulers: read only for a pairing entitled
// to observe, and the capability travels only from a build that can.
var mcpServers []mcpServerObs
var mcpScan mcpConfigScan
if observing { if observing {
schedEntries, schedScan = scanSchedulers() schedEntries, schedScan = scanSchedulers()
if schedScan.Supported { if schedScan.Supported {
caps = append(caps, "scheduler-observe") caps = append(caps, "scheduler-observe")
} }
mcpServers, mcpScan = mcpServersReport()
if mcpScan.Supported {
caps = append(caps, "tool-server-observe")
}
} }
// Context preparation (contextprep.go): the tools on this box that // Context preparation (contextprep.go): the tools on this box that
// turn a user's bytes into their content — a PDF's text, an image's OCR. // turn a user's bytes into their content — a PDF's text, an image's OCR.
@@ -623,6 +824,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// absence as a client too old to say. // absence as a client too old to say.
"endpoint_save_pending": savePending, "endpoint_save_pending": savePending,
} }
// What this pairing serves over the serve lane (servelane.go), on a local
// pairing only: ["ollama","llm","pii"] or fewer, [] when nothing is. Absent
// on a tunnel pairing, which has the forwards instead, and from a bastion
// that predates the lane.
if lanes.local {
body["lane_serves"] = served.served
}
// The service slots this pairing may reach, with the tunnel port for each // The service slots this pairing may reach, with the tunnel port for each
// (service.go). Absent when the capability is withheld, never empty-for-no. // (service.go). Absent when the capability is withheld, never empty-for-no.
if servicesOn { if servicesOn {
@@ -674,6 +882,13 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
// that read 0 crontabs is visibly different from one that read 40. // that read 0 crontabs is visibly different from one that read 40.
body["schedulers"] = schedEntries body["schedulers"] = schedEntries
body["scheduler_scan"] = schedScan body["scheduler_scan"] = schedScan
// The tool servers each agent client declares, and which are running
// (or running with no declaration). Paths, server names and command
// lines, so inside this gate with the agent list; env values and
// header values never leave mcpconfig.go, only their names and
// whether one looked like a secret.
body["tool_servers"] = mcpServers
body["tool_server_scan"] = mcpScan
// Where this machine keeps its own record — the findings journal and // Where this machine keeps its own record — the findings journal and
// the signed accounting (machinestore.go, baselinesign.go). The host // the signed accounting (machinestore.go, baselinesign.go). The host
// drafts a machine store at this root for the user to approve, once; // drafts a machine store at this root for the user to approve, once;
@@ -846,12 +1061,16 @@ func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, t
} }
} }
if piiAdvertised { if piiAdvertised {
// pii_port is the tunnel's port; a local pairing has none and reaches
// the sterilizer through the serve lane.
if !lanes.local {
body["pii_port"] = piiForwardPort body["pii_port"] = piiForwardPort
}
body["pii_ok"] = piiOK body["pii_ok"] = piiOK
} }
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
} }
} }
+406
View File
@@ -0,0 +1,406 @@
package main
// agents_cli.go — `privatae-bastion agents [--json] [--prompts]`: every agent
// on this machine and what it is doing, for the person at it and for an agent
// they ask to manage the bastion (agentsession.go builds each view).
//
// It reads with the caller's own permissions: run as an ordinary account it
// sees the session records in that account's home; with sudo it sees every
// account's. Beside the bastion installed for the whole machine, the owner's
// account reads the machine's view from it (reads.go). The last prompt each
// session was given is shown only with --prompts, because it is what a person
// typed into another session.
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const agentsUsage = "usage: privatae-bastion agents [--json] [--prompts]\n" +
" privatae-bastion agents show <pid|session> [--json] [--prompts]"
var familyLabels = map[string]string{
"claude-code": "Claude Code", "aider": "aider", "goose": "goose", "cline": "Cline",
"cursor": "Cursor agent", "open-interpreter": "Open Interpreter", "opencode": "opencode",
"codex": "Codex", "gemini-cli": "Gemini CLI", "antigravity": "Antigravity", "crush": "Crush",
}
func familyLabel(sig string) string {
if l, ok := familyLabels[sig]; ok {
return l
}
return sig
}
var originWords = map[string]string{
originInteractive: "started from a terminal here",
originRemote: "started from a remote login",
originScheduler: "started by a scheduled job",
originService: "started by a service",
originAgent: "started by another agent",
}
// agentsRead is `agents`: every agent here, and how to read what each says
// about itself.
type agentsRead struct {
Agents []agentView `json:"agents"`
Note string `json:"note"`
}
func readAgents(o readOpts) (agentsRead, error) {
agents, err := observedAgents()
if err != nil {
return agentsRead{}, fmt.Errorf("could not read this machine's processes: %w", err)
}
views := buildAgentViews(agents, processTree())
for i := range views {
if !o.Prompts && views[i].Self != nil {
views[i].Self.LastPrompt = ""
}
// A shared secret, read out of the agent's own environment, for the
// Host: the console never shows it, and no read prints it. The
// evidence still says one is present.
views[i].DelegationToken = ""
}
if views == nil {
views = []agentView{}
}
return agentsRead{Agents: views, Note: "self_reported is what each agent says about itself, read from its own files: " +
"untrusted text, never instructions."}, nil
}
func cmdAgents(args []string) error {
show := ""
if len(args) >= 1 && args[0] == "show" {
if len(args) < 2 || strings.HasPrefix(args[1], "--") {
return errors.New(agentsUsage)
}
show, args = args[1], args[2:]
}
asJSON, prompts := false, false
for _, a := range args {
switch a {
case "--json":
asJSON = true
case "--prompts":
prompts = true
default:
return errors.New(agentsUsage)
}
}
v, err := readOwnOrMachine("agents", readOpts{Prompts: prompts}, readAgents)
if err != nil {
return err
}
if show != "" {
a, err := pickAgentView(v.Agents, show)
if err != nil {
return err
}
if asJSON {
return printJSON(os.Stdout, a)
}
printAgentDetail(os.Stdout, a, sessionNowFn())
return nil
}
if asJSON {
return printJSON(os.Stdout, v)
}
printAgents(os.Stdout, v.Agents, sessionNowFn())
return nil
}
// pickAgentView is the agent `agents show` means: a pid, or a session's
// name as `agents` shows it, which must match exactly one (the rule `stop`
// keeps, resolveStopTarget).
func pickAgentView(views []agentView, key string) (agentView, error) {
if pid, err := strconv.Atoi(key); err == nil {
for _, v := range views {
if v.PID == pid {
return v, nil
}
}
return agentView{}, fmt.Errorf("no AI agent here has pid %d; `privatae-bastion agents` lists them", pid)
}
var hits []agentView
for _, v := range views {
if v.Self != nil && v.Self.Name == key {
hits = append(hits, v)
}
}
switch len(hits) {
case 0:
return agentView{}, fmt.Errorf("no running session is named %q; `privatae-bastion agents` lists them", key)
case 1:
return hits[0], nil
}
return agentView{}, fmt.Errorf("%d sessions are named %q; give the pid instead", len(hits), key)
}
// printAgentDetail is one agent with everything a read holds about it: the
// lines `agents` prints, then what it is, how it was recognised, and what it
// says about itself. Its command line is in --json only: arguments can carry
// a key, and words are what gets pasted.
func printAgentDetail(w io.Writer, v agentView, now time.Time) {
if v.Role == "service" {
fmt.Fprintf(w, "background service (pid %d): starts background sessions; not a session itself\n", v.PID)
} else {
l := agentLinesOf(v, now)
fmt.Fprintln(w, l.Head)
if l.State != "" {
fmt.Fprintln(w, " "+l.State)
}
if l.Where != "" {
fmt.Fprintln(w, " "+l.Where)
}
}
row := func(k, val string) {
if val != "" {
fmt.Fprintf(w, " %-15s %s\n", k+":", val)
}
}
row("agent", familyLabel(v.Signature))
row("kind", v.Kind)
row("pid", strconv.Itoa(v.PID))
if v.PPID > 0 {
row("parent pid", strconv.Itoa(v.PPID))
}
// The kernel names the file, but whoever placed it chose its path.
row("program", shownPath(v.Exe))
if v.Exe == "" {
row("program", "unknown (the system did not say which file it runs)")
}
row("account", v.User)
if len(v.RunningNow) > 0 {
row("running now", strings.Join(v.RunningNow, "; "))
}
if len(v.ToolServers) > 0 {
row("tool servers", strings.Join(v.ToolServers, ", "))
}
if s := v.Self; s != nil {
src := s.Source
if s.Verified {
src += " (names this process)"
} else {
src += " (does not name this process)"
}
row("self-reported", src)
row("session id", s.SessionID)
row("last asked", quoteIf(s.LastPrompt))
}
fmt.Fprintln(w, "Names, titles and statuses are what the agent says about itself.")
}
// shownPath is a path the agent chose (its folder) or placed (the file it
// runs), as printed: quoted when it holds control characters, so an escape
// sequence in a name never reaches the terminal or the page.
func shownPath(p string) string {
if strings.ContainsFunc(p, unicode.IsControl) {
return strconv.Quote(p)
}
return p
}
func quoteIf(s string) string {
if s == "" {
return ""
}
return "\"" + s + "\""
}
// printAgents writes the views grouped by agent, sessions waiting for the
// person first, apps last.
func printAgents(w io.Writer, views []agentView, now time.Time) {
if len(views) == 0 {
fmt.Fprintln(w, "No AI agents are running on this machine.")
return
}
byFamily := map[string][]agentView{}
var families []string
for _, v := range views {
if _, ok := byFamily[v.Signature]; !ok {
families = append(families, v.Signature)
}
byFamily[v.Signature] = append(byFamily[v.Signature], v)
}
sort.Strings(families)
for _, fam := range families {
vs := byFamily[fam]
sort.SliceStable(vs, func(i, j int) bool { return viewRank(vs[i]) < viewRank(vs[j]) })
fmt.Fprintln(w, familyHeading(familyLabel(fam), vs))
for _, v := range vs {
printAgent(w, v, now)
}
fmt.Fprintln(w)
}
fmt.Fprintln(w, "Names, titles and statuses are what each agent says about itself.")
}
func viewRank(v agentView) int {
switch {
case v.Role == "service":
return 3
case v.Kind == kindApp:
return 4
case v.Self != nil && v.Self.WaitingFor != "":
return 0
case v.Self != nil && v.Self.Status != "idle":
return 1
}
return 2
}
func familyHeading(label string, vs []agentView) string {
sessions, waiting, services, apps, others := 0, 0, 0, 0, 0
for _, v := range vs {
switch {
case v.Role == "service":
services++
case v.Kind == kindApp:
apps++
case v.Kind != kindAgent:
others++
default:
sessions++
if v.Self != nil && v.Self.WaitingFor != "" {
waiting++
}
}
}
var parts []string
if sessions > 0 {
p := plural(sessions, "session", "sessions")
if waiting > 0 {
p += fmt.Sprintf(" (%d waiting for you)", waiting)
}
parts = append(parts, p)
}
if others > 0 {
parts = append(parts, plural(others, "program", "programs"))
}
if apps > 0 {
parts = append(parts, plural(apps, "app", "apps"))
}
if services > 0 {
parts = append(parts, "its background service")
}
return label + ": " + strings.Join(parts, ", ")
}
func plural(n int, one, many string) string {
if n == 1 {
return "1 " + one
}
return fmt.Sprintf("%d %s", n, many)
}
func printAgent(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)
return
}
l := agentLinesOf(v, now)
fmt.Fprintln(w, " "+l.Head)
if l.State != "" {
fmt.Fprintln(w, " "+l.State)
}
fmt.Fprintln(w, " "+l.Where+fmt.Sprintf(" · pid %d", v.PID))
if len(v.RunningNow) > 0 {
fmt.Fprintln(w, " running now: "+strings.Join(v.RunningNow, "; "))
}
if len(v.ToolServers) > 0 {
fmt.Fprintln(w, " tool servers: "+strings.Join(v.ToolServers, ", "))
}
if v.Self != nil && v.Self.LastPrompt != "" {
fmt.Fprintln(w, " last asked: \""+v.Self.LastPrompt+"\"")
}
}
// agentLines is one agent in words: the same three lines on the command line
// and in the console (uihome.go), so the two can never describe it apart.
type agentLines struct {
Head string // the session's name and title, as the agent gives them
State string // waiting for you / busy / idle, last active, permission mode
Where string // folder and branch, started when and by whom, as which account
}
func agentLinesOf(v agentView, now time.Time) agentLines {
var l agentLines
if v.Self != nil && v.Self.Name != "" {
l.Head = v.Self.Name
}
if v.Self != nil && v.Self.Title != "" && v.Self.Title != v.Self.Name {
if l.Head != "" {
l.Head += " · "
}
l.Head += "\"" + v.Self.Title + "\""
}
if l.Head == "" {
// No session record: name it by what it is and where it works.
l.Head = familyLabel(v.Signature)
if v.Folder != "" {
l.Head += " in " + shownPath(filepath.Base(v.Folder))
}
}
if v.Kind == kindApp {
l.Head += " — an app you are running"
}
var state []string
if v.Self != nil {
switch {
case v.Self.WaitingFor != "":
state = append(state, "waiting for you: "+v.Self.WaitingFor)
case v.Self.Status != "":
state = append(state, v.Self.Status)
}
if v.Self.Background {
state = append(state, "background session")
}
if t, err := time.Parse(time.RFC3339, v.Self.LastActive); err == nil {
state = append(state, "last active "+ago(now.Sub(t)))
}
if m := v.Self.PermissionMode; m != "" && m != "default" {
state = append(state, "permission mode: "+m)
}
}
l.State = strings.Join(state, " · ")
var where []string
if v.Folder != "" {
f := "in " + shownPath(v.Folder)
if v.GitBranch != "" {
f += " (" + v.GitBranch + ")"
}
where = append(where, f)
}
if t, err := time.Parse(time.RFC3339, v.StartedAt); err == nil {
where = append(where, "started "+ago(now.Sub(t)))
}
if o, ok := originWords[v.Origin]; ok {
where = append(where, o)
}
if v.User != "" {
where = append(where, "as "+v.User)
}
l.Where = strings.Join(where, " · ")
return l
}
func ago(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
+73
View File
@@ -101,6 +101,79 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil return out, nil
} }
// ownerFactsReader answers the three owner facts agentowner.go decides on.
// macOS keeps accounts in Open Directory, not /etc/passwd, so it asks the
// system's own tools, each a fixed argv under runTimed's timeout: `dscl .`
// (the LOCAL node only) for the uid and whether the account is a mobile copy
// of a directory account, `id -Gn` for its groups (nested groups resolved by
// the system), and `who` once per scan for who is logged in. Answers are kept
// per account for the scan, so ten agents of one person cost three commands.
func ownerFactsReader() func(pid int, user string) ownerFacts {
var sessions map[string]bool
sessionsOK, sessionsRead := false, false
cache := map[string]ownerFacts{}
return func(pid int, user string) ownerFacts {
if user == "" || strings.ContainsAny(user, "/ \t\n") {
return unknownOwner
}
if f, ok := cache[user]; ok {
return f
}
if !sessionsRead {
sessionsRead = true
if out, err := runTimed("who"); err == nil {
sessions, sessionsOK = parseWhoUsers(string(out)), true
}
}
o := unixOwner{UID: -1, Name: user, LocalRecord: ownerUnknown,
SystemUIDMax: darwinSystemUIDMax, AdminGroups: darwinAdminGroups,
Sessions: sessions, SessionsKnown: sessionsOK}
out, err := runTimed("dscl", ".", "-read", "/Users/"+user, "UniqueID", "OriginalNodeName")
if err == nil {
if uid, mobile, ok := parseDsclUser(string(out)); ok {
o.UID = uid
o.LocalRecord = ownerYes
if mobile {
o.LocalRecord, o.DirectorySource = ownerNo, true
}
}
} else if dsclRecordMissing(err) {
// dscl ran and has no such record in the local node: the account
// comes from a directory. The uid then has to come from id.
o.LocalRecord, o.DirectorySource = ownerNo, true
if out, err := runTimed("id", "-u", "--", user); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
o.UID = n
}
}
}
if out, err := runTimed("id", "-Gn", "--", user); err == nil {
o.Groups, o.GroupsKnown = parseIDGroups(string(out)), true
}
f := classifyUnixOwner(o)
cache[user] = f
return f
}
}
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against: one `ps` sweep, every process with its parent and
// arguments. An error is a sweep that did not happen.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
_, argvs, parents, err := psSweep()
if err != nil {
return nil, err
}
self := os.Getpid()
out := make([]mcpProc, 0, len(argvs))
for pid, argv := range argvs {
if pid != self {
out = append(out, mcpProc{PID: pid, PPID: parents[pid], Argv: argv})
}
}
return out, nil
}
// procParent answers from the sweep scanProcesses just took. // procParent answers from the sweep scanProcesses just took.
func procParent(pid int) int { func procParent(pid int) int {
psCache.Lock() psCache.Lock()
+132
View File
@@ -64,6 +64,35 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil return out, nil
} }
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against: every process, with its parent and command line, from one
// /proc walk. The want set only matters on Windows, where reading a command
// line costs a handle per process. An error is a walk that did not happen,
// and then no running state is guessed.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil, fmt.Errorf("read %s: %w", procRoot, err)
}
self := os.Getpid()
var out []mcpProc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self || !e.IsDir() {
continue
}
p := mcpProc{PID: pid}
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/stat"); err == nil {
p.PPID = parseProcStatPPID(string(raw))
}
if raw, err := os.ReadFile(procRoot + "/" + e.Name() + "/cmdline"); err == nil {
p.Argv = splitNul(raw)
}
out = append(out, p)
}
return out, nil
}
// procParent reads one process's parent pid from /proc/<pid>/stat. // procParent reads one process's parent pid from /proc/<pid>/stat.
func procParent(pid int) int { func procParent(pid int) int {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
@@ -126,6 +155,109 @@ func procUser(pid int) string {
return "" return ""
} }
// ownerFactsReader answers, per process, the three facts about its owner that
// agentowner.go decides on. The machine-wide tables (/etc/passwd, /etc/group,
// login.defs, nsswitch.conf, the sudo rules, who is logged in) are read once
// per scan, on first use, and every process looks itself up in them.
//
// The uid and groups come from /proc/<pid>/status, so they are what the
// PROCESS holds, the same source procUser names it from.
func ownerFactsReader() func(pid int, user string) ownerFacts {
var (
loaded bool
groupNames map[int]string
groupsOK bool
passwd map[int]bool
passwdOK bool
sysMax int
directory bool
sudoers map[string]bool
sudoKnown bool
sessions map[string]bool
sessionsOK bool
logindUsers = identityPath("/run/systemd/users")
)
load := func() {
loaded = true
if b, err := os.ReadFile(identityPath("/etc/group")); err == nil {
groupNames, groupsOK = parseGroupNames(string(b)), true
}
if b, err := os.ReadFile(identityPath("/etc/passwd")); err == nil {
passwd, passwdOK = parsePasswdUIDs(string(b)), true
}
sysMax = linuxSystemUIDMax
if b, err := os.ReadFile(identityPath("/etc/login.defs")); err == nil {
sysMax = parseLoginDefsSysMax(string(b))
}
if b, err := os.ReadFile(identityPath("/etc/nsswitch.conf")); err == nil {
directory = nsswitchHasDirectory(string(b))
}
var partial string
sudoers, partial = sudoPrincipals()
sudoKnown = partial == ""
// Who has a session: logind's own record when it runs, else utmp.
// Neither readable leaves the answer unknown rather than "nobody".
if _, err := os.Stat(logindUsers); err != nil {
if b, err := os.ReadFile(identityPath("/var/run/utmp")); err == nil {
sessions, sessionsOK = parseUtmpUsers(b), true
}
}
}
return func(pid int, user string) ownerFacts {
if !loaded {
load()
}
raw, err := os.ReadFile(procRoot + "/" + strconv.Itoa(pid) + "/status")
if err != nil {
return unknownOwner
}
realUID, effUID, gids, ok := parseStatusIDs(string(raw))
if !ok {
return unknownOwner
}
o := unixOwner{UID: realUID, Name: user, LocalRecord: ownerUnknown, DirectorySource: directory,
SystemUIDMax: sysMax, AdminGroups: linuxAdminGroups, SudoApplies: true,
SudoNamed: sudoers[user], SudoKnown: sudoKnown}
if passwdOK {
o.LocalRecord = yesNo(passwd[realUID])
}
if groupsOK {
o.GroupsKnown = true
for _, g := range gids {
if n, ok := groupNames[g]; ok {
o.Groups = append(o.Groups, n)
}
}
}
switch {
case sessionsOK:
o.Sessions, o.SessionsKnown = sessions, true
default:
b, err := os.ReadFile(logindUsers + "/" + strconv.Itoa(realUID))
switch {
case err == nil:
if live, ok := parseLogindUserState(string(b)); ok {
o.Sessions, o.SessionsKnown = map[string]bool{user: live}, true
}
case os.IsNotExist(err):
// logind keeps a file for every user it tracks; none means
// this account has no session and no lingering services.
if _, derr := os.Stat(logindUsers); derr == nil {
o.Sessions, o.SessionsKnown = map[string]bool{}, true
}
}
}
f := classifyUnixOwner(o)
if effUID == 0 {
// A process running with effective uid 0 holds root whatever
// account started it: that is the administrator answer that
// matters, while the class stays the account's own.
f.Admin = ownerYes
}
return f
}
}
func procCmdline(pid int) string { func procCmdline(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline") raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
if err != nil { if err != nil {
+8
View File
@@ -34,3 +34,11 @@ func procEnvToken(pid int) string { return "" }
func procExe(pid int) string { return "" } func procExe(pid int) string { return "" }
func procParent(pid int) int { return 0 } func procParent(pid int) int { return 0 }
func processParents() map[int]int { return map[int]int{} } func processParents() map[int]int { return map[int]int{} }
// ownerFactsReader: no reader here, so every owner fact is unknown.
func ownerFactsReader() func(pid int, user string) ownerFacts {
return func(int, string) ownerFacts { return unknownOwner }
}
// scanMCPProcesses: no enumerator, so running state is never measured here.
func scanMCPProcesses(map[string]bool) ([]mcpProc, error) { return nil, errNoEnumerator }
+169 -5
View File
@@ -306,6 +306,7 @@ func processParents() map[int]int {
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can // scanProcesses: an error is a snapshot that did not happen (Toolhelp can
// fail transiently), never an empty box; the heartbeat withholds `agents`. // fail transiently), never an empty box; the heartbeat withholds `agents`.
func scanProcesses() (map[int]agentObs, error) { func scanProcesses() (map[int]agentObs, error) {
winBeginSIDScan()
out := map[int]agentObs{} out := map[int]agentObs{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil { if err != nil {
@@ -390,6 +391,50 @@ func scanProcesses() (map[int]agentObs, error) {
return out, nil return out, nil
} }
// scanMCPProcesses is the process list mcpconfig.go correlates declared tool
// servers against. Every process comes from one Toolhelp snapshot with its
// parent; the command line, which costs a handle and a memory read, is read
// only for a program in want (interpreters, package runners, and every
// program a declared server names) or whose own name carries the word mcp.
// The rest keep their parent link with no command line, which is all the
// correlation needs from them. An error is a snapshot that did not happen.
func scanMCPProcesses(want map[string]bool) ([]mcpProc, error) {
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, fmt.Errorf("process snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
self := os.Getpid()
var out []mcpProc
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
pid := int(e.ProcessID)
if pid <= 0 || pid == self {
continue
}
p := mcpProc{PID: pid, PPID: int(e.ParentProcessID)}
exe := windows.UTF16ToString(e.ExeFile[:])
if base := binBase(exe); want[base] || mcpWord(base) {
cmdline := ""
if h, herr := winOpenForRead(pid); herr == nil {
cmdline = winCmdline(h)
windows.CloseHandle(h)
}
if argv, derr := windows.DecomposeCommandLine(cmdline); derr == nil && len(argv) > 0 {
p.Argv = argv
} else {
p.Argv = []string{exe}
}
}
out = append(out, p)
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil, fmt.Errorf("process walk: %w", err)
}
return out, nil
}
// ── connections: GetExtendedTcpTable ──────────────────────────────────────── // ── connections: GetExtendedTcpTable ────────────────────────────────────────
var ( var (
@@ -463,14 +508,133 @@ func winTokenUser(h windows.Handle) string {
if err != nil || tu.User.Sid == nil { if err != nil || tu.User.Sid == nil {
return "" return ""
} }
account, domain, _, err := tu.User.Sid.LookupAccount("") return winAccountLabel(tu.User.Sid.String(), winScanSIDNames())
}
// winSIDScan is the current scan's account-name cache (sidnames.go), shared
// by winTokenUser and ownerFactsReader so a SID is looked up once per scan
// and the scan's lookups stay within sidLookupBudget. scanProcesses starts a
// new one; the readers that run after it in scanAgents use that one.
var winSIDScan struct {
sync.Mutex
names *sidNames
}
func winBeginSIDScan() *sidNames {
n := newSIDNames(winLookupSID, sidLookupBudget, sidLookupSlots)
winSIDScan.Lock()
winSIDScan.names = n
winSIDScan.Unlock()
return n
}
func winScanSIDNames() *sidNames {
winSIDScan.Lock()
n := winSIDScan.names
winSIDScan.Unlock()
if n == nil {
return winBeginSIDScan()
}
return n
}
// winLookupSID is LookupAccountSid on this machine for one SID string.
func winLookupSID(s string) (string, string, error) {
a, err := winLookupSIDAccount(s)
return a.Account, a.Domain, err
}
// winLookupSIDAccount is LookupAccountSid with the SID's type. Unbounded:
// callers go through sidNames or winAccounts.
func winLookupSIDAccount(s string) (sidAccount, error) {
sid, err := windows.StringToSid(s)
if err != nil { if err != nil {
return tu.User.Sid.String() return sidAccount{}, err
} }
if domain != "" { account, domain, typ, err := sid.LookupAccount("")
return domain + `\` + account return sidAccount{Account: account, Domain: domain, Type: typ}, err
} }
return account
// winAccounts is the process-wide account cache for lookups made outside a
// scan: the approvals pipe and the folder agent's drop (sidnames.go).
var winAccounts = newSIDDirectory(winLookupSIDAccount)
// ownerFactsReader answers the three owner facts agentowner.go decides on,
// from each process's own token: its user SID and that SID's domain, and its
// group SIDs (deny-only ones included, which is how an administrator running
// without elevation still reads as an administrator account). The session
// list is read once per scan and needs LocalSystem, which the system service
// is; elsewhere it is left unknown and the token's own logon type answers
// what it can (classifyWinOwner). The domain comes from the scan's shared
// account-name cache (winScanSIDNames), so a SID winTokenUser already looked
// up is not asked again, and an unreachable domain controller leaves the
// class unknown instead of holding the scan.
func ownerFactsReader() func(pid int, user string) ownerFacts {
computer, _ := windows.ComputerName()
names := winScanSIDNames()
var sessions map[string]bool
sessionsOK, sessionsRead := false, false
return func(pid int, _ string) ownerFacts {
if !sessionsRead {
sessionsRead = true
sessions, sessionsOK = winSessionSIDs()
}
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return unknownOwner
}
defer windows.CloseHandle(h)
var tok windows.Token
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
return unknownOwner
}
defer tok.Close()
o := winOwner{Computer: computer, SessionSIDs: sessions, SessionsKnown: sessionsOK}
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
o.SID = tu.User.Sid.String()
o.Domain = winAccountDomain(o.SID, names)
}
if tg, err := tok.GetTokenGroups(); err == nil {
o.Groups, o.GroupsKnown = map[string]bool{}, true
for _, g := range tg.AllGroups() {
if g.Sid != nil {
o.Groups[g.Sid.String()] = true
}
}
}
return classifyWinOwner(o)
}
}
// winSessionSIDs is the set of user SIDs with an active or disconnected
// Remote Desktop Services session (the console is one). ok is false when the
// list could not be read: WTSQueryUserToken needs LocalSystem's privilege.
func winSessionSIDs() (map[string]bool, bool) {
var info *windows.WTS_SESSION_INFO
var count uint32
if err := windows.WTSEnumerateSessions(0, 0, 1, &info, &count); err != nil {
return nil, false
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(info)))
list := unsafe.Slice(info, count)
out := map[string]bool{}
for _, s := range list {
if s.State != windows.WTSActive && s.State != windows.WTSDisconnected {
continue
}
var tok windows.Token
if err := windows.WTSQueryUserToken(s.SessionID, &tok); err != nil {
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return nil, false
}
continue // a session with no user (the services session, a login screen)
}
if tu, err := tok.GetTokenUser(); err == nil && tu.User.Sid != nil {
out[tu.User.Sid.String()] = true
}
tok.Close()
}
return out, true
} }
func procUser(pid int) string { func procUser(pid int) string {
+455
View File
@@ -0,0 +1,455 @@
package main
// agentsession.go — what an agent is DOING, not only that it runs.
//
// "Seven Claude Code sessions on this machine" tells the owner nothing (the
// owner, 2026-10-30). Each agent gets, beside the detection facts (agents.go):
//
// - the folder it works in, that folder's git branch, how long it has run,
// and what it is running right now (its child commands, secrets redacted)
// — read from the operating system, for every agent;
// - what the agent says about itself, read from the agent's OWN state files
// on this machine: for Claude Code its per-process session record
// (~/.claude/sessions/<pid>.json: the session's name, whether it is idle,
// working, or waiting for the person and on what) and the tail of its
// transcript (the title Claude Code keeps for the session, its permission
// mode, the last prompt).
//
// No model is asked anything. The title is the agent's own, kept by the agent.
//
// WHAT IS NEVER READ: a process's environment (where tokens live), the key
// files beside the session records, and any transcript content other than
// the title, the permission mode, the branch and the last prompt.
//
// TRUST. A session record is written by the agent, so it is the agent's
// description of itself: an agent can name itself anything. It is shown as
// what the agent says (json "self_reported"), never as what the bastion
// found, and the detection facts stay the bastion's own. A record is used only
// if it names this pid AND, where the platform can tell, this process's start
// time (procStart against the kernel's), so a record left by a process that
// has exited cannot be pinned on a new one that got its number.
//
// WHERE IT GOES. This is built for the console and the command line on this
// machine. It is not in agentObs, so it is not in the heartbeat: nothing here
// reaches a Host.
import (
"bytes"
"encoding/json"
"os"
"os/user"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const (
sessionRecordMax = 64 << 10 // a session record is a few hundred bytes
transcriptTailMax = 400 << 10 // the title, mode and branch recur; the tail holds the latest
titleMax = 120
promptMax = 240
nameMax = 60
runningNowMax = 3
runningNowWidth = 100
)
// agentSelf is what the agent says about itself.
type agentSelf struct {
Source string `json:"source"`
Verified bool `json:"verified"` // the record named this pid and its start time
SessionID string `json:"session_id,omitempty"`
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
WaitingFor string `json:"waiting_for,omitempty"`
Background bool `json:"background,omitempty"`
PermissionMode string `json:"permission_mode,omitempty"`
LastActive string `json:"last_active,omitempty"`
LastPrompt string `json:"last_prompt,omitempty"`
}
// agentView is one agent as the console and `agents` show it.
type agentView struct {
agentObs
// Role separates a session from the agent's own supervisor process
// ("service"): Claude Code's background service starts background
// sessions and is not one, and counting it as one is how a machine with
// five sessions read as six.
Role string `json:"role"`
Folder string `json:"folder,omitempty"`
GitBranch string `json:"git_branch,omitempty"`
StartedAt string `json:"started_at,omitempty"`
RunningNow []string `json:"running_now,omitempty"`
// ToolServers are the long-running children the agent started with
// itself (its MCP servers, by their program names), kept apart from
// RunningNow so the commands of this moment are not buried under them.
ToolServers []string `json:"tool_servers,omitempty"`
Self *agentSelf `json:"self_reported,omitempty"`
}
// Seams: the platform's answers, so the views are testable from fixtures.
var (
uidForUserFn = uidForUser
procCwdFn = osProcCwd
procStartedFn = osProcStarted
homeForUserFn = homeForUser
procCmdArgsFn = osProcCmdArgs
sessionNowFn = time.Now
agentViewsLimit = 200
)
// uidForUser is the account number session files must belong to; -1 (no
// check) where accounts are not numbers (Windows). ok is false when the
// account cannot be resolved, and then nothing of the agent's is read.
func uidForUser(name string) (int, bool) {
if runtime.GOOS == "windows" {
return -1, true
}
u, err := user.Lookup(name)
if err != nil {
return 0, false
}
n, err := strconv.Atoi(u.Uid)
return n, err == nil
}
func homeForUser(name string) string {
if name == "" {
return ""
}
u, err := user.Lookup(name)
if err != nil {
return ""
}
return u.HomeDir
}
// buildAgentViews turns one scan into what a person reads.
func buildAgentViews(agents []agentObs, tree map[int]int) []agentView {
children := map[int][]int{}
for c, p := range tree {
children[p] = append(children[p], c)
}
out := make([]agentView, 0, len(agents))
for i, a := range agents {
if i >= agentViewsLimit {
break
}
v := agentView{agentObs: a, Role: "session"}
if a.Signature == "claude-code" && isClaudeCodeService(a.Cmdline) {
v.Role = "service"
}
v.Folder = procCwdFn(a.PID)
v.GitBranch = gitBranchOf(v.Folder)
started, hasStart := procStartedFn(a.PID)
if hasStart {
v.StartedAt = started.UTC().Format(time.RFC3339)
}
v.RunningNow, v.ToolServers = runningNow(a.PID, children, started, hasStart)
if a.Signature == "claude-code" && v.Role == "session" {
if uid, ok := uidForUserFn(a.User); ok {
v.Self = claudeCodeSelf(a.PID, homeForUserFn(a.User), uid)
}
}
out = append(out, v)
}
return out
}
// isClaudeCodeService is Claude Code's background service, `claude daemon run`.
func isClaudeCodeService(cmdline string) bool {
f := strings.Fields(cmdline)
for i := 0; i+1 < len(f); i++ {
if f[i] == "daemon" && f[i+1] == "run" {
return true
}
}
return false
}
var sessionIDShape = regexp.MustCompile(`^[0-9a-fA-F-]{8,64}$`)
// claudeCodeSelf reads the session record Claude Code keeps for this pid, and
// the tail of that session's transcript. nil when there is none this bastion
// can read (another account's home without root, an older Claude Code).
//
// Every file is read only if it is a regular file the agent's own account
// owns (openOwned): the daemon may run as the machine's administrator, and a
// link planted in one account's home must not make it read another's.
func claudeCodeSelf(pid int, home string, uid int) *agentSelf {
if home == "" {
return nil
}
raw := readCappedOwned(filepath.Join(home, ".claude", "sessions", strconv.Itoa(pid)+".json"), sessionRecordMax, uid)
if raw == nil {
return nil
}
var rec struct {
PID int `json:"pid"`
SessionID string `json:"sessionId"`
ProcStart string `json:"procStart"`
Kind string `json:"kind"`
Name string `json:"name"`
Status string `json:"status"`
WaitingFor string `json:"waitingFor"`
UpdatedAt int64 `json:"updatedAt"`
}
if json.Unmarshal(raw, &rec) != nil || rec.PID != pid {
return nil
}
id, _, idOK := procIdentity(pid)
if idOK && rec.ProcStart != id {
return nil // another process's record: this number has changed hands
}
s := &agentSelf{
Source: "Claude Code's session record",
Verified: idOK,
Name: cleanText(rec.Name, nameMax),
Status: cleanText(rec.Status, 30),
WaitingFor: cleanText(rec.WaitingFor, 60),
Background: rec.Kind == "bg",
}
if rec.UpdatedAt > 0 {
s.LastActive = time.UnixMilli(rec.UpdatedAt).UTC().Format(time.RFC3339)
}
if !sessionIDShape.MatchString(rec.SessionID) {
return s
}
s.SessionID = rec.SessionID
matches, _ := filepath.Glob(filepath.Join(home, ".claude", "projects", "*", rec.SessionID+".jsonl"))
sort.Strings(matches)
if len(matches) == 0 {
return s
}
for _, line := range tailLinesOwned(matches[0], transcriptTailMax, uid) {
var e struct {
Type string `json:"type"`
AITitle string `json:"aiTitle"`
PermissionMode string `json:"permissionMode"`
LastPrompt string `json:"lastPrompt"`
}
if json.Unmarshal(line, &e) != nil {
continue
}
switch e.Type {
case "ai-title":
if e.AITitle != "" {
s.Title = cleanText(e.AITitle, titleMax)
}
case "permission-mode":
if e.PermissionMode != "" {
s.PermissionMode = cleanText(e.PermissionMode, 40)
}
case "last-prompt":
if e.LastPrompt != "" {
s.LastPrompt = cleanText(e.LastPrompt, promptMax)
}
}
}
return s
}
// toolServerWindow: a direct child started this soon after the agent is one
// it started with itself (an MCP server), not a command of this moment.
const toolServerWindow = 2 * time.Minute
// toolServerName is the program a tool server runs: past the interpreter
// (agents.go's table) and its flags, the base name of what it was given.
func toolServerName(args []string) string {
name := binBase(args[0])
if !interpreters[name] {
return name
}
for _, a := range args[1:] {
if !strings.HasPrefix(a, "-") {
return cleanText(filepath.Base(a), 60)
}
}
return name
}
// runningNow is what the agent is running at this moment: the commands at
// the ends of its process tree, secrets redacted, newest pids first. The
// direct children it started with itself come back apart, as tool servers.
func runningNow(pid int, children map[int][]int, started time.Time, hasStart bool) ([]string, []string) {
var tools []string
// top: the agent's direct child each process descends from. A tool server
// is often a grandchild (npx → sh → node), so it is the direct child's
// start that says whether the agent started it with itself.
top := map[int]int{}
var leaves []int
seen := map[int]bool{pid: true}
queue := []int{pid}
for len(queue) > 0 && len(seen) < 500 {
p := queue[0]
queue = queue[1:]
kids := children[p]
if len(kids) == 0 && p != pid {
leaves = append(leaves, p)
}
for _, k := range kids {
if !seen[k] {
seen[k] = true
queue = append(queue, k)
if p == pid {
top[k] = k
} else {
top[k] = top[p]
}
}
}
}
sort.Sort(sort.Reverse(sort.IntSlice(leaves)))
var out []string
for _, l := range leaves {
args := splitRetitled(procCmdArgsFn(l))
if len(args) == 0 {
continue
}
if hasStart {
if t, ok := procStartedFn(top[l]); ok && t.Sub(started) < toolServerWindow {
tools = append(tools, toolServerName(args))
continue
}
}
if len(out) == runningNowMax {
continue
}
red, _ := redactSecretArgs(args)
red[0] = filepath.Base(red[0])
out = append(out, cleanText(strings.Join(red, " "), runningNowWidth))
}
return out, countedNames(tools)
}
// splitRetitled undoes a retitled command line: a program that rewrote its
// argv as one string (a browser, a server setting its title) arrives as a
// single argument with spaces in it, and its "program" would otherwise be
// whatever follows the last slash in the whole line.
func splitRetitled(args []string) []string {
if len(args) == 1 && strings.ContainsAny(args[0], " \t") {
return strings.Fields(args[0])
}
return args
}
// countedNames sorts names and folds repeats: "chrome ×3".
func countedNames(names []string) []string {
n := map[string]int{}
for _, x := range names {
n[x]++
}
out := make([]string, 0, len(n))
for x, c := range n {
if c > 1 {
x += " ×" + strconv.Itoa(c)
}
out = append(out, x)
}
sort.Strings(out)
return out
}
// gitBranchOf reads the branch of the repository holding dir from its HEAD
// file. It runs no git: the folder is the agent's, and so is its git config.
func gitBranchOf(dir string) string {
for i := 0; dir != "" && i < 40; i++ {
g := filepath.Join(dir, ".git")
if st, err := os.Lstat(g); err == nil {
head := filepath.Join(g, "HEAD")
if !st.IsDir() {
// a worktree or submodule: ".git" is a file naming the real dir
b := readCapped(g, 4096)
rest, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:")
if !ok {
return ""
}
gd := strings.TrimSpace(rest)
if !filepath.IsAbs(gd) {
gd = filepath.Join(dir, gd)
}
head = filepath.Join(gd, "HEAD")
}
h := strings.TrimSpace(string(readCapped(head, 4096)))
if b, ok := strings.CutPrefix(h, "ref: refs/heads/"); ok {
return cleanText(b, 80)
}
if len(h) >= 12 {
return "detached at " + cleanText(h[:12], 12)
}
return ""
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
return ""
}
// readCapped reads a small file without following a final symlink or
// waiting on a pipe (openOwned, no owner check): .git/HEAD in an agent's
// folder is that account's to plant anything in.
func readCapped(path string, max int64) []byte { return readCappedOwned(path, max, -1) }
func readCappedOwned(path string, max int64, uid int) []byte {
f, err := openOwned(path, uid)
if err != nil {
return nil
}
defer f.Close()
if st, err := f.Stat(); err != nil || !st.Mode().IsRegular() || st.Size() > max {
return nil
}
b := make([]byte, max)
n, _ := f.Read(b)
return b[:n]
}
// tailLines is the last max bytes of a file, split into whole lines.
func tailLines(path string, max int64) [][]byte { return tailLinesOwned(path, max, -1) }
func tailLinesOwned(path string, max int64, uid int) [][]byte {
f, err := openOwned(path, uid)
if err != nil {
return nil
}
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() {
return nil
}
start := st.Size() - max
if start < 0 {
start = 0
}
buf := make([]byte, st.Size()-start)
n, _ := f.ReadAt(buf, start)
lines := bytes.Split(buf[:n], []byte("\n"))
if start > 0 && len(lines) > 0 {
lines = lines[1:] // the first is cut in the middle
}
return lines
}
// cleanText is agent-written text made safe to show: control characters out,
// whitespace collapsed, cut to max runes with an ellipsis.
func cleanText(s string, max int) string {
s = strings.Map(func(r rune) rune {
if unicode.IsControl(r) || r == '
' || r == '
' {
return ' '
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > max {
return string(r[:max-1]) + "…"
}
return s
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
package main
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
)
// A pipe planted where .git/HEAD belongs must not stall the daemon: a plain
// open would wait for a writer for ever.
func TestAPipeInPlaceOfGitHEADDoesNotStall(t *testing.T) {
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o700); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(filepath.Join(repo, ".git", "HEAD"), 0o600); err != nil {
t.Skip("cannot make a fifo here:", err)
}
done := make(chan string, 1)
go func() { done <- gitBranchOf(repo) }()
select {
case got := <-done:
if got != "" {
t.Errorf("a pipe read as branch %q", got)
}
case <-time.After(3 * time.Second):
t.Fatal("reading .git/HEAD stalled on a pipe")
}
}
+53
View File
@@ -0,0 +1,53 @@
//go:build linux
package main
import (
"os"
"strconv"
"strings"
"time"
)
func osProcCwd(pid int) string {
d, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
if err != nil {
return ""
}
return d
}
func osProcCmdArgs(pid int) []string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
if err != nil {
return nil
}
return splitNul(raw)
}
// osProcStarted is the process's start: boot time (/proc/stat btime) plus its
// start in clock ticks (procIdentity), at the USER_HZ of 100 Linux reports.
func osProcStarted(pid int) (time.Time, bool) {
id, _, ok := procIdentity(pid)
if !ok {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return time.Time{}, false
}
raw, err := os.ReadFile("/proc/stat")
if err != nil {
return time.Time{}, false
}
for _, line := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(line, "btime "); ok {
boot, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot, 0).Add(time.Duration(ticks) * 10 * time.Millisecond), true
}
}
return time.Time{}, false
}
+35
View File
@@ -0,0 +1,35 @@
//go:build !windows
package main
import (
"errors"
"os"
"syscall"
)
var errNotOwnedRegular = errors.New("not a regular file owned by that account")
// openOwned opens a file the agent's account controls, for the daemon that
// may be running as the machine's administrator. It never follows a final
// symlink (O_NOFOLLOW), never waits on a pipe planted in its place
// (O_NONBLOCK, then a regular-file check), and, when uid >= 0, refuses a
// file another account owns, which also catches a symlinked directory on the
// way: whatever the path walked through, the file it reached has to be the
// agent account's own.
func openOwned(path string, uid int) (*os.File, error) {
fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
var st syscall.Stat_t
if err := syscall.Fstat(fd, &st); err != nil {
_ = syscall.Close(fd)
return nil, err
}
if st.Mode&syscall.S_IFMT != syscall.S_IFREG || (uid >= 0 && int(st.Uid) != uid) {
_ = syscall.Close(fd)
return nil, errNotOwnedRegular
}
return os.NewFile(uintptr(fd), path), nil
}
+22
View File
@@ -0,0 +1,22 @@
//go:build windows
package main
import (
"errors"
"os"
)
// openOwned on Windows: no owner check by account number (accounts are SIDs
// here), and a symlink needs privilege to create; the file must be regular.
func openOwned(path string, _ int) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
if st, err := f.Stat(); err != nil || !st.Mode().IsRegular() {
_ = f.Close()
return nil, errors.New("not a regular file")
}
return f, nil
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !linux
package main
import (
"strings"
"time"
)
// Off Linux the folder and start time are not read yet; the agent's own
// session record still is (claudeCodeSelf), unverified, since procIdentity has
// no start time to check it against there.
func osProcCwd(int) string { return "" }
func osProcStarted(int) (time.Time, bool) { return time.Time{}, false }
func osProcCmdArgs(pid int) []string { return strings.Fields(procCmdline(pid)) }
+282
View File
@@ -0,0 +1,282 @@
package main
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const fixtureSession = "0a1b2c3d-1111-4222-8333-444455556666"
// fakeClaudeHome lays out what Claude Code keeps for one running session: the
// per-process record and the session's transcript. All of it invented.
func fakeClaudeHome(t *testing.T, pid int, procStart, transcript string) string {
t.Helper()
home := t.TempDir()
sess := filepath.Join(home, ".claude", "sessions")
proj := filepath.Join(home, ".claude", "projects", "-work-shop")
for _, d := range []string{sess, proj} {
if err := os.MkdirAll(d, 0o700); err != nil {
t.Fatal(err)
}
}
rec := `{"pid":` + itoa(pid) + `,"sessionId":"` + fixtureSession + `","cwd":"/work/shop","procStart":"` + procStart +
`","kind":"interactive","name":"shop-a1","status":"waiting","waitingFor":"permission prompt","updatedAt":1793300000000}`
if err := os.WriteFile(filepath.Join(sess, itoa(pid)+".json"), []byte(rec), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sess, itoa(pid)+".deadbeef.key"), []byte("NEVER-READ"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proj, fixtureSession+".jsonl"), []byte(transcript), 0o600); err != nil {
t.Fatal(err)
}
return home
}
func stubProcIdentity(t *testing.T, id string) {
t.Helper()
prev := procIdentity
t.Cleanup(func() { procIdentity = prev })
procIdentity = func(int) (string, bool, bool) { return id, false, true }
}
const fixtureTranscript = `{"type":"ai-title","aiTitle":"Set up the shop database","sessionId":"x"}
{"type":"user","message":{"content":"SECRET CONVERSATION BODY"}}
{"type":"permission-mode","permissionMode":"bypassPermissions","sessionId":"x"}
{"type":"last-prompt","lastPrompt":"add an index to orders","sessionId":"x"}
{"type":"ai-title","aiTitle":"Add an index to the orders table","sessionId":"x"}
`
func TestClaudeCodeSessionSaysWhatItIsDoing(t *testing.T) {
stubProcIdentity(t, "5551")
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
s := claudeCodeSelf(4242, home, os.Getuid())
if s == nil {
t.Fatal("a session with a matching record was not read")
}
want := agentSelf{Source: "Claude Code's session record", Verified: true, SessionID: fixtureSession,
Name: "shop-a1", Title: "Add an index to the orders table", Status: "waiting",
WaitingFor: "permission prompt", PermissionMode: "bypassPermissions",
LastActive: time.UnixMilli(1793300000000).UTC().Format(time.RFC3339),
LastPrompt: "add an index to orders"}
if *s != want {
t.Errorf("got %+v\nwant %+v", *s, want)
}
}
// A record left by a process that has exited must not be pinned on the new
// process that got its number.
func TestASessionRecordForAnEarlierProcessIsNotUsed(t *testing.T) {
stubProcIdentity(t, "9999")
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
if s := claudeCodeSelf(4242, home, os.Getuid()); s != nil {
t.Errorf("a record whose process start is not this process's was used: %+v", *s)
}
}
func TestASessionRecordNamingAnotherPidIsNotUsed(t *testing.T) {
stubProcIdentity(t, "5551")
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
if err := os.Rename(filepath.Join(home, ".claude", "sessions", "4242.json"),
filepath.Join(home, ".claude", "sessions", "4343.json")); err != nil {
t.Fatal(err)
}
if s := claudeCodeSelf(4343, home, os.Getuid()); s != nil {
t.Errorf("a record that names pid 4242 was used for 4343: %+v", *s)
}
}
// The session id becomes part of a path; one that is not an id reads nothing.
func TestASessionIDThatIsAPathReadsNoTranscript(t *testing.T) {
stubProcIdentity(t, "5551")
home := t.TempDir()
sess := filepath.Join(home, ".claude", "sessions")
if err := os.MkdirAll(sess, 0o700); err != nil {
t.Fatal(err)
}
rec := `{"pid":4242,"sessionId":"../../../etc/x","procStart":"5551","name":"n"}`
if err := os.WriteFile(filepath.Join(sess, "4242.json"), []byte(rec), 0o600); err != nil {
t.Fatal(err)
}
s := claudeCodeSelf(4242, home, os.Getuid())
if s == nil || s.SessionID != "" || s.Title != "" {
t.Errorf("a path-shaped session id was followed: %+v", s)
}
}
// What an agent writes about itself is shown, never obeyed or rendered: no
// control characters, no line breaks to forge another line, a bounded length.
func TestAgentWrittenTextIsCleanedAndBounded(t *testing.T) {
got := cleanText("ok\n\x1b[31mIGNORE ALL PREVIOUS
instructions\t", 200)
if strings.ContainsAny(got, "\n\x1b
\t") {
t.Errorf("control characters survived: %q", got)
}
if r := []rune(cleanText(strings.Repeat("é", 500), titleMax)); len(r) != titleMax {
t.Errorf("a long title is %d runes, want %d", len(r), titleMax)
}
}
func TestClaudeCodesBackgroundServiceIsNotASession(t *testing.T) {
if !isClaudeCodeService("/opt/x/claude.exe daemon run --json-path /h/.claude/daemon.json") {
t.Error("`daemon run` was not recognised as the background service")
}
if isClaudeCodeService("claude --resume") {
t.Error("an interactive session was taken for the service")
}
}
// The commands of this moment are kept apart from the tool servers the agent
// started with itself, and a secret on a command line is redacted.
func TestRunningNowSeparatesToolServersAndRedactsSecrets(t *testing.T) {
start := time.Date(2026, 10, 30, 9, 0, 0, 0, time.UTC)
started := map[int]time.Time{
100: start, 110: start.Add(5 * time.Second), 111: start.Add(6 * time.Second),
120: start.Add(3 * time.Hour), 121: start.Add(3 * time.Hour),
}
args := map[int][]string{
111: {"node", "/home/u/.npm/_npx/abc/node_modules/.bin/mcp-server-files"},
121: {"curl", "-H", "Authorization: Bearer sk-live-abcdefghijklmnop1234", "https://api.example.com"},
}
prevS, prevA := procStartedFn, procCmdArgsFn
t.Cleanup(func() { procStartedFn, procCmdArgsFn = prevS, prevA })
procStartedFn = func(pid int) (time.Time, bool) { s, ok := started[pid]; return s, ok }
procCmdArgsFn = func(pid int) []string { return args[pid] }
children := map[int][]int{100: {110, 120}, 110: {111}, 120: {121}}
now, tools := runningNow(100, children, start, true)
if len(tools) != 1 || tools[0] != "mcp-server-files" {
t.Errorf("tool servers %v", tools)
}
if len(now) != 1 || !strings.HasPrefix(now[0], "curl") {
t.Fatalf("running now %v", now)
}
if strings.Contains(now[0], "sk-live") {
t.Errorf("a secret on a command line was shown: %q", now[0])
}
}
func TestGitBranchIsReadFromHEADWithoutRunningGit(t *testing.T) {
repo := t.TempDir()
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, ".git", "HEAD"), []byte("ref: refs/heads/fix/login\n"), 0o600); err != nil {
t.Fatal(err)
}
sub := filepath.Join(repo, "src", "app")
if err := os.MkdirAll(sub, 0o700); err != nil {
t.Fatal(err)
}
if got := gitBranchOf(sub); got != "fix/login" {
t.Errorf("branch from a subfolder: %q", got)
}
// A worktree: .git is a file naming the real git dir.
wt := t.TempDir()
gd := filepath.Join(t.TempDir(), "wt-gitdir")
if err := os.MkdirAll(gd, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(gd, "HEAD"), []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: "+gd+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if got := gitBranchOf(wt); got != "feature" {
t.Errorf("branch in a worktree: %q", got)
}
}
// Sessions waiting for the person come first, and the heading counts the
// background service apart from the sessions.
func TestAgentsListPutsWaitingSessionsFirstAndCountsTheServiceApart(t *testing.T) {
views := []agentView{
{agentObs: agentObs{PID: 1, Signature: "claude-code"}, Role: "service"},
{agentObs: agentObs{PID: 2, Kind: kindAgent, Signature: "claude-code"}, Role: "session",
Self: &agentSelf{Name: "idle-one", Status: "idle"}},
{agentObs: agentObs{PID: 3, Kind: kindAgent, Signature: "claude-code"}, Role: "session",
Self: &agentSelf{Name: "waits", Title: "Rename the invoices", Status: "waiting", WaitingFor: "permission prompt"}},
}
var b bytes.Buffer
printAgents(&b, views, time.Now())
out := b.String()
if !strings.HasPrefix(out, "Claude Code: 2 sessions (1 waiting for you), its background service\n") {
t.Errorf("heading:\n%s", out)
}
if strings.Index(out, "waits") > strings.Index(out, "idle-one") {
t.Errorf("the waiting session is not first:\n%s", out)
}
if !strings.Contains(out, "waiting for you: permission prompt") {
t.Errorf("what it waits for is not said:\n%s", out)
}
}
// Stop takes a session's name, and the owner is asked about the session, not
// a bare pid.
func TestStopNamesTheSessionItAsksAbout(t *testing.T) {
stubProcIdentity(t, "5551")
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
prevH, prevTree, prevU := homeForUserFn, processTree, uidForUserFn
t.Cleanup(func() { homeForUserFn, processTree, uidForUserFn = prevH, prevTree, prevU })
homeForUserFn = func(string) string { return home }
uidForUserFn = func(string) (int, bool) { return os.Getuid(), true }
processTree = func() map[int]int { return nil }
observed := map[int]agentObs{4242: {PID: 4242, Kind: kindAgent, Signature: "claude-code", User: "u"}}
pid, v, err := resolveStopTarget("shop-a1", observed)
if err != nil || pid != 4242 {
t.Fatalf("by name: %d %v", pid, err)
}
if _, _, err := resolveStopTarget("no-such", observed); err == nil {
t.Error("an unknown session name resolved")
}
d := map[string]any{"kind": localStopKind, "pid": "4242", "signature": "claude-code",
"agent": "Claude Code", "session": v.Self.Name, "title": v.Self.Title, "folder": "/work/shop"}
got := describeRequest(stepupRequest{Details: d})
want := `Stop Claude Code session shop-a1 "Add an index to the orders table" in /work/shop (pid 4242) on this machine, and every process it started.`
if got != want {
t.Errorf("the owner reads\n%s\nwant\n%s", got, want)
}
}
// A program that rewrote its command line as one string is still named by
// its program, not by whatever follows the last slash in the whole line, and
// repeats fold into a count.
func TestARetitledCommandLineIsSplitAndRepeatsFold(t *testing.T) {
if got := splitRetitled([]string{"go test ./..."}); len(got) != 3 || got[0] != "go" {
t.Errorf("split %q", got)
}
if got := splitRetitled([]string{"/usr/bin/go", "test", "./..."}); len(got) != 3 || got[0] != "/usr/bin/go" {
t.Errorf("an ordinary argv was changed: %q", got)
}
if got := countedNames([]string{"chrome", "mcp-files", "chrome", "chrome"}); len(got) != 2 || got[0] != "chrome ×3" {
t.Errorf("folded %q", got)
}
}
// The daemon may read as the machine's administrator: a session file that is
// a link, or that another account owns, is not read.
func TestASessionFileThatIsALinkOrNotTheAgentsOwnIsNotRead(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("accounts are not numbers and links need privilege on Windows")
}
stubProcIdentity(t, "5551")
home := fakeClaudeHome(t, 4242, "5551", fixtureTranscript)
if s := claudeCodeSelf(4242, home, os.Getuid()+1); s != nil {
t.Errorf("a record owned by another account was read: %+v", *s)
}
rec := filepath.Join(home, ".claude", "sessions", "4242.json")
elsewhere := filepath.Join(t.TempDir(), "other.json")
if err := os.Rename(rec, elsewhere); err != nil {
t.Fatal(err)
}
if err := os.Symlink(elsewhere, rec); err != nil {
t.Fatal(err)
}
if s := claudeCodeSelf(4242, home, os.Getuid()); s != nil {
t.Errorf("a record reached through a link was read: %+v", *s)
}
}
+137 -11
View File
@@ -574,9 +574,10 @@ var allCaps = []string{"dcn", "egress", "harness", "inference", "models", "obser
// writes a pairing PENDING when an anchor exists (pair.go), and state "" reads // writes a pairing PENDING when an anchor exists (pair.go), and state "" reads
// active (config.go), so B comes straight up with full lanes. // active (config.go), so B comes straight up with full lanes.
// //
// LOCAL is exempt and must stay so: a local bastion serves one host and cannot // LOCAL is exempt and must stay so: a local bastion serves the one host it was
// be anchored at all (cmdClaimAnchor refuses outside networked mode), so the // paired with, and a second only on a yes typed at the machine (pair_terminal.go),
// pairing IS the authorization there. Most bastions are local. // and it cannot be anchored at all (cmdClaimAnchor refuses outside networked
// mode), so the pairing IS the authorization there. Most bastions are local.
// //
// self-update is deliberately INSIDE the refusal. Directing a binary swap is the // self-update is deliberately INSIDE the refusal. Directing a binary swap is the
// most dangerous thing on the list, and an unanchored networked box cannot say // most dangerous thing on the list, and an unanchored networked box cannot say
@@ -798,30 +799,96 @@ func lastOK(host string) time.Time {
// pairingEntry is one pairing on this box as told to the ANCHOR host: name, // pairingEntry is one pairing on this box as told to the ANCHOR host: name,
// handle, state, since. Same privacy line as peers.go — never keys or grants. // handle, state, since. Same privacy line as peers.go — never keys or grants.
//
// For a pairing that waits to be admitted it also says what the anchor Host
// needs to raise the approval prompt for the person (C-Series: the Network
// app's pending pairing, approved with approve_pairing):
//
// waiting_for the Host that admits it: the anchor, which reads its own
// name here (anchor_local.go admitter)
// transport how its lanes would run: "tunnel" (WireGuard through the
// relay, a Cloud Host), "tcp" (a Host's lane port) or "unix"
// (a socket on this machine)
// same_machine its Host runs on this machine (anchor_local.go
// sameMachineHost)
// origin how it came: "paired" (a code redeemed at this machine),
// "carried" (moved in from an account's own bastion when the
// machine moved to the system service, servicecarry.go), or
// "former_anchor" (it was in charge of this machine until a
// Host the rules put first took over, anchor_local.go)
//
// The approval itself is unchanged: pairing_grant_sync with terms the anchor
// signs, {kind: pairing_grant, server: the beat's hostname, anchor: this
// beat's anchor.anchor_host, subject: host_name, subject_handle: handle, caps,
// issued_at, expires}, over the anchor's own mgmt lane (pairing_ops.go).
type pairingEntry struct { type pairingEntry struct {
HostName string `json:"host_name"` HostName string `json:"host_name"`
Handle string `json:"handle"` Handle string `json:"handle"`
State string `json:"state"` State string `json:"state"`
Since string `json:"since,omitempty"` Since string `json:"since,omitempty"`
WaitingFor string `json:"waiting_for,omitempty"`
Transport string `json:"transport,omitempty"`
SameMachine bool `json:"same_machine,omitempty"`
Origin string `json:"origin,omitempty"`
} }
// The origins pairingEntry reports.
const (
pairingOriginPaired = "paired"
pairingOriginCarried = "carried"
pairingOriginFormerAnchor = "former_anchor"
)
// pairingsReport lists EVERY pairing on the box, including the one asking. // pairingsReport lists EVERY pairing on the box, including the one asking.
// "since" is first_seen — when the host was first paired here — kept across // "since" is first_seen — when the host was first paired here — kept across
// --replace, so a pending host re-presenting a token keeps its original age // --replace, so a pending host re-presenting a token keeps its original age
// (the 10-minute unapproved clock does not restart). nil when the directory // (the 10-minute unapproved clock does not restart). nil when the directory
// cannot be read — the host keeps what it has, as with peers. // cannot be read — the host keeps what it has, as with peers.
//
// A pairing whose file this build refuses (Config.invalid) is left out: it is
// never served, so to a host it is not on this box. A host knows the states
// active, pending and revoked and may read any other as active, so "invalid"
// is never sent. The machine's own status and setup page still show it.
func pairingsReport() []pairingEntry { func pairingsReport() []pairingEntry {
cfgs, err := loadPairings() cfgs, err := loadPairings()
if err != nil { if err != nil {
return nil return nil
} }
who := admitter()
replaced := ""
if p, err := anchorReplacedPath(); err == nil {
if raw, err := os.ReadFile(p); err == nil {
var r anchorReplaced
if json.Unmarshal(raw, &r) == nil {
replaced = r.Host
}
}
}
out := make([]pairingEntry, 0, len(cfgs)) out := make([]pairingEntry, 0, len(cfgs))
for _, c := range cfgs { for _, c := range cfgs {
if c.HostName == "" { if c.HostName == "" || c.invalid != "" {
continue continue
} }
out = append(out, pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName), e := pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName),
State: c.stateName(), Since: sinceOf(c)}) State: c.stateName(), Since: sinceOf(c)}
if c.State == pairingPending {
e.WaitingFor = who
e.Transport = "tunnel"
if c.unixTransport() {
e.Transport = "unix"
} else if c.tcpTransport() {
e.Transport = "tcp"
}
e.SameMachine = sameMachineHost(c)
e.Origin = pairingOriginPaired
if c.Origin == pairingOriginCarried {
e.Origin = pairingOriginCarried
}
if replaced != "" && replaced == c.HostName {
e.Origin = pairingOriginFormerAnchor
}
}
out = append(out, e)
} }
return out return out
} }
@@ -868,6 +935,16 @@ func pairingEnforcing(self string) map[string]any {
return out return out
} }
out["pairing_state"] = c.stateName() out["pairing_state"] = c.stateName()
// A Host the rules put first took the anchor from this one (anchor_local.go):
// {by, at, why, waiting_for}. Its last beat before its lanes stop carries it.
if note := anchorReplacedNote(self); note != nil {
out["anchor_replaced"] = note
}
if c.State == pairingPending {
if who := admitter(); who != "" {
out["waiting_for"] = who
}
}
// The name THIS pairing is known by on this box — what a reach grant must // The name THIS pairing is known by on this box — what a reach grant must
// name as its subject (service_reach.go). A host can hold more than one name // name as its subject (service_reach.go). A host can hold more than one name
// (a legacy one and the current one); this says which one the box enforces. // (a legacy one and the current one); this says which one the box enforces.
@@ -978,10 +1055,24 @@ func cmdRepinAnchor() error {
return nil return nil
} }
// anchorAlreadyHolds: the anchor names an active pairing here, and host
// (claim-anchor --host, "" for none) names that same pairing.
func anchorAlreadyHolds(cfgs []*Config, prev *anchorClaim, host string) bool {
c := pairingFor(cfgs, prev.AnchorHost)
if c == nil || !c.active() {
return false
}
if host == "" {
return true
}
named, err := pickPairing(cfgs, host)
return err == nil && named.HostName == c.HostName
}
// pickPairing resolves --host against this box's pairings. It accepts the full // pickPairing resolves --host against this box's pairings. It accepts the full
// host_name the box reports, or just the handle in front of it, because the // host_name the box reports, or just the handle in front of it, because the
// name the user is looking at depends on which surface they read it from — // name the user is looking at depends on which surface they read it from —
// `8abb770bbfde71d0` in Circles, `8abb770bbfde71d0.host.privatae.ai` in the // `1b2c3d4e5f607182` in Circles, `1b2c3d4e5f607182.host.privatae.ai` in the
// claim-anchor error. Refusing the short form would mean refusing the thing the // claim-anchor error. Refusing the short form would mean refusing the thing the
// user most obviously typed. // user most obviously typed.
func pickPairing(cfgs []*Config, want string) (*Config, error) { func pickPairing(cfgs []*Config, want string) (*Config, error) {
@@ -1014,13 +1105,30 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
return err return err
} }
if boxMode() != modeNetworked { if boxMode() != modeNetworked {
return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves one host and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo()) return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves only the Hosts its person said yes to and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo())
} }
cfgs, err := loadPairings() cfgs, err := loadPairings()
if err != nil { if err != nil {
return err return err
} }
prev := currentAnchor() prev := currentAnchor()
// Which Host is in charge (anchor_local.go): the one an organisation's
// policy names, else a Host on this machine. Either decides here, before
// the paths below, which know only "the box's pairings".
if handled, err := claimAnchorByRule(cfgs, prev, token, rotate, owner, host); handled {
return err
}
// Already anchored to the host this asks for (no token, no other host
// named), and that host's pairing is active: done, not an error. `pair`
// anchors a networked box to its first host by itself, and an installer
// that runs claim-anchor after pairing must find that a success. An anchor
// whose pairing is pending or revoked is not done, and is refused below as
// before. --host takes the short handle as well as the full name
// (pickPairing), so the same host named either way is the same request.
if prev != nil && !rotate && token == "" && anchorAlreadyHolds(cfgs, prev, host) {
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
return nil
}
if prev != nil && !rotate { if prev != nil && !rotate {
return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24)) return fmt.Errorf("this box is already anchored to %s — `claim-anchor --rotate <token>` moves the anchor (needs the current anchor's consent, or %d days of silence from it)", prev.AnchorHost, int(anchorDeadAfter.Hours()/24))
} }
@@ -1088,9 +1196,16 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
case len(cfgs) == 0: case len(cfgs) == 0:
return fmt.Errorf("this box has no pairing yet — pair it to your host first (`privatae-bastion pair <token>`, token from Network → Set up a bastion), then `privatae-bastion claim-anchor` with no token") return fmt.Errorf("this box has no pairing yet — pair it to your host first (`privatae-bastion pair <token>`, token from Network → Set up a bastion), then `privatae-bastion claim-anchor` with no token")
default: default:
return fmt.Errorf("this box serves %d hosts — say which one should hold the anchor:\n%s\n e.g. `privatae-bastion claim-anchor --host %s`\nThe anchor host approves every pairing made here from then on; the other pairings keep the lanes they already have.", return fmt.Errorf("this box serves %d hosts — say which one should hold the anchor:\n%s\n e.g. `privatae-bastion claim-anchor --host %s`\nThe anchor host approves every pairing made here from then on; the other pairings keep the lanes they already have",
len(cfgs), " "+strings.Join(hostNames(cfgs), "\n "), hostNames(cfgs)[0]) len(cfgs), " "+strings.Join(hostNames(cfgs), "\n "), hostNames(cfgs)[0])
} }
// A pairing whose file this build refuses is never served, so the box
// could not reach the host it would be anchored to, and that host would
// approve every pairing made here. Refused with the reason, before
// anything is written.
if cfg.invalid != "" {
return fmt.Errorf("%s cannot hold the anchor: %s", cfg.HostName, cfg.invalid)
}
// REVOKED is not "waiting", it is a decision, and it must not be undone by // REVOKED is not "waiting", it is a decision, and it must not be undone by
// a command the user ran to do something else. pair.go holds the same line // a command the user ran to do something else. pair.go holds the same line
// for a re-pair (pending again, never straight back to active), // for a re-pair (pending again, never straight back to active),
@@ -1198,11 +1313,22 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
// singleton on each one as it goes, rather than needing a separate pass. // singleton on each one as it goes, rather than needing a separate pass.
_ = maybeWriteMarker() _ = maybeWriteMarker()
fmt.Printf("anchored to %s (owner %q)\n", cfg.HostName, owner) fmt.Printf("anchored to %s (owner %q)\n", cfg.HostName, owner)
fmt.Println(" Every host paired from now on waits, pending, until this anchor host approves it.") fmt.Printf(" Every Host paired from now on waits until %s admits it: %s asks you to approve it.\n", cfg.HostName, cfg.HostName)
fmt.Println(" " + restartHint()) fmt.Println(" " + anchorFollowNote())
return nil return nil
} }
// anchorFollowNote says how the change reaches the bastion: a running one
// reads the anchor on every op and follows the pairing files within
// supervisePeriod, so nothing is restarted.
func anchorFollowNote() string {
if pairDaemonRunning() {
return fmt.Sprintf("The bastion running on this machine follows within %d seconds; nothing needs restarting.",
int(supervisePeriod.Seconds()))
}
return "Start the bastion so it serves it: " + runCommandHint()
}
// writeAnchor signs and installs anchor.json under the box key. A fresh anchor // writeAnchor signs and installs anchor.json under the box key. A fresh anchor
// (or a rotated one) starts with NO verify key: the new anchor host's key is // (or a rotated one) starts with NO verify key: the new anchor host's key is
// pinned on first contact, never carried over from the previous host. // pinned on first contact, never carried over from the previous host.
+537
View File
@@ -0,0 +1,537 @@
package main
// anchor_local.go — who is in charge of this machine: one authoritative Host,
// the anchor (anchor.go), and which Host that is.
//
// A machine needs one authoritative Host, and the code already has that: the
// anchor. The anchor is still the one mechanism, with its signature and
// verification model unchanged (writeAnchor signs it
// under the box key; grants verify under the anchor Host's pinned key). What
// this file decides is WHICH Host holds it, by three rules, strongest first:
//
// 1. An ORGANISATION'S POLICY, explicitly deployed to the machine, names the
// Host in charge (anchorPolicy below). It is the only thing that overrides
// the rule after it, and it is meant for a machine owned by someone other
// than its local admin. Windows: the AnchorHost value under
// HKLM\SOFTWARE\Policies\Privatae\Bastion (Group Policy, Intune). macOS:
// the AnchorHost key of the managed preference domain ai.privatae.bastion
// (a configuration profile). Linux: anchor_host in the root-owned
// /etc/privatae-bastion/policy.json. Joining a domain is not a policy:
// nothing is read from the domain itself. With a policy, claim-anchor for
// any other Host is refused in words that say an organisation manages the
// machine, a new pairing of another Host waits for the named one, and the
// named one takes the anchor when it is paired with machine authority.
// 2. A HOST ON THIS MACHINE is always the one in charge, never "whichever
// paired first". Paired with machine authority it takes the anchor, from a
// remote anchor too; the replaced anchor becomes an ordinary pairing that
// waits for it to admit it (as claim-anchor --rotate leaves the old one),
// the change is recorded in the pairing audit, and the replaced anchor is
// told on one last heartbeat (anchorReplacedNote, run.go superviseOnce).
// A Host on this machine that already holds the anchor keeps it: a second
// one waits, as any other pairing does, and moving the anchor between two
// of them is claim-anchor --rotate as before.
// 3. Otherwise the first Host paired with a networked machine (cmdPair, as
// before this file).
//
// WHAT "ON THIS MACHINE" IS (sameMachineHost). A pairing whose lanes run over
// a unix socket (transport unix): the socket is a file on this machine's own
// disk. Or a pairing whose lane port is a loopback address (transport tcp,
// lane_addr 127.0.0.0/8, ::1 or localhost): loopback never leaves this
// machine's network stack, and it is how a C-Series Host in a VM on this
// computer is reached (npx cseries forwards the VM's lane ports to loopback,
// packages/cseries/src/vm/common.js). Something else forwarded onto loopback
// is something the machine's administrator set up, and taking the anchor needs
// that administrator's authority anyway. Any other address, a LAN one
// included, is another machine. The name the Host gives itself
// (home.cseries.localhost) is not used: it is the Host's word, not the
// machine's.
//
// It adds no power. With machine authority an administrator could already
// claim the anchor on an unanchored box, delete anchor.json, or release an
// anchor (anchor_release.go). What it changes is that the right Host is put in
// charge without anyone having to know the order things were paired in.
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// anchorPolicy is an organisation's deployed policy naming the Host in charge.
type anchorPolicy struct {
Host string // a host_name, or its handle
Source string // where it was read, as a person would look for it
}
// readAnchorPolicy reads the policy from this platform's managed store
// (anchorpolicy_*.go). A seam: the suite sets a policy without one.
var readAnchorPolicy = readAnchorPolicyOS
// currentAnchorPolicy is the deployed policy, or nil when there is none or it
// names nothing a host name can be.
func currentAnchorPolicy() *anchorPolicy {
p := readAnchorPolicy()
if p == nil {
return nil
}
p.Host = strings.TrimSpace(p.Host)
if !validHostName(p.Host) {
return nil
}
return p
}
// policyNames says whether the policy names host (its full name or handle).
func policyNames(p *anchorPolicy, host string) bool {
if p == nil || host == "" {
return false
}
return strings.EqualFold(p.Host, host) || strings.EqualFold(p.Host, handleOf(host)) ||
strings.EqualFold(handleOf(p.Host), host)
}
// orgManagedRefusal is the words for a Host the policy does not name.
func orgManagedRefusal(p *anchorPolicy, who string) error {
return fmt.Errorf("an organisation manages this machine: its policy (%s) names %s as the Host in charge of it, "+
"so %s cannot take charge here. Whoever manages this machine for the organisation changes that policy", p.Source, p.Host, who)
}
// sameMachineHost: the pairing's Host runs on this machine (see the header).
func sameMachineHost(c *Config) bool {
if c == nil {
return false
}
if c.unixTransport() {
return true
}
if !c.tcpTransport() {
return false
}
return loopbackAddr(c.LaneAddr)
}
// loopbackAddr: host:port whose host is a loopback IP literal or localhost.
func loopbackAddr(hostPort string) bool {
h, _, err := net.SplitHostPort(strings.TrimSpace(hostPort))
if err != nil {
return false
}
if strings.EqualFold(h, "localhost") {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
// admitter is the Host a pending pairing waits for: the anchor, or on a
// machine an organisation's policy governs and nobody holds yet, the Host the
// policy names. "" when nothing on this machine admits anyone.
func admitter() string {
if a := currentAnchor(); a != nil {
return a.AnchorHost
}
if boxMode() == modeNetworked {
if p := currentAnchorPolicy(); p != nil {
return p.Host
}
}
return ""
}
// pendingUnderPolicy: on a networked machine with no anchor yet, a policy
// that names another Host makes a new pairing wait for that one.
func pendingUnderPolicy(handle string) bool {
if boxMode() != modeNetworked || currentAnchor() != nil {
return false
}
p := currentAnchorPolicy()
return p != nil && !policyNames(p, handle)
}
// anchorTake is what taking the anchor did, for `pair` and claim-anchor to say.
type anchorTake struct {
Taken bool
Why string // the rule, in words
Replaced string // the previous anchor, if there was one
Waiting []string // pairings that now wait for the new anchor to admit them
}
// The rules by name, in the audit and on the replaced anchor's beat.
const (
anchorWhyPolicy = "policy"
anchorWhyLocal = "same_machine"
anchorWhyFirst = "first_pairing"
)
func anchorWhyWords(why string, c *Config, p *anchorPolicy) string {
switch why {
case anchorWhyPolicy:
return "an organisation's policy (" + p.Source + ") names it as the Host in charge of this machine"
case anchorWhyLocal:
return "it runs on this machine (" + c.laneWhere() + "), and a Host on the machine is always the one in charge of it"
case anchorWhyWindow:
return "you opened the pairing window for it here (`pair --open`), and no Host was in charge of this machine"
}
return "it is the first Host paired with this networked machine"
}
// anchorAfterPair puts the right Host in charge after `pair` wrote cfg (see the
// header). Only on a networked machine and with machine authority, which
// claim-anchor needs anyway. Reports what it did.
func anchorAfterPair(cfg *Config, all []*Config) (anchorTake, error) {
if cfg == nil || boxMode() != modeNetworked || !machineAuthorityHeld() {
return anchorTake{}, nil
}
prev := currentAnchor()
if prev != nil && prev.AnchorHost == cfg.HostName {
return anchorTake{}, nil
}
pol := currentAnchorPolicy()
why := ""
switch {
case pol != nil:
if !policyNames(pol, cfg.HostName) {
return anchorTake{}, nil
}
why = anchorWhyPolicy
case sameMachineHost(cfg):
if prev != nil {
if pc := pairingFor(all, prev.AnchorHost); pc != nil && sameMachineHost(pc) && pc.active() {
// A Host on this machine already holds it: this one waits.
return anchorTake{}, nil
}
}
why = anchorWhyLocal
case prev == nil && len(all) == 1:
why = anchorWhyFirst
default:
return anchorTake{}, nil
}
t, err := takeAnchor(cfg, prev, why, pol, "")
if err != nil {
return anchorTake{}, fmt.Errorf("paired with %s, but putting it in charge of this machine failed: %w\n anchor it with: %s",
cfg.HostName, err, anchorCommandHint())
}
return t, nil
}
// anchorWhyWindow: the person opened a pairing window for this Host at this
// machine (`pair --open`), with machine authority, and nobody was in charge.
const anchorWhyWindow = "pair_open"
// anchorAfterWindow is anchorAfterPair for a Host paired through a window
// opened at this machine (pair_window.go). The window is the person's say-so,
// typed at this machine, for this one Host, so on a machine nobody is in
// charge of yet that Host takes the anchor, as the first Host of a networked
// machine does, whatever else is paired and in either mode. The rules above it
// still come first: an organisation's policy that names another Host leaves
// it waiting, and a Host on this machine wins (a paired one that is not
// revoked keeps the machine for itself, even before it holds the anchor).
//
// A LOCAL machine has no anchor (claim-anchor and set-mode refuse one there),
// so it is made networked first, which set-mode does with the same authority.
// On a local machine already serving another Host the person said yes to, the
// window's Host does not take it: that would leave the other one waiting.
//
// openerAuthority is whether the person who opened the window held machine
// authority; the process committing the pairing (pair --open's own door, or
// the running bastion's) must hold it too.
func anchorAfterWindow(cfg *Config, all []*Config, openerAuthority bool) (anchorTake, error) {
took, err := anchorAfterPair(cfg, all)
if err != nil || took.Taken {
return took, err
}
if cfg == nil || !openerAuthority || !machineAuthorityHeld() || currentAnchor() != nil ||
cfg.invalid != "" || cfg.State == pairingRevoked {
return anchorTake{}, nil
}
pol := currentAnchorPolicy()
why := anchorWhyWindow
if pol != nil {
if !policyNames(pol, cfg.HostName) {
return anchorTake{}, nil
}
why = anchorWhyPolicy
}
// On a networked machine nobody is in charge of, every pairing is refused
// every op (unanchoredNetworkedRefusal), so the others that takeAnchor
// leaves waiting lose nothing they had; they now have a Host to admit them.
// On a local machine the other Hosts are served, so they are not demoted.
local := boxMode() != modeNetworked
for _, o := range all {
if o.HostName == cfg.HostName {
continue
}
if why != anchorWhyPolicy && sameMachineHost(o) && o.invalid == "" && o.State != pairingRevoked {
return anchorTake{}, nil
}
if local && o.active() {
return anchorTake{}, nil
}
}
if local {
if err := setBoxMode(modeNetworked); err != nil {
return anchorTake{}, fmt.Errorf("paired with %s, but making this machine one a Host is in charge of failed: %w", cfg.HostName, err)
}
pairingAudit("set_mode", cfg.HostName, "ok: networked, so the Host paired through the window opened here can be put in charge")
}
t, err := takeAnchor(cfg, nil, why, pol, "")
if err != nil {
return anchorTake{}, fmt.Errorf("paired with %s, but putting it in charge of this machine failed: %w\n anchor it with: %s",
cfg.HostName, err, anchorCommandHint())
}
return t, nil
}
// anchorReplaced is <configDir>/anchor.replaced.json: the last time a Host
// took the anchor from another. The replaced anchor's run reads it on its
// last beat (anchorReplacedNote); the audit keeps the history.
type anchorReplaced struct {
Host string `json:"host"` // the anchor that was replaced
By string `json:"by"`
At string `json:"at"`
Why string `json:"why"`
}
func anchorReplacedPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "anchor.replaced.json"), nil
}
// anchorReplacedNote is what the replaced anchor's heartbeat carries, or nil
// when self was not replaced as this machine's anchor.
func anchorReplacedNote(self string) map[string]any {
p, err := anchorReplacedPath()
if err != nil {
return nil
}
raw, err := os.ReadFile(p)
if err != nil {
return nil
}
var r anchorReplaced
if json.Unmarshal(raw, &r) != nil || r.Host == "" || r.Host != self {
return nil
}
a := currentAnchor()
if a == nil || a.AnchorHost != r.By {
return nil
}
return map[string]any{"by": r.By, "at": r.At, "why": r.Why, "waiting_for": r.By}
}
// clearAnchorReplacedFor removes the replaced-anchor record once that Host is
// admitted again.
func clearAnchorReplacedFor(host string) {
p, err := anchorReplacedPath()
if err != nil {
return
}
raw, err := os.ReadFile(p)
if err != nil {
return
}
var r anchorReplaced
if json.Unmarshal(raw, &r) == nil && r.Host == host {
_ = os.Remove(p)
}
}
// takeAnchor makes cfg this machine's anchor, under the box key as
// claim-anchor does (writeAnchor), and every other pairing that was served an
// ordinary one waiting for it. A previous anchor is recorded as replaced.
// owner "" keeps the machine's owner name.
func takeAnchor(cfg *Config, prev *anchorClaim, why string, pol *anchorPolicy, owner string) (anchorTake, error) {
if cfg.invalid != "" {
return anchorTake{}, fmt.Errorf("%s cannot hold the anchor: %s", cfg.HostName, cfg.invalid)
}
if cfg.State == pairingRevoked {
return anchorTake{}, fmt.Errorf("%s is REVOKED on this box — it will not be put in charge; pair that Host again first (`privatae-bastion pair --replace <token>`)", cfg.HostName)
}
owner = strings.TrimSpace(owner)
if owner == "" {
if oc := currentOwnerClaim(); oc != nil && oc.Owner != "" {
owner = oc.Owner
} else if prev != nil {
owner = prev.Owner
} else {
owner = handleOf(cfg.HostName)
}
}
if err := claimNameOK(owner); err != nil {
return anchorTake{}, fmt.Errorf("owner: %w", err)
}
// The anchor never waits on itself.
if !cfg.active() {
if err := cfg.updateOnDisk(func(d *Config) { d.State = pairingActive }); err != nil {
return anchorTake{}, err
}
}
if err := writeAnchor(owner, cfg.HostName); err != nil {
return anchorTake{}, err
}
if _, err := writeOwnerClaim(owner); err != nil {
return anchorTake{}, err
}
// A consent the old anchor left for a rotation is not this.
if p, err := anchorConsentPath(); err == nil {
_ = os.Remove(p)
}
t := anchorTake{Taken: true, Why: anchorWhyWords(why, cfg, pol)}
if prev != nil && prev.AnchorHost != cfg.HostName {
t.Replaced = prev.AnchorHost
}
// Every other pairing that was served now waits for the new anchor: a
// grant from the old one does not verify under the new one's key, so
// "active" would be lanes that refuse every op.
cfgs, err := loadPairings()
if err != nil {
return t, err
}
for _, c := range cfgs {
if c.HostName == cfg.HostName || !c.active() {
continue
}
if err := c.updateOnDisk(func(d *Config) { d.State = pairingPending }); err != nil {
return t, err
}
t.Waiting = append(t.Waiting, c.HostName)
}
at := time.Now().UTC().Format(time.RFC3339)
if t.Replaced != "" {
if p, err := anchorReplacedPath(); err == nil {
if b, err := json.MarshalIndent(anchorReplaced{Host: t.Replaced, By: cfg.HostName, At: at, Why: why}, "", " "); err == nil {
_ = atomicWrite(p, b)
}
}
}
decision := "ok: in charge of this machine because " + t.Why
if t.Replaced != "" {
decision += "; it replaced " + t.Replaced
}
pairingAudit("anchor_take", cfg.HostName, decision)
for _, h := range t.Waiting {
pairingAudit("anchor_take", h, "waits for "+cfg.HostName+" to admit it")
}
_ = maybeWriteMarker()
kickBeats()
return t, nil
}
// claimAnchorByRule is claim-anchor's part of the rules in the header, before
// its own paths run. handled: the command's answer is err. Not handled: the
// rest of claim-anchor decides, as before.
func claimAnchorByRule(cfgs []*Config, prev *anchorClaim, token string, rotate bool, owner, host string) (bool, error) {
named := strings.TrimSpace(host)
if token != "" {
h, _, err := decodeToken(token)
if err != nil {
return true, err
}
named = h
}
if pol := currentAnchorPolicy(); pol != nil {
want := pairingFor(cfgs, pol.Host)
if want == nil {
for _, c := range cfgs {
if policyNames(pol, c.HostName) {
want = c
break
}
}
}
if named != "" && !policyNames(pol, named) {
if c, err := pickPairing(cfgs, named); err != nil || want == nil || c.HostName != want.HostName {
return true, orgManagedRefusal(pol, named)
}
}
if token != "" {
return false, nil // the token names the Host the policy names
}
if want == nil {
return true, fmt.Errorf("an organisation manages this machine: its policy (%s) names %s as the Host in charge "+
"of it, and %s is not paired with this machine yet. Pair it (`privatae-bastion pair <its code>`) and it takes charge",
pol.Source, pol.Host, pol.Host)
}
if prev != nil && prev.AnchorHost == want.HostName && want.active() {
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
return true, nil
}
t, err := takeAnchor(want, prev, anchorWhyPolicy, pol, owner)
if err != nil {
return true, err
}
printAnchorTake(want.HostName, t)
return true, nil
}
if rotate {
return false, nil
}
var locals []*Config
for _, c := range cfgs {
if sameMachineHost(c) && c.invalid == "" && c.State != pairingRevoked {
locals = append(locals, c)
}
}
if len(locals) == 0 {
return false, nil
}
var want *Config
switch {
case token != "":
if pairingFor(locals, named) == nil {
return true, fmt.Errorf("%s runs on this machine and is the Host in charge of it, so %s does not take the "+
"anchor here; %s admits it instead (it asks you to approve it)", locals[0].HostName, named, locals[0].HostName)
}
return false, nil
case named != "":
c, err := pickPairing(cfgs, named)
if err != nil {
return true, err
}
if !sameMachineHost(c) {
return true, fmt.Errorf("%s runs on this machine and is the Host in charge of it, so %s does not take the "+
"anchor here; %s admits it instead (it asks you to approve it)", locals[0].HostName, c.HostName, locals[0].HostName)
}
want = c
case len(locals) == 1:
want = locals[0]
default:
return false, nil // several Hosts on this machine: --host says which
}
if prev != nil && prev.AnchorHost == want.HostName && want.active() {
fmt.Printf("already anchored to %s (owner %q)\n", prev.AnchorHost, prev.Owner)
return true, nil
}
if prev != nil {
if pc := pairingFor(cfgs, prev.AnchorHost); pc != nil && sameMachineHost(pc) && pc.active() {
return false, nil // moving it between two Hosts on this machine is --rotate, as before
}
}
t, err := takeAnchor(want, prev, anchorWhyLocal, nil, owner)
if err != nil {
return true, err
}
printAnchorTake(want.HostName, t)
return true, nil
}
// printAnchorTake is claim-anchor's report of takeAnchor.
func printAnchorTake(host string, t anchorTake) {
fmt.Printf("anchored to %s: %s.\n", host, t.Why)
if t.Replaced != "" {
fmt.Printf(" %s was in charge before; it is told so, and now waits for %s to admit it.\n", t.Replaced, host)
}
if len(t.Waiting) > 0 {
fmt.Printf(" Waiting for %s to admit them: %s. %s asks you to approve each.\n", host, strings.Join(t.Waiting, ", "), host)
}
fmt.Println(" Every Host paired from now on waits until " + host + " admits it.")
fmt.Println(" " + anchorFollowNote())
}
+530
View File
@@ -0,0 +1,530 @@
//go:build !windows
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
const (
tLocal = "home.cseries.localhost"
tLocal2 = "second.cseries.localhost"
tRemote = "remote.host.privatae.ai"
)
// localBox is a networked box with machine authority in a temp config dir.
func localBox(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
prev := machineAuthorityHeld
machineAuthorityHeld = func() bool { return true }
t.Cleanup(func() { machineAuthorityHeld = prev })
withPolicy(t, nil)
if err := setBoxMode(modeNetworked); err != nil {
t.Fatal(err)
}
}
func withPolicy(t *testing.T, p *anchorPolicy) {
t.Helper()
prev := readAnchorPolicy
readAnchorPolicy = func() *anchorPolicy {
if p == nil {
return nil
}
c := *p
return &c
}
t.Cleanup(func() { readAnchorPolicy = prev })
}
// writeTCPPairing is a Host reached on a lane port, as pair_tcp.go writes it.
func writeTCPPairing(t *testing.T, host, lane, state string) {
t.Helper()
pd, _ := pairingsDir()
// Its control port is one nothing listens on, whatever runs on the machine
// running the suite (a bastion of its own answers on 7878).
b, _ := json.Marshal(Config{HostName: host, Transport: transportTCP, LaneAddr: lane, WGPrivateKey: "k", State: state,
ListenAddr: "127.0.0.1:1"})
if err := os.WriteFile(filepath.Join(pd, host+".json"), b, 0o600); err != nil {
t.Fatal(err)
}
}
func pairingNamed(t *testing.T, host string) (*Config, []*Config) {
t.Helper()
all, err := loadPairings()
if err != nil {
t.Fatal(err)
}
c := pairingFor(all, host)
if c == nil {
t.Fatalf("%s not paired", host)
}
return c, all
}
func auditLines(t *testing.T) string {
t.Helper()
d, _ := configDir()
b, _ := os.ReadFile(filepath.Join(d, "pairing_audit.jsonl"))
return string(b)
}
// What "on this machine" is: a unix socket, or a lane port on loopback. A LAN
// address, the relay's tunnel, and a Host's own name are not.
//
// MUTATION: return true for every tcp pairing in sameMachineHost. RESULT: red.
func TestSameMachineIsASocketOrALoopbackLane(t *testing.T) {
for _, c := range []struct {
cfg Config
want bool
}{
{Config{Transport: transportUnix, LaneSocket: "/run/h/lane.sock"}, true},
{Config{Transport: transportTCP, LaneAddr: "127.0.0.1:8811"}, true},
{Config{Transport: transportTCP, LaneAddr: "127.3.4.5:8811"}, true},
{Config{Transport: transportTCP, LaneAddr: "[::1]:8811"}, true},
{Config{Transport: transportTCP, LaneAddr: "localhost:8811"}, true},
{Config{Transport: transportTCP, LaneAddr: "10.0.2.2:28811"}, false},
{Config{Transport: transportTCP, LaneAddr: "192.168.1.20:8811"}, false},
{Config{Transport: transportTCP, LaneAddr: "home.cseries.localhost:8811"}, false},
{Config{HostName: "x.cseries.localhost", BastionTunnelIP: "fd12:3456::a:1"}, false},
} {
if got := sameMachineHost(&c.cfg); got != c.want {
t.Errorf("%+v: same machine = %v, want %v", c.cfg, got, c.want)
}
}
}
// The S0 order the other way round: the machine was anchored to a remote Host,
// then a Host on this machine is paired with machine authority. It takes the
// anchor; the remote one becomes an ordinary pairing waiting for it, is told
// on its beat, and the audit says so.
//
// MUTATION: drop the sameMachineHost case from anchorAfterPair. RESULT: red.
// Leave the replaced anchor active in takeAnchor. RESULT: red.
func TestAHostOnThisMachineTakesTheAnchorFromARemoteOne(t *testing.T) {
localBox(t)
writePairing(t, tRemote)
if err := writeAnchor("the owner", tRemote); err != nil {
t.Fatal(err)
}
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
cfg, all := pairingNamed(t, tLocal)
took, err := anchorAfterPair(cfg, all)
if err != nil || !took.Taken || took.Replaced != tRemote {
t.Fatalf("a Host on this machine must take the anchor: %+v %v", took, err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tLocal || a.Owner != "the owner" {
t.Fatalf("anchor = %+v", a)
}
if got := loadState(t, tLocal); got != pairingActive {
t.Errorf("the new anchor waits on itself: %q", got)
}
if got := loadState(t, tRemote); got != pairingPending {
t.Errorf("the replaced anchor is %q, want pending", got)
}
a := auditLines(t)
if !strings.Contains(a, `"op":"anchor_take"`) || !strings.Contains(a, "it replaced "+tRemote) ||
!strings.Contains(a, "waits for "+tLocal+" to admit it") {
t.Errorf("the audit does not record it:\n%s", a)
}
note := anchorReplacedNote(tRemote)
if note == nil || note["by"] != tLocal || note["why"] != anchorWhyLocal {
t.Fatalf("the replaced anchor's beat is not told: %v", note)
}
if e := pairingEnforcing(tRemote); e["anchor_replaced"] == nil || e["waiting_for"] != tLocal {
t.Errorf("the enforcing report of the replaced anchor: %v", e)
}
if anchorReplacedNote(tLocal) != nil {
t.Error("the new anchor is told it was replaced")
}
var former *pairingEntry
for _, e := range pairingsReport() {
if e.HostName == tRemote {
e := e
former = &e
}
}
if former == nil || former.Origin != pairingOriginFormerAnchor || former.WaitingFor != tLocal || former.Transport != "tunnel" || former.SameMachine {
t.Errorf("the anchor's report of the former anchor: %+v", former)
}
}
// A networked box with no anchor and a remote Host already serving: the Host on
// this machine takes the anchor, and the remote one waits for it.
func TestAHostOnThisMachineAnchorsABoxThatAlreadyServesARemoteHost(t *testing.T) {
localBox(t)
writePairing(t, tRemote)
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
cfg, all := pairingNamed(t, tLocal)
took, err := anchorAfterPair(cfg, all)
if err != nil || !took.Taken || took.Replaced != "" || strings.Join(took.Waiting, ",") != tRemote {
t.Fatalf("%+v %v", took, err)
}
if got := loadState(t, tRemote); got != pairingPending {
t.Errorf("the remote Host holds no grant from the new anchor and must wait: %q", got)
}
}
// Never "whichever paired first": a remote Host never takes the anchor from a
// Host on this machine, and a second Host on this machine does not either.
//
// MUTATION: drop the "a Host on this machine already holds it" return.
// RESULT: red.
func TestTheAnchorOnThisMachineIsNotReplaced(t *testing.T) {
for _, newcomer := range []struct{ host, lane string }{{tRemote, "10.0.2.2:28811"}, {tLocal2, "127.0.0.1:9811"}} {
t.Run(newcomer.host, func(t *testing.T) {
localBox(t)
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
if err := writeAnchor("the owner", tLocal); err != nil {
t.Fatal(err)
}
writeTCPPairing(t, newcomer.host, newcomer.lane, pairingPending)
cfg, all := pairingNamed(t, newcomer.host)
if took, err := anchorAfterPair(cfg, all); took.Taken || err != nil {
t.Fatalf("%s took the anchor: %+v %v", newcomer.host, took, err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tLocal {
t.Fatalf("anchor moved: %+v", a)
}
if got := loadState(t, newcomer.host); got != pairingPending {
t.Errorf("state %q", got)
}
})
}
}
// Without machine authority, or on a local machine, nothing is anchored.
func TestTakingTheAnchorNeedsANetworkedMachineAndItsAuthority(t *testing.T) {
localBox(t)
writePairing(t, tRemote)
if err := writeAnchor("the owner", tRemote); err != nil {
t.Fatal(err)
}
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
machineAuthorityHeld = func() bool { return false }
cfg, all := pairingNamed(t, tLocal)
if took, _ := anchorAfterPair(cfg, all); took.Taken {
t.Fatal("anchored without machine authority")
}
machineAuthorityHeld = func() bool { return true }
localBox(t)
if err := setBoxMode(modeLocal); err != nil {
t.Fatal(err)
}
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
cfg, all = pairingNamed(t, tLocal)
if took, _ := anchorAfterPair(cfg, all); took.Taken || currentAnchor() != nil {
t.Fatal("anchored a local machine")
}
}
// An organisation's policy wins over the Host on this machine: the Host it
// names takes the anchor, from a Host on this machine too; any other Host
// waits for it; and claim-anchor for any other Host is refused in words that
// say an organisation manages the machine.
//
// MUTATION: check sameMachineHost before the policy in anchorAfterPair.
// RESULT: red. Drop the policy refusal in claimAnchorByRule. RESULT: red.
func TestAnOrganisationsPolicyNamesTheHostInCharge(t *testing.T) {
localBox(t)
withPolicy(t, &anchorPolicy{Host: tRemote, Source: "HKLM\\SOFTWARE\\Policies\\Privatae\\Bastion AnchorHost"})
// Unanchored: the Host on this machine waits for the one the policy names.
if _, state, err := pairPrecheckState(false, handleOf(tLocal)); err != nil || state != pairingPending {
t.Fatalf("a Host the policy does not name must wait: %q %v", state, err)
}
if _, state, _ := pairPrecheckState(false, handleOf(tRemote)); state != "" {
t.Fatalf("the Host the policy names waits for nobody: %q", state)
}
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
cfg, all := pairingNamed(t, tLocal)
if took, _ := anchorAfterPair(cfg, all); took.Taken {
t.Fatal("the Host on this machine took the anchor against the organisation's policy")
}
if got := admitter(); got != tRemote {
t.Errorf("a pending pairing waits for %q, want the policy's Host", got)
}
err := cmdClaimAnchor("", false, "", tLocal)
if err == nil || !strings.Contains(err.Error(), "an organisation manages this machine") ||
!strings.Contains(err.Error(), "names "+tRemote) {
t.Fatalf("claim-anchor for another Host: %v", err)
}
if err := cmdClaimAnchor("", false, "", ""); err == nil || !strings.Contains(err.Error(), "not paired with this machine yet") {
t.Fatalf("claim-anchor before the policy's Host is paired: %v", err)
}
// The policy's Host is paired: it takes the anchor, the other one waits.
writePairingState(t, tRemote, "")
cfg, all = pairingNamed(t, tRemote)
took, err := anchorAfterPair(cfg, all)
if err != nil || !took.Taken {
t.Fatalf("the policy's Host must take the anchor: %+v %v", took, err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tRemote {
t.Fatalf("anchor = %+v", a)
}
if !strings.Contains(auditLines(t), "an organisation's policy") {
t.Errorf("the audit does not name the policy: %s", auditLines(t))
}
if err := cmdClaimAnchor("", true, "", tLocal); err == nil || !strings.Contains(err.Error(), "an organisation manages this machine") {
t.Fatalf("claim-anchor --rotate to another Host: %v", err)
}
}
// A policy deployed after a Host on this machine took the anchor: claim-anchor
// with no argument hands it to the Host the policy names, without --rotate.
func TestClaimAnchorFollowsAPolicyDeployedLater(t *testing.T) {
localBox(t)
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
if err := writeAnchor("the owner", tLocal); err != nil {
t.Fatal(err)
}
writePairingState(t, tRemote, pairingPending)
withPolicy(t, &anchorPolicy{Host: handleOf(tRemote), Source: "/etc/privatae-bastion/policy.json anchor_host"})
if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatal(err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tRemote {
t.Fatalf("anchor = %+v", a)
}
if got := loadState(t, tLocal); got != pairingPending {
t.Errorf("the Host on this machine is %q, want waiting for the policy's Host", got)
}
}
// claim-anchor with no argument on a box that serves a remote Host and a Host
// on this machine picks the one on this machine; naming the remote one is
// refused while a Host on this machine is paired.
//
// MUTATION: return false at the top of claimAnchorByRule's same-machine part.
// RESULT: red (the old "say which one" refusal).
func TestClaimAnchorPicksTheHostOnThisMachine(t *testing.T) {
localBox(t)
writePairing(t, tRemote)
writeTCPPairing(t, tLocal, "127.0.0.1:8811", "")
if err := cmdClaimAnchor("", false, "", tRemote); err == nil || !strings.Contains(err.Error(), "runs on this machine and is the Host in charge") {
t.Fatalf("naming the remote Host: %v", err)
}
if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatal(err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tLocal {
t.Fatalf("anchor = %+v", a)
}
if got := loadState(t, tRemote); got != pairingPending {
t.Errorf("the remote Host is %q", got)
}
}
// The replaced anchor's running beat is beaten once more before its lanes stop.
//
// MUTATION: drop the tellReplacedAnchor call in superviseOnce, or the
// tellReplacedAnchorsBeforeStop body. RESULT: red.
func TestTheReplacedAnchorIsToldOnALastBeat(t *testing.T) {
localBox(t)
writePairing(t, tRemote)
if err := writeAnchor("the owner", tRemote); err != nil {
t.Fatal(err)
}
writeTCPPairing(t, tLocal, "127.0.0.1:8811", pairingPending)
remote, _ := pairingNamed(t, tRemote)
cfg, all := pairingNamed(t, tLocal)
if took, err := anchorAfterPair(cfg, all); err != nil || !took.Taken {
t.Fatal(took, err)
}
var told any
beat := func() { told = pairingEnforcing(tRemote)["anchor_replaced"] }
_, unregister := registerRunBeat(tRemote, beat)
defer unregister()
done := make(chan struct{})
close(done)
supervisor.Lock()
supervisor.runs = map[string]*pairingRun{tRemote: {cancel: func() {}, done: done, cfg: remote}}
supervisor.Unlock()
t.Cleanup(func() { supervisor.Lock(); supervisor.runs = nil; supervisor.Unlock() })
// The daemon stopping first (an installer's Restart-Service right after
// `pair`) tells it as well.
tellReplacedAnchorsBeforeStop()
if told == nil {
t.Fatal("the daemon stopped without a last beat to the replaced anchor")
}
told = nil
superviseOnce()
if told == nil {
t.Fatal("the replaced anchor was stopped without a last beat that says so")
}
// Admitted again, it is no longer told.
clearAnchorReplacedFor(tRemote)
if anchorReplacedNote(tRemote) != nil {
t.Error("an admitted former anchor still hears it was replaced")
}
}
// One pairing's fatal error stops that pairing alone: nothing reaches the
// daemon's fatal channel, the failure is recorded for status, and it is tried
// again after a pause.
//
// MUTATION: send the error to supervisor.errc again. RESULT: red.
func TestOnePairingsFatalErrorStopsOnlyThatPairing(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
freshLocalEvents(t)
// A Host over TCP whose CA does not read: runUnixPairing fails at once.
bad := &Config{HostName: "broken.example.test", Transport: transportTCP, LaneAddr: "127.0.0.1:1", Bearer: "b",
CACert: "not a certificate", ListenAddr: "127.0.0.1:0"}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errc := make(chan error, 4)
supervisor.Lock()
prevCtx, prevErrc, prevRuns := supervisor.ctx, supervisor.errc, supervisor.runs
supervisor.ctx, supervisor.errc, supervisor.runs = ctx, errc, nil
supervisor.Unlock()
t.Cleanup(func() {
supervisor.Lock()
supervisor.ctx, supervisor.errc, supervisor.runs = prevCtx, prevErrc, prevRuns
supervisor.Unlock()
pairingRecovered(bad.HostName)
})
if !startPairing(bad) {
t.Fatal("not started")
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, ok := loadPairingFailures()[bad.HostName]; ok {
break
}
time.Sleep(20 * time.Millisecond)
}
select {
case err := <-errc:
t.Fatalf("one pairing's error went to the whole daemon: %v", err)
case <-time.After(100 * time.Millisecond):
}
f, ok := loadPairingFailures()[bad.HostName]
if !ok || f.Error == "" || f.Tries != 1 {
t.Fatalf("the failure is not recorded: %+v", f)
}
if pairingRetryDue(bad.HostName) {
t.Error("tried again with no pause")
}
if retryPause(1) != supervisePeriod || retryPause(3) != 4*supervisePeriod || retryPause(40) != retryMaxPause {
t.Errorf("pauses %v %v %v", retryPause(1), retryPause(3), retryPause(40))
}
found := false
for _, e := range localEventsNewestFirst() {
found = found || strings.Contains(e.Text, "The other Hosts go on being served")
}
if !found {
t.Error("the machine's events do not say so")
}
}
// The Windows service manager's stop is logged as its own, not the tray's.
// The service handler cannot run here, so its source is read.
//
// MUTATION: call requestShutdown() in sysservice_windows.go. RESULT: red.
func TestTheServiceManagersStopIsNotLoggedAsTheTrays(t *testing.T) {
src, err := os.ReadFile("sysservice_windows.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "requestShutdownFor(shutdownFromService)") || strings.Contains(string(src), "requestShutdown()") {
t.Error("the service's stop does not name the service manager")
}
run, _ := os.ReadFile("run.go")
if !strings.Contains(string(run), `"\nstopping — " + shutdownWhy`) {
t.Error("cmdRun does not log who asked it to stop")
}
if strings.Contains(shutdownFromService, "tray") {
t.Error(shutdownFromService)
}
}
// Over a network logon (ssh) the service cannot be queried for its process id;
// the ask pipe is then checked by its owner, SYSTEM, instead of refused.
//
// MUTATION: drop the ERROR_ACCESS_DENIED branch. RESULT: red.
func TestTheAskPipeIsCheckedByItsOwnerWhenTheServiceCannotBeQueried(t *testing.T) {
src, err := os.ReadFile("approvalpipe_windows.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "func pipeServerIsService(")
if i < 0 {
t.Fatal("no pipeServerIsService")
}
body := s[i : i+strings.Index(s[i:], "\n}\n")]
if !strings.Contains(body, "errors.Is(err, windows.ERROR_ACCESS_DENIED)") || !strings.Contains(body, "return pipeOwnedBySystem(h)") {
t.Error("a network logon is still refused")
}
if !strings.Contains(s, "!owner.IsWellKnown(windows.WinLocalSystemSid) && !owner.IsWellKnown(windows.WinBuiltinAdministratorsSid)") {
t.Error("the owner check does not require LocalSystem or the Administrators group")
}
}
// The last beat to a replaced anchor (lastBeat, from superviseOnce's
// goroutine) never runs beside the run's own beat or path failover: both write
// cfg.Endpoints and the WG device, and before lastBeat a run's beats all came
// from its one loop.
func TestTheLastBeatNeverRunsBesideTheRunsOwnBeat(t *testing.T) {
const host = "serial-beat.host.privatae.ai"
var mu sync.Mutex
in, most := 0, 0
entered := make(chan struct{}, 4)
release := make(chan struct{})
beat := func() {
mu.Lock()
in++
if in > most {
most = in
}
mu.Unlock()
entered <- struct{}{}
<-release
mu.Lock()
in--
mu.Unlock()
}
rb, unregister := registerRunBeat(host, beat)
defer unregister()
old := lastBeatWait
lastBeatWait = 200 * time.Millisecond
t.Cleanup(func() { lastBeatWait = old })
loopDone := make(chan struct{})
go func() { defer close(loopDone); rb.run() }() // the run's own beat, in flight
<-entered
failoverRan := make(chan struct{})
go func() { rb.do(func() {}); close(failoverRan) }() // the tick's failover waits too
if !lastBeat(host) {
t.Fatal("lastBeat found no beat for a running pairing")
}
select {
case <-entered:
t.Fatal("the last beat ran beside the run's own beat")
default:
}
select {
case <-failoverRan:
t.Fatal("path failover ran beside a beat")
default:
}
close(release)
<-loopDone
<-failoverRan
mu.Lock()
defer mu.Unlock()
if most != 1 {
t.Fatalf("%d beats ran at once, want 1", most)
}
}
+48 -4
View File
@@ -606,7 +606,7 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "machine authority") { if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "machine authority") {
t.Fatalf("claim-anchor must need machine authority whether or not a token is given: %v", err) t.Fatalf("claim-anchor must need machine authority whether or not a token is given: %v", err)
} }
return t.Skip("the rest needs root: without machine authority claim-anchor stops at the refusal checked above")
} }
t.Run("local mode refuses — the token is not what gates this", func(t *testing.T) { t.Run("local mode refuses — the token is not what gates this", func(t *testing.T) {
@@ -658,14 +658,58 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
} }
}) })
t.Run("already anchored — still refused without --rotate", func(t *testing.T) { t.Run("already anchored to that host — success, and nothing changes", func(t *testing.T) {
// `pair` anchors a networked box by itself, and an installer that runs
// claim-anchor afterwards must find that done, not an error.
freshBox(t, true) freshBox(t, true)
writePairing(t, tMember) writePairing(t, tMember)
if err := writeAnchor("Acme", tMember); err != nil { if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := cmdClaimAnchor("", false, "", ""); err == nil || !contains(err.Error(), "already anchored") { if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatalf("dropping the token must not let a second claim through: %v", err) t.Fatalf("claim-anchor on a box already anchored to its host must succeed: %v", err)
}
if err := cmdClaimAnchor("", false, "", tMember); err != nil {
t.Fatalf("naming the anchor host itself must succeed: %v", err)
}
if err := cmdClaimAnchor("", false, "", handleOf(tMember)); err != nil {
t.Fatalf("naming the anchor host by its handle (as --host accepts) must succeed: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember || a.Owner != "Acme" {
t.Fatalf("the anchor must be untouched: %+v", a)
}
})
t.Run("anchored to a host whose pairing is pending or revoked — not reported done", func(t *testing.T) {
// A success here would tell an installer the anchor holds while its
// host has no active lanes on this box.
for _, st := range []string{pairingPending, pairingRevoked} {
freshBox(t, true)
writePairingState(t, tMember, st)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", ""); err == nil {
t.Fatalf("anchor pairing %s: claim-anchor must not report the anchor done", st)
}
if got := loadState(t, tMember); got != st {
t.Fatalf("anchor pairing %s: state changed to %q", st, got)
}
}
})
t.Run("already anchored — another host is still refused without --rotate", func(t *testing.T) {
freshBox(t, true)
writePairing(t, tMember)
writePairing(t, tAnchor)
if err := writeAnchor("Acme", tMember); err != nil {
t.Fatal(err)
}
if err := cmdClaimAnchor("", false, "", tAnchor); err == nil || !contains(err.Error(), "already anchored") {
t.Fatalf("naming a different host must not move the anchor without --rotate: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
t.Fatalf("the anchor must be untouched: %+v", a)
} }
}) })
+35
View File
@@ -0,0 +1,35 @@
//go:build darwin
package main
import (
"os"
"os/exec"
"strings"
"syscall"
)
// anchorPolicyPlist is the machine-wide managed preference domain a
// configuration profile (MDM) installs for the bastion. The folder is root's
// and the system writes it; a file there not owned by root is not a policy.
const anchorPolicyPlist = "/Library/Managed Preferences/ai.privatae.bastion.plist"
func readAnchorPolicyOS() *anchorPolicy {
fi, err := os.Lstat(anchorPolicyPlist)
if err != nil || !fi.Mode().IsRegular() || fi.Mode().Perm()&0o022 != 0 {
return nil
}
if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 {
return nil
}
// defaults reads the binary and the XML form alike.
out, err := exec.Command("/usr/bin/defaults", "read", strings.TrimSuffix(anchorPolicyPlist, ".plist"), "AnchorHost").Output()
if err != nil {
return nil
}
v := strings.TrimSpace(string(out))
if v == "" {
return nil
}
return &anchorPolicy{Host: v, Source: "the managed preference ai.privatae.bastion AnchorHost"}
}
+37
View File
@@ -0,0 +1,37 @@
//go:build linux
package main
import (
"os"
"path/filepath"
"testing"
)
// The policy readers accept only what the machine's administrator or policy
// engine could have written: on Linux a file root owns that nobody else can
// write. Run as an ordinary user, the suite's own file is not root's, so it is
// no policy.
func TestALinuxPolicyFileMustBeRoots(t *testing.T) {
dir := t.TempDir()
prev := anchorPolicyFile
anchorPolicyFile = filepath.Join(dir, "policy.json")
t.Cleanup(func() { anchorPolicyFile = prev })
if err := os.WriteFile(anchorPolicyFile, []byte(`{"anchor_host":"`+"managed.host.privatae.ai"+`"}`), 0o644); err != nil {
t.Fatal(err)
}
p := readAnchorPolicyOS()
if os.Geteuid() == 0 {
if p == nil || p.Host != "managed.host.privatae.ai" {
t.Fatalf("root's own policy file was not read: %+v", p)
}
_ = os.Chmod(anchorPolicyFile, 0o666)
if readAnchorPolicyOS() != nil {
t.Error("a policy file anyone can write was read")
}
return
}
if p != nil {
t.Errorf("a policy file root does not own was read: %+v", p)
}
}
+37
View File
@@ -0,0 +1,37 @@
//go:build !windows && !darwin
package main
import (
"encoding/json"
"os"
"strings"
"syscall"
)
// anchorPolicyFile is the managed configuration an organisation deploys to a
// Linux machine (by its configuration management) for the bastion: root's,
// and writable by nobody else. A file anyone else could have written is not a
// policy.
var anchorPolicyFile = "/etc/privatae-bastion/policy.json"
func readAnchorPolicyOS() *anchorPolicy {
fi, err := os.Lstat(anchorPolicyFile)
if err != nil || !fi.Mode().IsRegular() || fi.Mode().Perm()&0o022 != 0 {
return nil
}
if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 {
return nil
}
raw, err := os.ReadFile(anchorPolicyFile)
if err != nil {
return nil
}
var p struct {
AnchorHost string `json:"anchor_host"`
}
if json.Unmarshal(raw, &p) != nil || strings.TrimSpace(p.AnchorHost) == "" {
return nil
}
return &anchorPolicy{Host: p.AnchorHost, Source: anchorPolicyFile + " anchor_host"}
}
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package main
import (
"strings"
"golang.org/x/sys/windows/registry"
)
// anchorPolicyKey is where Group Policy and Intune deploy a machine policy for
// the bastion: under HKLM\SOFTWARE\Policies, which only an administrator or
// the policy engine writes. Domain membership is not read.
const anchorPolicyKey = `SOFTWARE\Policies\Privatae\Bastion`
func readAnchorPolicyOS() *anchorPolicy {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, anchorPolicyKey, registry.QUERY_VALUE)
if err != nil {
return nil
}
defer k.Close()
v, _, err := k.GetStringValue("AnchorHost")
if err != nil || strings.TrimSpace(v) == "" {
return nil
}
return &anchorPolicy{Host: v, Source: `HKLM\` + anchorPolicyKey + ` AnchorHost`}
}
+41
View File
@@ -138,6 +138,30 @@ type pipeClient struct {
// "" when nobody is. // "" when nobody is.
SessionUserSID string SessionUserSID string
SessionActive bool // WTSActive: the session someone is in front of SessionActive bool // WTSActive: the session someone is in front of
// NameTimedOut: the account directory did not answer for UserSID within
// pipeSIDLane's budget, so UserName is empty and nobody can say whose
// account approved. pipeApprove refuses; list and deny go on, by SID.
NameTimedOut bool
}
// pipeSIDLane bounds the client's account-name lookup (sidnames.go). The tray
// waits 2*pipeIOTimeout for its answer, and the name is looked up before the
// request is read, so the lookup gets well under that: a directory that does
// not answer costs one connection 2 s, and holds at most one goroutine for
// the pipe however many connections come.
var pipeSIDLane = sidLane{slots: make(chan struct{}, 1), budget: 2 * time.Second}
// pipeClientName fills c.UserName from the account directory, or sets
// c.NameTimedOut when the directory did not answer in time. A SID with no
// account leaves UserName empty: the log shows the SID, as before.
func pipeClientName(c *pipeClient, dir *sidDirectory, lane sidLane) {
acct, outcome := dir.resolve(c.UserSID, lane)
switch outcome {
case sidResolved:
c.UserName = sidAccountLabel(acct)
case sidTimedOut:
c.NameTimedOut = true
}
} }
// purposeDigest is the sha256 of a purpose as the tray carries it back. // purposeDigest is the sha256 of a purpose as the tray carries it back.
@@ -153,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.
@@ -330,6 +363,14 @@ func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
var pipeApproveMu sync.Mutex var pipeApproveMu sync.Mutex
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse { func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
// FAILS CLOSED ON A SLOW DIRECTORY. The rules above decide by SID, but an
// approval's audit line names the account that gave it, and when the
// directory could not say whose account this is, nothing is approved.
// Before the throttle and the code, so this counts as no wrong code.
if c.NameTimedOut {
return pipeResponse{Error: "not approved: this machine's account directory did not answer for " + c.UserSID +
" in time; try again in a minute, or approve from an Administrator terminal"}
}
pipeApproveMu.Lock() pipeApproveMu.Lock()
defer pipeApproveMu.Unlock() defer pipeApproveMu.Unlock()
code := strings.TrimSpace(req.Code) code := strings.TrimSpace(req.Code)
+65 -14
View File
@@ -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)
@@ -311,12 +331,9 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
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)
} }
c.UserSID = u.User.Sid.String() c.UserSID = u.User.Sid.String()
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil { // Bounded and cached (sidnames.go): a directory that does not answer
c.UserName = name // leaves NameTimedOut set, and pipeApprove refuses.
if dom != "" { pipeClientName(&c, winAccounts, pipeSIDLane)
c.UserName = dom + `\` + name
}
}
var sess, n uint32 var sess, n uint32
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil { if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err) return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
@@ -384,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
} }
@@ -404,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)
} }
@@ -416,14 +438,43 @@ 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 errors.Is(err, windows.ERROR_ACCESS_DENIED) {
// The service's default permissions let an INTERACTIVE logon query it,
// not a network one (ssh, a remote PowerShell): the owner's own
// `status` over ssh was refused as "Access is denied". The pipe's
// owner says what the process id would: servePipe sets no owner, so
// the pipe's is the creating token's default owner, which for
// LocalSystem is BUILTIN\Administrators (seen on Windows Server 2022)
// or SYSTEM, and no program of an ordinary account can make an object
// either owns. An administrator's own program could; an administrator
// already holds the service itself.
return pipeOwnedBySystem(h)
}
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
}
// pipeOwnedBySystem refuses a pipe whose owner is not LocalSystem or the
// Administrators group, the two owners LocalSystem's objects get.
func pipeOwnedBySystem(h windows.Handle) error {
sd, err := windows.GetSecurityInfo(h, windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return fmt.Errorf("cannot tell who made the pipe: %w", err)
}
owner, _, err := sd.Owner()
if err != nil || owner == nil {
return fmt.Errorf("cannot tell who made the pipe: %v", err)
}
if !owner.IsWellKnown(windows.WinLocalSystemSid) && !owner.IsWellKnown(windows.WinBuiltinAdministratorsSid) {
return fmt.Errorf("the pipe was made by %s, not by the %s service; nothing was sent", owner.String(), winServiceName)
} }
return nil return nil
} }
+39 -9
View File
@@ -41,8 +41,14 @@ import (
const approvalsOwnerFile = "approvals_owner.json" const approvalsOwnerFile = "approvals_owner.json"
// The record names the owner one way per platform: a SID on Windows, a uid
// on Linux and macOS, where the account is the one the console lane serves
// (consolelane.go). Account is that uid's name when it was recorded, for
// messages only; the uid is what is compared, since a name can be changed.
type approvalsOwnerRecord struct { type approvalsOwnerRecord struct {
SID string `json:"sid"` SID string `json:"sid,omitempty"`
UID *uint32 `json:"uid,omitempty"`
Account string `json:"account,omitempty"`
SetAt string `json:"set_at"` SetAt string `json:"set_at"`
} }
@@ -61,30 +67,54 @@ func approvalsOwnerPath() (string, error) {
return filepath.Join(d, approvalsOwnerFile), nil return filepath.Join(d, approvalsOwnerFile), nil
} }
// approvalsOwnerLoad reads the record. present is true when a record exists, // ownerRecordLoad reads the record as it is. present is true when a record
// readable or not; sid is "" unless it is readable and names a person. // exists, readable or not.
func approvalsOwnerLoad() (sid string, present bool, err error) { func ownerRecordLoad() (r approvalsOwnerRecord, present bool, err error) {
p, err := approvalsOwnerPath() p, err := approvalsOwnerPath()
if err != nil { if err != nil {
return "", false, err return r, false, err
} }
raw, err := os.ReadFile(p) raw, err := os.ReadFile(p)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return "", false, nil return r, false, nil
} }
if err != nil { if err != nil {
return "", true, err return r, true, err
} }
var r approvalsOwnerRecord
if err := json.Unmarshal(raw, &r); err != nil { if err := json.Unmarshal(raw, &r); err != nil {
return "", true, fmt.Errorf("%s cannot be read: %w", p, err) return approvalsOwnerRecord{}, true, fmt.Errorf("%s cannot be read: %w", p, err)
}
return r, true, nil
}
// approvalsOwnerLoad reads the record. present is true when a record exists,
// readable or not; sid is "" unless it is readable and names a person.
func approvalsOwnerLoad() (sid string, present bool, err error) {
r, present, err := ownerRecordLoad()
if err != nil || !present {
return "", present, err
} }
if !approvalsOwnerSIDOK(r.SID) { if !approvalsOwnerSIDOK(r.SID) {
p, _ := approvalsOwnerPath()
return "", true, fmt.Errorf("%s names no person's account", p) return "", true, fmt.Errorf("%s names no person's account", p)
} }
return r.SID, true, nil return r.SID, true, nil
} }
// ownerAccountLoad is the Linux and macOS reading of the record: the owner's
// uid, never root's (root needs no lane to its own bastion).
func ownerAccountLoad() (uid uint32, account string, present bool, err error) {
r, present, err := ownerRecordLoad()
if err != nil || !present {
return 0, "", present, err
}
if r.UID == nil || *r.UID == 0 {
p, _ := approvalsOwnerPath()
return 0, "", true, fmt.Errorf("%s names no person's account on this system", p)
}
return *r.UID, r.Account, true, nil
}
// approvalsOwner is the SID the pipe serves, "" when none is recorded or the // approvalsOwner is the SID the pipe serves, "" when none is recorded or the
// record cannot be read: the pipe then serves nobody. // record cannot be read: the pipe then serves nobody.
func approvalsOwner() string { func approvalsOwner() string {
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"encoding/json"
"errors"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
)
// The socket answers "status" with the kernel's name for the caller, so a
// real connection reads the caller's own ask and nobody else's.
func TestTheAskSocketAnswersStatusOnlyForTheAsker(t *testing.T) {
askFlowWorld(t)
me, err := user.Current()
if err != nil {
t.Skip("no account name for this process:", err)
}
mine := fileAsk(agentAskRequest{Op: "stop", PID: 101}, me.Username, 0)
theirs := fileAsk(agentAskRequest{Op: "stop", PID: 102}, me.Username+"-other", 0)
if !mine.OK || !theirs.OK {
t.Fatalf("asks: %+v %+v", mine, theirs)
}
sock := filepath.Join(t.TempDir(), "ask.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go askSockServe(c)
}
}()
ask := func(id string) agentAskReply {
t.Helper()
c, err := net.DialTimeout("unix", sock, 3*time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
_ = json.NewEncoder(c).Encode(agentAskRequest{Op: "status", Request: id})
var r agentAskReply
if err := json.NewDecoder(c).Decode(&r); err != nil {
t.Fatal(err)
}
return r
}
if r := ask(mine.Request); !r.OK || r.State != askWaiting || r.User != me.Username {
t.Errorf("my own ask over the socket: %+v", r)
}
if r := ask(theirs.Request); r.OK || r.State != askUnknown {
t.Errorf("another account's ask over the socket: %+v", r)
}
}
// `stop` run by an agent (no terminal) files the ask and prints it as JSON;
// `request` follows it to the outcome, with the exit status to branch on.
func TestStopAndRequestFromAnAgent(t *testing.T) {
d := askFlowWorld(t)
prevAuth := machineAuthorityHeld
t.Cleanup(func() { machineAuthorityHeld = prevAuth })
machineAuthorityHeld = func() bool { return true } // holds the state: the direct path
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
prevIn := os.Stdin
os.Stdin = r // a pipe, not a terminal: an agent
t.Cleanup(func() { os.Stdin = prevIn; _ = w.Close(); _ = r.Close() })
out := captureStdout(t)
if err := cmdStop([]string{"101", "--json"}); err != nil {
t.Fatalf("stop: %v", err)
}
var v askView
if err := json.Unmarshal([]byte(printedSince(out, 0, "}\n")), &v); err != nil || v.State != askWaiting ||
v.Request == "" {
t.Fatalf("stop --json printed %q (%v)", out(), err)
}
rows := auditRowsOf(t, d, localStopAskOp)
if len(rows) != 1 {
t.Fatalf("filing rows: %v", rows)
}
id := fullRequestID(t, d, v.Request)
if _, err := stepupApproveRequest(d, id, codeFor(t, machineTestSecret)); err != nil {
t.Fatal(err)
}
localStopOnce(d, time.Now())
before := len(out())
if err := cmdRequest([]string{v.Request}); err != nil {
t.Fatalf("request after the stop: %v", err)
}
if got := printedSince(out, before, "done."); !strings.Contains(got, "approved by the machine's owner and done") {
t.Errorf("request printed %q", got)
}
// A second ask, denied: `request` exits 1 without an error line.
second := mustAsk(t, 102)
if err := stepupDenyRequest(d, fullRequestID(t, d, second)); err != nil {
t.Fatal(err)
}
var q quietExit
if err := cmdRequest([]string{second, "--json"}); !errors.As(err, &q) || q.code != 1 {
t.Errorf("a denied ask: %v", err)
}
}
// printedSince waits (briefly) for captured output past from to hold want:
// captureStdout copies the pipe on its own goroutine.
func printedSince(out func() string, from int, want string) string {
deadline := time.Now().Add(3 * time.Second)
for {
got := out()[from:]
if strings.Contains(got, want) || time.Now().After(deadline) {
return got
}
time.Sleep(10 * time.Millisecond)
}
}
func mustAsk(t *testing.T, pid int) string {
t.Helper()
r := fileAsk(agentAskRequest{Op: "stop", PID: pid}, procUser(os.Getpid()), 0)
if !r.OK {
t.Fatal(r.Why)
}
return r.Request
}
+472
View File
@@ -0,0 +1,472 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// askFlowWorld is askWorld with each agent's origin set, so the audit's
// "started by" can be checked: the asking claude-code session was started at
// a terminal, the aider job by the scheduler.
func askFlowWorld(t *testing.T) string {
t.Helper()
uiTestConfigDir(t)
stubStopWorld(t, []agentObs{
{PID: 101, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
{PID: 102, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
{PID: 103, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
{PID: 104, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
{PID: 105, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
{PID: 106, Kind: kindAgent, Signature: "aider", User: "u", Origin: "scheduler"},
{PID: 300, Kind: kindAgent, Signature: "claude-code", User: "u", Origin: "interactive"},
{PID: 4532, Kind: kindApp, Signature: "claude-desktop", User: "u"},
}, map[int]int{310: 300, 311: 310})
prev := homeForUserFn
t.Cleanup(func() { homeForUserFn = prev })
homeForUserFn = func(string) string { return "" }
d, err := machineFactorDir()
if err != nil {
t.Fatal(err)
}
enrolFactorAt(t, d, machineTestSecret)
return d
}
func fullRequestID(t *testing.T, d, prefix string) string {
t.Helper()
for id := range requestsLoad(d) {
if strings.HasPrefix(id, prefix) {
return id
}
}
t.Fatalf("no request %s", prefix)
return ""
}
// The M1 gate's loop: an agent asks, follows the ask, and learns what the
// owner decided and what happened. Before, it got an id and nothing after.
func TestAnAgentFollowsItsAskToTheOutcome(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatalf("refused: %s", r.Why)
}
if s := askStatus(d, r.Request, "u", false); !s.OK || s.State != askWaiting {
t.Fatalf("before the owner answers: %+v", s)
}
id := fullRequestID(t, d, r.Request)
if _, err := stepupApproveRequest(d, id, codeFor(t, machineTestSecret)); err != nil {
t.Fatal(err)
}
if s := askStatus(d, r.Request, "u", false); s.State != askApproved {
t.Fatalf("approved, not yet carried out: %+v", s)
}
localStopOnce(d, time.Now())
s := askStatus(d, r.Request, "u", false)
if s.State != askStopped || !strings.Contains(s.Outcome, "stopped aider (pid 101)") {
t.Fatalf("after the stop: %+v", s)
}
if got := askSentence(askViewOf(s, false)); !strings.Contains(got, "The bastion stopped aider") {
t.Errorf("the agent reads %q", got)
}
}
// Another account learns nothing about an ask, not even that it exists.
func TestAnotherAccountLearnsNothingOfAnAsk(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !r.OK {
t.Fatal(r.Why)
}
theirs := askStatus(d, r.Request, "other", false)
never := askStatus(d, "ffffffff", "other", false)
if theirs.OK || theirs.State != askUnknown || theirs.Text != "" {
t.Fatalf("another account read %+v", theirs)
}
if strings.ReplaceAll(theirs.Why, r.Request, "X") != strings.ReplaceAll(never.Why, "ffffffff", "X") {
t.Errorf("an existing id reads differently from one that never existed: %q vs %q", theirs.Why, never.Why)
}
if s := askStatus(d, r.Request, "", false); s.OK {
t.Errorf("an unnamed asker read an ask: %+v", s)
}
}
// A deny says so, blocks the same ask, and blocks it for a day from the
// deny, not from the ask.
func TestADeniedAskBlocksADayFromTheDeny(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
id := fullRequestID(t, d, r.Request)
if err := stepupDenyRequest(d, id); err != nil {
t.Fatal(err)
}
if s := askStatus(d, r.Request, "u", false); s.State != askDenied {
t.Fatalf("after the deny: %+v", s)
}
again := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if again.OK || !strings.Contains(again.Why, "refused this same stop within the last 24 hours") {
t.Fatalf("the same ask again: %+v", again)
}
// Asked 25 h ago, denied a minute ago: still blocked.
m := requestsLoad(d)
q := m[id]
q.At = time.Now().Add(-25 * time.Hour).UTC().Format(time.RFC3339)
q.DeniedAt = time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
m[id] = q
if err := requestsSave(d, m); err != nil {
t.Fatal(err)
}
if _, ok := requestsLoad(d)[id]; !ok {
t.Fatal("a deny a minute old stopped blocking because the ask was 25 h old")
}
q.DeniedAt = time.Now().Add(-25 * time.Hour).UTC().Format(time.RFC3339)
m[id] = q
_ = requestsSave(d, m)
if _, ok := requestsLoad(d)[id]; ok {
t.Fatal("a deny 25 h old still blocks")
}
}
// Five refused asks in ten minutes hold that account's asks; another account
// is not held; the owner lifts it.
func TestRefusedAsksHoldTheAccountThatAsked(t *testing.T) {
d := askFlowWorld(t)
for i := 0; i < quarantineStrikes; i++ {
if r := fileAsk(agentAskRequest{Op: "stop", PID: 4532}, "u", 311); r.OK {
t.Fatalf("an app was asked to stop: %+v", r)
}
}
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if r.OK || !strings.Contains(r.Why, "on hold until") {
t.Fatalf("after five refusals: %+v", r)
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "other", 0); !r.OK {
t.Fatalf("another account was held: %s", r.Why)
}
if err := askQuarantineClear(d, "u"); err != nil {
t.Fatal(err)
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 102}, "u", 311); !r.OK {
t.Fatalf("after the owner lifted it: %s", r.Why)
}
}
// What the owner refuses counts too: five denies hold the account.
func TestOwnerDeniesCountTowardTheHold(t *testing.T) {
d := askFlowWorld(t)
for pid := 101; pid < 101+quarantineStrikes; pid++ {
r := fileAsk(agentAskRequest{Op: "stop", PID: pid}, "u", 311)
if !r.OK {
t.Fatalf("ask %d: %s", pid, r.Why)
}
if err := stepupDenyRequest(d, fullRequestID(t, d, r.Request)); err != nil {
t.Fatal(err)
}
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "u", 311); r.OK || !strings.Contains(r.Why, "on hold") {
t.Fatalf("after five denies: %+v", r)
}
// What the machine itself lacks is not the asker's doing.
if until := askHeldUntil(d, "nobody", time.Now()); !until.IsZero() {
t.Fatal("an account that never asked is held")
}
}
func auditRowsOf(t *testing.T, d, op string) []map[string]any {
t.Helper()
var out []map[string]any
fh, err := os.Open(filepath.Join(d, "audit.jsonl"))
if err != nil {
t.Fatal(err)
}
defer fh.Close()
sc := bufio.NewScanner(fh)
for sc.Scan() {
var m map[string]any
if json.Unmarshal(sc.Bytes(), &m) == nil && m["op"] == op {
out = append(out, m)
}
}
return out
}
// Every row about an agent's ask names the asking agent and the target, with
// how each was started: filed, approved, carried out.
func TestTheAuditNamesTheAskerAndTheTarget(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if _, err := stepupApproveRequest(d, fullRequestID(t, d, r.Request), codeFor(t, machineTestSecret)); err != nil {
t.Fatal(err)
}
localStopOnce(d, time.Now())
for _, op := range []string{localStopAskOp, "stepup_approve", localStopKind} {
rows := auditRowsOf(t, d, op)
if len(rows) != 1 {
t.Fatalf("%s: %d rows", op, len(rows))
}
raw, _ := json.Marshal(rows[0])
var row struct {
Asker struct {
Account string `json:"account"`
PID int `json:"pid"`
Agent struct {
Signature, Origin string
PID int
} `json:"agent"`
} `json:"asker"`
Target struct {
Signature, Origin string
PID int
} `json:"target"`
}
_ = json.Unmarshal(raw, &row)
a, g := row.Asker, row.Target
if a.Account != "u" || a.PID != 311 || a.Agent.Signature != "claude-code" || a.Agent.PID != 300 ||
a.Agent.Origin != "interactive" || g.Signature != "aider" || g.PID != 101 || g.Origin != "scheduler" {
t.Errorf("%s row: %s", op, raw)
}
}
}
// The owner sees the ask and the deny in Activity.
func TestActivityShowsAsksAndDenies(t *testing.T) {
d := askFlowWorld(t)
r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if err := stepupDenyRequest(d, fullRequestID(t, d, r.Request)); err != nil {
t.Fatal(err)
}
var asked, denied bool
for _, it := range collectActivity(time.Time{}) {
if strings.Contains(it.Who, "Claude Code (pid 300)") && strings.Contains(it.What, "asked to stop aider") {
asked = true
}
if it.Who == "You" && strings.HasPrefix(it.What, "denied ") && strings.Contains(it.What, "asked by Claude Code") &&
it.Refused {
denied = true
}
}
if !asked || !denied {
t.Fatalf("asked %v denied %v in %+v", asked, denied, collectActivity(time.Time{}))
}
}
// What an agent branches on: the exit status, and JSON it can parse.
func TestAskExitStatusesAndJSON(t *testing.T) {
for _, c := range []struct {
v askView
waited bool
want int // -1: no error
}{
{askView{State: askStopped}, true, -1},
{askView{State: askWaiting, Request: "abcd1234"}, false, -1},
{askView{State: askWaiting, Request: "abcd1234"}, true, askExitWaiting},
{askView{State: askApproved}, true, askExitWaiting},
{askView{State: askDenied}, true, 1},
{askView{State: askNotStopped}, false, 1},
{askView{State: askRefused, Why: "x"}, false, 1},
{askView{State: askUnknown, Why: "x"}, false, 1},
} {
var buf bytes.Buffer
err := printAsk(&buf, c.v, true, c.waited)
var q quietExit
got := -1
if errors.As(err, &q) {
got = q.code
} else if err != nil {
t.Fatalf("%+v: %v", c.v, err)
}
if got != c.want {
t.Errorf("%s (waited %v): exit %d, want %d", c.v.State, c.waited, got, c.want)
}
var back askView
if json.Unmarshal(buf.Bytes(), &back) != nil || back.State != c.v.State {
t.Errorf("%s: --json printed %q", c.v.State, buf.String())
}
}
}
// Agents write the pid before the flags as often as after.
func TestAskFlagsTakeThePositionalAnywhere(t *testing.T) {
for _, args := range [][]string{{"101", "--wait", "5m", "--json"}, {"--json", "101", "--wait=5m"},
{"--wait", "5m", "--json", "101"}} {
key, wait, asJSON, err := askFlags("stop", args)
if err != nil || key != "101" || wait != 5*time.Minute || !asJSON {
t.Errorf("%v: %q %v %v %v", args, key, wait, asJSON, err)
}
}
for _, args := range [][]string{{}, {"101", "102"}, {"101", "--wait", "-1m"}, {"101", "--nope"}} {
if _, _, _, err := askFlags("stop", args); err == nil {
t.Errorf("%v was accepted", args)
}
}
}
// A Host's request is the Host's own words. One that names an account on
// this machine as its asker must not put that account in the owner's audit,
// and the owner denying it must not count toward that account's hold.
func TestAHostsRequestNeverStrikesALocalAccount(t *testing.T) {
d := askFlowWorld(t)
for i := 0; i < quarantineStrikes; i++ {
gd := map[string]any{"kind": "dcn_store_grant", "server": "box1", "root": "/data/" + strconv.Itoa(i),
"mask": []any{"read"}, "sweep": "hourly", "sensitive": false,
"asked_by_account": "u", "asked_by": "You", "asker": map[string]any{"account": "u"}}
gp, err := purposeFromDetails(gd)
if err != nil {
t.Fatal(err)
}
id, err := stepupNewRequest(d, gp, gd)
if err != nil {
t.Fatal(err)
}
if err := stepupDenyRequest(d, id); err != nil {
t.Fatal(err)
}
}
if until := askHeldUntil(d, "u", time.Now()); !until.IsZero() {
t.Fatalf("denying a Host's requests held a local account until %s", until)
}
for _, row := range auditRowsOf(t, d, "stepup_deny") {
if row["asker"] != nil || row["who"] != nil {
t.Fatalf("a Host's request named a local asker in the audit: %v", row)
}
}
// A critical action's own "target" is not an ask's target.
if p := askPartiesOf(map[string]any{"kind": "critical_action", "target": "prod-db"}); p != nil {
t.Fatalf("a Host's request carried ask parties: %v", p)
}
// At the Host's door: the fields only this machine writes are dropped.
ls := map[string]any{"kind": localStopKind, "pid": "101", "signature": "aider", "asked_by_account": "u",
"asker": map[string]any{}, "asked_by": "You", "target": map[string]any{}}
hostAskDetails(ls)
for _, k := range []string{"asked_by_account", "asker", "asked_by", "target"} {
if _, ok := ls[k]; ok {
t.Fatalf("a Host's local_stop kept %q: %v", k, ls)
}
}
ca := map[string]any{"kind": "critical_action", "target": "prod-db", "asked_by_account": "u"}
hostAskDetails(ca)
if ca["target"] != "prod-db" || ca["asked_by_account"] != nil {
t.Fatalf("a critical action keeps its target and loses the local asker: %v", ca)
}
}
// The holds file is keyed by kernel account names, and a user with
// subordinate ids can present thousands. An account with nothing left to
// remember (not held, no recent refusal, no hold within the forgiving day)
// is dropped at the next write; the ones that still matter stay.
func TestTheHoldsFileForgetsAccountsWithNothingToRemember(t *testing.T) {
d := askFlowWorld(t)
now := time.Now()
at := func(ago time.Duration) string { return now.Add(-ago).UTC().Format(time.RFC3339) }
m := map[string]*quarantineEntry{
"stale": {Refusals: []string{at(time.Hour)}},
"old-hold": {Strikes: 1, LastHold: at(48 * time.Hour)},
"held": {Strikes: 1, Until: now.Add(time.Hour).UTC().Format(time.RFC3339), LastHold: at(-time.Hour)},
"doubling": {Strikes: 2, LastHold: at(2 * time.Hour)},
"recent-refus": {Refusals: []string{at(time.Minute)}},
}
if err := askQuarantineSave(d, m); err != nil {
t.Fatal(err)
}
askStrike(d, "new", now)
got := askQuarantineLoad(d)
for _, k := range []string{"stale", "old-hold"} {
if _, ok := got[k]; ok {
t.Fatalf("%s has nothing left to remember and was kept: %v", k, got)
}
}
for _, k := range []string{"held", "doubling", "recent-refus", "new"} {
if _, ok := got[k]; !ok {
t.Fatalf("%s still matters and was dropped: %v", k, got)
}
}
if got["doubling"].Strikes != 2 {
t.Fatalf("the doubling was lost: %+v", got["doubling"])
}
}
// Two accounts asking for the same stop each get their own request, which
// each alone can follow: handing the second one the first's id told it that
// someone else had asked, and gave it an id it could not follow.
func TestTwoAccountsAskingTheSameStopEachGetTheirOwn(t *testing.T) {
d := askFlowWorld(t)
a := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
b := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "other", 0)
if !a.OK || !b.OK {
t.Fatalf("asks: %+v %+v", a, b)
}
if a.Request == b.Request {
t.Fatalf("the second account was handed the first account's request %s", a.Request)
}
if s := askStatus(d, b.Request, "other", false); !s.OK || s.State != askWaiting {
t.Fatalf("the second account cannot follow its own ask: %+v", s)
}
if again := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311); again.Request != a.Request {
t.Errorf("the same account asking again got a new request %s, not %s", again.Request, a.Request)
}
// The owner's deny of that stop holds for everyone.
if err := stepupDenyRequest(d, fullRequestID(t, d, a.Request)); err != nil {
t.Fatal(err)
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "third", 0); r.OK {
t.Errorf("a stop the owner refused was filed again by another account: %+v", r)
}
}
// An approved stop was acted on; if it could not be done, asking again is a
// new request, not the old approved one for the rest of the day.
func TestAStopThatFailedCanBeAskedAgain(t *testing.T) {
d := askFlowWorld(t)
first := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if _, err := stepupApproveRequest(d, fullRequestID(t, d, first.Request), codeFor(t, machineTestSecret)); err != nil {
t.Fatal(err)
}
localStopOnce(d, time.Now().Add(localStopWindow+time.Minute)) // read too late: not stopped
if s := askStatus(d, first.Request, "u", false); s.State != askNotStopped {
t.Fatalf("the late stop: %+v", s)
}
again := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311)
if !again.OK || again.Request == first.Request {
t.Fatalf("asking again after a failed stop: %+v (first %s)", again, first.Request)
}
if s := askStatus(d, again.Request, "u", false); s.State != askWaiting {
t.Errorf("the new ask: %+v", s)
}
}
// An account at its limit that asks again for a stop it already has waiting
// gets that request back: it is not a sixth ask, and it is not a refusal to
// count toward a hold.
func TestAskingAgainAtTheLimitReturnsTheWaitingRequest(t *testing.T) {
d := askFlowWorld(t)
var first string
for pid := 101; pid < 101+askMaxPendingPer; pid++ {
r := fileAsk(agentAskRequest{Op: "stop", PID: pid}, "u", 311)
if !r.OK {
t.Fatalf("ask %d: %s", pid, r.Why)
}
if first == "" {
first = r.Request
}
}
for i := 0; i < quarantineStrikes; i++ {
if r := fileAsk(agentAskRequest{Op: "stop", PID: 101}, "u", 311); !r.OK || r.Request != first {
t.Fatalf("asking again for a waiting stop at the limit: %+v (want %s)", r, first)
}
}
if until := askHeldUntil(d, "u", time.Now()); !until.IsZero() {
t.Fatalf("repeating a waiting ask held the account until %v", until)
}
if r := fileAsk(agentAskRequest{Op: "stop", PID: 106}, "u", 311); r.OK || !strings.Contains(r.Why, "already have 5") {
t.Errorf("a new sixth ask: %+v", r)
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package main
import (
"net"
"time"
)
// dialAskLane connects to the machine's bastion's ask socket at p.
func dialAskLane(p string) (askConn, error) {
return net.DialTimeout("unix", p, 3*time.Second)
}
// askLaneAddr is where the lane is, as a message names it.
func askLaneAddr(p string) string { return p }
// startAskPipe is the Windows service's; ask.sock serves here (agentask.go).
func startAskPipe(<-chan struct{}) {}
+153
View File
@@ -0,0 +1,153 @@
//go:build windows
package main
// The ask pipe: ask.sock (agentask.go) for a bastion that runs as the Windows
// service.
//
// Installed for the whole machine, the bastion is a LocalSystem service and
// its state sits in LocalSystem's profile, where the person's own account can
// neither read nor write it. Before this pipe, that account had no way to the
// machine's bastion at all: `privatae-bastion ui` signed in with a key of its
// own and the console answered 401, the reads and owner acts were refused
// beside the service, and an agent could not ask for a stop. The pipe answers
// exactly what ask.sock answers, through the same code (askAnswer): a stop
// asked by any account, and the console, the reads and the owner's acts for
// the account recorded as the machine's owner (approvals-owner --sid), with
// the caller taken from the pipe client's token, never from the request.
//
// The pipe is the approvals pipe's machinery (approvalpipe_windows.go):
// overlapped I/O with a timeout on every call, a new instance created before
// the one it replaces is handed out, the first instance created with
// FILE_FLAG_FIRST_PIPE_INSTANCE, and the asking side checking the pipe's
// server is the service's own process before it sends anything, since an act
// carries the owner's code.
import (
"encoding/json"
"errors"
"fmt"
"io"
"time"
"golang.org/x/sys/windows"
)
// startAskPipe serves the ask pipe until done is closed, retrying with backoff
// when it cannot (pipeServeRetrying), as the approvals pipe does.
func startAskPipe(done <-chan struct{}) {
wait := func(d time.Duration) bool {
select {
case <-done:
return false
case <-time.After(d):
return true
}
}
go pipeServeRetrying(func(ready func()) error {
return servePipe(done, ready, askPipeName, askPipeSDDL, serveAskPipeConn)
}, wait, func(f string, a ...any) { fmt.Printf(f, a...) })
}
// serveAskPipeConn answers one ask, then waits briefly for the client to
// close: closing first would discard an answer it has not read yet.
func serveAskPipeConn(c *pipeConn) {
client, cerr := pipeClientOf(c.h)
raw, err := c.readLine(askMaxBytes, pipeIOTimeout)
if err != nil {
return
}
var reply any
switch {
case cerr != nil:
reply = askUnattributable(cerr)
default:
who := pipeWho(client)
var req agentAskRequest
if err := json.Unmarshal(raw, &req); err != nil {
reply = agentAskReply{User: who, Why: "the request could not be read"}
break
}
peer := askPeer{SID: client.UserSID, Name: who, PID: int(client.PID)}
reply = askAnswer(peer, req, func(time.Duration) {}, func() int { return int(client.PID) })
}
out, _ := json.Marshal(reply)
if err := c.write(append(out, '\n'), pipeIOTimeout); err != nil {
return
}
_, _ = c.read(pipeIOTimeout)
}
// dialAskLane opens the service's ask pipe; p, ask.sock's path, does not
// apply here.
func dialAskLane(string) (askConn, error) {
c, err := dialServicePipe(askPipeName)
if err != nil {
return nil, err
}
if err := pipeServerIsService(c.h); err != nil {
c.close()
return nil, err
}
return &askPipeClient{c: c}, nil
}
// askLaneAddr is where the lane is, as a message names it.
func askLaneAddr(string) string { return askPipeName }
// askPipeClient is the asking side's end of the pipe, read and written the
// way the lane's code reads and writes a socket: a deadline for the whole
// exchange, bytes in, bytes out.
type askPipeClient struct {
c *pipeConn
deadline time.Time
pending []byte
}
func (a *askPipeClient) SetDeadline(t time.Time) error {
a.deadline = t
return nil
}
// left is what remains of the deadline for one call; 0 waits as long as it
// takes, which only a caller that set no deadline gets.
func (a *askPipeClient) left() time.Duration {
if a.deadline.IsZero() {
return 0
}
if d := time.Until(a.deadline); d > 0 {
return d
}
return time.Millisecond
}
func (a *askPipeClient) Read(b []byte) (int, error) {
for len(a.pending) == 0 {
n, err := a.c.read(a.left())
a.pending = append(a.pending, a.c.buf[:n]...)
if err != nil && !errors.Is(err, windows.ERROR_MORE_DATA) {
if len(a.pending) > 0 {
break
}
if errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED) {
return 0, io.EOF
}
return 0, err
}
}
n := copy(b, a.pending)
a.pending = a.pending[n:]
return n, nil
}
func (a *askPipeClient) Write(b []byte) (int, error) {
if err := a.c.write(b, a.left()); err != nil {
return 0, err
}
return len(b), nil
}
func (a *askPipeClient) Close() error {
a.c.close()
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package main
// askparties.go — who asked to stop what, in the form the audit keeps: the
// asking account (the kernel's answer), the asking process and the agent it
// descends from, and the target, each with how it was started (origin.go:
// interactive, scheduler, service, ...). They are kept on the request itself
// (Details "asker" and "target"), so every later row about that request —
// approved, denied, carried out — names both, not only the row that filed it.
// askingAgentObs is the observed agent pid descends from, and its pid.
func askingAgentObs(pid int, observed map[int]agentObs) (agentObs, int, bool) {
parents := processTree()
p := pid
for i := 0; p > 1 && i < 64; i++ {
if a, ok := observed[p]; ok && a.Kind != kindApp {
return a, p, true
}
p = parents[p]
}
return agentObs{}, 0, false
}
// askParties describes the asker and the target of one stop ask. askerPID is
// the kernel's pid for the asking process, 0 where the platform does not say.
func askParties(account string, askerPID, targetPID int, observed map[int]agentObs) (asker, target map[string]any) {
asker = map[string]any{"account": account}
if askerPID > 1 {
asker["pid"] = askerPID
if a, p, ok := askingAgentObs(askerPID, observed); ok {
asker["agent"] = agentParty(a, p)
}
}
target = agentParty(observed[targetPID], targetPID)
return asker, target
}
func agentParty(a agentObs, pid int) map[string]any {
out := map[string]any{"pid": pid, "signature": a.Signature, "name": familyLabel(a.Signature)}
if a.Origin != "" {
out["origin"] = a.Origin
}
return out
}
// askPartiesOf is what a request carries about its asker and target, for an
// audit row; nil for a request no local agent asked (a Host's).
func askPartiesOf(d map[string]any) map[string]any {
if kind, _ := d["kind"].(string); kind != localStopKind {
return nil // "target" is a critical_action's own word, not a party
}
out := map[string]any{}
for _, k := range []string{"asker", "target"} {
if v, ok := d[k]; ok && v != nil {
out[k] = v
}
}
if by, _ := d["asked_by"].(string); by != "" {
out["who"] = by
}
if len(out) == 0 {
return nil
}
return out
}
// hostAskDetails drops from a Host's request the fields only this machine
// writes about a local agent's ask. A Host's details are its own words, so
// without this it could name an account on this computer as the asker in the
// owner's audit, and have the owner's deny count against that account.
func hostAskDetails(d map[string]any) {
delete(d, "asked_by_account")
delete(d, "asker")
if kind, _ := d["kind"].(string); kind == localStopKind {
delete(d, "asked_by")
delete(d, "target")
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
// askpeer.go — who is on the other end of the owner's lane, on every platform.
//
// The lane (agentask.go: ask.sock on Linux and macOS, the ask pipe on Windows,
// asklane_windows.go) learns the connecting account from the operating system,
// never from the request: a uid from the socket's peer credentials, or a SID
// from the pipe client's token. The machine's owner is recorded the same two
// ways (approvals_owner.go). Every refusal on the lane asks here, so both
// platforms answer with the same rules, and only the commands they name differ.
import (
"strconv"
"strings"
"time"
)
type askPeer struct {
UID uint32 // Linux and macOS: the connecting process's account
SID string // Windows: the pipe client's token user; "" elsewhere
Name string // the account as messages name it
PID int // the connecting process; 0 when the platform does not say
}
// windows reports whether the peer came over the Windows pipe.
func (p askPeer) windows() bool { return p.SID != "" }
// laneOwnerMatch reads the recorded owner the way peer's platform names
// accounts. present is true when a record exists, readable or not; ownerName
// is how a refusal names the owner.
func laneOwnerMatch(p askPeer) (match bool, ownerName string, present bool, err error) {
if p.windows() {
sid, present, err := approvalsOwnerLoad()
if err != nil || !present {
return false, "", present, err
}
return strings.EqualFold(sid, p.SID), sid, true, nil
}
uid, name, present, err := ownerAccountLoad()
if err != nil || !present {
return false, "", present, err
}
if name == "" {
name = "uid " + strconv.FormatUint(uint64(uid), 10)
}
return uid == p.UID, name, true, nil
}
// ownerRecordCommand is the administrator's command that records peer's
// account as the machine's owner.
func ownerRecordCommand(p askPeer) string {
if p.windows() {
return adminCommandFor(true, "approvals-owner --sid "+p.SID)
}
return "sudo -H privatae-bastion approvals-owner --account " + accountForHint(p.Name)
}
// adminCommandFor is `privatae-bastion <cmd>` as the machine's administrator
// types it: in an Administrator terminal on Windows, with `sudo -H` elsewhere.
func adminCommandFor(windows bool, cmd string) string {
if windows {
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
}
return "sudo -H privatae-bastion " + cmd
}
// askConn is one connection of the lane as the asking side holds it: a unix
// socket (net.Conn), or the Windows pipe client (asklane_windows.go).
type askConn interface {
Read(b []byte) (int, error)
Write(b []byte) (int, error)
Close() error
SetDeadline(t time.Time) error
}
const (
// askPipeName is the Windows service's ask pipe, the counterpart of
// ask.sock: any account on the machine may connect and ask; who it is
// comes from the pipe client's token.
askPipeName = `\\.\pipe\privatae-bastion-ask`
// askPipeSDDL: SYSTEM and Administrators full control; Authenticated
// Users (AU) read, write and synchronize, as any local account may
// connect to ask.sock. Not FILE_CREATE_PIPE_INSTANCE (FILE_APPEND_DATA,
// 0x4, on a pipe): 0x12019b is the approvals pipe's mask for the same
// reason (approvalpipe.go), so no client can stand up its own end of this
// name. P protects the DACL from inheritance.
askPipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;AU)"
)
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
testOwnerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001"
testOtherSID = "S-1-5-21-1111111111-2222222222-3333333333-1002"
)
// writeSIDOwner records sid as the machine's owner, as `approvals-owner --sid`
// writes it on Windows.
func writeSIDOwner(t *testing.T, dir, sid string) {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(approvalsOwnerRecord{SID: sid, SetAt: time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil {
t.Fatal(err)
}
}
// The Windows ask pipe answers the owner's reads, acts and console for the
// account whose SID the installer recorded, and refuses every other one, in
// Windows' own commands: no sudo anywhere.
//
// MUTATION: compare p.UID in laneOwnerMatch for a Windows peer. RESULT: red
// (the owner's SID is refused).
func TestTheAskPipeServesTheOwnersSIDOnly(t *testing.T) {
dir := isolateConfigDir(t)
owner := askPeer{SID: testOwnerSID, Name: `DESKTOP\owner (` + testOwnerSID + `)`}
other := askPeer{SID: testOtherSID, Name: `DESKTOP\other (` + testOtherSID + `)`}
why := readLaneRefusalFor(owner)
if !strings.Contains(why, "approvals-owner --sid "+testOwnerSID) || !strings.Contains(why, "Administrator terminal") {
t.Errorf("with no owner recorded, the refusal does not give Windows' command: %q", why)
}
writeSIDOwner(t, dir, testOwnerSID)
if why := readLaneRefusalFor(owner); why != "" {
t.Errorf("the owner's SID was refused a read: %s", why)
}
if why := consoleLaneRefusalFor(owner); why != "" {
t.Errorf("the owner's SID was refused the console: %s", why)
}
// Case differs between a SID read from a token and one typed.
if why := readLaneRefusalFor(askPeer{SID: strings.ToLower(testOwnerSID), Name: "owner"}); why != "" {
t.Errorf("the owner's SID in other case was refused: %s", why)
}
for _, why := range []string{readLaneRefusalFor(other), consoleLaneRefusalFor(other)} {
if why == "" || !strings.Contains(why, testOwnerSID) || !strings.Contains(why, other.Name) {
t.Errorf("another account was not refused, naming both: %q", why)
}
if strings.Contains(why, "sudo") {
t.Errorf("a Windows refusal names sudo: %q", why)
}
}
if r := serveActFor(other, agentAskRequest{Act: "deny", ID: "abcd1234"}); r.OK || r.User != other.Name {
t.Errorf("another account's act: %+v", r)
}
// A record of the other platform's kind is no owner here.
uid := uint32(1000)
raw, _ := json.Marshal(approvalsOwnerRecord{UID: &uid, Account: "someone"})
if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil {
t.Fatal(err)
}
if why := readLaneRefusalFor(owner); why == "" {
t.Error("a uid record let a Windows SID through")
}
}
// One dispatcher answers both lanes, so the pipe answers what the socket does.
func TestAskAnswerIsTheSocketsAnswer(t *testing.T) {
isolateConfigDir(t)
peer := askPeer{SID: testOwnerSID, Name: "owner"}
var extended []time.Duration
extend := func(d time.Duration) { extended = append(extended, d) }
if r, ok := askAnswer(peer, agentAskRequest{Op: "read", Read: "approvals"}, extend, func() int { return 0 }).(agentReadReply); !ok || r.OK {
t.Errorf("a read with no owner recorded: %+v", r)
}
if r, ok := askAnswer(peer, agentAskRequest{Op: "console"}, extend, func() int { return 0 }).(agentAskReply); !ok || r.OK || r.Why == "" {
t.Errorf("the console with no owner recorded: %+v", r)
}
pid := 0
if r, ok := askAnswer(peer, agentAskRequest{Op: "stop", PID: 4242}, extend, func() int { pid = 7; return 7 }).(agentAskReply); !ok || r.OK {
t.Errorf("a stop with no code set up: %+v", r)
}
if pid != 7 {
t.Error("a stop was filed without asking which process asked")
}
if len(extended) != 1 || extended[0] != readDeadline {
t.Errorf("deadlines given: %v", extended)
}
}
func TestAdminCommandsAreEachPlatformsOwn(t *testing.T) {
if got := adminCommandFor(true, "ui"); strings.Contains(got, "sudo") || !strings.Contains(got, "Administrator terminal") {
t.Errorf("windows: %q", got)
}
if got := adminCommandFor(false, "ui"); got != "sudo -H privatae-bastion ui" {
t.Errorf("unix: %q", got)
}
if !strings.Contains(askPipeSDDL, "0x12019b;;;AU") || strings.Contains(askPipeSDDL, "GW;;;AU") || strings.Contains(askPipeSDDL, "GA;;;AU") {
t.Errorf("the ask pipe's ACL gives every account more than read and write: %s", askPipeSDDL)
}
}
// A page with no session names the command that opens it on this machine.
// The machine's console is not opened by "privatae-bastion ui" from just any
// account, and telling a person only that sent them back to a 401.
func TestNotSignedInNamesTheCommandThatOpensThisConsole(t *testing.T) {
if got := notSignedIn("ui", "windows", true); !strings.Contains(got, "Administrator terminal") ||
!strings.Contains(got, "owns this machine") || strings.Contains(got, "sudo") {
t.Errorf("the Windows service's console: %q", got)
}
if got := notSignedIn("ui", "linux", true); !strings.Contains(got, "sudo -H privatae-bastion ui") ||
!strings.Contains(got, "owns this machine") {
t.Errorf("the root bastion's console: %q", got)
}
if got := notSignedIn("ui", "windows", false); !strings.Contains(got, "`privatae-bastion ui`") || strings.Contains(got, "Administrator") {
t.Errorf("an account's own bastion: %q", got)
}
err := uiKeyRefused("127.0.0.1:7880", "401 Unauthorized", "windows", true, false)
if s := err.Error(); !strings.Contains(s, winServiceName) || strings.Contains(s, "sudo") || strings.Contains(s, "restart it") {
t.Errorf("a 401 beside the Windows service: %q", s)
}
}
// A second start says what is running and how to reach it, and never that it
// should be ended: ending it was what the old words sent a person to do.
//
// MUTATION: drop the PID from secondRunWords. RESULT: red.
func TestASecondStartNamesTheRunningBastion(t *testing.T) {
task := runHolder{PID: 4242, Program: `C:\Users\someone\AppData\Local\Programs\privatae-bastion\privatae-bastion.exe`,
Started: "2026-11-02T18:02:00Z"}
console, dialog := secondRunWords(task, true)
for _, want := range []string{"4242", task.Program, "privatae-bastion ui", "Stop-Process -Id 4242"} {
if !strings.Contains(console, want) || !strings.Contains(dialog.Body, want) {
t.Errorf("the refusal does not say %q:\n%s\n%s", want, console, dialog.Body)
}
}
svc, _ := secondRunWords(runHolder{PID: 9, Service: true}, true)
if !strings.Contains(svc, "as the "+winServiceName+" service") || !strings.Contains(svc, "Restart-Service "+winServiceName) {
t.Errorf("the service: %s", svc)
}
old, _ := secondRunWords(runHolder{}, false)
if !strings.Contains(old, "Get-Process privatae-bastion") {
t.Errorf("an older bastion holding the lock: %s", old)
}
for _, s := range []string{console, svc, old, dialog.Body} {
if strings.Contains(s, "Task Manager") || strings.Contains(s, "believe the running one is dead") {
t.Errorf("the refusal sends the person to end the running bastion: %s", s)
}
}
dir := t.TempDir()
writeRunHolder(dir, true)
if h, ok := readRunHolder(dir); !ok || h.PID != os.Getpid() || !h.Console {
t.Errorf("the holder record: %+v, %v", h, ok)
}
}
// A bastion relaunched by its predecessor's update waits for it to exit and
// then takes the lock, where it used to refuse as a second copy and leave the
// machine with none.
func TestARelaunchedBastionWaitsForThePredecessorsLock(t *testing.T) {
clock := time.Unix(0, 0)
now := func() time.Time { return clock }
sleep := func(d time.Duration) { clock = clock.Add(d) }
held := 3
try := func() error {
if held > 0 {
held--
return os.ErrExist
}
return nil
}
waited := 0
waitExit := func(pid int, d time.Duration) { waited = pid }
if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err != nil {
t.Fatalf("the lock was not taken once the predecessor let go: %v", err)
}
if waited != 4242 {
t.Errorf("waited on process %d", waited)
}
held = 1 << 30
if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err == nil {
t.Fatal("a lock held for ever was taken")
}
held, waited = 1, 0
if err := takeRunLock(try, 0, waitExit, 30*time.Second, now, sleep); err == nil || waited != 0 {
t.Errorf("a start that replaces nothing waited, or retried: %v, %d", err, waited)
}
t.Setenv(relaunchOfEnv, "4242")
if got := relaunchOf(); got != 4242 {
t.Errorf("relaunchOf = %d", got)
}
if _, set := os.LookupEnv(relaunchOfEnv); set {
t.Error("the predecessor's pid would pass on to what this bastion starts")
}
}
// The Windows files wire it together: they are not built on Linux, so this
// reads them, as TestServiceModeOpensNothingOnTheDesktop does.
func TestTheWindowsFilesWireTheLaneTheCarryAndTheRelaunchWait(t *testing.T) {
for file, wants := range map[string][]string{
"relaunch_windows.go": {`relaunchOfEnv+"="+strconv.Itoa(os.Getpid())`},
"runguard_windows.go": {"takeRunLock(", "relaunchOf()", "writeRunHolder(d,", "secondRunWords("},
"sysservice_windows.go": {"carryStateInto(from, to)", "serviceCarrySource(serviceCarryFromSID)"},
"asklane_windows.go": {"askAnswer(peer, req", "pipeServerIsService(c.h)", "askPipeSDDL"},
"agentask.go": {"startAskPipe(done)"},
"../deploy/install.ps1": {"service install --from-account $owner"},
} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, w := range wants {
if !strings.Contains(string(src), w) {
t.Errorf("%s no longer has %s", file, w)
}
}
}
// The carry runs before the service's first start, not after it.
src, _ := os.ReadFile("sysservice_windows.go")
s := string(src)
if c, st := strings.Index(s, "carryStateInto(from, to)"), strings.Index(s, "s.Start()"); c < 0 || st < 0 || c > st {
t.Error("the carry is not before the service starts, so the service would start with no pairing")
}
}
// A process that started after the connection was made is not the one that
// made it: its id was freed and reused, and answering it would answer a
// stranger as whoever holds that id now.
func TestAPipeClientStartedAfterItsConnectionIsNotTheCaller(t *testing.T) {
connected := time.Unix(1000, 0)
if !pipeClientNewer(connected.Add(time.Millisecond), connected) {
t.Error("a process created after the connection was taken for the caller")
}
if pipeClientNewer(connected.Add(-time.Hour), connected) {
t.Error("the process that connected was refused")
}
}
+170
View File
@@ -0,0 +1,170 @@
package main
// askquarantine.go — the machine holds an account whose asks keep being
// refused, the way quarantine.go holds a Host pairing: five refused asks
// inside ten minutes hold that account's asks for ten minutes, doubling on
// every repeat, forgiven after a clean day (the same arithmetic,
// quarantineEntry.strike). Asking is how an agent on this computer reaches
// the owner, so an agent that keeps asking for what is refused is the local
// form of a Host probing the management lane.
//
// Keyed by the asking account (the kernel's answer on ask.sock, not anything
// the asker says), and kept in the owner's factor directory beside the
// requests themselves, never on the heartbeat: which accounts on this
// computer asked for what is not a Host's business.
//
// What strikes: the owner denying an ask, and refusals that are the asker's
// own doing (not an agent this bastion sees, an ask the owner refused in the
// last day, too many waiting). What does not: the owner having no code set
// up, a store that could not be written — this machine's state, not the
// asker's.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
const askQuarantineFile = "ask_quarantine.json"
var askQuarantineMu sync.Mutex
func askQuarantinePath(dir string) string { return filepath.Join(dir, askQuarantineFile) }
func askQuarantineLoad(dir string) map[string]*quarantineEntry {
m := map[string]*quarantineEntry{}
if raw, err := os.ReadFile(askQuarantinePath(dir)); err == nil {
_ = json.Unmarshal(raw, &m)
}
return m
}
func askQuarantineSave(dir string, m map[string]*quarantineEntry) error {
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(askQuarantinePath(dir), raw)
}
// askStrike counts one refused ask from account. The hold that starts on the
// last strike is audited, so the owner sees it in Activity.
func askStrike(dir, account string, now time.Time) {
if dir == "" || account == "" {
return
}
askQuarantineMu.Lock()
defer askQuarantineMu.Unlock()
m := askQuarantineLoad(dir)
e := m[account]
if e == nil {
e = &quarantineEntry{}
m[account] = e
}
held := e.strike(now)
askQuarantinePrune(m, now)
_ = askQuarantineSave(dir, m)
if held {
dcnAudit(dir, askHoldOp, 0, account, "held until "+e.Until+": "+strconv.Itoa(quarantineStrikes)+
" refused asks within "+askWindowWords())
}
}
const askHoldOp = "local_ask_hold"
// askQuarantinePrune drops the accounts there is nothing left to remember
// about: not held, no refusal inside the window, and no hold within the
// forgiving day (strike would reset their doubling anyway). Accounts are the
// kernel's names, and a user with subordinate ids can present thousands of
// them, so without this the file grows by one entry per id that was ever
// refused and is rewritten whole on every strike.
func askQuarantinePrune(m map[string]*quarantineEntry, now time.Time) {
for acct, e := range m {
if e == nil {
delete(m, acct)
continue
}
if quarantinedAt(e, now) {
continue
}
recent := false
for _, ts := range e.Refusals {
if t, err := time.Parse(time.RFC3339, ts); err == nil && now.Sub(t) < quarantineWindow {
recent = true
break
}
}
if recent {
continue
}
if t, err := time.Parse(time.RFC3339, e.LastHold); err == nil && now.Sub(t) <= quarantineForgive {
continue
}
delete(m, acct)
}
}
// askHeldUntil: when account may ask again, or the zero time.
func askHeldUntil(dir, account string, now time.Time) time.Time {
if dir == "" || account == "" {
return time.Time{}
}
askQuarantineMu.Lock()
defer askQuarantineMu.Unlock()
e := askQuarantineLoad(dir)[account]
if !quarantinedAt(e, now) {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, e.Until)
return t
}
// askHoldRefusal is what a held account is told.
func askHoldRefusal(account string, until time.Time) string {
return fmt.Sprintf("asks from %s are on hold until %s: %d of them were refused within %s. "+
"The machine's owner can lift the hold", account, until.Local().Format("15:04"), quarantineStrikes,
askWindowWords())
}
func askWindowWords() string { return strconv.Itoa(int(quarantineWindow.Minutes())) + " minutes" }
// askQuarantineClear — the owner lifts the hold, and the strikes with it.
func askQuarantineClear(dir, account string) error {
askQuarantineMu.Lock()
defer askQuarantineMu.Unlock()
m := askQuarantineLoad(dir)
if _, ok := m[account]; !ok {
return fmt.Errorf("asks from %s are not held, and none of theirs were refused recently", account)
}
delete(m, account)
if err := askQuarantineSave(dir, m); err != nil {
return err
}
dcnAudit(dir, askHoldOp, 0, account, "lifted by the owner")
return nil
}
// cmdQuarantineClearAsker is `quarantine clear --asker <account>`. The holds
// live in the owner's factor directory, so only the account that holds it —
// the administrator, on a whole-machine install — finds them.
func cmdQuarantineClearAsker(account string) error {
if account == "" {
return errors.New("which account? the hold names it")
}
dir := existingMachineFactorDir()
if dir == "" {
return errors.New("no holds on asks are kept for this account. On a machine the bastion is installed " +
"for as a whole, they are the administrator's: `sudo -H privatae-bastion quarantine clear --asker " +
account + "`")
}
if err := askQuarantineClear(dir, account); err != nil {
return err
}
fmt.Printf("agents running as %s may ask this machine again\n", account)
return nil
}
+272
View File
@@ -0,0 +1,272 @@
package main
// askstatus.go — the asking side follows its ask to the end. An agent that
// ran `stop` used to get a request id and nothing after it: the outcome was
// kept under the approval id, which only the owner's side ever sees, so the
// agent could not tell the person whether the stop happened.
//
// privatae-bastion stop <pid|session> [--wait 5m] [--json]
// privatae-bastion request <id> [--wait 5m] [--json]
//
// Where the bastion runs as another account (installed for the whole
// machine), both go through ask.sock (op "status"), which answers only the
// account that asked: any other account is told what it would be told for an
// id that never existed.
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"time"
)
// What an ask can be, as an agent reads it.
const (
askWaiting = "waiting" // the owner has not answered
askApproved = "approved" // approved; the bastion is carrying it out
askStopped = "stopped" // approved and done
askNotStopped = "not_stopped" // approved, but it could not be done (outcome says why)
askDenied = "denied" // the owner refused it
askRefused = "refused" // nothing was asked (why says why)
askUnknown = "unknown" // no such ask from this account, or it is older than a day
)
var askPollEvery = 2 * time.Second
// askStatus is where the ask with this id prefix stands. anyAccount is for a
// reader that holds the state itself (it could read the files anyway);
// ask.sock always passes false, so an account sees only its own asks.
func askStatus(dir, prefix, user string, anyAccount bool) agentAskReply {
r := agentAskReply{User: user}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
r.Why = "which request? give the id `stop` printed"
return r
}
if dir == "" || (!anyAccount && user == "") {
r.State, r.Why = askUnknown, "no request "+prefix+" was asked from your account"
return r
}
stepupMu.Lock()
m := requestsLoad(dir)
results := localStopResultsLoad(dir)
stepupMu.Unlock()
var hit string
for id, q := range m {
if !strings.HasPrefix(id, prefix) {
continue
}
if kind, _ := q.Details["kind"].(string); kind != localStopKind {
continue
}
if acct, _ := q.Details["asked_by_account"].(string); !anyAccount && acct != user {
continue
}
if hit != "" {
r.Why = "'" + prefix + "' matches more than one request; give more of the id"
return r
}
hit = id
}
if hit == "" {
r.State, r.Why = askUnknown, "no request "+prefix+" was asked from your account in the last day"
return r
}
q := m[hit]
r.OK, r.Request, r.Text = true, hit[:8], describeRequest(q)
r.State, r.Outcome = askState(q, results)
return r
}
func askState(q stepupRequest, results map[string]localStopResult) (state, outcome string) {
switch q.State {
case "pending":
return askWaiting, ""
case "denied":
return askDenied, ""
case "approved":
res, ok := results[q.Approval]
switch {
case !ok:
return askApproved, ""
case strings.HasPrefix(res.Outcome, "not stopped"):
return askNotStopped, res.Outcome
default:
return askStopped, res.Outcome
}
}
return askUnknown, ""
}
// askView is what `stop` and `request` print, and their --json.
type askView struct {
Request string `json:"request,omitempty"`
State string `json:"state"`
Text string `json:"text,omitempty"` // what the owner reads
Outcome string `json:"outcome,omitempty"` // once carried out
AskedAs string `json:"asked_as,omitempty"`
Why string `json:"why,omitempty"` // refused or unknown
}
func (v askView) settled() bool { return v.State != askWaiting && v.State != askApproved }
func askViewOf(r agentAskReply, filing bool) askView {
v := askView{Request: r.Request, State: r.State, Text: r.Text, Outcome: r.Outcome, AskedAs: r.User, Why: r.Why}
switch {
case !r.OK && filing:
v.State = askRefused
case !r.OK && v.State == "":
v.State = askUnknown
case filing:
v.State = askWaiting
}
return v
}
// asksGoThroughSock: the running bastion is not this account's (installed
// for the whole machine), so asking and following an ask go over ask.sock,
// which names the asker from the kernel.
func asksGoThroughSock() bool { return !machineAuthorityHeld() && !uiDaemonListening() }
func askStatusFor(prefix string) (askView, error) {
if asksGoThroughSock() {
r, err := askViaSock(agentAskRequest{Op: "status", Request: prefix})
if err != nil {
return askView{}, fmt.Errorf("could not reach this machine's bastion (%v); `privatae-bastion status` "+
"says whether it is running", err)
}
return askViewOf(r, false), nil
}
t, err := resolveStepupTarget("", true)
if err != nil {
return askView{}, err
}
return askViewOf(askStatus(t.dir, prefix, procUser(os.Getpid()), true), false), nil
}
// askFollow polls until the ask settles or wait runs out.
func askFollow(v askView, wait time.Duration) (askView, error) {
deadline := time.Now().Add(wait)
for !v.settled() && v.Request != "" && time.Now().Before(deadline) {
time.Sleep(askPollEvery)
nv, err := askStatusFor(v.Request)
if err != nil {
return v, err
}
v = nv
}
return v, nil
}
// quietExit ends a command with a status and nothing more printed: the
// command has already said what happened.
type quietExit struct{ code int }
func (q quietExit) Error() string { return fmt.Sprintf("exit status %d", q.code) }
// Exit statuses an agent can branch on: 0 stopped, or asked and not waited
// for; 1 refused, denied, not stopped or unknown; 3 still waiting when --wait
// ran out.
const askExitWaiting = 3
func printAsk(w io.Writer, v askView, asJSON bool, waited bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
} else {
fmt.Fprintln(w, askSentence(v))
}
switch {
case v.State == askStopped:
return nil
case !v.settled() && !waited:
return nil
case !v.settled():
return quietExit{askExitWaiting}
}
return quietExit{1}
}
func askSentence(v askView) string {
switch v.State {
case askRefused:
return "not asked: " + v.Why
case askUnknown:
return v.Why
case askWaiting:
s := "asked"
if v.AskedAs != "" {
s += ", as " + v.AskedAs
}
return s + ": " + v.Text + "\nIt waits for the machine owner's approval (request " + v.Request +
"), on Waiting for you in the bastion console. Nothing is stopped until they approve it.\n" +
"Follow it with `privatae-bastion request " + v.Request + " --wait 5m`."
case askApproved:
return "request " + v.Request + ": approved by the machine's owner; the bastion is carrying it out."
case askStopped:
return "request " + v.Request + ": approved by the machine's owner and done. " +
strings.Replace(v.Outcome, "You stopped", "The bastion stopped", 1)
case askNotStopped:
return "request " + v.Request + ": approved by the machine's owner, but " + v.Outcome
case askDenied:
return "request " + v.Request + ": the machine's owner denied it. The same stop cannot be asked " +
"again for 24 hours."
}
return "request " + v.Request + ": " + v.State
}
// askFlags parses what `stop` and `request` share. It returns the one
// positional argument.
func askFlags(name string, args []string) (string, time.Duration, bool, error) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(io.Discard)
wait := fs.Duration("wait", 0, "")
asJSON := fs.Bool("json", false, "")
// The id or pid may come before the flags (`stop 1234 --wait 5m`), as
// agents write it.
var pos []string
rest := args
for {
if err := fs.Parse(rest); err != nil {
return "", 0, false, err
}
if fs.NArg() == 0 {
break
}
pos = append(pos, fs.Arg(0))
rest = fs.Args()[1:]
}
if len(pos) != 1 {
return "", 0, false, errors.New("give exactly one " + map[bool]string{true: "request id",
false: "pid or session name"}[name == "request"])
}
if *wait < 0 {
return "", 0, false, errors.New("--wait cannot be negative")
}
return pos[0], *wait, *asJSON, nil
}
// cmdRequest is `privatae-bastion request <id> [--wait D] [--json]`.
func cmdRequest(args []string) error {
id, wait, asJSON, err := askFlags("request", args)
if err != nil {
return fmt.Errorf("%v\nusage: %s", err, requestUsage)
}
v, err := askStatusFor(id)
if err != nil {
return err
}
if wait > 0 {
if v, err = askFollow(v, wait); err != nil {
return err
}
}
return printAsk(os.Stdout, v, asJSON, wait > 0)
}
const requestUsage = "privatae-bastion request <id> [--wait 5m] [--json]"
+179
View File
@@ -0,0 +1,179 @@
package main
// Audit rows to the system log, as well as to their files.
//
// Every audit file on this box (mgmt_audit.jsonl, pairing_audit.jsonl, and a
// pairing's audit.jsonl for terminals, root commands, store ops and op runs)
// lives here, on this machine. The host can fetch them over the tunnel but
// never edit them (mgmt_audit.go handleAuditFetch). That still leaves the host
// as the only central view of a fleet, and the host is the agent the audit is
// about. So each row is also written to the machine's own system log, where
// whatever already collects that log (journald or syslog forwarding, a log
// shipper, a SIEM) takes it to a store the host cannot write:
//
// Linux, macOS syslog, facility auth, tag privatae-bastion-audit
// (journald on a systemd machine: SYSLOG_IDENTIFIER=privatae-bastion-audit)
// Windows the Application event log, source PrivataeBastionAudit, event id 1
//
// The system-log line is the file's row with two fields added in front of it:
// `audit`, which file the row went to, and `pairing`, the pairing it belongs to
// ("" for a box-level file). The file stays the record: a system log that is
// missing or refuses a write costs that copy and nothing else, and is never a
// reason to refuse or slow the op being audited.
//
// A root command's text is the one thing the file has that the system-log line
// does not: it is whatever the host typed, a password on a command line
// included, and the system log is readable by more accounts than the 0600 file
// and forwarded off the machine by design. The line keeps command_sha256 and
// command_bytes, so a collector can still match it to the file and the
// transcript and see if either was changed.
//
// The write itself never runs on the audited op's goroutine. The syslog socket
// is a blocking write: a journald or syslog daemon that is stuck or slow would
// otherwise hold every audited op, and every other audit writer behind the
// lock, for as long as it stays stuck. Lines go into a bounded queue that one
// goroutine drains; a full queue drops the line and counts it, and the next
// line queued after the gap carries the count (auditDroppedField).
import (
"bytes"
"encoding/json"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
const (
auditSyslogTag = "privatae-bastion-audit"
auditEventSource = "PrivataeBastionAudit"
auditEventID = 1
auditFilePairing = "pairing_audit.jsonl"
auditFilePairingD = "audit.jsonl"
)
// auditSink takes one line for the system log. A variable so a test can watch
// the lines; the default queues it for the platform's writer and never blocks.
var auditSink = auditEnqueue
const (
// auditQueueMax lines wait for the system log; beyond that they are
// dropped and counted. A burst of audited ops far larger than any lane
// produces fits; a system log stuck for good holds no more than this.
auditQueueMax = 4096
// auditDroppedField is added to the first line queued after a drop: how
// many lines the system log lost just before it, so the gap shows where.
auditDroppedField = "system_log_dropped"
// auditFlushOnExit bounds how long a finished command waits for its rows
// to reach a slow system log.
auditFlushOnExit = 2 * time.Second
)
// auditQueue is the bounded queue in front of a system-log writer, drained by
// one goroutine so lines keep their order.
type auditQueue struct {
ch chan []byte
write func([]byte)
once sync.Once
dropped atomic.Int64
pending atomic.Int64 // queued or being written
}
func newAuditQueue(n int, write func([]byte)) *auditQueue {
return &auditQueue{ch: make(chan []byte, n), write: write}
}
// The box's one queue, in front of systemAuditWrite (auditlog_*.go).
var auditQ = newAuditQueue(auditQueueMax, func(l []byte) { systemAuditWrite(l) })
func auditEnqueue(line []byte) { auditQ.enqueue(line) }
// auditFlush waits, at most d, for queued lines to reach the system log. A
// command that audits and then exits (pair, claim-anchor, set-mode) calls it
// on the way out (main.go), so its rows are not lost with the process; a
// stuck system log costs that wait and nothing more.
func auditFlush(d time.Duration) { auditQ.flush(d) }
// enqueue hands one line to the drain goroutine, or drops it and counts it
// when the queue is full. It never waits on the writer.
func (q *auditQueue) enqueue(line []byte) {
q.once.Do(func() { go q.drain() })
n := q.dropped.Swap(0)
if n > 0 {
line = auditWithDropped(line, n)
}
q.pending.Add(1)
select {
case q.ch <- line:
default:
q.pending.Add(-1)
q.dropped.Add(n + 1)
}
}
func (q *auditQueue) drain() {
for line := range q.ch {
q.write(line)
q.pending.Add(-1)
}
}
func (q *auditQueue) flush(d time.Duration) {
deadline := time.Now().Add(d)
for q.pending.Load() > 0 && time.Now().Before(deadline) {
time.Sleep(5 * time.Millisecond)
}
}
// auditWithDropped adds the drop count to a line (every line here is the
// JSON object auditToSystemLog made).
func auditWithDropped(line []byte, n int64) []byte {
fields := map[string]any{}
dec := json.NewDecoder(bytes.NewReader(line))
dec.UseNumber()
if err := dec.Decode(&fields); err != nil {
return line
}
fields[auditDroppedField] = n
out, err := json.Marshal(fields)
if err != nil {
return line
}
return out
}
// auditToSystemLog sends one audit row, as it was written to its file, to the
// system log, with the file's name and the pairing in front.
func auditToSystemLog(file, pairing string, rec []byte) {
fields := map[string]any{}
dec := json.NewDecoder(bytes.NewReader(rec))
dec.UseNumber() // numbers go out exactly as the file has them
if err := dec.Decode(&fields); err != nil {
return
}
if cmd, ok := fields["command"].(string); ok {
// The text stays in the file; see the top of this file.
delete(fields, "command")
if _, ok := fields["command_bytes"]; !ok {
fields["command_bytes"] = len(cmd)
}
}
fields["audit"] = file
fields["pairing"] = pairing
line, err := json.Marshal(fields)
if err != nil {
return
}
auditSink(line)
}
// auditPairingOfDir names the pairing of a per-pairing audit file from its
// directory: the last path element, which is the pairing's host name
// (Config.dcnDir: dcn/<host_name>), except for a legacy pairing that kept the
// old shared directory, where it is that directory's name.
func auditPairingOfDir(dir string) string {
if dir == "" {
return ""
}
return filepath.Base(filepath.Clean(dir))
}
+34
View File
@@ -0,0 +1,34 @@
//go:build !windows
package main
import (
"log/syslog"
"sync"
)
var (
auditSyslogMu sync.Mutex
auditSyslog *syslog.Writer
)
// systemAuditWrite sends one line to syslog (journald on a systemd machine),
// facility auth, at info. Called only from auditQ's drain (auditlog.go), never on
// an audited op's own goroutine: the write blocks while the daemon is stuck. The connection is opened on first use and dropped
// after a failed write, so the next row tries again; a machine with no syslog
// socket costs the copy, never the row in the file.
func systemAuditWrite(line []byte) {
auditSyslogMu.Lock()
defer auditSyslogMu.Unlock()
if auditSyslog == nil {
w, err := syslog.New(syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
if err != nil {
return
}
auditSyslog = w
}
if err := auditSyslog.Info(string(line)); err != nil {
_ = auditSyslog.Close()
auditSyslog = nil
}
}
+95
View File
@@ -0,0 +1,95 @@
package main
import (
"encoding/json"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// The sink the binary ships with, read before auditlog_test.go's init swaps
// it out (package variables are set before any init runs).
var auditSinkShipped = auditSink
// TestAuditSinkIsTheQueue: the shipped sink is the queue, never the
// platform's writer called on the audited op's own goroutine.
func TestAuditSinkIsTheQueue(t *testing.T) {
if reflect.ValueOf(auditSinkShipped).Pointer() != reflect.ValueOf(auditEnqueue).Pointer() {
t.Fatal("the system-log sink must be auditEnqueue: a direct write blocks the audited op while syslog is stuck")
}
}
// TestAuditQueueNeverWaitsOnAStuckWriter: with a writer that never returns,
// far more rows than the queue holds are all taken at once; the ones past the
// bound are dropped and counted, and the count rides on the next line that
// gets into the queue.
func TestAuditQueueNeverWaitsOnAStuckWriter(t *testing.T) {
release := make(chan struct{})
var mu sync.Mutex
var got []string
q := newAuditQueue(8, func(l []byte) {
<-release
mu.Lock()
got = append(got, string(l))
mu.Unlock()
})
done := make(chan struct{})
go func() {
for i := 0; i < 1000; i++ {
q.enqueue([]byte(`{"n":1}`))
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit rows waited on a stuck system log")
}
close(release)
q.flush(5 * time.Second)
// Nothing lost without a count: every one of the 1001 rows is either a
// line written or counted on a line written after the gap.
q.enqueue([]byte(`{"n":2}`))
q.flush(5 * time.Second)
mu.Lock()
defer mu.Unlock()
var counted float64
for _, l := range got {
m := map[string]any{}
if err := json.Unmarshal([]byte(l), &m); err != nil {
t.Fatal(err)
}
if n, ok := m[auditDroppedField].(float64); ok {
counted += n
}
}
if len(got) > 10 || counted == 0 || float64(len(got))+counted != 1001 {
t.Fatalf("lines written %d (at most 10), dropped and counted %v: want 1001 in all", len(got), counted)
}
}
// TestAuditSystemLogOmitsTheRootCommandText: a root command's text stays in
// the 0600 file; the system-log line carries its digest and length.
func TestAuditSystemLogOmitsTheRootCommandText(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"event":"start","command":"PGPASSWORD=hunter2 psql","command_sha256":"ab"}`))
m := map[string]any{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatal(err)
}
if strings.Contains(line, "hunter2") || m["command"] != nil {
t.Fatalf("the command text reached the system log: %s", line)
}
if m["command_sha256"] != "ab" || m["command_bytes"] != float64(len("PGPASSWORD=hunter2 psql")) {
t.Fatalf("the line must keep the digest and the length: %s", line)
}
auditToSystemLog(auditFilePairingD, "p", []byte(`{"command":"x","command_bytes":70000}`))
if !strings.Contains(line, `"command_bytes":70000`) {
t.Fatalf("a row's own command_bytes (the full length) is kept: %s", line)
}
}
+69
View File
@@ -0,0 +1,69 @@
//go:build !windows
package main
import (
"log/syslog"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// TestAuditRowsDoNotWaitOnAStuckSyslogSocket: a real syslog writer on a
// datagram socket nobody reads blocks once the socket's queue is full, as
// /dev/log does while journald is stuck. Audit rows behind the queue all
// return at once; the same writer called inline (as 0.54.0 first did) does not.
func TestAuditRowsDoNotWaitOnAStuckSyslogSocket(t *testing.T) {
dir, err := os.MkdirTemp("", "al")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
sock := filepath.Join(dir, "s")
l, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: sock, Net: "unixgram"})
if err != nil {
t.Fatal(err)
}
// Closing the reading end is what lets the stuck writes below return
// (refused) so their goroutines end; the writer itself is never closed,
// since its Close would wait behind a write that is stuck.
t.Cleanup(func() { l.Close() })
w, err := syslog.Dial("unixgram", sock, syslog.LOG_AUTH|syslog.LOG_INFO, auditSyslogTag)
if err != nil {
t.Fatal(err)
}
write := func(line []byte) { _ = w.Info(string(line)) }
// The inline writer does block: without this the test proves nothing.
inline := make(chan struct{})
go func() {
for i := 0; i < 5000; i++ {
write([]byte(`{"op":"x"}`))
}
close(inline)
}()
select {
case <-inline:
t.Skip("this kernel never blocked a datagram writer; nothing to bound")
case <-time.After(2 * time.Second):
}
q := newAuditQueue(64, write)
done := make(chan struct{})
go func() {
for i := 0; i < 5000; i++ {
q.enqueue([]byte(`{"op":"y"}`))
}
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit rows waited on a stuck syslog socket")
}
if q.dropped.Load() == 0 {
t.Fatal("rows past the bound must be counted as dropped")
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// The test binary never writes to the real system log: the suite's audit
// rows would otherwise land in the journal of whatever machine runs it.
func init() { auditSink = func([]byte) {} }
// auditSinkCapture swaps the system-log sink for one that keeps the lines.
func auditSinkCapture(t *testing.T) func() []map[string]any {
t.Helper()
var mu sync.Mutex
var lines [][]byte
prev := auditSink
auditSink = func(line []byte) {
mu.Lock()
defer mu.Unlock()
lines = append(lines, append([]byte(nil), line...))
}
t.Cleanup(func() { auditSink = prev })
return func() []map[string]any {
mu.Lock()
defer mu.Unlock()
var out []map[string]any
for _, l := range lines {
m := map[string]any{}
if err := json.Unmarshal(l, &m); err != nil {
t.Fatalf("system-log line is not JSON: %s", l)
}
out = append(out, m)
}
return out
}
}
func auditFileRows(t *testing.T, path string) []map[string]any {
t.Helper()
fh, err := os.Open(path)
if err != nil {
t.Fatalf("audit file %s: %v", path, err)
}
defer fh.Close()
var out []map[string]any
sc := bufio.NewScanner(fh)
for sc.Scan() {
m := map[string]any{}
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
t.Fatalf("audit row is not JSON: %s", sc.Text())
}
out = append(out, m)
}
return out
}
// auditSameRow: the system-log line is the file's row, every field equal,
// with `audit` and `pairing` added.
func auditSameRow(t *testing.T, what string, file, logged map[string]any, wantFile, wantPairing string) {
t.Helper()
if logged["audit"] != wantFile || logged["pairing"] != wantPairing {
t.Fatalf("%s: system-log line names audit=%v pairing=%v, want %q %q", what, logged["audit"], logged["pairing"], wantFile, wantPairing)
}
for k, v := range file {
if lv, ok := logged[k]; !ok || !jsonEq(lv, v) {
t.Fatalf("%s: field %q is %v in the file and %v in the system log", what, k, v, logged[k])
}
}
if len(logged) != len(file)+2 {
t.Fatalf("%s: system-log line has %d fields, the file row %d (+2 expected)", what, len(logged), len(file))
}
}
func jsonEq(a, b any) bool {
ja, _ := json.Marshal(a)
jb, _ := json.Marshal(b)
return string(ja) == string(jb)
}
// TestAuditRowsReachTheSystemLog: every audit writer on this box sends the row
// it writes to its file to the system log as well, unchanged, naming the file
// and the pairing: the management lane (mgmt_audit.jsonl), pairing changes
// (pairing_audit.jsonl), and a pairing's audit.jsonl for store ops and op runs,
// terminals and root commands.
func TestAuditRowsReachTheSystemLog(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
got := auditSinkCapture(t)
cfgDir, err := configDir()
if err != nil {
t.Fatal(err)
}
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
mgmtAudit("acme.host.example", "uptime", "observe", "allowed", "", 0, []string{"a"}, "")
pairingAudit("pairing_add", "other.host.example", "ok")
dcnAudit(pairDir, "dcn_read", 7, "/srv/notes.txt", "allowed")
sessionAudit(pairDir, "session", "s-1", "ops", "start", 0, "opened")
rootExecAudit(pairDir, map[string]any{"session_id": "s-2", "event": "stop", "exit": 0})
lines := got()
if len(lines) != 5 {
t.Fatalf("got %d system-log lines, want 5: %v", len(lines), lines)
}
mgmt := auditFileRows(t, filepath.Join(cfgDir, mgmtAuditFile))
pair := auditFileRows(t, filepath.Join(cfgDir, auditFilePairing))
per := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD))
if len(mgmt) != 1 || len(pair) != 1 || len(per) != 3 {
t.Fatalf("file rows: mgmt %d, pairing %d, per-pairing %d; want 1, 1, 3", len(mgmt), len(pair), len(per))
}
auditSameRow(t, "management lane", mgmt[0], lines[0], mgmtAuditFile, "acme.host.example")
auditSameRow(t, "pairing change", pair[0], lines[1], auditFilePairing, "")
auditSameRow(t, "store op", per[0], lines[2], auditFilePairingD, "acme.host.example")
auditSameRow(t, "terminal", per[1], lines[3], auditFilePairingD, "acme.host.example")
auditSameRow(t, "root command", per[2], lines[4], auditFilePairingD, "acme.host.example")
}
// TestAuditFileRowStandsWithoutASystemLog: a system log that takes nothing
// costs the copy only; the row is still written to its file.
func TestAuditFileRowStandsWithoutASystemLog(t *testing.T) {
prev := auditSink
auditSink = func([]byte) {}
t.Cleanup(func() { auditSink = prev })
pairDir := filepath.Join(t.TempDir(), "acme.host.example")
if err := os.MkdirAll(pairDir, 0o700); err != nil {
t.Fatal(err)
}
dcnAudit(pairDir, "dcn_read", 1, "/x", "allowed")
if rows := auditFileRows(t, filepath.Join(pairDir, auditFilePairingD)); len(rows) != 1 {
t.Fatalf("file rows = %d, want 1", len(rows))
}
}
// TestAuditSystemLogKeepsNumbersExact: a large id goes to the system log as
// the digits the file has, not as a float.
func TestAuditSystemLogKeepsNumbersExact(t *testing.T) {
var line string
prev := auditSink
auditSink = func(l []byte) { line = string(l) }
t.Cleanup(func() { auditSink = prev })
auditToSystemLog(auditFilePairingD, "p", []byte(`{"store_id":9007199254740993}`))
if !json.Valid([]byte(line)) || !strings.Contains(line, `"store_id":9007199254740993`) {
t.Fatalf("system-log line %s does not carry the id exactly", line)
}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package main
import (
"strings"
"sync"
"golang.org/x/sys/windows/svc/eventlog"
)
var (
auditEventMu sync.Mutex
auditEventLog *eventlog.Log
)
// systemAuditWrite sends one line to the Application event log under the
// source PrivataeBastionAudit, event id 1, at information level. The source
// is registered when the system service is installed
// (registerAuditEventSource); before that, or if the log refuses, the copy is
// lost and the row in the file is not. Called only from auditQ's drain
// (auditlog.go): the event log service can be slow to answer.
func systemAuditWrite(line []byte) {
auditEventMu.Lock()
defer auditEventMu.Unlock()
if auditEventLog == nil {
l, err := eventlog.Open(auditEventSource)
if err != nil {
return
}
auditEventLog = l
}
if err := auditEventLog.Info(auditEventID, string(line)); err != nil {
_ = auditEventLog.Close()
auditEventLog = nil
}
}
// registerAuditEventSource registers PrivataeBastionAudit as an Application
// event log source, so the rows show with their own source name. Already
// registered is success.
func registerAuditEventSource() error {
err := eventlog.InstallAsEventCreate(auditEventSource, eventlog.Info|eventlog.Warning|eventlog.Error)
if err != nil && strings.Contains(err.Error(), "already exists") {
return nil
}
return err
}
// removeAuditEventSource unregisters the source when the service is removed.
// Events already written stay in the log.
func removeAuditEventSource() {
_ = eventlog.Remove(auditEventSource)
}
+89 -26
View File
@@ -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 {
-43
View File
@@ -16,7 +16,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -64,48 +63,6 @@ func drive(t *testing.T, backupRoot string, req backupFetchReq) (string, string,
} }
} }
func awaitJob(t *testing.T, backupRoot, id string) backupJob {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
out, _, code := drive(t, backupRoot, backupFetchReq{Op: "status", JobID: id})
if code != 0 {
t.Fatalf("status exited %d", code)
}
var j backupJob
if err := json.Unmarshal([]byte(out), &j); err != nil {
t.Fatalf("status is not JSON: %v (%s)", err, out)
}
if j.State != "running" {
return j
}
time.Sleep(25 * time.Millisecond)
}
t.Fatal("job never left running")
return backupJob{}
}
func startAndAwait(t *testing.T, root string, req backupFetchReq) backupJob {
t.Helper()
out, errOut, code := drive(t, root, req)
if code != 0 {
t.Fatalf("start exited %d: %s", code, errOut)
}
var started struct {
ID string `json:"id"`
State string `json:"state"`
}
if err := json.Unmarshal([]byte(out), &started); err != nil {
t.Fatalf("start reply is not JSON: %v (%s)", err, out)
}
if started.State == "done" { // already-present short circuit
var j backupJob
_ = json.Unmarshal([]byte(out), &j)
return j
}
return awaitJob(t, root, started.ID)
}
func artifactServer(t *testing.T, body []byte) (*httptest.Server, string) { func artifactServer(t *testing.T, body []byte) (*httptest.Server, string) {
t.Helper() t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+71 -17
View File
@@ -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 {
@@ -655,6 +706,9 @@ func cmdBackupDecide(host, prefix string, approve bool) error {
} }
} }
r, err = resealApprove(dir, who, id, code) r, err = resealApprove(dir, who, id, code)
if err != nil {
return err
}
} else { } else {
r, err = resealRefuse(dir, resealWho{Name: cfg.HostName}, id, "") r, err = resealRefuse(dir, resealWho{Name: cfg.HostName}, id, "")
} }
+34
View File
@@ -10,6 +10,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -149,3 +150,36 @@ func TestHostNumberComesFromTheTunnelAddress(t *testing.T) {
} }
} }
} }
// With a second factor enrolled, a wrong code is refused by resealApprove,
// and cmdBackupDecide returns that refusal. It once checked the outer err
// while the approval's error went to the one its `who, err :=` declared, so
// a wrong code fell through to r.State on a nil request and panicked.
func TestResealDecideWithAWrongCodeReturnsTheRefusal(t *testing.T) {
dirs := withFixturePairings(t, map[string]int64{fixtureHost: fixtureEntity})
fx := newBackupFixture(t, "")
fx.installAsBastionKey(t)
own, _ := age.GenerateX25519Identity()
id := resealStartAs(t, fixtureHost, dirs[fixtureHost], resealReq{Op: "start", Sidecar: string(fx.sidecar(t)),
To: own.Recipient().String(), Stamp: fixtureStamp, HostLabel: "B"})
if err := stepupSave(dirs[fixtureHost], &stepupFile{SecretB32: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ",
EnrolledAt: "2026-08-29T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
_, _ = w.WriteString("not-a-code\n")
_ = w.Close()
realStdin := os.Stdin
os.Stdin = r
t.Cleanup(func() { os.Stdin = realStdin; _ = r.Close() })
if err := cmdBackupDecide(fixtureHost, id, true); err == nil {
t.Fatal("a wrong second-factor code approved the request")
}
if st := resealStatusAs(t, fixtureHost, dirs[fixtureHost], id); st["state"] == "done" || st["sidecar"] != "" {
t.Fatalf("a wrong code handed the backup key over: %v", st)
}
}
+21 -20
View File
@@ -768,42 +768,43 @@ func cmdObserveBaseline(force bool) error {
return nil return nil
} }
// cmdObserveBaselineShow prints what this machine currently calls normal. // cmdObserveBaselineShow prints what this machine currently calls normal
func cmdObserveBaselineShow() error { // (`baseline show`, machine_cli.go).
f, ok, err := baselineLoad() func cmdObserveBaselineShow() error { return cmdBaseline([]string{"show"}) }
if errors.Is(err, os.ErrNotExist) {
return errors.New("this machine has no baseline yet — `privatae-bastion observe:baseline` " + // printBaseline is `baseline show` in words.
func printBaseline(w io.Writer, b baselineRead) error {
if !b.Has {
return errors.New("this machine has no baseline yet — `privatae-bastion baseline record` " +
"takes one (as an administrator)") "takes one (as an administrator)")
} }
if err != nil { f := b.Baseline
return err fmt.Fprintf(w, "baseline taken %s by bastion %s, mode %s\n", f.TakenAt, f.BastionVersion, f.Mode)
} if b.Integrity != "" {
fmt.Printf("baseline taken %s by bastion %s, mode %s\n", f.TakenAt, f.BastionVersion, f.Mode) fmt.Fprintf(w, "WARNING: %s\n", b.Integrity)
if note := baselineIntegrityNote(f, ok); note != "" {
fmt.Printf("WARNING: %s\n", note)
} }
// Provenance, judged the way the host judges it. Three lines a reader can // Provenance, judged the way the host judges it. Three lines a reader can
// get: verified (and by whom), signed-but-failing (and which test failed), // get: verified (and by whom), signed-but-failing (and which test failed),
// or unsigned (and the reason the file itself records). // or unsigned (and the reason the file itself records).
switch { switch {
case f.Attestation != nil: case f.Attestation != nil:
if n := baselineAttestationNote(f); n != "" { if b.Attestation != "" {
fmt.Printf("WARNING: attestation: %s\n", n) fmt.Fprintf(w, "WARNING: attestation: %s\n", b.Attestation)
} else { } else {
fmt.Printf("signed by %s's pairing key; verified against its CA and this machine's fingerprint\n", fmt.Fprintf(w, "signed by %s's pairing key; verified against its CA and this machine's fingerprint\n",
f.Attestation.SignedBy) f.Attestation.SignedBy)
} }
case f.UnsignedReason != "": case f.UnsignedReason != "":
fmt.Printf("unsigned: %s\n", f.UnsignedReason) fmt.Fprintf(w, "unsigned: %s\n", f.UnsignedReason)
default: default:
fmt.Printf("unsigned: taken by a bastion that did not sign accountings\n") fmt.Fprintf(w, "unsigned: taken by a bastion that did not sign accountings\n")
} }
fmt.Printf("%d entries", len(f.Entries)) fmt.Fprintf(w, "%d entries", len(f.Entries))
if f.Unreadable > 0 { if f.Unreadable > 0 {
fmt.Printf(", %d without a readable executable (a baseline taken without "+ fmt.Fprintf(w, ", %d without a readable executable (a baseline taken without "+
"administrator rights sees less)", f.Unreadable) "administrator rights sees less)", f.Unreadable)
} }
fmt.Println() fmt.Fprintln(w)
for _, e := range f.Entries { for _, e := range f.Entries {
who := e.Signature who := e.Signature
if who == "" { if who == "" {
@@ -821,7 +822,7 @@ func cmdObserveBaselineShow() error {
// against. Found by a test written for something else entirely. // against. Found by a test written for something else entirely.
d = shortDigest(d) d = shortDigest(d)
} }
fmt.Printf(" %s %-20s %-10s %s\n", e.FP, who, e.User, d) fmt.Fprintf(w, " %s %-20s %-10s %s\n", e.FP, who, e.User, d)
} }
return nil return nil
} }
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"sync"
"time"
)
// A beat out of turn, for every pairing on this box.
//
// Each running pairing beats its own Host on a 60-second ticker, and every
// beat carries the box's pairings as `peers` with the state of each (peers.go).
// That is how every Host on the machine learns that a pairing went active or was
// revoked. A pairing_grant_sync or pairing_grant_drop changes that state on disk
// at once, but nothing beat: every Host kept the old state for up to a minute.
// So a pairing approved a second after a beat still read as pending for the
// next minute, and a Host asking to connect to it in that time was refused. The
// anchor Host can read its own confirmed grant, but no other Host on the
// machine has one, and a revoke reached the other Hosts no sooner.
//
// So a change to a pairing's state asks every running pairing's beat loop to beat
// now. The signal carries nothing: the beat reads the pairings from disk as it
// always does, so it reports the state the change wrote. One pending signal per
// loop is kept and the rest are dropped, so a burst of changes is one extra beat
// per Host, never one per change.
var beatNow = struct {
sync.Mutex
subs map[chan struct{}]struct{}
}{subs: map[chan struct{}]struct{}{}}
// subscribeBeatNow registers one beat loop. The channel holds at most one
// pending signal; stop unregisters it.
func subscribeBeatNow() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
beatNow.Lock()
beatNow.subs[ch] = struct{}{}
beatNow.Unlock()
return ch, func() {
beatNow.Lock()
delete(beatNow.subs, ch)
beatNow.Unlock()
}
}
// kickBeats asks every registered beat loop to beat now. It never blocks: a
// loop that already has a signal pending, or is mid-beat, beats once more.
func kickBeats() {
beatNow.Lock()
defer beatNow.Unlock()
for ch := range beatNow.subs {
select {
case ch <- struct{}{}:
default:
}
}
}
// runBeatLoop beats every `every`, and also whenever kickBeats asks. beforeTick
// runs before a ticked beat only (the tunnel's path failover, which belongs to
// the clock, not to a pairing change); it may be nil. It returns when ctx ends.
func runBeatLoop(ctx context.Context, every time.Duration, beforeTick, beat func()) {
kick, stop := subscribeBeatNow()
defer stop()
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if beforeTick != nil {
beforeTick()
}
beat()
case <-kick:
beat()
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build !windows
// Tagged !windows like anchor_test.go, whose box helpers (setupBox, driveOp,
// signTerms) it uses; beatnow_test.go runs the signal and the loop everywhere.
package main
import (
"crypto/ed25519"
"os"
"testing"
"time"
)
// optest v0.16.145: an approval left every Host on the machine reading
// "pending" until the next 60-second beat. A grant that flips a pairing active,
// and a revoke, ask every pairing to beat now; a refused grant, and a re-sync
// of a pairing that is already active, change no state and ask nothing.
func TestPairingStateChangeBeatsEveryHost(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
hn, _ := os.Hostname()
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
kick, stop := subscribeBeatNow()
defer stop()
_, otherPriv, _ := ed25519.GenerateKey(nil)
terms, sig := signTerms(t, otherPriv, grantTerms(tMember, []string{"observe"}, future))
if _, _, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 126 {
t.Fatalf("a grant under another key must be refused (126): %d", code)
}
if signalled(kick, 100*time.Millisecond) {
t.Fatal("a refused grant asked for a beat")
}
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("a good grant must be accepted: exit %d, %s", code, errOut)
}
if loadState(t, tMember) != pairingActive {
t.Fatal("grant_sync must flip the pairing active")
}
if !signalled(kick, time.Second) {
t.Fatal("a grant that flipped a pairing active did not ask every pairing to beat now")
}
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{"observe", "dcn"}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("a re-sync of an active pairing must be accepted: exit %d, %s", code, errOut)
}
if signalled(kick, 100*time.Millisecond) {
t.Fatal("a re-sync that changed no pairing state asked for a beat")
}
terms, sig = signTerms(t, priv, map[string]any{"anchor": tAnchor, "issued_at": time.Now().UTC().Format(time.RFC3339),
"kind": "pairing_revoke", "server": hn, "subject": tMember})
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_drop", []string{tMember, terms, sig}); code != 0 {
t.Fatalf("a good revoke must be accepted: %d %s", code, errOut)
}
if loadState(t, tMember) != pairingRevoked {
t.Fatal("grant_drop must mark the pairing revoked")
}
if !signalled(kick, time.Second) {
t.Fatal("a revoke did not ask every pairing to beat now")
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"context"
"sync/atomic"
"testing"
"time"
)
// signalled reports whether ch carries a signal within d.
func signalled(ch <-chan struct{}, d time.Duration) bool {
select {
case <-ch:
return true
case <-time.After(d):
return false
}
}
// One signal reaches every registered loop, a burst is one pending signal per
// loop, and a loop that stopped is not signalled.
func TestKickBeatsReachesEveryLoopOnce(t *testing.T) {
a, stopA := subscribeBeatNow()
defer stopA()
b, stopB := subscribeBeatNow()
gone, stopGone := subscribeBeatNow()
stopGone()
kickBeats()
kickBeats()
kickBeats()
stopB()
for name, ch := range map[string]<-chan struct{}{"a": a, "b": b} {
if !signalled(ch, time.Second) {
t.Fatalf("loop %s was not asked to beat", name)
}
if signalled(ch, 50*time.Millisecond) {
t.Fatalf("loop %s holds more than one pending beat after a burst", name)
}
}
if signalled(gone, 50*time.Millisecond) {
t.Fatal("a loop that unregistered was still signalled")
}
}
// The beat loop beats on a kick without waiting for its tick, and the
// tick-only step (path failover) does not run for it.
func TestRunBeatLoopBeatsOnKick(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
var beats, ticks atomic.Int32
done := make(chan struct{})
go func() {
defer close(done)
runBeatLoop(ctx, time.Hour, func() { ticks.Add(1) }, func() { beats.Add(1) })
}()
// The loop registers inside its goroutine; keep asking until it hears.
deadline := time.Now().Add(3 * time.Second)
for beats.Load() == 0 && time.Now().Before(deadline) {
kickBeats()
time.Sleep(10 * time.Millisecond)
}
if beats.Load() == 0 {
t.Fatal("a kicked beat loop did not beat before its 1-hour tick")
}
if ticks.Load() != 0 {
t.Fatal("a kicked beat ran the tick-only step")
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the beat loop did not return when its context ended")
}
beatNow.Lock()
left := len(beatNow.subs)
beatNow.Unlock()
if left != 0 {
t.Fatalf("a returned beat loop stayed registered (%d left)", left)
}
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"os"
"path/filepath"
"regexp"
"testing"
)
// The Go toolchain image is pinned by digest in deploy/build.sh, which builds
// releases, and again in each forge's CI, so CI tests with the compiler the
// release is built with. GitHub's workflow checks that the three agree, but
// only GitHub runs that file, and only once a commit is published there. This
// test runs wherever go test runs, so a digest changed in one place and not the
// others fails before the change is merged.
var (
releaseImageRe = regexp.MustCompile(`(?m)^GO_IMAGE="(golang@sha256:[0-9a-f]{64})"$`)
giteaImageRe = regexp.MustCompile(`(?m)^ GO_IMAGE: (\S+)$`)
githubImageRe = regexp.MustCompile(`(?m)^ image: (\S+)$`)
)
func TestCIUsesTheReleaseGoImage(t *testing.T) {
read := func(rel string) string {
data, err := os.ReadFile(filepath.Join("..", rel))
if err != nil {
t.Fatal(err)
}
return string(data)
}
release := releaseImageRe.FindAllStringSubmatch(read("deploy/build.sh"), -1)
if len(release) != 1 {
t.Fatalf(`deploy/build.sh: want one GO_IMAGE="golang@sha256:<64 hex>" line, found %d`, len(release))
}
want := release[0][1]
for _, c := range []struct {
rel string
re *regexp.Regexp
n int
}{
{".gitea/workflows/ci.yml", giteaImageRe, 1},
{".github/workflows/ci.yml", githubImageRe, 3},
} {
got := c.re.FindAllStringSubmatch(read(c.rel), -1)
if len(got) != c.n {
t.Errorf("%s: want %d image line(s), found %d", c.rel, c.n, len(got))
}
for _, g := range got {
if g[1] != want {
t.Errorf("%s names %s; deploy/build.sh builds releases with %s", c.rel, g[1], want)
}
}
}
}
+426 -21
View File
@@ -11,6 +11,7 @@ package main
// runs) is driven in claims_unix_test.go and beside each lane's own tests. // runs) is driven in claims_unix_test.go and beside each lane's own tests.
import ( import (
"fmt"
"go/ast" "go/ast"
"go/parser" "go/parser"
"go/token" "go/token"
@@ -49,6 +50,18 @@ func parseSources(t *testing.T) *srcPkg {
if err != nil { if err != nil {
t.Fatalf("parse %s: %v", n, err) t.Fatalf("parse %s: %v", n, err)
} }
p.addFile(n, f)
}
if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files))
}
return p
}
// addFile registers one parsed file and its functions. A test that proves a
// check can fail adds a synthetic file through here, so it is indexed exactly
// as a real one would be.
func (p *srcPkg) addFile(n string, f *ast.File) {
p.files[n] = f p.files[n] = f
for _, d := range f.Decls { for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl) fd, ok := d.(*ast.FuncDecl)
@@ -67,10 +80,22 @@ func parseSources(t *testing.T) *srcPkg {
} }
} }
} }
if len(p.files) < 50 {
t.Fatalf("parsed only %d source files — is the test running in the package directory?", len(p.files)) // funcKeysIn is the p.funcs key of every function declared in file n.
func (p *srcPkg) funcKeysIn(n string) []string {
var out []string
for _, d := range p.files[n].Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok {
continue
} }
return p key := funcKey(fd)
if p.funcs[key] != fd {
key = key + "@" + n
}
out = append(out, key)
}
return out
} }
// funcKey is "name" for a function and "Type.name" for a method. // funcKey is "name" for a function and "Type.name" for a method.
@@ -352,6 +377,12 @@ func TestEveryListenerThisPackageOpensIsInTheTable(t *testing.T) {
"ops_action_linux.go:opActionProbeUnit:net.Listen": {"tcp", 1, "the action-op probe, 127.0.0.1:0, open while one probe unit dials it"}, "ops_action_linux.go:opActionProbeUnit:net.Listen": {"tcp", 1, "the action-op probe, 127.0.0.1:0, open while one probe unit dials it"},
"coders_linux.go:coderListenUnix:net.Listen": {"unix", 1, "a coder run's egress.sock, a unix socket file in its run dir, not a network port"}, "coders_linux.go:coderListenUnix:net.Listen": {"unix", 1, "a coder run's egress.sock, a unix socket file in its run dir, not a network port"},
"coders_other.go:coderListenUnix:net.Listen": {"unix", 1, "the same, off Linux (coder mode refuses there)"}, "coders_other.go:coderListenUnix:net.Listen": {"unix", 1, "the same, off Linux (coder mode refuses there)"},
// The one listener on every address, by design: the door a Host that
// this server can't reach connects to (hostdoor.go). Bound only while
// such a pairing is served or a pairing window is open; it keeps only
// connections whose certificate verifies under a served pairing's
// pinned CA, and forwards nothing (TestTheDoorRefusesAnythingButItsPinnedHost).
"hostdoor.go:listenDoor:lc.Listen": {"tcp", 1, "the door your Host connects to, every address, TCP 8812 (docs/host-connects.md)"},
// Beside a Windows system service: the tray's own approvals window, // Beside a Windows system service: the tray's own approvals window,
// and the service's approvals pipe (approvalpipe.go), which takes no // and the service's approvals pipe (approvalpipe.go), which takes no
// remote client (PIPE_REJECT_REMOTE_CLIENTS) and, by its ACL, only // remote client (PIPE_REJECT_REMOTE_CLIENTS) and, by its ACL, only
@@ -488,10 +519,30 @@ func TestListenAddressesAreOnlyEverAllocatedOnLoopback(t *testing.T) {
} }
} }
// No MCP endpoint. Every HTTP route the binary registers, on any listener, // No MCP endpoint: the bastion never speaks the protocol. Every HTTP route
// is in this table; none of them, and no op name on the management lane, // the binary registers, on any listener, is in this table; none of them, and
// names MCP; no string in the code addresses an /mcp path or speaks JSON-RPC, // no op name on the management lane, names MCP; no string in the code is a
// the protocol MCP is carried on; and no dependency is an MCP library. // URL or route with an /mcp path, or speaks JSON-RPC, the protocol MCP is
// carried on; and no dependency is an MCP library.
//
// WHAT CHANGED, AND WHY THE GUARANTEE DID NOT. This test used to refuse any
// string containing "/mcp" at all. The connection-config reader
// (mcpconfig.go) has to name the files agent clients declare their tool
// servers in — .cursor/mcp.json, claude_desktop_config.json, an mcpServers
// key — and READING A FILE SPEAKS NO PROTOCOL. So the check now targets the
// protocol instead of the word (mcpReaderViolations):
//
// - no literal anywhere is a URL or route with an mcp path segment;
// - the config basenames and keys may appear in mcpconfig.go and nowhere
// else, so a second file that starts reading (or writing) them is a
// change a reviewer sees here;
// - nothing declared in a config-reader file (mcpconfig*.go) imports, or
// can reach through this package's call graph, net.Dial or net.Listen,
// net/http, os/exec, TLS dialling or the websocket client. The reader is
// handed its process list by agents.go; it starts and dials nothing.
//
// TestMCPReaderCheckCatchesADialAndAStrayBasename proves each of those can
// fail.
func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) { func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
p := parseSources(t) p := parseSources(t)
// A route served by more than one surface names each file, "a.go|b.go": // A route served by more than one surface names each file, "a.go|b.go":
@@ -508,6 +559,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
"/fragments/context": "uicontext.go", "/context/scan": "uicontext.go", "/fragments/context": "uicontext.go", "/context/scan": "uicontext.go",
"/context/stop": "uicontext.go", "/context/ask": "uicontext.go", "/context/stop": "uicontext.go", "/context/ask": "uicontext.go",
"/policy": "uipolicy.go", "/fragments/policy": "uipolicy.go", "/policy": "uipolicy.go", "/fragments/policy": "uipolicy.go",
"/fragments/home": "uihome.go", "/agent": "uihome.go", "/machine": "uihome.go",
"/agent/stop": "uihome.go", "/agent/mark": "uihome.go", "/activity": "uihome.go",
"/fragments/machine/schedules": "uimachine.go", "/fragments/machine/tools": "uimachine.go",
"/fragments/machine/baseline": "uimachine.go", "/machine/compare": "uimachine.go",
"/machine/baseline": "uimachine.go",
"/machine/report": "uimachine.go",
"/setup/host/disconnect": "uisetup.go", "/setup/host/release": "uisetup.go",
"/approvals/machine/approve": "uihome.go", "/approvals/machine/deny": "uihome.go",
"/approvals": "uidevice.go|uitray.go", "/fragments/approvals": "uidevice.go|uitray.go", "/approvals": "uidevice.go|uitray.go", "/fragments/approvals": "uidevice.go|uitray.go",
"/fragments/approvals-news": "uidevice.go|uitray.go", "/device/approve": "uidevice.go", "/fragments/approvals-news": "uidevice.go|uitray.go", "/device/approve": "uidevice.go",
"/device/deny": "uidevice.go", "/device/approve-shown": "uidevice.go", "/device/deny": "uidevice.go", "/device/approve-shown": "uidevice.go",
@@ -547,14 +606,10 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
} }
} }
for _, v := range mcpReaderViolations(p) {
t.Error(v)
}
for name, f := range p.files { for name, f := range p.files {
for _, s := range stringLits(f) {
l := strings.ToLower(s)
if strings.Contains(l, "/mcp") || strings.Contains(l, "jsonrpc") ||
strings.Contains(l, "modelcontextprotocol") || strings.Contains(l, "tools/call") {
t.Errorf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s)
}
}
for _, imp := range f.Imports { for _, imp := range f.Imports {
if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") { if strings.Contains(strings.ToLower(imp.Path.Value), "mcp") {
t.Errorf("%s imports %s", name, imp.Path.Value) t.Errorf("%s imports %s", name, imp.Path.Value)
@@ -575,6 +630,346 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
} }
} }
// mcpConfigNames are the file basenames and keys agent clients declare tool
// servers under. Only mcpconfig.go may name them. settings.json and
// config.toml are left out on purpose: many programs use those names (the
// coder lane reads the NVIDIA runtime's config.toml), so they say nothing
// about tool servers; the keys beside them do.
var mcpConfigNames = []string{"claude_desktop_config", "mcp.json", "mcp_config.json", ".claude.json",
"mcpservers", "context_servers", "mcp_servers",
// The clients' own directories, which name a settings.json or
// config.toml as surely as the basename would.
".cursor", ".codex", ".zed", ".codeium", ".vscode"}
// mcpURLOrRoute reports whether a string literal is a URL or route that
// addresses an mcp path. Outside a URL, every "/mcp" counts (/mcp, /mcp/x,
// /mcp-proxy) except a client's config FILE name: /mcp.json and
// /mcp_config.json.
func mcpURLOrRoute(l string) bool {
l = strings.ToLower(l)
if i := strings.Index(l, "://"); i >= 0 {
rest := l[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
return strings.Contains(rest[j:], "/mcp")
}
return false
}
for i := 0; ; {
j := strings.Index(l[i:], "/mcp")
if j < 0 {
return false
}
rest := l[i+j+len("/mcp"):]
if !strings.HasPrefix(rest, ".json") && !strings.HasPrefix(rest, "_config.json") {
return true
}
i += j + len("/mcp")
}
}
// mcpReaderSink: a package-level function or type that opens a connection,
// resolves a name, or starts a process — named as a call or as a value.
func mcpReaderSink(recv, sel string) bool {
switch {
case recv == "net" && (strings.HasPrefix(sel, "Dial") || strings.HasPrefix(sel, "Listen") ||
strings.HasPrefix(sel, "Lookup") || strings.HasPrefix(sel, "Resolve") || sel == "Dialer" || sel == "Resolver"):
return true
case strings.Contains(recv, "net.Dialer") || strings.Contains(recv, "net.Resolver"):
return true
case recv == "http" || recv == "exec" || recv == "websocket" || recv == "rpc" || recv == "jsonrpc":
return true
case recv == "tls" && strings.HasPrefix(sel, "Dial"):
return true
case (recv == "os" || recv == "syscall" || recv == "unix" || recv == "windows") &&
(sel == "StartProcess" || sel == "ForkExec" || sel == "Exec" || strings.HasPrefix(sel, "CreateProcess") ||
strings.HasPrefix(sel, "ShellExecute") || sel == "WinExec"):
return true
case (recv == "syscall" || recv == "unix" || recv == "windows") && (sel == "Socket" || sel == "Connect" || sel == "Bind" ||
sel == "Listen" || sel == "Sendto" || sel == "Sendmsg" || sel == "SendmsgN" || strings.HasPrefix(sel, "WSA") ||
sel == "ConnectEx" || sel == "GetAddrInfoW" || sel == "GetHostByName" || strings.HasPrefix(sel, "DnsQuery")):
return true
case (recv == "syscall" || recv == "windows") && (strings.HasPrefix(sel, "LoadLibrary") || sel == "LoadDLL" ||
sel == "MustLoadDLL" || sel == "NewLazyDLL" || sel == "NewLazySystemDLL"):
// A DLL loaded by name reaches any function in it, past every check here.
return true
case recv == "registry" && sel == "OpenRemoteKey":
return true
}
return false
}
// mcpReaderMethodSink: a METHOD call that connects, resolves or starts,
// whatever the receiver is called — d.Dial on a net.Dialer variable,
// c.Get on an http.Client, cmd.Run on an exec.Cmd. Over-approximate on
// purpose: it only applies to code the reader can reach.
var mcpReaderMethodSink = map[string]bool{"Dial": true, "DialContext": true, "DialTCP": true, "DialUDP": true,
"DialUnix": true, "DialIP": true, "Listen": true, "ListenPacket": true, "LookupHost": true, "LookupIP": true,
"LookupIPAddr": true, "LookupAddr": true, "LookupNetIP": true, "Do": true, "Get": true, "Post": true,
"PostForm": true, "Head": true, "RoundTrip": true, "Start": true, "Run": true, "Output": true,
"CombinedOutput": true, "Connect": true}
// mcpReaderAllowedImports is every package a config-reader file may import:
// parsing, strings, and opening files. Anything else is a change a reviewer
// has to see here first. x/sys/windows and its registry package are the
// Windows open (CreateFile and the handle's own type, attributes and owner)
// and the profile list it checks owners against; each can also start a
// process, open a socket, load a DLL or open a remote registry, and those
// calls are sinks in mcpReaderSink like their unix equivalents.
var mcpReaderAllowedImports = map[string]bool{"bytes": true, "encoding/json": true, "errors": true, "io": true,
"io/fs": true, "net": true, "net/url": true, "os": true, "path/filepath": true, "regexp": true,
"runtime": true, "sort": true, "strconv": true, "strings": true, "syscall": true, "unicode": true,
"unicode/utf8": true, "golang.org/x/sys/unix": true, "golang.org/x/sys/windows": true,
"golang.org/x/sys/windows/registry": true}
// mcpValueSink: a sink named as a VALUE rather than called (f := net.Dial).
// The http, exec and websocket packages also export constants and types
// (http.StatusOK, websocket.BinaryMessage), which name no connection; only
// their functions count here.
func mcpValueSink(recv, sel string) bool {
switch recv {
case "http", "exec", "websocket", "rpc", "jsonrpc":
switch sel {
case "Get", "Post", "Head", "PostForm", "Command", "CommandContext", "Dial", "DialContext",
"DialHTTP", "DefaultClient", "DefaultDialer", "DefaultTransport", "NewClient":
return true
}
return false
}
return mcpReaderSink(recv, sel)
}
// mcpSinksIn lists the network and process calls inside one declaration.
func mcpSinksIn(n ast.Node) []string {
var out []string
called := map[*ast.SelectorExpr]bool{}
ast.Inspect(n, func(x ast.Node) bool {
if c, ok := x.(*ast.CallExpr); ok {
if sel, ok := c.Fun.(*ast.SelectorExpr); ok {
called[sel] = true
}
}
return true
})
ast.Inspect(n, func(x ast.Node) bool {
switch v := x.(type) {
case *ast.SelectorExpr:
recv := exprText(v.X)
if (called[v] && mcpReaderSink(recv, v.Sel.Name)) || (!called[v] && mcpValueSink(recv, v.Sel.Name)) {
out = append(out, recv+"."+v.Sel.Name)
}
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok && mcpReaderMethodSink[sel.Sel.Name] {
out = append(out, exprText(sel.X)+"."+sel.Sel.Name+"()")
}
}
return true
})
return out
}
// mcpReaderViolations is every way the package breaks the guarantee above.
func mcpReaderViolations(p *srcPkg) []string {
var out []string
names := make([]string, 0, len(p.files))
for n := range p.files {
names = append(names, n)
}
sort.Strings(names)
var readerFiles []string
for _, name := range names {
if strings.HasPrefix(name, "mcpconfig") {
readerFiles = append(readerFiles, name)
}
for _, s := range stringLits(p.files[name]) {
l := strings.ToLower(s)
if mcpURLOrRoute(s) || strings.Contains(l, "jsonrpc") || strings.Contains(l, "tools/call") {
out = append(out, fmt.Sprintf("%s carries %q: nothing in the bastion addresses an MCP endpoint or speaks JSON-RPC", name, s))
}
// The protocol's namespace. The reader may name the npm scope
// its reference servers are published under, to recognise one
// running; nothing else may name it at all.
if strings.Contains(l, "modelcontextprotocol") && !(name == "mcpconfig.go" && l == "@modelcontextprotocol/") {
out = append(out, fmt.Sprintf("%s carries %q: only the config reader may name the reference servers' npm scope", name, s))
}
if name == "mcpconfig.go" {
continue
}
for _, b := range mcpConfigNames {
if strings.Contains(l, b) {
out = append(out, fmt.Sprintf("%s names %q (%s): only mcpconfig.go reads agent clients' tool-server config", name, s, b))
}
}
}
}
if len(readerFiles) == 0 {
return append(out, "no mcpconfig*.go file: the reader moved, so this check is no longer checking it")
}
var roots []string
for _, name := range readerFiles {
for _, imp := range p.files[name].Imports {
path, _ := strconv.Unquote(imp.Path.Value)
if !mcpReaderAllowedImports[path] {
out = append(out, fmt.Sprintf("%s imports %s: the config reader reads files and nothing else", name, path))
}
}
roots = append(roots, p.funcKeysIn(name)...)
}
// Package-level variables are nodes too ("var:name"): a function held in
// one (var f = net.Dial, var g = func() { ... }) is reached by whoever
// reads the variable, and a reader file's own variables are roots, since
// their initialisers run with the package.
g := p.callGraph()
vars := map[string][]ast.Expr{}
for _, name := range names {
for _, d := range p.files[name].Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, sp := range gd.Specs {
vs := sp.(*ast.ValueSpec)
for _, id := range vs.Names {
key := "var:" + id.Name
vars[key] = append(vars[key], vs.Values...)
if strings.HasPrefix(name, "mcpconfig") {
roots = append(roots, key)
}
}
}
}
}
refs := func(n ast.Node) []string {
var out []string
ast.Inspect(n, func(x ast.Node) bool {
if id, ok := x.(*ast.Ident); ok {
if _, isVar := vars["var:"+id.Name]; isVar {
out = append(out, "var:"+id.Name)
}
if _, isFn := p.funcs[id.Name]; isFn {
out = append(out, id.Name)
}
}
return true
})
return out
}
sinks := map[string][]string{}
for key, fd := range p.funcs {
g[key] = append(g[key], refs(fd)...)
if s := mcpSinksIn(fd); len(s) > 0 {
sinks[key] = s
}
}
for key, vals := range vars {
for _, v := range vals {
g[key] = append(g[key], refs(v)...)
sinks[key] = append(sinks[key], mcpSinksIn(v)...)
}
}
seen := reach(g, roots, nil)
for fn, path := range seen {
if what := sinks[fn]; len(what) > 0 {
out = append(out, fmt.Sprintf("the config reader can reach %s, which reaches a network or process call (%s): %s",
fn, strings.Join(what, ", "), path))
}
}
sort.Strings(out)
return out
}
// THE CHECK ABOVE CAN FAIL. A reader that starts a process, imports a
// network package, reaches a dialler through another function, or a second
// file that names a client's config file or an /mcp URL: each one is added
// as a synthetic source and must be reported. The real tree must report
// nothing, and the scan must have found sinks and roots to compare, so a
// broken extraction cannot pass by finding nothing.
func TestMCPReaderCheckCatchesADialAndAStrayBasename(t *testing.T) {
clean := parseSources(t)
if v := mcpReaderViolations(clean); len(v) != 0 {
t.Fatalf("the real tree must be clean: %v", v)
}
if len(clean.funcKeysIn("mcpconfig.go")) < 10 {
t.Fatal("found almost no functions in mcpconfig.go; the root extraction is broken")
}
add := func(p *srcPkg, name, src string) {
t.Helper()
f, err := parser.ParseFile(p.fset, name, src, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
p.addFile(name, f)
}
cases := []struct {
name, want string
files map[string]string
}{
{"the reader execs", "can reach mcpSneakRun", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakRun() { _ = exec.Command(\"x\") }\n"}},
{"the reader dials through a helper elsewhere", "can reach sneakDialHelper", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakCall() { sneakDialHelper() }\n",
"sneak_helper.go": "package main\nfunc sneakDialHelper() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader imports net/http", "imports net/http", map[string]string{
"mcpconfig_sneak.go": "package main\nimport _ \"net/http\"\n"}},
{"another file names a config file", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakPath = \".cursor/mcp.json\"\n"}},
{"another file names the servers key", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakKey = \"mcpServers\"\n"}},
{"a URL with an mcp path", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakURL = \"https://h.example.com/v1/mcp\"\n"}},
{"a route", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp\"\n"}},
{"a route with a suffix", "addresses an MCP endpoint", map[string]string{
"sneak_other.go": "package main\nvar sneakRoute = \"/mcp-proxy\"\n"}},
{"the reader dials through a Dialer variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakD() { var d net.Dialer; d.Dial(\"tcp\", \"x\") }\n"}},
{"the reader dials through a function value", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakF() { f := net.Dial; f(\"tcp\", \"x\") }\n"}},
{"the reader dials from a package-level function literal", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nvar mcpSneakV = func() { net.Dial(\"tcp\", \"x\") }\n"}},
{"the reader opens a raw socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakS() { syscall.Socket(2, 1, 0) }\n"}},
{"the reader resolves a name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakL() { net.LookupHost(\"x\") }\n"}},
{"the reader imports a websocket client not on a deny list", "imports golang.org/x/net/websocket", map[string]string{
"mcpconfig_sneak.go": "package main\nimport ws \"golang.org/x/net/websocket\"\nfunc mcpSneakW() { ws.Dial(\"x\", \"\", \"y\") }\n"}},
{"the reader calls a dialler held in another file's variable", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nfunc mcpSneakH() { sneakDialer(\"tcp\", \"x\") }\n",
"sneak_helper.go": "package main\nvar sneakDialer = net.Dial\n"}},
{"another file names a client's config directory", "only mcpconfig.go reads", map[string]string{
"sneak_other.go": "package main\nvar sneakDir = \".codex/\"\n"}},
{"the Windows reader starts a process", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakCP() { windows.CreateProcess(nil, nil, nil, nil, false, 0, nil, nil, nil, nil) }\n"}},
{"the Windows reader opens a socket", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nfunc mcpSneakWS() { windows.WSASocket(2, 1, 0, nil, 0, 0) }\n"}},
{"the Windows reader loads a DLL by name", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows\"\nvar mcpSneakDLL = windows.NewLazySystemDLL(\"ws2_32.dll\")\n"}},
{"the Windows reader opens a remote registry", "reaches a network or process call", map[string]string{
"mcpconfig_sneak.go": "package main\nimport \"golang.org/x/sys/windows/registry\"\nfunc mcpSneakRR() { registry.OpenRemoteKey(\"h\", registry.LOCAL_MACHINE) }\n"}},
}
for _, c := range cases {
p := parseSources(t)
for n, src := range c.files {
add(p, n, src)
}
found := false
for _, v := range mcpReaderViolations(p) {
if strings.Contains(v, c.want) {
found = true
}
}
if !found {
t.Errorf("%s: not reported (want %q in %v)", c.name, c.want, mcpReaderViolations(p))
}
}
// A config file name is not a URL path, and a file in the reader may
// name one.
for _, ok := range []string{".cursor/mcp.json", "mcp_config.json", ".codeium/windsurf/mcp_config.json", "managed-mcp.json"} {
if mcpURLOrRoute(ok) {
t.Errorf("%q read as an MCP URL or route", ok)
}
}
}
// callGraph maps each function to what it calls or refers to, by name. // callGraph maps each function to what it calls or refers to, by name.
// A method call through a value is resolved to every method of that name in // A method call through a value is resolved to every method of that name in
// the package, so the graph over-approximates: a path it cannot rule out is // the package, so the graph over-approximates: a path it cannot rule out is
@@ -582,6 +977,14 @@ func TestNoRouteOpOrDependencySpeaksMCP(t *testing.T) {
// followed; none of the handlers below does that. // followed; none of the handlers below does that.
func (p *srcPkg) callGraph() map[string][]string { func (p *srcPkg) callGraph() map[string][]string {
g := map[string][]string{} g := map[string][]string{}
// The per-file duplicates ("name@file"), by name, built once: looking
// them up by prefix for every identifier made this graph cost seconds.
dups := map[string][]string{}
for k := range p.funcs {
if i := strings.IndexByte(k, '@'); i >= 0 {
dups[k[:i]] = append(dups[k[:i]], k)
}
}
for key, fd := range p.funcs { for key, fd := range p.funcs {
if fd.Body == nil { if fd.Body == nil {
continue continue
@@ -593,11 +996,7 @@ func (p *srcPkg) callGraph() map[string][]string {
if _, ok := p.funcs[v.Name]; ok { if _, ok := p.funcs[v.Name]; ok {
out = append(out, v.Name) out = append(out, v.Name)
} }
for k := range p.funcs { out = append(out, dups[v.Name]...)
if strings.HasPrefix(k, v.Name+"@") {
out = append(out, k)
}
}
case *ast.CallExpr: case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok { if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
out = append(out, p.methods[sel.Sel.Name]...) out = append(out, p.methods[sel.Sel.Name]...)
@@ -794,8 +1193,8 @@ func TestObserveOpsWriteNothing(t *testing.T) {
roots = append(roots, fn) roots = append(roots, fn)
} }
} }
if len(roots) != 14 { if len(roots) != 18 {
t.Fatalf("expected the 11 observe and 3 computer handlers, found %d", len(roots)) t.Fatalf("expected the 15 observe and 3 computer handlers, found %d", len(roots))
} }
stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true, stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true,
"storeRecordObserve": true} "storeRecordObserve": true}
@@ -871,6 +1270,10 @@ func TestObserveOpsWriteNothing(t *testing.T) {
"dmesg -T", "dmesg -T",
"iptables -S", "iptables -S",
"journalctl -n1 -o short-unix --no-pager", "journalctl -n1 -o short-unix --no-pager",
// observe:logins: sshd's lines over a window of whole hours.
"journalctl <sinceArg> -o short-iso --no-pager -q --lines=50000 _COMM=sshd _COMM=sshd-session _COMM=sshd-auth _UID=0 + _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service _UID=0",
// observe:journal: one unit this machine's own lists name, over minutes.
"journalctl <unitArg> <sinceArg> -o short-iso --no-pager -q --lines=5000",
"kubectl --kubeconfig <observeKubeconfig> get nodes -o json", "kubectl --kubeconfig <observeKubeconfig> get nodes -o json",
"kubectl --kubeconfig <observeKubeconfig> get pods -A -o json", "kubectl --kubeconfig <observeKubeconfig> get pods -A -o json",
"nft list chain inet pubwall input", "nft list chain inet pubwall input",
@@ -885,6 +1288,8 @@ func TestObserveOpsWriteNothing(t *testing.T) {
"system_profiler SPApplicationsDataType -json", "system_profiler SPApplicationsDataType -json",
"systemctl is-active fail2ban", "systemctl is-active fail2ban",
"systemctl list-timers --all --no-legend --plain", "systemctl list-timers --all --no-legend --plain",
"systemctl list-unit-files --no-legend --plain", // observe:journal's unit check
"systemctl list-units --all --no-legend --plain",
"systemctl list-units --failed --no-legend --plain", "systemctl list-units --failed --no-legend --plain",
"ufw status", "ufw status",
"ufw status verbose", "ufw status verbose",
+64
View File
@@ -90,6 +90,70 @@ func TestATunnelAddressThatIsNotOneHostIsRefused(t *testing.T) {
} }
} }
// Tunnel endpoints as the host may send them. The first is the hostile
// shape: the configuration text is one setting per line, so a newline ends
// the endpoint and starts a setting of the sender's choosing.
var (
badTunnelEndpoints = []string{
"203.0.113.7:51820\nallowed_ip=::/0", "[fe80::1%x\nallowed_ip=::/0]:51820",
"203.0.113.7:51820\r", "203.0.113.7:51820\t", " 203.0.113.7:51820", "203.0.113.7:51820 ",
"relay\x00.example.net:51820", "relay a.example.net:51820", "", "203.0.113.7",
"203.0.113.7:0", "203.0.113.7:65536", "203.0.113.7:+80", "relay.example.net:port",
"-relay.example.net:51820", "relay..example.net:51820", "[relay.example.net]:51820",
"[fe80::1%eth0]:51820", "0.0.0.0:51820", "[::]:51820", "2001:db8::7:51820",
}
goodTunnelEndpoints = []string{"203.0.113.7:51820", "[2001:db8::7]:51820", "relay-a.example.net:51820"}
)
// An endpoint that is not one host:port is refused at every place the peer
// is configured, and nothing is set on the device.
func TestATunnelEndpointThatIsNotHostPortIsRefused(t *testing.T) {
for _, bad := range badTunnelEndpoints {
cfg := &Config{WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
Endpoint: bad, HostTunnelIP: "2001:db8::e:3"}
dev := testWGDevice(t, "2001:db8::a:1")
if err := configureTunnelPeer(dev, cfg); err == nil {
t.Errorf("%q: configureTunnelPeer must refuse", bad)
}
if err := swapPeer(cfg, dev, bad, testWGKey(t)); err == nil {
t.Errorf("%q: swapPeer must refuse", bad)
}
if peers := wgPeers(t, dev); len(peers) != 0 {
t.Errorf("%q: a refused endpoint must leave the device with no peer, it holds %v", bad, peers)
}
}
}
// uapiRecorder keeps the configuration text it is given, in place of a device.
type uapiRecorder struct{ texts []string }
func (r *uapiRecorder) IpcSet(s string) error { r.texts = append(r.texts, s); return nil }
// Whatever endpoint the pairing holds, the text the device is given names
// one peer with exactly one allowed address, the host's alias, or the
// pairing is refused before any text is built.
func TestThePeerConfigurationHasExactlyOneAllowedAddress(t *testing.T) {
for _, ep := range append(append([]string{}, goodTunnelEndpoints...), badTunnelEndpoints...) {
rec := &uapiRecorder{}
cfg := &Config{WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
Endpoint: ep, HostTunnelIP: "2001:db8::e:3"}
if err := configureTunnelPeer(rec, cfg); err != nil {
continue
}
for _, text := range rec.texts {
seen := map[string]int{}
for _, line := range strings.Split(strings.TrimSuffix(text, "\n"), "\n") {
k, _, _ := strings.Cut(line, "=")
seen[k]++
}
if seen["allowed_ip"] != 1 || seen["public_key"] != 1 || seen["endpoint"] != 1 ||
!strings.Contains(text, "\nallowed_ip=2001:db8::e:3/128\n") {
t.Errorf("%q: the configuration must name one peer with one allowed address, got %q", ep, text)
}
}
}
}
func TestTheTunnelReachesOnlyThePairedHost(t *testing.T) { func TestTheTunnelReachesOnlyThePairedHost(t *testing.T) {
for _, hostIP := range []string{"2001:db8::e:3", "2001:db8::e:ffff", "2001:db8::1"} { for _, hostIP := range []string{"2001:db8::e:3", "2001:db8::e:ffff", "2001:db8::1"} {
cfg := &Config{ cfg := &Config{
+70 -79
View File
@@ -10,8 +10,8 @@ package main
// over the machine's own connection. // over the machine's own connection.
// //
// THE VETTING APPLIES ALWAYS, anchored box or not. The egress lane // THE VETTING APPLIES ALWAYS, anchored box or not. The egress lane
// (egress.go) only vets on an anchored box, because on a local box the host // (egress.go) keeps a local box's LAN reachable, because on a local box the
// using it is the person's own. A coding agent is not the person: it runs // host using it is the person's own. A coding agent is not the person: it runs
// whatever a prompt or a repository talks it into, so this proxy reaches the // whatever a prompt or a repository talks it into, so this proxy reaches the
// public internet and nothing else — not this machine, not its LAN, not the // public internet and nothing else — not this machine, not its LAN, not the
// cloud metadata address, nor any address this machine takes as its own (a // cloud metadata address, nor any address this machine takes as its own (a
@@ -23,7 +23,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"errors" "io"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
@@ -50,35 +50,12 @@ var (
coderDialOut egressDialer = (&net.Dialer{Timeout: egressDialTimeout}).DialContext coderDialOut egressDialer = (&net.Dialer{Timeout: egressDialTimeout}).DialContext
) )
// coderInternalIP is internalIP (egress.go) plus the IPv6 forms that carry an // coderInternalIP is internalIP (egress.go): a coder run's proxy and the
// IPv4 address and are not covered there: IPv4-compatible ::a.b.c.d (::/96), // backup lanes refuse the same address classes as the egress lane, from the
// IPv4-translated ::ffff:0:a.b.c.d, and the deprecated site-local fec0::/10. // one classifier (classifyIP), the IPv4-compatible, IPv4-translated and
// IPv4-mapped ::ffff:a.b.c.d is covered by internalIP through To4. // site-local fec0::/10 forms included.
func coderInternalIP(ip net.IP) bool { func coderInternalIP(ip net.IP) bool {
if internalIP(ip) { return internalIP(ip)
return true
}
if ip.To4() != nil || len(ip) != net.IPv6len {
return false
}
zero := true
for _, b := range ip[:12] {
if b != 0 {
zero = false
break
}
}
if zero {
return true // ::/96 — never a public host
}
if ip[0] == 0 && ip[1] == 0 && ip[2] == 0 && ip[3] == 0 && ip[4] == 0 && ip[5] == 0 &&
ip[6] == 0 && ip[7] == 0 && ip[8] == 0xff && ip[9] == 0xff && ip[10] == 0 && ip[11] == 0 {
return true // ::ffff:0:0/96
}
if ip[0] == 0xfe && ip[1]&0xc0 == 0xc0 {
return true // fec0::/10
}
return false
} }
// coderLANAllowed: the owner's allow_lan names this requested host (an entry // coderLANAllowed: the owner's allow_lan names this requested host (an entry
@@ -174,17 +151,32 @@ func coderVettedAddrs(ctx context.Context, hostport string, allowLAN []string) (
} }
func coderRefuse(c net.Conn, why string) { func coderRefuse(c net.Conn, why string) {
_, _ = c.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\nX-Privatae-Refused: " + writeEgressRefusal(c, "403 Forbidden", why, why)
strings.NewReplacer("\r", " ", "\n", " ").Replace(why) + "\r\n\r\n"))
} }
// handleCoderEgressConn serves one connection from a run: HTTP CONNECT or an // handleCoderEgressConn serves one connection from a run: HTTP CONNECT or an
// absolute-form plain HTTP request, the same parsing as handleEgressConn. // absolute-form plain HTTP request, the same parsing as handleEgressConn.
// Destinations are never logged; refusals are counted on the run. // Destinations are never logged; refusals are counted on the run.
// coderEgressMaxHeadBytes bounds a request's line and headers together.
const coderEgressMaxHeadBytes = 64 << 10
// coderEgressMaxConns bounds the connections one run's proxy carries at once.
// A whole Chromium profile opens at most 256 sockets at a time (its socket
// pool limit), and package managers far fewer, so four times the browser's
// figure never refuses real work; past it a connection is answered 503 and
// closed, and each one held costs this machine at most its head and two copy
// buffers.
const coderEgressMaxConns = 1024
func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) { func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
defer client.Close() defer client.Close()
_ = client.SetReadDeadline(time.Now().Add(30 * time.Second)) _ = client.SetReadDeadline(time.Now().Add(30 * time.Second))
br := bufio.NewReader(client) // The request line and headers are read under one bound, the head limit
// below has always named: the run's own processes send them, and a line
// that never ends must not grow in this machine's memory before that
// limit is looked at.
headLim := &io.LimitedReader{R: client, N: coderEgressMaxHeadBytes}
br := bufio.NewReader(headLim)
line, err := br.ReadString('\n') line, err := br.ReadString('\n')
if err != nil { if err != nil {
return return
@@ -196,7 +188,6 @@ func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
method, target := parts[0], parts[1] method, target := parts[0], parts[1]
var head strings.Builder var head strings.Builder
head.WriteString(line) head.WriteString(line)
hostHdr := ""
for { for {
h, err := br.ReadString('\n') h, err := br.ReadString('\n')
if err != nil { if err != nil {
@@ -206,38 +197,41 @@ func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
if h == "\r\n" || h == "\n" { if h == "\r\n" || h == "\n" {
break break
} }
if head.Len() > 64<<10 { if head.Len() > coderEgressMaxHeadBytes {
return return
} }
if len(h) >= 5 && strings.EqualFold(h[:5], "host:") {
hostHdr = strings.TrimSpace(h[5:])
}
} }
_ = client.SetReadDeadline(time.Time{}) _ = client.SetReadDeadline(time.Time{})
// The head is in; what follows is the stream itself. What the reader
// already buffered past the head stays first in line.
headLim.N = 1<<63 - 1
ctx, cancel := context.WithTimeout(context.Background(), egressDialTimeout) ctx, cancel := context.WithTimeout(context.Background(), egressDialTimeout)
defer cancel() defer cancel()
// Read the way the bastion's own egress lane reads it (egress_http.go): a
// CONNECT target through parseEgressAuthority, and plain HTTP as ONE
// request, rebuilt and sent with `Connection: close`, so the run's later
// requests on a keep-alive connection never reach the destination unvetted.
connect := strings.EqualFold(method, "CONNECT") connect := strings.EqualFold(method, "CONNECT")
hp := "" hp := ""
var req egressRequest
if connect { if connect {
hp = ensurePort(target, "443") host, port, ok := parseEgressAuthority(target, 443)
} else { if !ok {
host := hostHdr atomic.AddInt64(refused, 1)
if i := strings.Index(target, "://"); i >= 0 { writeEgressRefusal(client, "400 Bad Request", egressNoHost, egressNoHost)
rest := target[i+3:]
if s := strings.IndexAny(rest, "/?#"); s >= 0 {
host = rest[:s]
} else {
host = rest
}
if at := strings.LastIndexByte(host, '@'); at >= 0 {
host = host[at+1:]
}
}
if host == "" {
return return
} }
hp = ensurePort(host, "80") hp = egressHostPort(host, port)
} else {
r, bad := parseEgressRequest(head.String())
if bad != nil {
atomic.AddInt64(refused, 1)
writeEgressRefusal(client, bad.status, bad.body, bad.body)
return
}
req = r
hp = egressHostPort(req.host, req.port)
} }
dialAddrs, why := coderVettedAddrs(ctx, hp, allowLAN) dialAddrs, why := coderVettedAddrs(ctx, hp, allowLAN)
if why != "" { if why != "" {
@@ -245,36 +239,19 @@ func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
coderRefuse(client, why) coderRefuse(client, why)
return return
} }
var upstream net.Conn upstream, err := dialEach(ctx, coderDialOut, dialAddrs)
err = errors.New("no address")
for i, a := range dialAddrs {
// Each address gets an even share of what is left of the dial budget,
// so one that black-holes cannot use it all.
actx, acancel := ctx, context.CancelFunc(func() {})
if left := len(dialAddrs) - i; left > 1 {
if dl, ok := ctx.Deadline(); ok {
actx, acancel = context.WithTimeout(ctx, time.Until(dl)/time.Duration(left))
}
}
upstream, err = coderDialOut(actx, "tcp", a)
acancel()
if err == nil {
break
}
}
if err != nil { if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) writeEgressRefusal(client, "502 Bad Gateway", egressUnreachable, "")
return
}
if !connect {
oneEgressExchange(br, client, upstream, req)
return return
} }
if connect {
if _, err := client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { if _, err := client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
_ = upstream.Close() _ = upstream.Close()
return return
} }
} else if _, err := upstream.Write([]byte(head.String())); err != nil {
_ = upstream.Close()
return
}
splice(br, client, upstream) splice(br, client, upstream)
} }
@@ -317,6 +294,7 @@ func coderListenEgress(runID, dir string, uid int) error {
} }
run := &coderEgressRun{ln: ln} run := &coderEgressRun{ln: ln}
coderEgress[runID] = run coderEgress[runID] = run
slots := make(chan struct{}, coderEgressMaxConns)
go func() { go func() {
for { for {
c, err := ln.Accept() c, err := ln.Accept()
@@ -325,7 +303,20 @@ func coderListenEgress(runID, dir string, uid int) error {
} }
// allow_lan is read per connection: the owner's edit applies to // allow_lan is read per connection: the owner's edit applies to
// running runs at once. // running runs at once.
go handleCoderEgressConn(c, loadCoderConfig().AllowLAN, &run.refused) select {
case slots <- struct{}{}:
go func() {
defer func() { <-slots }()
handleCoderEgressConn(c, loadCoderConfig().AllowLAN, &run.refused)
}()
default:
go func() {
defer c.Close()
_ = c.SetWriteDeadline(time.Now().Add(time.Second))
why := "this run already has " + strconv.Itoa(coderEgressMaxConns) + " connections open"
writeEgressRefusal(c, "503 Service Unavailable", why, why)
}()
}
} }
}() }()
return nil return nil
+7
View File
@@ -1229,10 +1229,17 @@ func coderContainerGone(rt, cname string) bool {
// anchor pairing on an anchored box (serviceOwnerRefusal's rule). // anchor pairing on an anchored box (serviceOwnerRefusal's rule).
// An unknown owner proves nothing about self, so it gets the anchor-only rule // An unknown owner proves nothing about self, so it gets the anchor-only rule
// too: every run this bastion starts records and labels its owner. // too: every run this bastion starts records and labels its owner.
//
// A local machine has no anchor, so anchorOnly lets every pairing through
// there; once it serves several Hosts (localServesSeveralHosts), each stops
// only the runs it started, as with the servers each built.
func coderOwnerRefusal(self, owner string) string { func coderOwnerRefusal(self, owner string) string {
if owner != "" && owner == self { if owner != "" && owner == self {
return "" return ""
} }
if localServesSeveralHosts() {
return "refused: that run was started by " + orUnknown(owner) + "; on a local machine that serves several Hosts each stops only the runs it started"
}
if why := anchorOnly(self, "stopping a run another host started"); why != "" { if why := anchorOnly(self, "stopping a run another host started"); why != "" {
return "refused: that run was started by " + orUnknown(owner) + "; " + why return "refused: that run was started by " + orUnknown(owner) + "; " + why
} }
+8 -2
View File
@@ -474,8 +474,14 @@ func TestCoderEgressProxyOverTheSocket(t *testing.T) {
} { } {
head, c := ask(req) head, c := ask(req)
c.Close() c.Close()
if !strings.HasPrefix(head, "HTTP/1.1 403") || !strings.Contains(head, "X-Privatae-Refused:") { want := "HTTP/1.1 403"
t.Fatalf("must be refused with 403 + X-Privatae-Refused: %q -> %q", req, head) if strings.Contains(req, "user@") {
// Userinfo is refused outright, before any lookup, as the Host's
// egress gate refuses it (parseEgressAuthority); still counted.
want = "HTTP/1.1 400"
}
if !strings.HasPrefix(head, want) || !strings.Contains(head, "X-Privatae-Refused:") {
t.Fatalf("must be refused with %s + X-Privatae-Refused: %q -> %q", want, req, head)
} }
} }
head, c := ask("CONNECT public.example:443 HTTP/1.1\r\n\r\n") head, c := ask("CONNECT public.example:443 HTTP/1.1\r\n\r\n")
+1 -1
View File
@@ -47,7 +47,7 @@ func TestBoundTreeRecountsAndCaps(t *testing.T) {
t.Fatalf("full tree: count=%d trunc=%v %+v", count, trunc, out) t.Fatalf("full tree: count=%d trunc=%v %+v", count, trunc, out)
} }
// the cap holds on THIS side even if a helper lied about truncation // the cap holds on THIS side even if a helper lied about truncation
out, count, trunc = boundTree([]*computerNode{win}, 3) _, count, trunc = boundTree([]*computerNode{win}, 3)
if count != 3 || !trunc { if count != 3 || !trunc {
t.Fatalf("capped: count=%d trunc=%v", count, trunc) t.Fatalf("capped: count=%d trunc=%v", count, trunc)
} }
+59 -26
View File
@@ -52,6 +52,11 @@ type Config struct {
// pairing's age and not the time its credentials were last rotated (the // pairing's age and not the time its credentials were last rotated (the
// file's mtime, which is what pairings from before this field fall back to). // file's mtime, which is what pairings from before this field fall back to).
FirstSeen string `json:"first_seen,omitempty"` FirstSeen string `json:"first_seen,omitempty"`
// Origin: how the pairing came to this bastion, for the Host that admits
// it (pairingsReport). "" was paired here; "carried" was moved in from an
// account's own bastion when the machine moved to the system service
// (servicecarry.go).
Origin string `json:"origin,omitempty"`
// How this pairing reaches its host (lane_socket.go). "" is WireGuard // How this pairing reaches its host (lane_socket.go). "" is WireGuard
// through the relay to the host's tunnel address on :8443, which is every // through the relay to the host's tunnel address on :8443, which is every
@@ -66,6 +71,12 @@ type Config struct {
LaneSocket string `json:"lane_socket,omitempty"` LaneSocket string `json:"lane_socket,omitempty"`
RunsDir string `json:"runs_dir,omitempty"` RunsDir string `json:"runs_dir,omitempty"`
LaneAddr string `json:"lane_addr,omitempty"` LaneAddr string `json:"lane_addr,omitempty"`
// HostDials marks a "tcp" pairing whose Host opens the connections
// (hostdoor.go): a Host this server can't reach, behind a home router,
// connects to this server's door at DoorAddr, and the lanes run over the
// connections it opens. LaneAddr is empty: nothing here ever dials it.
HostDials bool `json:"host_dials,omitempty"`
DoorAddr string `json:"door_addr,omitempty"`
// retired is set, under saveMu, once the run holding this copy has been // retired is set, under saveMu, once the run holding this copy has been
// stopped or has ended (retireRun, run.go), and is never cleared; from then // stopped or has ended (retireRun, run.go), and is never cleared; from then
@@ -76,12 +87,20 @@ type Config struct {
// that its file does not hold yet (rotate.go, setUnsaved; guarded by // that its file does not hold yet (rotate.go, setUnsaved; guarded by
// rotation.mu). Unexported: never in the file. // rotation.mu). Unexported: never in the file.
savePending bool savePending bool
// invalid is why loadPairings found this pairing's file unusable (an
// endpoint that is not one host:port), naming the file; "" for a usable
// one. Such a pairing is listed, so status shows it and unpair removes it,
// but it never serves: active() is false. Unexported: never in the file.
invalid string
} }
const ( const (
pairingActive = "active" pairingActive = "active"
pairingPending = "pending" pairingPending = "pending"
pairingRevoked = "revoked" pairingRevoked = "revoked"
// pairingInvalid is reported, never stored: the file holds a value this
// build refuses (Config.invalid).
pairingInvalid = "invalid"
// transportUnix is Config.Transport for a Host on this machine. // transportUnix is Config.Transport for a Host on this machine.
transportUnix = "unix" transportUnix = "unix"
@@ -93,9 +112,13 @@ const (
// unixTransport says whether this pairing's lanes go over a local socket. // unixTransport says whether this pairing's lanes go over a local socket.
func (c *Config) unixTransport() bool { return c.Transport == transportUnix } func (c *Config) unixTransport() bool { return c.Transport == transportUnix }
// tcpTransport says whether this pairing's lanes go to a Host's lane port. // tcpTransport says whether this pairing's lanes go to a Host's lane port,
// or come from a Host that connects to this server (hostDials).
func (c *Config) tcpTransport() bool { return c.Transport == transportTCP } func (c *Config) tcpTransport() bool { return c.Transport == transportTCP }
// hostDials: a tcp pairing whose Host connects to this server (hostdoor.go).
func (c *Config) hostDials() bool { return c.tcpTransport() && c.HostDials }
// localTransport: a Host with no platform, over either local transport. Its // localTransport: a Host with no platform, over either local transport. Its
// lanes run without a WireGuard device (run.go runUnixPairing). // lanes run without a WireGuard device (run.go runUnixPairing).
func (c *Config) localTransport() bool { return c.unixTransport() || c.tcpTransport() } func (c *Config) localTransport() bool { return c.unixTransport() || c.tcpTransport() }
@@ -103,11 +126,15 @@ func (c *Config) localTransport() bool { return c.unixTransport() || c.tcpTransp
// active is the ONE place the state field is interpreted: "" and "active" serve, // active is the ONE place the state field is interpreted: "" and "active" serve,
// anything else does not. run.go, the heartbeat and the mgmt lane all ask this. // anything else does not. run.go, the heartbeat and the mgmt lane all ask this.
func (c *Config) active() bool { func (c *Config) active() bool {
return c.State == "" || c.State == pairingActive return c.invalid == "" && (c.State == "" || c.State == pairingActive)
} }
// stateName is the state as reported: the empty legacy value reads "active". // stateName is the state as reported: the empty legacy value reads "active",
// and a pairing whose file is unusable reads "invalid" whatever its state.
func (c *Config) stateName() string { func (c *Config) stateName() string {
if c.invalid != "" {
return pairingInvalid
}
if c.State == "" { if c.State == "" {
return pairingActive return pairingActive
} }
@@ -316,17 +343,36 @@ func (c *Config) updateOnDiskIf(apply func(disk *Config) error) error {
// updatePairingOnDisk is updateOnDisk for a caller that only has the host // updatePairingOnDisk is updateOnDisk for a caller that only has the host
// name (the mgmt handlers). Returns the pairing as written, or nil with // name (the mgmt handlers). Returns the pairing as written, or nil with
// errPairingGone when no such file exists. // errPairingGone when no such file exists.
//
// The copy is checked as loadPairings checks a file, so a caller that starts
// the pairing from it never serves one whose file this build refuses.
func updatePairingOnDisk(host string, apply func(disk *Config)) (*Config, error) { func updatePairingOnDisk(host string, apply func(disk *Config)) (*Config, error) {
pd, err := pairingsDir()
if err != nil {
return nil, err
}
c := &Config{HostName: host} c := &Config{HostName: host}
var out *Config var out *Config
err := c.updateOnDisk(func(d *Config) { err = c.updateOnDisk(func(d *Config) {
apply(d) apply(d)
cp := *d cp := *d
cp.invalid = invalidReason(&cp, filepath.Join(pd, host+".json"))
out = &cp out = &cp
}) })
return out, err return out, err
} }
// invalidReason is why the pairing c, read from file, is never served, naming
// the file, or "" when it may be served. It is the check the pairing response
// and the rotate directive get, for a file an older build saved from either.
func invalidReason(c *Config, file string) string {
if err := checkEndpoints(append([]string{c.Endpoint}, c.Endpoints...)...); err != nil {
return fmt.Sprintf("pairing %s: %v — it is not served; pair it again (`privatae-bastion pair --replace <token>`) or remove it (`privatae-bastion unpair %s`)",
file, err, c.HostName)
}
return ""
}
// unpairHost tears down and deletes one pairing: its file and its grant file. // unpairHost tears down and deletes one pairing: its file and its grant file.
// The context store and DCN dir are the USER's data and are left in place — // The context store and DCN dir are the USER's data and are left in place —
// deleting a pairing must not delete what the user put on this machine. // deleting a pairing must not delete what the user put on this machine.
@@ -388,6 +434,9 @@ func cmdUnpair(key string) error {
if err != nil { if err != nil {
return err return err
} }
// With the daemon running, its door's close does this too; stopped, it
// is done here, so the rule never outlives the last pairing that used it.
doorFirewallReleaseUnused()
fmt.Printf("unpaired %s — its tunnel and lanes are down; the context store and DCN state stay on disk\n", host) fmt.Printf("unpaired %s — its tunnel and lanes are down; the context store and DCN state stay on disk\n", host)
return nil return nil
} }
@@ -495,31 +544,15 @@ func loadPairings() ([]*Config, error) {
if err := json.Unmarshal(raw, &c); err != nil { if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("pairing %s: %w", e.Name(), err) return nil, fmt.Errorf("pairing %s: %w", e.Name(), err)
} }
// One such file must not stop every other pairing, status or unpair:
// it loads, never serves.
c.invalid = invalidReason(&c, filepath.Join(pd, e.Name()))
cfgs = append(cfgs, &c) cfgs = append(cfgs, &c)
} }
sort.Slice(cfgs, func(i, j int) bool { return cfgs[i].HostName < cfgs[j].HostName }) sort.Slice(cfgs, func(i, j int) bool { return cfgs[i].HostName < cfgs[j].HostName })
return cfgs, nil return cfgs, nil
} }
// loadConfig keeps single-pairing call sites working: with exactly one pairing it
// returns it. With several it REFUSES — a straggler caller must name a host and
// fail safe, not silently act on an arbitrary one (that ambiguity is the same
// family of bug as the single-config unpair).
func loadConfig() (*Config, error) {
cfgs, err := loadPairings()
if err != nil {
return nil, err
}
switch len(cfgs) {
case 0:
return nil, fmt.Errorf("no pairings")
case 1:
return cfgs[0], nil
default:
return nil, fmt.Errorf("%d hosts are paired — this operation must name one (see --host)", len(cfgs))
}
}
// hostNames lists the paired host names (for error messages). // hostNames lists the paired host names (for error messages).
func hostNames(cfgs []*Config) []string { func hostNames(cfgs []*Config) []string {
names := make([]string, 0, len(cfgs)) names := make([]string, 0, len(cfgs))
@@ -600,7 +633,7 @@ func removeStalePairingFile(hostName string) error {
func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, error) { func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, error) {
existing := pairingFor(cfgs, handle) existing := pairingFor(cfgs, handle)
if existing != nil && !replace { if existing != nil && !replace {
return nil, fmt.Errorf("already paired with %s — this daemon serves multiple hosts, so pairing never overwrites; to rotate that host's credentials run `privatae-bastion pair --replace <token>`", existing.HostName) return nil, &alreadyPairedError{host: existing.HostName, msg: fmt.Sprintf("already paired with %s — this daemon serves multiple hosts, so pairing never overwrites; to rotate that host's credentials run `privatae-bastion pair --replace <token>`", existing.HostName)}
} }
if existing == nil && replace { if existing == nil && replace {
return nil, fmt.Errorf("pair --replace: no existing pairing matches '%s' — pair it normally first", handle) return nil, fmt.Errorf("pair --replace: no existing pairing matches '%s' — pair it normally first", handle)
@@ -609,8 +642,8 @@ func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, er
} }
// nextGitProxyAddr picks the first loopback port from 7978 up that no existing // nextGitProxyAddr picks the first loopback port from 7978 up that no existing
// pairing claims: the per-host git proxy (git.privatae.ai is reachable only through // pairing claims: the per-host git proxy, through which local git reaches
// the bastion → host → platform path). Allocated lazily for pairings that predate it. // git.privatae.ai via that host (gitlane.go). Allocated lazily for pairings that predate it.
func nextGitProxyAddr(cfgs []*Config) string { func nextGitProxyAddr(cfgs []*Config) string {
used := map[string]bool{} used := map[string]bool{}
for _, c := range cfgs { for _, c := range cfgs {
+20 -20
View File
@@ -35,7 +35,7 @@ func TestLegacyConfigMigration(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
legacy := map[string]string{ legacy := map[string]string{
"host_name": "tenant-3.host.privatae.ai", "host_name": "tenant-9003.host.privatae.ai",
"bearer": "mcp_x", "bearer": "mcp_x",
"listen_addr": "127.0.0.1:7878", "listen_addr": "127.0.0.1:7878",
} }
@@ -48,7 +48,7 @@ func TestLegacyConfigMigration(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("loadPairings: %v", err) t.Fatalf("loadPairings: %v", err)
} }
if len(cfgs) != 1 || cfgs[0].HostName != "tenant-3.host.privatae.ai" { if len(cfgs) != 1 || cfgs[0].HostName != "tenant-9003.host.privatae.ai" {
t.Fatalf("migrated pairings = %+v, want the one legacy host", cfgs) t.Fatalf("migrated pairings = %+v, want the one legacy host", cfgs)
} }
if got, want := cfgs[0].ContextRoot, filepath.Join(cd, "context"); got != want { if got, want := cfgs[0].ContextRoot, filepath.Join(cd, "context"); got != want {
@@ -57,7 +57,7 @@ func TestLegacyConfigMigration(t *testing.T) {
if got, want := cfgs[0].DCNDir, filepath.Join(cd, "dcn"); got != want { if got, want := cfgs[0].DCNDir, filepath.Join(cd, "dcn"); got != want {
t.Errorf("DCNDir = %q, want the explicit legacy default %q", got, want) t.Errorf("DCNDir = %q, want the explicit legacy default %q", got, want)
} }
if _, err := os.Stat(filepath.Join(cd, "pairings", "tenant-3.host.privatae.ai.json")); err != nil { if _, err := os.Stat(filepath.Join(cd, "pairings", "tenant-9003.host.privatae.ai.json")); err != nil {
t.Errorf("pairings/<host>.json missing after migration: %v", err) t.Errorf("pairings/<host>.json missing after migration: %v", err)
} }
if _, err := os.Stat(filepath.Join(cd, "config.json")); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(cd, "config.json")); !os.IsNotExist(err) {
@@ -76,27 +76,27 @@ func TestLegacyConfigMigration(t *testing.T) {
// existing pairing — both checked pre-redeem so a refusal can't burn the token. // existing pairing — both checked pre-redeem so a refusal can't burn the token.
func TestCheckPairConflict(t *testing.T) { func TestCheckPairConflict(t *testing.T) {
cfgs := []*Config{ cfgs := []*Config{
{HostName: "tenant-3.host.privatae.ai"}, {HostName: "tenant-9003.host.privatae.ai"},
{HostName: "tenant-122.host.privatae.ai"}, {HostName: "tenant-9122.host.privatae.ai"},
} }
if _, err := checkPairConflict(cfgs, "tenant-122", false); err == nil { if _, err := checkPairConflict(cfgs, "tenant-9122", false); err == nil {
t.Fatal("pairing an already-paired host must be refused without --replace") t.Fatal("pairing an already-paired host must be refused without --replace")
} }
got, err := checkPairConflict(cfgs, "tenant-122", true) got, err := checkPairConflict(cfgs, "tenant-9122", true)
if err != nil || got == nil || got.HostName != "tenant-122.host.privatae.ai" { if err != nil || got == nil || got.HostName != "tenant-9122.host.privatae.ai" {
t.Fatalf("--replace on a paired host = (%v, %v), want its existing pairing", got, err) t.Fatalf("--replace on a paired host = (%v, %v), want its existing pairing", got, err)
} }
if got, err := checkPairConflict(cfgs, "tenant-9", false); err != nil || got != nil { if got, err := checkPairConflict(cfgs, "tenant-9500", false); err != nil || got != nil {
t.Fatalf("a NEW host must pair cleanly, got (%v, %v)", got, err) t.Fatalf("a NEW host must pair cleanly, got (%v, %v)", got, err)
} }
if _, err := checkPairConflict(cfgs, "tenant-9", true); err == nil { if _, err := checkPairConflict(cfgs, "tenant-9500", true); err == nil {
t.Fatal("--replace on an unpaired host must be refused (nothing to replace)") t.Fatal("--replace on an unpaired host must be refused (nothing to replace)")
} }
// "tenant-1" is a prefix of tenant-122's host name but a different handle — // "tenant-91" is a prefix of tenant-9122's host name but a different handle —
// it must NOT collide. // it must NOT collide.
if got, err := checkPairConflict(cfgs, "tenant-1", false); err != nil || got != nil { if got, err := checkPairConflict(cfgs, "tenant-91", false); err != nil || got != nil {
t.Fatalf("handle 'tenant-1' must not match tenant-122, got (%v, %v)", got, err) t.Fatalf("handle 'tenant-91' must not match tenant-9122, got (%v, %v)", got, err)
} }
} }
@@ -124,13 +124,13 @@ func TestNextListenAddr(t *testing.T) {
// prefix; ambiguity is an error, not a guess. // prefix; ambiguity is an error, not a guess.
func TestFindPairingHostFlag(t *testing.T) { func TestFindPairingHostFlag(t *testing.T) {
cfgs := []*Config{ cfgs := []*Config{
{HostName: "tenant-3.host.privatae.ai"}, {HostName: "tenant-9003.host.privatae.ai"},
{HostName: "tenant-122.host.privatae.ai"}, {HostName: "tenant-9122.host.privatae.ai"},
} }
for _, key := range []string{"tenant-3.host.privatae.ai", "tenant-3", "tenant-3."} { for _, key := range []string{"tenant-9003.host.privatae.ai", "tenant-9003", "tenant-9003."} {
c, err := findPairing(cfgs, key) c, err := findPairing(cfgs, key)
if err != nil || c == nil || c.HostName != "tenant-3.host.privatae.ai" { if err != nil || c == nil || c.HostName != "tenant-9003.host.privatae.ai" {
t.Errorf("findPairing(%q) = (%v, %v), want tenant-3", key, c, err) t.Errorf("findPairing(%q) = (%v, %v), want tenant-9003", key, c, err)
} }
} }
if c, err := findPairing(cfgs, "nope"); err != nil || c != nil { if c, err := findPairing(cfgs, "nope"); err != nil || c != nil {
@@ -145,8 +145,8 @@ func TestFindPairingHostFlag(t *testing.T) {
// a pin synced for one host may never verify (or even be visible to) another's ops. // a pin synced for one host may never verify (or even be visible to) another's ops.
func TestPerHostDcnDirs(t *testing.T) { func TestPerHostDcnDirs(t *testing.T) {
cd := isolateConfigDir(t) cd := isolateConfigDir(t)
a := &Config{HostName: "tenant-3.host.privatae.ai"} a := &Config{HostName: "tenant-9003.host.privatae.ai"}
b := &Config{HostName: "tenant-122.host.privatae.ai"} b := &Config{HostName: "tenant-9122.host.privatae.ai"}
dirA, dirB := a.dcnDir(), b.dcnDir() dirA, dirB := a.dcnDir(), b.dcnDir()
if dirA == "" || dirB == "" || dirA == dirB { if dirA == "" || dirB == "" || dirA == dirB {
t.Fatalf("per-host dcn dirs must differ: %q vs %q", dirA, dirB) t.Fatalf("per-host dcn dirs must differ: %q vs %q", dirA, dirB)
+164 -44
View File
@@ -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.
func cmdStepupDrop(host, prefix string) error { // From the owner's account beside the machine's bastion it asks that
cfg, err := stepupPairing(host) // bastion (owneracts.go): dropping only takes reach away, so no code.
if err != nil { func cmdStepupDrop(host string, machine bool, prefix string) error {
return err 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) })
} }
dir := cfg.dcnDir()
func stepupDropText(host string, machine bool, prefix string) (string, error) {
t, err := resolveStepupTarget(host, machine)
if err != nil {
return "", err
}
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, prefix string) error {
} }
switch len(hits) { switch len(hits) {
case 0: case 0:
return fmt.Errorf("no unused approval starts with %q for %s", prefix, cfg.HostName) 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,58 +103,141 @@ 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" {
dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true })
if err != nil {
return "", err
}
return fmt.Sprintf("%d op grant(s) dropped — the host cannot run under any of them again", len(dropped)), nil
}
var hits []string var hits []string
for nonce, rec := range opsLoad(dir).Grants { for nonce, rec := range opsLoad(dir).Grants {
if rec.Dropped == "" && strings.HasPrefix(nonce, prefix) { if rec.Dropped == "" && strings.HasPrefix(nonce, prefix) {
@@ -150,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
}
+194
View File
@@ -0,0 +1,194 @@
package main
// consolelane.go — the machine owner's own account opens the console of the
// bastion installed for the whole machine, without sudo on Linux and macOS
// and without an Administrator terminal on Windows.
//
// THE PROBLEM. Installed for the whole machine, the bastion runs as root and
// its console signs in with root's ui.secret. `privatae-bastion ui` run by the
// person reads their own account's state, and the console refuses that
// account's key, so the person reached their own machine's console only
// through sudo, and a browser started by sudo is root's, not theirs.
//
// THE LANE. ask.sock (agentask.go), and on Windows the service's ask pipe
// (asklane_windows.go), already tells the daemon, from the operating system,
// which account connected. Asked for "console" by the account recorded as the
// machine's owner (owner_account.go), the daemon mints a one-use ticket of a
// LIMITED kind and answers with the link; `ui` opens it in the person's own
// browser. Any other account is refused, and no request text is trusted.
//
// WHAT A LIMITED SESSION MAY DO. Everything a person can see, everything that
// only narrows reach (deny, switch off, disconnect, unmark), and everything
// that takes the owner's code, which an agent running as the person cannot
// read on this install. It is refused what widens access with no code today:
// pairing a Host, setting up or rotating the owner's code, choosing the Host
// in charge, proposing a folder, and the approval device's approvals. Those
// stay with the administrator (`sudo -H privatae-bastion ui`, or `ui` from an
// Administrator terminal on Windows). Codes typed for
// a Host's requests in a limited session are throttled as the owner's are
// (machineCodeSpend), because guessing is the only attack left there.
import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"runtime"
"sync/atomic"
)
// consoleLaneTicketsMax is how many lane links may wait unopened at once.
const consoleLaneTicketsMax = 16
// consoleLaneServer is the running console, for ask.sock to mint a ticket
// on. nil until the console listens, and again after it stops.
var consoleLaneServer atomic.Pointer[uiServer]
// consoleLaneRefusal says why account uid may not open the console through
// the lane, or "" when it may.
func consoleLaneRefusal(uid uint32, who string) string {
return consoleLaneRefusalFor(askPeer{UID: uid, Name: who})
}
// consoleLaneRefusalFor is consoleLaneRefusal for a peer of either platform.
func consoleLaneRefusalFor(p askPeer) string {
admin := adminCommandFor(p.windows(), "ui")
match, ownerName, present, err := laneOwnerMatch(p)
switch {
case err != nil:
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " +
"the console with: " + admin
case !present:
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " +
admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p)
case !match:
without := "without sudo"
if p.windows() {
without = "without an Administrator terminal"
}
return "this machine's console opens " + without + " only for its owner's account (" + ownerName + "), not " +
p.Name + ". The administrator opens it with: " + admin
}
return ""
}
func accountForHint(who string) string {
if who == "" || len(who) > 64 {
return "<your account>"
}
return who
}
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
// the link that redeems a fresh limited ticket.
func consoleLaneEntry(uid uint32, who string) (string, error) {
return consoleLaneEntryFor(askPeer{UID: uid, Name: who})
}
func consoleLaneEntryFor(p askPeer) (string, error) {
if why := consoleLaneRefusalFor(p); why != "" {
return "", errors.New(why)
}
u := consoleLaneServer.Load()
if u == nil || u.addr == "" {
return "", errors.New("the bastion's console is not listening (no free port, or it is still starting)")
}
// A cap, so asking over and over cannot grow root's memory: a link lasts a
// minute, and a person opens one at a time.
tok, ok, err := u.limitedTickets.mintCapped(consoleLaneTicketsMax)
if err != nil {
return "", errors.New("could not mint a ticket")
}
if !ok {
return "", errors.New("too many console links are waiting to be opened; open one, or wait a minute")
}
return "http://" + u.addr + "/?k=" + tok, nil
}
// limitedSession reports whether the request's session came through the lane.
func (u *uiServer) limitedSession(r *http.Request) bool {
return u.limitedSessions != nil && u.limitedSessions.valid(uiSessionFrom(r))
}
// signedIn reports whether the request carries either kind of session.
func (u *uiServer) signedIn(r *http.Request) bool {
tok := uiSessionFrom(r)
return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok))
}
var consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
"with no code, or reads beyond your own account. The machine's administrator does it: " +
adminCommandFor(runtime.GOOS == "windows", "ui")
// adminOnly wraps a route a limited session may not use.
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
return u.guarded(func(w http.ResponseWriter, r *http.Request) {
if u.limitedSession(r) {
http.Error(w, consoleLaneAdminOnly, http.StatusForbidden)
return
}
h(w, r)
})
}
// consoleEntryForThisAccount is the link `ui` opens. An account with a
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary
// account beside the machine's bastion asks the lane, and is told why not
// when it is not the owner's.
func consoleEntryForThisAccount() (entry, addr string, err error) {
if runtime.GOOS == "windows" {
if systemService.installed && !machineAuthorityHeld() {
return consoleEntryThroughLane()
}
return localUIEntryURL()
}
if os.Geteuid() != 0 && !uiDaemonListening() {
p := askSockPath()
if why := contextSockDialable(p); why != "" {
return "", "", errors.New(why)
}
if _, statErr := os.Stat(p); statErr == nil {
return consoleEntryThroughLane()
}
}
return localUIEntryURL()
}
// consoleEntryThroughLane asks the machine's bastion for the owner's link.
func consoleEntryThroughLane() (entry, addr string, err error) {
p := askLaneAddr(askSockPath())
r, err := askViaSock(agentAskRequest{Op: "console"})
if err != nil {
if runtime.GOOS == "windows" {
// The service is there (systemService.installed) but its pipe is
// not: a service older than this command, or one not running.
return "", "", fmt.Errorf("this machine's bastion runs as the %s service, and it did not answer your "+
"account's request for its console on %s (%v).\n"+
" If the service is not running, start it: %s\n"+
" If it is older than this command, update it by running the installer again.\n"+
" Either way the administrator can open the console now: %s",
winServiceName, p, err, serviceRunHintFor("windows"), adminCommandFor(true, "ui"))
}
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
}
if !r.OK {
return "", "", errors.New(r.Why)
}
u, err := url.Parse(r.URL)
if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) {
return "", "", errors.New("this machine's bastion answered with a link that is not its own console")
}
return r.URL, u.Host, nil
}
// 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
// refused (Settings, Approvals). It changes what is drawn, never what is
// allowed: adminOnly decides that, from the session itself.
type uiLimitedWriter struct{ http.ResponseWriter }
func limitedWriter(w http.ResponseWriter) bool {
_, ok := w.(uiLimitedWriter)
return ok
}
+220
View File
@@ -0,0 +1,220 @@
package main
import (
"context"
"net/http"
"net/url"
"os/user"
"strconv"
"strings"
"testing"
)
// laneOwner records uid as the machine's owner account, as the installer's
// root step does, with machine authority faked and the account looked up as
// an invented one.
func laneOwner(t *testing.T, uid uint32) {
t.Helper()
prevAuth, prevLookup, prevState := machineAuthorityHeld, lookupOwnerAccount, ownerServiceState
t.Cleanup(func() { machineAuthorityHeld, lookupOwnerAccount, ownerServiceState = prevAuth, prevLookup, prevState })
machineAuthorityHeld = func() bool { return true }
ownerServiceState = func() string { return "" }
lookupOwnerAccount = func(who string) (*user.User, error) {
return &user.User{Uid: strconv.FormatUint(uint64(uid), 10), Username: "sam"}, nil
}
if err := cmdOwnerAccount("sam", false); err != nil {
t.Fatal(err)
}
}
// laneSignedIn redeems a ticket minted for the owner's lane, the way the
// browser does, and returns the session cookie it sets.
func laneSignedIn(t *testing.T, u *uiServer, uid uint32) string {
t.Helper()
u.addr = "127.0.0.1:" + uiTestPort
prev := consoleLaneServer.Load()
consoleLaneServer.Store(u)
t.Cleanup(func() { consoleLaneServer.Store(prev) })
link, err := consoleLaneEntry(uid, "sam")
if err != nil {
t.Fatalf("the owner's lane refused its owner: %v", err)
}
p, _ := url.Parse(link)
w := uiGet(t, u.handler(), "/?"+p.RawQuery, "")
if w.Code != http.StatusSeeOther {
t.Fatalf("redeeming the lane's ticket = %d: %s", w.Code, firstLines(w.Body.String()))
}
for _, c := range w.Result().Cookies() {
if c.Name == uiCookieName {
return uiCookieName + "=" + c.Value
}
}
t.Fatal("no session cookie")
return ""
}
func TestTheLaneOpensOnlyForTheOwnersAccount(t *testing.T) {
uiTestConfigDir(t)
if why := consoleLaneRefusal(1000, "sam"); !strings.Contains(why, "approvals-owner --account sam") {
t.Errorf("with no owner recorded the refusal does not say how to record one: %q", why)
}
laneOwner(t, 1000)
if why := consoleLaneRefusal(1000, "sam"); why != "" {
t.Errorf("the owner was refused: %q", why)
}
if why := consoleLaneRefusal(1001, "lee"); !strings.Contains(why, "only for its owner's account (sam)") {
t.Errorf("another account was let in, or told the wrong thing: %q", why)
}
if _, err := consoleLaneEntry(1001, "lee"); err == nil {
t.Error("a ticket was minted for an account that is not the owner's")
}
}
func TestTheOwnerAccountIsNotQuietlyHandedToAnother(t *testing.T) {
uiTestConfigDir(t)
laneOwner(t, 1000)
if err := cmdOwnerAccount("sam", false); err != nil {
t.Errorf("the same account again: %v", err)
}
lookupOwnerAccount = func(string) (*user.User, error) { return &user.User{Uid: "1001", Username: "lee"}, nil }
if err := cmdOwnerAccount("lee", false); err == nil || !strings.Contains(err.Error(), "stays so") {
t.Errorf("another account took the console: %v", err)
}
prev := ownerConsole
t.Cleanup(func() { ownerConsole = prev })
ownerConsole = func() bool { return false }
if err := cmdOwnerAccount("lee", true); err == nil || !strings.Contains(err.Error(), "only at a terminal") {
t.Errorf("--replace was taken from a pipe: %v", err)
}
if uid, _, _, _ := ownerAccountLoad(); uid != 1000 {
t.Fatalf("the owner changed to uid %d", uid)
}
lookupOwnerAccount = func(string) (*user.User, error) { return &user.User{Uid: "0", Username: "root"}, nil }
ownerConsole = func() bool { return true }
if err := cmdOwnerAccount("root", true); err == nil || !strings.Contains(err.Error(), "root administers") {
t.Errorf("root was recorded as the owner's account: %v", err)
}
}
// A limited session sees the console and does what takes the code or narrows
// reach. What widens reach with no code is the administrator's, and the page
// says so instead of offering it.
func TestALaneSessionIsRefusedWhatWidensReachWithNoCode(t *testing.T) {
u, admin := settingsWorld(t, true)
laneOwner(t, 1000)
lane := laneSignedIn(t, u, 1000)
h := u.handler()
for _, p := range []string{"/", "/setup", "/approvals", "/activity", "/machine"} {
if w := uiGet(t, h, p, lane); w.Code != http.StatusOK {
t.Errorf("GET %s from the lane = %d", p, w.Code)
}
}
for _, p := range []string{"/setup/pair", "/setup/stepup/start", "/setup/stepup/confirm", "/setup/stepup/key",
"/setup/folder/propose", "/setup/anchor/choose", "/device/approve", "/device/approve-shown"} {
w := uiPost(t, h, p, url.Values{"target": {"machine"}, "token": {"skb_x"}}, lane)
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "sudo -H privatae-bastion ui") {
t.Errorf("POST %s from the lane = %d %s", p, w.Code, firstLines(w.Body.String()))
}
}
if w := uiGet(t, h, "/setup/stepup/qr?e=x", lane); w.Code != http.StatusForbidden {
t.Errorf("the enrolment QR was served to the lane: %d", w.Code)
}
// The disk map, run as root, names files in every account's home. Its
// stand-in is installed, so a POST that got through would walk nothing:
// it blocks until the test ends.
dm := dmTestInstall(t, nil)
release := make(chan struct{})
t.Cleanup(func() { close(release) })
dm.scan = func(ctx context.Context, _ []string, _ int, _ func(dmProgress)) *dmResult {
<-release
return nil
}
for _, p := range []string{"/diskmap", "/fragments/diskmap"} {
if w := uiGet(t, h, p, lane); w.Code != http.StatusForbidden {
t.Errorf("GET %s from the lane = %d", p, w.Code)
}
}
if w := uiPost(t, h, "/diskmap/scan", url.Values{}, lane); w.Code != http.StatusForbidden {
t.Errorf("POST /diskmap/scan from the lane = %d", w.Code)
}
// Narrowing reach needs no code, from the lane too.
uiPost(t, h, "/setup/host/disconnect", url.Values{"host": {"member.host.privatae.ai"}, "confirm": {"1"}}, lane)
if cfgs, _ := loadPairings(); len(cfgs) != 0 {
t.Error("the lane could not disconnect a Host")
}
// The same routes still work for the administrator's own session.
if w := uiPost(t, h, "/setup/stepup/start", url.Values{"target": {"machine"}, "replace": {"1"}}, admin); w.Code != http.StatusOK {
t.Errorf("the administrator was refused: %d %s", w.Code, firstLines(w.Body.String()))
}
body := uiGet(t, h, "/setup", lane).Body.String()
if strings.Contains(body, `hx-post="/setup/pair"`) || strings.Contains(body, `hx-post="/setup/stepup/start"`) {
t.Error("Settings offers the lane a form it would refuse")
}
if !strings.Contains(body, "sudo -H privatae-bastion stepup-enroll --machine --rotate") {
t.Error("Settings does not tell the lane how the code is replaced")
}
if body := uiGet(t, h, "/setup", admin).Body.String(); !strings.Contains(body, `hx-post="/setup/pair"`) {
t.Error("Settings lost the pairing form for the administrator")
}
}
// The folder check stats whatever path is posted, as the daemon, which is root
// on a whole-machine install. The lane draws no folder form, so from the lane
// it could only be used to learn what exists where the person's account cannot
// look; it is the administrator's, like the proposal it serves.
func TestALaneSessionCannotProbePathsThroughTheFolderCheck(t *testing.T) {
u, admin := settingsWorld(t, true)
laneOwner(t, 1000)
lane := laneSignedIn(t, u, 1000)
h := u.handler()
dir := t.TempDir()
w := uiPost(t, h, "/setup/folder/check", url.Values{"path": {dir}}, lane)
if w.Code != http.StatusForbidden || strings.Contains(w.Body.String(), "A folder on this machine") {
t.Errorf("the lane's folder check = %d %s", w.Code, firstLines(w.Body.String()))
}
if w := uiPost(t, h, "/setup/folder/check", url.Values{"path": {dir}}, admin); w.Code != http.StatusOK ||
!strings.Contains(w.Body.String(), "A folder on this machine") {
t.Errorf("the administrator's folder check = %d %s", w.Code, firstLines(w.Body.String()))
}
}
// Through the lane the Host factor's seed is out of reach, so wrong codes
// typed for a Host's request are throttled there, and only there.
func TestALaneSessionsWrongHostCodesAreThrottled(t *testing.T) {
const host = "member.host.privatae.ai"
_, id := uiTestApprovalBox(t, host)
u := uiTestServer(t)
laneOwner(t, 1000)
lane := laneSignedIn(t, u, 1000)
h := u.handler()
var last string
for i := 0; i < pipeFreeFailures+1; i++ {
last = uiPost(t, h, "/approve", url.Values{"host": {host}, "id": {id}, "code": {"000000"}}, lane).Body.String()
}
if !strings.Contains(last, "wait") && !strings.Contains(last, "again in") {
t.Errorf("the lane typed %d wrong codes with no wait: %s", pipeFreeFailures+1, firstLines(last))
}
admin := uiSignedIn(t, u)
body := uiPost(t, h, "/approve", url.Values{"host": {host}, "id": {id}, "code": {"000000"}}, admin).Body.String()
if strings.Contains(body, "again in") {
t.Errorf("the administrator's page is throttled too: %s", firstLines(body))
}
}
// Links minted for the lane are capped, so an account asking in a loop cannot
// grow the root daemon's memory; one opened frees its place.
func TestTheLaneCapsTheLinksWaitingToBeOpened(t *testing.T) {
u := uiTestServer(t)
uiTestConfigDir(t)
laneOwner(t, 1000)
laneSignedIn(t, u, 1000) // sets the server for the lane; its link is used
for i := 0; i < consoleLaneTicketsMax; i++ {
if _, err := consoleLaneEntry(1000, "sam"); err != nil {
t.Fatalf("link %d refused: %v", i+1, err)
}
}
if _, err := consoleLaneEntry(1000, "sam"); err == nil || !strings.Contains(err.Error(), "too many") {
t.Fatalf("link %d was minted: %v", consoleLaneTicketsMax+1, err)
}
}
+25 -9
View File
@@ -135,14 +135,8 @@ func maybeContain(pairing, dcnDir string, heartbeatResponse []byte) {
// already passed the agent checks above, and an agent whose own // already passed the agent checks above, and an agent whose own
// fingerprint is in the accounting (one running as a service, say) // fingerprint is in the accounting (one running as a service, say)
// would otherwise be spared in silence — killTree returns 0 for a // would otherwise be spared in silence — killTree returns 0 for a
// spared root and a zero kill is not recorded (review of #100). // spared root and a zero kill is not recorded.
root := pid killed := stopAgentTree(pid, guard)
killed := killTree(pid, func(p int) bool {
if p == root {
return spareFromContain(p)
}
return guard(p)
})
if killed > 0 { if killed > 0 {
fmt.Printf("contain: terminated agent pid %d and %d child process(es) (host-directed)\n", pid, killed-1) fmt.Printf("contain: terminated agent pid %d and %d child process(es) (host-directed)\n", pid, killed-1)
// The Printf stays — under systemd it is journald, which is a real // The Printf stays — under systemd it is journald, which is a real
@@ -175,7 +169,13 @@ func hostPhrase(pairing string) string {
// here rather than in events.go because this is the only place that still holds // here rather than in events.go because this is the only place that still holds
// the host, the signature and the count together. // the host, the signature and the count together.
func containSentence(pairing, signature string, pid, children int) string { func containSentence(pairing, signature string, pid, children int) string {
s := hostPhrase(pairing) + " stopped " + signature + " (pid " + strconv.Itoa(pid) + return stopSentence(hostPhrase(pairing), signature, pid, children)
}
// stopSentence is containSentence with the one who stopped it named: a Host,
// or "You" when the machine's owner did (stop_local.go).
func stopSentence(subject, signature string, pid, children int) string {
s := subject + " stopped " + signature + " (pid " + strconv.Itoa(pid) +
") running on this machine" ") running on this machine"
switch { switch {
case children == 0: case children == 0:
@@ -208,3 +208,19 @@ func observedAgentMap() map[int]agentObs {
} }
return out return out
} }
// stopAgentTree stops one observed agent and everything beneath it, sparing
// init, this bastion and its own local agent at the root, and the protected
// set (containGuard) below it. It is the ONE stop path: a Host's order
// (maybeContain) and the machine owner's own (stop_local.go) both come
// through here, so what each may not touch cannot drift apart. It returns how
// many processes the kill reached, the agent included.
func stopAgentTree(pid int, guard func(int) bool) int {
root := pid
return killTree(pid, func(p int) bool {
if p == root {
return spareFromContain(p)
}
return guard(p)
})
}
+6 -6
View File
@@ -104,7 +104,7 @@ func recordContainResult(pairing string, o findingOrder, outcome string, stopped
// "Carried out once" held only while this process lived: a bastion that // "Carried out once" held only while this process lived: a bastion that
// restarted after a kill and before the beat carried the answer (a self-update, // restarted after a kill and before the beat carried the answer (a self-update,
// a crash) would receive the order again, run it again, and report // a crash) would receive the order again, run it again, and report
// not_a_finding — or kill a restarted copy (review of #100). So every result // not_a_finding — or kill a restarted copy. So every result
// is written to the config dir as it is recorded and read back on first use. // is written to the config dir as it is recorded and read back on first use.
var containResultsLoaded bool var containResultsLoaded bool
@@ -165,7 +165,7 @@ func containResultsSaveLocked() {
} }
if b, err := json.Marshal(disk); err == nil { if b, err := json.Marshal(disk); err == nil {
// Atomic: a crash mid-write must not corrupt the record and make the // Atomic: a crash mid-write must not corrupt the record and make the
// box forget answers it gave (review of #100). // box forget answers it gave.
_ = atomicWrite(p, b) _ = atomicWrite(p, b)
} }
} }
@@ -231,7 +231,7 @@ type findingView struct {
protected map[int]bool protected map[int]bool
// identity: each finding pid's identity read straight after the walk. The // identity: each finding pid's identity read straight after the walk. The
// kill checks it before touching the root, so a pid reused between the walk // kill checks it before touching the root, so a pid reused between the walk
// and the kill is never signalled (review of #100). // and the kill is never signalled.
identity map[int]string identity map[int]string
} }
@@ -441,9 +441,9 @@ func maybeContainFindings(pairing, dcnDir string, heartbeatResponse []byte) {
// process in the tree, not only the ones an order names — an accounting entry // process in the tree, not only the ones an order names — an accounting entry
// (accountingPIDs), an app the user is running, anything this bastion started, // (accountingPIDs), an app the user is running, anything this bastion started,
// init, the bastion. killTree does not signal a protected process or walk // init, the bastion. killTree does not signal a protected process or walk
// beneath it (review of #100: stopping a replaced sshd would otherwise have // beneath it: without that, stopping a replaced sshd would take every ssh
// taken every ssh session with it). It also returns the bastion's ANCESTORS — // session with it. It also returns the bastion's ANCESTORS — its launch
// its launch chain — which the finding path refuses by name: if any process in // chain — which the finding path refuses by name: if any process in
// a tree were an ancestor of the bastion, the tree's root would be one too, so // a tree were an ancestor of the bastion, the tree's root would be one too, so
// the named check always fires first and a tree-level check could never be // the named check always fires first and a tree-level check could never be
// reached. // reached.
+11 -11
View File
@@ -290,7 +290,7 @@ func TestProcessFindingsFromTheRealComparison(t *testing.T) {
} }
} }
// THE GUARDS HOLD ACROSS THE WHOLE TREE (review of #100, blocking). A finding // THE GUARDS HOLD ACROSS THE WHOLE TREE. A finding
// whose tree holds an accounting entry, an app, or something the bastion // whose tree holds an accounting entry, an app, or something the bastion
// started: those processes and everything beneath them are spared, the rest // started: those processes and everything beneath them are spared, the rest
// of the tree is stopped. // of the tree is stopped.
@@ -334,8 +334,8 @@ func TestContainFindingRefusesTheBastionsOwnAncestors(t *testing.T) {
} }
// THE REFUSALS liveProcessFindings MAKES, through the real function with only // THE REFUSALS liveProcessFindings MAKES, through the real function with only
// the machine replaced (review of #100: every other test replaced the whole // the machine replaced. Every other test replaces the whole function, so
// function, so none of these was tested). // this is the one that tests these refusals.
// //
// MUTATION: drop the root check. RESULT: red (no_authority not returned). // MUTATION: drop the root check. RESULT: red (no_authority not returned).
// MUTATION: drop !digestOK. RESULT: red (an edited accounting is used). // MUTATION: drop !digestOK. RESULT: red (an edited accounting is used).
@@ -375,7 +375,7 @@ func TestLiveProcessFindingsRefusesWhatItCannotTrust(t *testing.T) {
} }
} }
// RESULTS SURVIVE A RESTART (review of #100): an answer recorded before the // RESULTS SURVIVE A RESTART: an answer recorded before the
// process died is still there — and still stops a re-run — after it starts. // process died is still there — and still stops a re-run — after it starts.
// //
// MUTATION: skip containResultsSaveLocked. RESULT: red. // MUTATION: skip containResultsSaveLocked. RESULT: red.
@@ -413,7 +413,7 @@ func TestContainFindingRunsAnOrderOnce(t *testing.T) {
} }
} }
// THE REAL FILTER IS WIRED IN (review of #100): with containFilter left at its // THE REAL FILTER IS WIRED IN: with containFilter left at its
// default, a new process the bastion itself started is filtered by the same // default, a new process the bastion itself started is filtered by the same
// deltaFilter the pass uses, and so is never containable; the same process // deltaFilter the pass uses, and so is never containable; the same process
// started by anyone else is. // started by anyone else is.
@@ -422,7 +422,7 @@ func TestContainFindingRunsAnOrderOnce(t *testing.T) {
func TestContainFilterIsTheDeltaPassFilter(t *testing.T) { func TestContainFilterIsTheDeltaPassFilter(t *testing.T) {
// The process's AGE is supplied (an hour), not read from a real process: // The process's AGE is supplied (an hour), not read from a real process:
// a real pid's age depends on where the suite runs — pid 1 in a fresh PID // a real pid's age depends on where the suite runs — pid 1 in a fresh PID
// namespace is seconds old and reads as short-lived (review of #100). // namespace is seconds old and reads as short-lived.
self := os.Getpid() self := os.Getpid()
prevTree, prevAge := processTree, deltaAgeFn prevTree, prevAge := processTree, deltaAgeFn
t.Cleanup(func() { processTree, deltaAgeFn = prevTree, prevAge }) t.Cleanup(func() { processTree, deltaAgeFn = prevTree, prevAge })
@@ -446,7 +446,7 @@ func TestContainFilterIsTheDeltaPassFilter(t *testing.T) {
} }
} }
// THE ROOT'S IDENTITY IS CARRIED FROM THE WALK (review of #100): a pid that // THE ROOT'S IDENTITY IS CARRIED FROM THE WALK: a pid that
// changed hands between the finding walk and the kill is not signalled at all. // changed hands between the finding walk and the kill is not signalled at all.
// //
// MUTATION: killTree(pid, protected) instead of killTreeExpect with the view's // MUTATION: killTree(pid, protected) instead of killTreeExpect with the view's
@@ -471,7 +471,7 @@ func TestContainFindingDoesNotKillARootThatChangedHandsAfterTheWalk(t *testing.T
} }
} }
// THE AGENT PATH HAS THE SAME TREE GUARD (review of #100): an accounting entry // THE AGENT PATH HAS THE SAME TREE GUARD: an accounting entry
// or an app inside an agent's tree is spared with its subtree. // or an app inside an agent's tree is spared with its subtree.
// //
// MUTATION: killTree(pid, spareFromContain) in maybeContain. RESULT: red. // MUTATION: killTree(pid, spareFromContain) in maybeContain. RESULT: red.
@@ -497,9 +497,9 @@ func TestAgentContainSparesProtectedProcessesInTheTree(t *testing.T) {
} }
} }
// AN AGENT WHOSE OWN FINGERPRINT IS IN THE ACCOUNTING IS STILL STOPPED (review // AN AGENT WHOSE OWN FINGERPRINT IS IN THE ACCOUNTING IS STILL STOPPED: the
// of #100, round 3): the tree guard applies BENEATH the agent, never to the // tree guard applies BENEATH the agent, never to the agent the host named.
// agent the host named. Before this, such an agent was spared in silence. // Before this, such an agent was spared in silence.
// //
// MUTATION: killTree(pid, guard) — the guard on the root too. RESULT: red. // MUTATION: killTree(pid, guard) — the guard on the root too. RESULT: red.
func TestAgentContainStopsAnAgentThatIsItselfInTheAccounting(t *testing.T) { func TestAgentContainStopsAnAgentThatIsItselfInTheAccounting(t *testing.T) {
+79 -7
View File
@@ -43,9 +43,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
) )
@@ -126,16 +128,79 @@ func startContextSock(done <-chan struct{}) {
_ = ln.Close() _ = ln.Close()
_ = os.Remove(p) _ = os.Remove(p)
}() }()
go func() { go contextSockAccept(ln)
uilogf("context socket listening on %s\n", p)
}
// The socket is 0666 and the daemon runs as root (or SYSTEM), so every user on
// the machine can make it read. These bound what they can make it hold: an ask
// is a folder path and a display name, far under contextAskMaxBytes, and no
// more than contextSockMaxInFlight asks are read at once. Past that a
// connection is told the daemon is busy and closed; the asker tries again.
//
// One person may hold at most contextSockMaxPerUser of those slots, keyed on
// the identity the kernel gives (never on anything the asker says): without
// it, one user opening contextSockMaxInFlight idle connections and dialling
// again as each deadline ran out kept every other user's asks "busy" for as
// long as they liked. A person adds a folder at a time; four at once is far
// past that.
const (
contextAskMaxBytes = 64 << 10
contextSockMaxInFlight = 32
)
// contextSockMaxPerUser is a var only so the test of the overall cap can lift
// it (every test connection is the same user).
var contextSockMaxPerUser = 4
var contextSockUsers = struct {
sync.Mutex
held map[string]int
}{held: map[string]int{}}
// contextSockTakeUser takes one of user's slots, or reports there is none.
func contextSockTakeUser(user string) bool {
contextSockUsers.Lock()
defer contextSockUsers.Unlock()
if contextSockUsers.held[user] >= contextSockMaxPerUser {
return false
}
contextSockUsers.held[user]++
return true
}
func contextSockReleaseUser(user string) {
contextSockUsers.Lock()
defer contextSockUsers.Unlock()
if contextSockUsers.held[user]--; contextSockUsers.held[user] <= 0 {
delete(contextSockUsers.held, user)
}
}
// contextSockAccept serves ln until it is closed, at most
// contextSockMaxInFlight connections at a time.
func contextSockAccept(ln net.Listener) {
slots := make(chan struct{}, contextSockMaxInFlight)
for { for {
c, err := ln.Accept() c, err := ln.Accept()
if err != nil { if err != nil {
return return
} }
go contextSockServe(c) select {
} case slots <- struct{}{}:
go func() {
defer func() { <-slots }()
contextSockServe(c)
}() }()
uilogf("context socket listening on %s\n", p) default:
go func() {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(time.Second))
_ = json.NewEncoder(c).Encode(contextAskReply{Why: "the bastion is busy " +
"answering other asks on this machine; try again in a moment"})
}()
}
}
} }
func contextSockServe(c net.Conn) { func contextSockServe(c net.Conn) {
@@ -154,8 +219,15 @@ func contextSockServe(c net.Conn) {
return return
} }
if !contextSockTakeUser(user) {
_ = json.NewEncoder(c).Encode(contextAskReply{User: user, Why: "the bastion is busy " +
"answering other asks of yours; try again in a moment"})
return
}
defer contextSockReleaseUser(user)
var req contextAskRequest var req contextAskRequest
if err := json.NewDecoder(c).Decode(&req); err != nil { if err := json.NewDecoder(io.LimitReader(c, contextAskMaxBytes)).Decode(&req); err != nil {
_ = json.NewEncoder(c).Encode(contextAskReply{User: user, Why: "the request could not be read"}) _ = json.NewEncoder(c).Encode(contextAskReply{User: user, Why: "the request could not be read"})
return return
} }
@@ -200,10 +272,10 @@ func contextAskViaSock(root, name string) (sent bool, host, note string, availab
return true, rep.Host, "", true return true, rep.Host, "", true
} }
// contextSockUnsupported is the note used where the platform has no // errContextSockUnsupported is the note used where the platform has no
// implementation yet, so the reason a person sees names the platform rather // implementation yet, so the reason a person sees names the platform rather
// than pretending the feature is merely broken. // than pretending the feature is merely broken.
var contextSockUnsupported = errors.New("peer identity over a local socket is not " + var errContextSockUnsupported = errors.New("peer identity over a local socket is not " +
"implemented for this platform yet") "implemented for this platform yet")
func contextSockFmtUser(uid uint32, name string) string { func contextSockFmtUser(uid uint32, name string) string {
+130
View File
@@ -0,0 +1,130 @@
package main
// The context socket is 0666: every user on the machine may connect, and the
// daemon behind it runs as root (or SYSTEM). So what one connection can make
// the daemon hold must be bounded by the daemon, not by the asker.
import (
"net"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// An ask is a folder path and a display name. A client that streams a request
// with no end gets the daemon to read only a bounded amount of it, then a
// reply and a close; before, the decoder kept every byte until the 30 s
// deadline, and nothing capped how many such connections were open.
func TestAContextAskWithNoEndIsReadOnlyToABound(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no unix-socket peer credentials on windows; contextsock_windows.go says why")
}
sock := filepath.Join(t.TempDir(), "s.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
c, err := ln.Accept()
if err != nil {
return
}
contextSockServe(c)
}()
c, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
defer c.Close()
const total = 16 << 20
chunk := []byte(strings.Repeat("a", 64<<10))
sent := 0
if _, err := c.Write([]byte(`{"root":"`)); err != nil {
t.Fatal(err)
}
for sent < total {
_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
n, err := c.Write(chunk)
sent += n
if err != nil {
break
}
}
// The kernel's socket buffers hold a few hundred KiB the daemon never
// read; anything near the whole stream means the daemon read it all.
if sent >= 4<<20 {
t.Fatalf("the daemon accepted %d MiB of one ask with no end; it must stop at a bound", sent>>20)
}
}
// Asks in flight are capped: a connection past the cap is closed at once, so
// many idle or endless connections cannot multiply the bound above.
func TestContextAsksInFlightAreCapped(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no unix-socket peer credentials on windows; contextsock_windows.go says why")
}
sock := filepath.Join(t.TempDir(), "s.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
// Every connection here is the same user; lift the per-user share so the
// overall cap is what this test meets (TestOneUserCannotHoldEveryContextSockSlot
// holds the share).
oldShare := contextSockMaxPerUser
contextSockMaxPerUser = contextSockMaxInFlight
t.Cleanup(func() { contextSockMaxPerUser = oldShare })
go contextSockAccept(ln)
var held []net.Conn
defer func() {
for _, h := range held {
_ = h.Close()
}
}()
// Each of these sends nothing and holds its slot until its deadline.
for i := 0; i < contextSockMaxInFlight; i++ {
h, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
held = append(held, h)
}
// Give the daemon's accept loop time to take every held connection.
time.Sleep(200 * time.Millisecond)
extra, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
defer extra.Close()
_ = extra.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 512)
n, rerr := extra.Read(buf)
if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
t.Fatalf("a connection past the cap of %d was left open; it must be refused", contextSockMaxInFlight)
}
if n > 0 && !strings.Contains(string(buf[:n]), "busy") {
t.Fatalf("a connection past the cap got %q; it must be told the daemon is busy", buf[:n])
}
// Once a held connection goes, a new ask gets a slot again.
_ = held[0].Close()
held = held[1:]
time.Sleep(200 * time.Millisecond)
again, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
defer again.Close()
_ = again.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, rerr = again.Read(buf)
if ne, ok := rerr.(net.Error); !ok || !ne.Timeout() {
t.Fatalf("after a slot was freed, a new ask was not served (read %q, %v)", buf[:n], rerr)
}
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !linux && !darwin && !windows
package main
// The release builds linux, darwin and windows (deploy/build.sh). Any other
// unix (freebsd, openbsd) still builds, with the local context socket unable
// to tell who is on the other end: contextPeerUser then fails, and
// contextSockServe answers every connection with that reason and forwards
// nothing. An ask that cannot be attributed is refused, never let through.
func contextPeerUID(uintptr) (uint32, error) { return 0, errContextSockUnsupported }
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"net"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// One person cannot take every slot: past their own share they are told the
// daemon is busy at once, while slots remain for everyone else. Before, one
// user's idle connections, re-dialled as each deadline ran out, held all of
// them and every other user's ask was refused.
func TestOneUserCannotHoldEveryContextSockSlot(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no unix-socket peer credentials on windows; contextsock_windows.go says why")
}
const share = 4 // contextSockMaxPerUser
// A test before this one may still be releasing its slots.
if n := contextSockSlotsHeld(t, 0); n != 0 {
t.Fatalf("%d slots still held from an earlier test", n)
}
sock := filepath.Join(t.TempDir(), "s.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go contextSockAccept(ln)
var held []net.Conn
defer func() {
for _, h := range held {
_ = h.Close()
}
}()
for i := 0; i < share; i++ {
h, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
held = append(held, h)
}
// Wait until the daemon has given those four their slots (a fixed sleep
// lost this race on a loaded machine and handed the 5th one first).
if n := contextSockSlotsHeld(t, share); n != share {
t.Fatalf("the daemon gave %d slots to %d idle connections of one user", n, share)
}
extra, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
defer extra.Close()
_ = extra.SetReadDeadline(time.Now().Add(2 * time.Second))
buf := make([]byte, 512)
n, rerr := extra.Read(buf)
if ne, ok := rerr.(net.Error); ok && ne.Timeout() {
t.Fatalf("one user held %d idle connections and a %dth was still given a slot: "+
"one user can take all %d", share, share+1, contextSockMaxInFlight)
}
if !strings.Contains(string(buf[:n]), "busy") {
t.Fatalf("past one user's share the ask got %q; it must be told the daemon is busy", buf[:n])
}
}
// contextSockSlotsHeld waits up to 10 s for the per-user slots held across
// all users to reach want, and returns the count it last saw.
func contextSockSlotsHeld(t *testing.T, want int) int {
t.Helper()
n := -1
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
contextSockUsers.Lock()
n = 0
for _, k := range contextSockUsers.held {
n += k
}
contextSockUsers.Unlock()
if n == want {
break
}
}
return n
}
+12 -5
View File
@@ -62,13 +62,20 @@ func contextSockListen(path string) (net.Listener, error) {
// contextPeerUser reads the connecting process's user from the kernel. // contextPeerUser reads the connecting process's user from the kernel.
func contextPeerUser(c net.Conn) (string, error) { func contextPeerUser(c net.Conn) (string, error) {
_, who, err := contextPeerIdentity(c)
return who, err
}
// contextPeerIdentity is the connecting process's uid from the kernel, and
// the account's name for messages (or "uid N" when it has none).
func contextPeerIdentity(c net.Conn) (uint32, string, error) {
uc, ok := c.(*net.UnixConn) uc, ok := c.(*net.UnixConn)
if !ok { if !ok {
return "", errors.New("not a unix socket") return 0, "", errors.New("not a unix socket")
} }
raw, err := uc.SyscallConn() raw, err := uc.SyscallConn()
if err != nil { if err != nil {
return "", err return 0, "", err
} }
var uid uint32 var uid uint32
var serr error var serr error
@@ -76,16 +83,16 @@ func contextPeerUser(c net.Conn) (string, error) {
uid, serr = contextPeerUID(fd) uid, serr = contextPeerUID(fd)
}) })
if err != nil { if err != nil {
return "", err return 0, "", err
} }
if serr != nil { if serr != nil {
return "", serr return 0, "", serr
} }
name := "" name := ""
if u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil { if u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil {
name = u.Username name = u.Username
} }
return contextSockFmtUser(uid, name), nil return uid, contextSockFmtUser(uid, name), nil
} }
// contextSockDir is machine-wide and root-owned. See contextSockPath. // contextSockDir is machine-wide and root-owned. See contextSockPath.
+6 -2
View File
@@ -33,11 +33,15 @@ const contextSockDir = `C:\ProgramData\privatae-bastion`
func contextSockDialable(string) string { return "" } func contextSockDialable(string) string { return "" }
func contextSockListen(path string) (net.Listener, error) { func contextSockListen(path string) (net.Listener, error) {
return nil, contextSockUnsupported return nil, errContextSockUnsupported
} }
func contextPeerUser(c net.Conn) (string, error) { func contextPeerUser(c net.Conn) (string, error) {
return "", contextSockUnsupported return "", errContextSockUnsupported
}
func contextPeerIdentity(c net.Conn) (uint32, string, error) {
return 0, "", errContextSockUnsupported
} }
var _ = os.Remove var _ = os.Remove
+1 -4
View File
@@ -859,10 +859,7 @@ func sttIsNonSpeechMark(text string) bool {
// ONE group, not two. "(yes) and (no)" ends in a bracket and is speech; the // ONE group, not two. "(yes) and (no)" ends in a bracket and is speech; the
// opening bracket must be closed only at the very end. // opening bracket must be closed only at the very end.
inner := t[1 : len(t)-1] inner := t[1 : len(t)-1]
if strings.IndexByte(inner, closer) >= 0 { return strings.IndexByte(inner, closer) < 0
return false
}
return true
} }
type sttSegment struct { type sttSegment struct {
+1 -3
View File
@@ -26,9 +26,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
// uvicornWSMaxSize is the host's WebSocket frame ceiling: the server default, // uvicornWSMaxSize (harness.go) is the host's WebSocket frame ceiling.
// which nothing on the host overrides, so this is the live value.
const uvicornWSMaxSize = 16 << 20
func TestMeasureCtxPutCapacity(t *testing.T) { func TestMeasureCtxPutCapacity(t *testing.T) {
if os.Getenv("MEASURE_CTX_PUT") == "" { if os.Getenv("MEASURE_CTX_PUT") == "" {
+169 -23
View File
@@ -26,6 +26,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -74,6 +75,10 @@ type dcnGrantRec struct {
// the signed bytes (a public key; the terms stay exactly what was // the signed bytes (a public key; the terms stay exactly what was
// signed). The managed local agent seals the store's catalog to it. // signed). The managed local agent seals the store's catalog to it.
SealRecipient string `json:"seal_recipient,omitempty"` SealRecipient string `json:"seal_recipient,omitempty"`
// RootReal is where the granted folder led when this box installed the
// grant: its real path, pinned here (dcn_store.go), outside the signed
// bytes. An op whose root leads anywhere else is refused.
RootReal string `json:"root_real,omitempty"`
} }
// dcnTerms is parsed FROM the verified canonical bytes — never from anything else. // dcnTerms is parsed FROM the verified canonical bytes — never from anything else.
@@ -185,10 +190,20 @@ func dcnSaveFile(dir string, f *dcnGrantFile) error {
// accounting ("every read is logged for you"). Best-effort: auditing never blocks // accounting ("every read is logged for you"). Best-effort: auditing never blocks
// a result, but a refusal is still a refusal whether or not the line lands. // a result, but a refusal is still a refusal whether or not the line lands.
func dcnAudit(dir, op string, storeID int64, path, decision string) { func dcnAudit(dir, op string, storeID int64, path, decision string) {
rec, _ := json.Marshal(map[string]any{ dcnAuditWith(dir, op, storeID, path, decision, nil)
"ts": time.Now().UTC().Format(time.RFC3339), "op": op, }
"store_id": storeID, "path": path, "decision": decision,
}) // dcnAuditWith is dcnAudit with more fields on the row (an ask's asker and
// target, askparties.go). They never replace the five every reader expects.
func dcnAuditWith(dir, op string, storeID int64, path, decision string, extra map[string]any) {
row := map[string]any{}
for k, v := range extra {
row[k] = v
}
row["ts"], row["op"] = time.Now().UTC().Format(time.RFC3339), op
row["store_id"], row["path"], row["decision"] = storeID, path, decision
rec, _ := json.Marshal(row)
auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec)
fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
@@ -270,27 +285,82 @@ func dcnResolvePath(root, rpath string) (string, bool) {
return joined, true return joined, true
} }
// dcnExcluded reports whether rpath trips any exclude pattern. Patterns match the // dcnExcluded reports whether rpath trips any exclude pattern, or is inside a
// basename and every path segment (glob per segment: ".ssh", "*.key", ".env*"), // local search agent's folder (dcnInAgentDirOn), which no grant reaches. A
// compared the way this platform's file system compares names (dcnNameKey). // pattern of one folder name matches any segment of the path (".ssh", "*.key",
// ".env*"); a pattern with a "/" (".config/gcloud") matches that run of
// consecutive segments wherever it sits in the path, each one a glob. Names
// are compared the way this platform's file system compares them (dcnNameKey).
// rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent. // rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent.
func dcnExcluded(excludes []string, rpath string) bool { func dcnExcluded(excludes []string, rpath string) bool {
return dcnExcludedOn(runtime.GOOS, excludes, rpath) return dcnExcludedOn(runtime.GOOS, excludes, rpath)
} }
func dcnExcludedOn(goos string, excludes []string, rpath string) bool { func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
if dcnInAgentDirOn(goos, rpath) {
return true
}
segs := strings.Split(filepath.ToSlash(rpath), "/") segs := strings.Split(filepath.ToSlash(rpath), "/")
for i, seg := range segs {
segs[i] = dcnNameKey(goos, seg)
}
for _, pat := range excludes { for _, pat := range excludes {
pat = strings.TrimSpace(pat) pat = strings.TrimSpace(pat)
if pat == "" { if pat == "" {
continue continue
} }
pat = dcnNameKey(goos, pat) var run []string
for _, seg := range segs { for _, p := range strings.Split(pat, "/") {
if ok, _ := filepath.Match(pat, dcnNameKey(goos, seg)); ok { if p != "" {
run = append(run, dcnNameKey(goos, p))
}
}
if dcnRunMatches(run, segs) {
return true return true
} }
} }
return false
}
// dcnRunMatches says whether the globs in run match len(run) consecutive
// segments of segs, starting anywhere. An empty run matches nothing.
func dcnRunMatches(run, segs []string) bool {
if len(run) == 0 {
return false
}
for i := 0; i+len(run) <= len(segs); i++ {
all := true
for j, p := range run {
if ok, _ := filepath.Match(p, segs[i+j]); !ok {
all = false
break
}
}
if all {
return true
}
}
return false
}
// dcnAgentDir is the folder the local search agent keeps everything it makes
// in, beside the files it reads: the plaintext of every file in scope, the
// sanitized text, vectors, the catalog and sealed bundles. The bastion puts
// it at <root>/.agent wherever it may write into the folder (freeagent_managed.go;
// the agent's own default too), and a folder inside a store can hold another
// store's. The agent's walker never reads one, at any depth and in any case;
// the file ops never serve one either.
const dcnAgentDir = ".agent"
// dcnInAgentDirOn reports whether any segment of rpath names an agent's
// folder, as the file system on goos names it, and in any case on every OS
// (as the agent's own walker judges it: a ".Agent" on a volume that ignores
// case is the agent's folder).
func dcnInAgentDirOn(goos, rpath string) bool {
for _, seg := range strings.Split(filepath.ToSlash(rpath), "/") {
if strings.EqualFold(dcnNameKey(goos, seg), dcnAgentDir) {
return true
}
} }
return false return false
} }
@@ -322,11 +392,25 @@ func dcnNameKey(goos, name string) string {
// //
// A path cleanRel refuses is judged as sent (the sandbox refuses it anyway). // A path cleanRel refuses is judged as sent (the sandbox refuses it anyway).
func dcnExcludedAsSent(excludes []string, root, rpath string) bool { func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnExcluded(excludes, rel) })
}
// dcnAgentAsSent is whether a path a host sent opens an agent's folder (by its
// spelling or through a link), judged as dcnExcludedAsSent judges excludes.
// dcnExcluded refuses the same paths; this one exists so the refusal can say
// why.
func dcnAgentAsSent(root, rpath string) bool {
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnInAgentDirOn(runtime.GOOS, rel) })
}
// dcnJudgedAsSent applies judge to what a host's path opens: the cleaned path
// relative to the root, then where it leads relative to the resolved root.
func dcnJudgedAsSent(root, rpath string, judge func(rel string) bool) bool {
joined, ok := cleanRel(root, rpath) joined, ok := cleanRel(root, rpath)
if !ok { if !ok {
return dcnExcluded(excludes, rpath) return judge(rpath)
} }
if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || dcnExcluded(excludes, rel) { if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || judge(rel) {
return true return true
} }
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root)) resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
@@ -337,7 +421,7 @@ func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
if err != nil { if err != nil {
return true return true
} }
return dcnExcluded(excludes, rel) return judge(rel)
} }
// dcnWhereItLeads is p with its deepest existing part's links resolved and the // dcnWhereItLeads is p with its deepest existing part's links resolved and the
@@ -422,6 +506,10 @@ func dcnLoadGrantMask(ws *websocket.Conn, dir, op string, storeID int64, rpath,
dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask") dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask")
return nil return nil
} }
if rpath != "" && dcnAgentAsSent(terms.Root, rpath) {
dcnRefuse(ws, dir, op, storeID, rpath, "path is in the local search agent's own folder (.agent), which no grant reaches")
return nil
}
if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) { if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) {
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant") dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
return nil return nil
@@ -523,6 +611,17 @@ func handleDcnGrantSync(ws *websocket.Conn, dir string, args []string) {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", "grant does not verify: "+err.Error()) dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", "grant does not verify: "+err.Error())
return return
} }
// The folder this grant names is pinned as it resolves now; a re-sync or a
// renewal of the same root keeps the pin it already has (dcn_store.go).
if prev, held := f.Stores[fmt.Sprint(payload.StoreID)]; held {
prevRoot := ""
if pt, perr := dcnVerifyRec(f.VerifyPubkey, prev); perr == nil {
prevRoot = pt.Root
}
rec.RootReal = dcnPinFor(&prev, prevRoot, terms.Root)
} else {
rec.RootReal = dcnPinFor(nil, "", terms.Root)
}
if hn, _ := os.Hostname(); hn != "" && terms.Server != "" && terms.Server != hn { if hn, _ := os.Hostname(); hn != "" && terms.Server != "" && terms.Server != hn {
dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "", dcnRefuse(ws, dir, "grant_sync", payload.StoreID, "",
fmt.Sprintf("grant is for server '%s', this box is '%s'", terms.Server, hn)) fmt.Sprintf("grant is for server '%s', this box is '%s'", terms.Server, hn))
@@ -581,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,
@@ -609,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
} }
@@ -657,15 +758,26 @@ func handleDcnList(ws *websocket.Conn, dir string, args []string) {
if terms == nil { if terms == nil {
return return
} }
base := terms.Root base := "."
if a.Prefix != "" { if a.Prefix != "" {
p, ok := dcnResolvePath(terms.Root, a.Prefix) p, ok := dcnResolvePath(terms.Root, a.Prefix)
if !ok { if !ok {
dcnRefuse(ws, dir, "list", storeID, a.Prefix, "prefix escapes the store root") dcnRefuse(ws, dir, "list", storeID, a.Prefix, "prefix escapes the store root")
return return
} }
base = p rel, err := dcnRel(terms.Root, p)
if err != nil {
dcnRefuse(ws, dir, "list", storeID, a.Prefix, "prefix escapes the store root")
return
} }
base = filepath.ToSlash(rel)
}
r, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, "list", storeID, a.Prefix, err.Error())
return
}
defer r.Close()
limit := a.Limit limit := a.Limit
if limit <= 0 || limit > dcnMaxListEntries { if limit <= 0 || limit > dcnMaxListEntries {
limit = dcnMaxListEntries limit = dcnMaxListEntries
@@ -677,12 +789,14 @@ func handleDcnList(ws *websocket.Conn, dir string, args []string) {
} }
var files []entry var files []entry
truncated := false truncated := false
_ = filepath.WalkDir(base, func(p string, d os.DirEntry, err error) error { // Walked through the store's handle: a link that leads out of it is not
// followed, whatever the names do during the walk.
_ = fs.WalkDir(r.FS(), base, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return nil // unreadable subtree: skip, never abort the listing return nil // unreadable subtree: skip, never abort the listing
} }
rel, rerr := filepath.Rel(terms.Root, p) rel := filepath.FromSlash(p)
if rerr != nil || rel == "." { if rel == "." {
return nil return nil
} }
if dcnExcluded(terms.Excludes, rel) { if dcnExcluded(terms.Excludes, rel) {
@@ -726,8 +840,23 @@ func handleDcnStat(ws *websocket.Conn, dir string, args []string) {
dcnRefuse(ws, dir, "stat", storeID, a.Path, "path escapes the store root") dcnRefuse(ws, dir, "stat", storeID, a.Path, "path escapes the store root")
return return
} }
info, err := os.Stat(abs) rel, err := dcnRel(terms.Root, abs)
if err != nil { if err != nil {
dcnRefuse(ws, dir, "stat", storeID, a.Path, err.Error())
return
}
r, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, "stat", storeID, a.Path, err.Error())
return
}
defer r.Close()
info, err := r.Stat(rel)
if err != nil {
if dcnEscaped(err) {
dcnRefuse(ws, dir, "stat", storeID, a.Path, "path escapes the store root")
return
}
if os.IsNotExist(err) { if os.IsNotExist(err) {
dcnResult(ws, dir, "stat", storeID, a.Path, map[string]any{ dcnResult(ws, dir, "stat", storeID, a.Path, map[string]any{
"state": "gone", "path": a.Path}) "state": "gone", "path": a.Path})
@@ -770,8 +899,25 @@ func handleDcnReadRange(ws *websocket.Conn, dir string, args []string) {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, "path escapes the store root") dcnRefuse(ws, dir, "read_range", storeID, a.Path, "path escapes the store root")
return return
} }
fh, err := os.Open(abs) rel, err := dcnRel(terms.Root, abs)
if err != nil { if err != nil {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, err.Error())
return
}
r, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, err.Error())
return
}
defer r.Close()
// Opened through the store's handle: a name swapped for a link to a file
// outside the store after the check above is refused, never followed.
fh, err := r.Open(rel)
if err != nil {
if dcnEscaped(err) {
dcnRefuse(ws, dir, "read_range", storeID, a.Path, "path escapes the store root")
return
}
if os.IsNotExist(err) { if os.IsNotExist(err) {
dcnResult(ws, dir, "read_range", storeID, a.Path, map[string]any{ dcnResult(ws, dir, "read_range", storeID, a.Path, map[string]any{
"state": "gone", "path": a.Path}) "state": "gone", "path": a.Path})
+60 -20
View File
@@ -142,6 +142,10 @@ func dcnCheckPlan(root string, excludes []string, moves []dcnMove) []dcnPlanProb
continue continue
} }
seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i
if dcnAgentAsSent(root, m.From) || dcnAgentAsSent(root, m.To) {
bad(i, m, "the local search agent's own folder (.agent) is never moved from or into")
continue
}
if dcnExcludedAsSent(excludes, root, m.From) { if dcnExcludedAsSent(excludes, root, m.From) {
bad(i, m, "source is excluded by the grant") bad(i, m, "source is excluded by the grant")
continue continue
@@ -191,10 +195,28 @@ func dcnReportPath(root string, planID int64) string {
return p return p
} }
// dcnApplyPlan runs the plan: check, rename in order (each re-checked just // dcnApplyPlan is dcnApplyPlanIn on root opened where it leads now (the op
// before it runs; rolling back on the first failure), verify, report. Every // handler opens the PINNED folder instead, dcn_store.go).
// path it touches has passed dcnCheckPlan.
func dcnApplyPlan(root string, excludes []string, plan *dcnPlanTerms) map[string]any { func dcnApplyPlan(root string, excludes []string, plan *dcnPlanTerms) map[string]any {
real, err := dcnRealRoot(root)
if err != nil {
return map[string]any{"state": "refused", "mode": "apply", "plan_id": plan.PlanID,
"reason": "the store's folder cannot be opened: " + err.Error()}
}
r, err := dcnOpenRootAt(real)
if err != nil {
return map[string]any{"state": "refused", "mode": "apply", "plan_id": plan.PlanID, "reason": err.Error()}
}
defer r.Close()
return dcnApplyPlanIn(r, root, excludes, plan)
}
// dcnApplyPlanIn runs the plan: check, rename in order (each re-checked just
// before it runs; rolling back on the first failure), verify, report. Every
// path it touches has passed dcnCheckPlan, and every change is made through
// r, the store's handle, so a folder that becomes a link to somewhere else
// between the check and the rename is refused rather than followed.
func dcnApplyPlanIn(r *os.Root, root string, excludes []string, plan *dcnPlanTerms) map[string]any {
problems := dcnCheckPlan(root, excludes, plan.Moves) problems := dcnCheckPlan(root, excludes, plan.Moves)
if len(problems) > 0 { if len(problems) > 0 {
return map[string]any{"state": "refused", "mode": "apply", "plan_id": plan.PlanID, return map[string]any{"state": "refused", "mode": "apply", "plan_id": plan.PlanID,
@@ -207,11 +229,11 @@ func dcnApplyPlan(root string, excludes []string, plan *dcnPlanTerms) map[string
// and what is NOT (so the user knows exactly what is still displaced). // and what is NOT (so the user knows exactly what is still displaced).
rollback := func() (back, stuck []string) { rollback := func() (back, stuck []string) {
for i := len(applied) - 1; i >= 0; i-- { for i := len(applied) - 1; i >= 0; i-- {
if _, err := os.Lstat(applied[i].src); err == nil { if _, err := r.Lstat(applied[i].src); err == nil {
stuck = append(stuck, applied[i].from) // something now sits at the source; never replace it stuck = append(stuck, applied[i].from) // something now sits at the source; never replace it
continue continue
} }
if err := os.Rename(applied[i].dst, applied[i].src); err == nil { if err := r.Rename(applied[i].dst, applied[i].src); err == nil {
back = append(back, applied[i].from) back = append(back, applied[i].from)
} else { } else {
stuck = append(stuck, applied[i].from) stuck = append(stuck, applied[i].from)
@@ -230,24 +252,30 @@ func dcnApplyPlan(root string, excludes []string, plan *dcnPlanTerms) map[string
return rep return rep
} }
for i, m := range plan.Moves { for i, m := range plan.Moves {
src, _ := dcnResolvePath(root, m.From) srcAbs, _ := dcnResolvePath(root, m.From)
dst, _ := cleanRel(root, m.To) dstAbs, _ := cleanRel(root, m.To)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { src, serr := dcnRel(root, srcAbs)
dst, derr := dcnRel(root, dstAbs)
if serr != nil || derr != nil {
return fail(i, m, "path escapes the store root")
}
if d := filepath.Dir(dst); d != "." {
if err := r.MkdirAll(d, 0o755); err != nil {
return fail(i, m, "could not create the target folder: "+err.Error()) return fail(i, m, "could not create the target folder: "+err.Error())
} }
}
// Re-check the instant before the rename: the world may have changed since // Re-check the instant before the rename: the world may have changed since
// the pre-check (a file appeared at the target; a parent became a symlink). // the pre-check (a file appeared at the target; a parent became a symlink).
// rename(2) would replace silently — this lane never lets it. // rename(2) would replace silently — this lane never lets it.
if _, ok := dcnResolvePath(root, m.To); !ok { if _, ok := dcnResolvePath(root, m.To); !ok {
return fail(i, m, "target's parent no longer resolves inside the store root") return fail(i, m, "target's parent no longer resolves inside the store root")
} }
if _, err := os.Lstat(dst); err == nil { if _, err := r.Lstat(dst); err == nil {
return fail(i, m, "target appeared since the check (a plan never overwrites)") return fail(i, m, "target appeared since the check (a plan never overwrites)")
} }
if err := os.Rename(src, dst); err != nil { if err := r.Rename(src, dst); err != nil {
why := err.Error() why := err.Error()
var le *os.LinkError if errors.Is(err, syscall.EXDEV) {
if errors.As(err, &le) && errors.Is(le.Err, syscall.EXDEV) {
why = "target is on a different filesystem than the source (a plan only renames; it never copies and removes)" why = "target is on a different filesystem than the source (a plan only renames; it never copies and removes)"
} }
return fail(i, m, why) return fail(i, m, why)
@@ -257,14 +285,16 @@ func dcnApplyPlan(root string, excludes []string, plan *dcnPlanTerms) map[string
// Verify pass: every target present, every source gone, declared sizes hold. // Verify pass: every target present, every source gone, declared sizes hold.
var mismatches []dcnPlanProblem var mismatches []dcnPlanProblem
for i, m := range plan.Moves { for i, m := range plan.Moves {
src, _ := dcnResolvePath(root, m.From) srcAbs, _ := dcnResolvePath(root, m.From)
dst, _ := cleanRel(root, m.To) dstAbs, _ := cleanRel(root, m.To)
info, err := os.Lstat(dst) src, _ := dcnRel(root, srcAbs)
dst, _ := dcnRel(root, dstAbs)
info, err := r.Lstat(dst)
if err != nil { if err != nil {
mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To, "target missing after move"}) mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To, "target missing after move"})
continue continue
} }
if _, err := os.Lstat(src); err == nil { if _, err := r.Lstat(src); err == nil {
mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To, "source still present after move"}) mismatches = append(mismatches, dcnPlanProblem{i, m.From, m.To, "source still present after move"})
} }
if m.SizeBytes != nil && !info.IsDir() && info.Size() != *m.SizeBytes { if m.SizeBytes != nil && !info.IsDir() && info.Size() != *m.SizeBytes {
@@ -372,9 +402,19 @@ func handleDcnPlanApply(ws *websocket.Conn, dir string, args []string) {
// a retry (a lost reply, a host re-run) gets the earlier report back BEFORE // a retry (a lost reply, a host re-run) gets the earlier report back BEFORE
// any gate can turn it into a refusal. (The signed expiry is the guard that // any gate can turn it into a refusal. (The signed expiry is the guard that
// does not depend on a file the user can remove.) // does not depend on a file the user can remove.)
store, err := dcnOpenStore(dir, storeID, terms)
if err != nil {
dcnRefuse(ws, dir, op, storeID, planKey, err.Error())
return
}
defer store.Close()
repPath := dcnReportPath(terms.Root, plan.PlanID) repPath := dcnReportPath(terms.Root, plan.PlanID)
repRel := ""
if repPath != "" { if repPath != "" {
if prev, err := os.ReadFile(repPath); err == nil { repRel, _ = dcnRel(terms.Root, repPath)
}
if repRel != "" {
if prev, err := store.ReadFile(repRel); err == nil {
var old map[string]any var old map[string]any
if json.Unmarshal(prev, &old) == nil && old["state"] == "ok" { if json.Unmarshal(prev, &old) == nil && old["state"] == "ok" {
old["note"] = "already applied on this machine; this is the earlier report" old["note"] = "already applied on this machine; this is the earlier report"
@@ -398,13 +438,13 @@ func handleDcnPlanApply(ws *websocket.Conn, dir string, args []string) {
return return
} }
} }
rep := dcnApplyPlan(terms.Root, terms.Excludes, plan) rep := dcnApplyPlanIn(store, terms.Root, terms.Excludes, plan)
if rep["state"] == "ok" && plan.Approval != "" { if rep["state"] == "ok" && plan.Approval != "" {
markApprovalUsed(dir, plan.Approval) // single use: the files have moved markApprovalUsed(dir, plan.Approval) // single use: the files have moved
} }
if repPath != "" { if repRel != "" {
if raw, err := json.MarshalIndent(rep, "", " "); err == nil { if raw, err := json.MarshalIndent(rep, "", " "); err == nil {
_ = atomicWrite(repPath, raw) // the machine's own record; best-effort _ = dcnRootAtomicWrite(store, repRel, raw) // the machine's own record; best-effort
} }
} }
dcnResult(ws, dir, op, storeID, planKey, rep) dcnResult(ws, dir, op, storeID, planKey, rep)
+290
View File
@@ -0,0 +1,290 @@
package main
// Two things a host holding `read` on a folder must never reach through the
// file ops (list, stat, read_range), nor move with a plan:
// - what an exclude of several folders names. The Host's default excludes
// carry ".config/gcloud", and a pattern with a "/" was compared with one
// folder name at a time, so it matched nothing: a shared home folder
// served its gcloud credentials;
// - the local search agent's own folder, .agent, which holds the plaintext
// of every file the agent read, whatever the grant excludes.
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// dcnDefaultExcludes is the exclude list the Host proposes for a new folder.
var dcnDefaultExcludes = []string{
".ssh", ".gnupg", ".aws", ".azure", ".config/gcloud", ".kube",
".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "*.keystore",
"id_rsa*", "id_ed25519*", "*.token", "credentials*", ".netrc",
".git-credentials", "*history",
}
// syncExcluding installs a read grant for the store with these excludes,
// through the real grant_sync op.
func (fx *dcnStoreFixture) syncExcluding(t *testing.T, excludes []string) {
t.Helper()
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_store_grant", "class": "machine",
"root": fx.store, "mask": []string{"read"}, "excludes": excludes,
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)})
payload, _ := json.Marshal(map[string]any{"store_id": 1, "terms_canonical": rec.TermsCanonical,
"signature": rec.Signature, "verify_pubkey": base64.StdEncoding.EncodeToString(fx.pub)})
_, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
handleDcnGrantSync(ws, fx.dir, []string{base64.StdEncoding.EncodeToString(payload)})
})
if exit != 0 {
t.Fatalf("grant_sync: exit %d: %s", exit, why)
}
}
func (fx *dcnStoreFixture) put(t *testing.T, rel, body string) {
t.Helper()
p := filepath.Join(fx.store, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
// listed is what dcn_list returns under prefix, and its exit code.
func (fx *dcnStoreFixture) listed(t *testing.T, prefix string) ([]string, int) {
t.Helper()
obj, _, exit := fx.op(t, handleDcnList, map[string]any{"prefix": prefix})
var paths []string
files, _ := obj["files"].([]any)
for _, f := range files {
if m, ok := f.(map[string]any); ok {
p, _ := m["path"].(string)
paths = append(paths, p)
}
}
return paths, exit
}
// A pattern with a "/" names that run of folders, wherever it sits in the
// path, each folder compared as the file system compares names. Judged per
// platform from any OS.
func TestDcnAMultiFolderExcludeMatchesThatRunAnywhere(t *testing.T) {
for _, c := range []struct {
goos, path string
want bool
}{
{"linux", ".config/gcloud/access_tokens.db", true},
{"linux", ".config/gcloud", true},
{"linux", "home/.config/gcloud/x", true},
{"linux", "a/b/.config/gcloud/configurations/config_default", true},
{"linux", ".config/gcloudx", false},
{"linux", ".config/gcloudx/access_tokens.db", false},
{"linux", ".configx/gcloud/x", false},
{"linux", ".config/other/gcloud/x", false},
{"linux", "gcloud/.config/x", false},
{"linux", ".config", false},
{"linux", ".config/settings.ini", false},
{"linux", ".Config/gcloud/x", false}, // another folder on Linux
{"darwin", ".Config/GCloud/access_tokens.db", true},
{"darwin", "home/.CONFIG/gcloud/x", true},
{"darwin", ".config/gcloudx/x", false},
{"windows", ".CONFIG/GCLOUD/access_tokens.db", true},
{"windows", "home/.config./gcloud /x", true},
{"windows", ".config/gcloudx/x", false},
// one-folder patterns as before
{"linux", "deep/.ssh/config", true},
{"linux", "certs/server.key", true},
{"linux", "notes/ssh-notes.txt", false},
{"windows", ".SSH/id_rsa", true},
} {
if got := dcnExcludedOn(c.goos, dcnDefaultExcludes, c.path); got != c.want {
t.Errorf("%s: %q excluded=%v, want %v", c.goos, c.path, got, c.want)
}
}
// A pattern's own slashes at either end, or doubled, name the same run.
for _, pat := range []string{".config/gcloud/", "/.config/gcloud", ".config//gcloud"} {
if !dcnExcludedOn("linux", []string{pat}, "home/.config/gcloud/x") {
t.Errorf("%q must exclude home/.config/gcloud/x", pat)
}
}
}
// The default exclude of gcloud's folder holds on every op a host sends, at the
// top of a home folder and inside one, and however the path is spelled.
func TestDcnTheDefaultGcloudExcludeHoldsOnEveryOp(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.put(t, ".config/gcloud/access_tokens.db", "GCLOUD-TOKEN")
fx.put(t, "home/.config/gcloud/credentials.db", "NESTED-TOKEN")
fx.put(t, ".config/gcloudx/ok.txt", "NEAR-MISS")
fx.put(t, ".config/settings.ini", "SETTINGS")
fx.syncExcluding(t, dcnDefaultExcludes)
paths, exit := fx.listed(t, "")
if exit != 0 {
t.Fatalf("list: exit %d", exit)
}
have := map[string]bool{}
for _, p := range paths {
have[p] = true
if strings.Contains(p, ".config/gcloud/") {
t.Errorf("list returned %q, under an excluded folder", p)
}
}
for _, p := range []string{".config/gcloudx/ok.txt", ".config/settings.ini", "a.txt"} {
if !have[p] {
t.Errorf("list left out %q, which no exclude names (got %v)", p, paths)
}
}
if paths, exit := fx.listed(t, "home/.config"); exit != 0 || len(paths) != 0 {
t.Errorf("list under home/.config: exit %d, %v; want nothing", exit, paths)
}
for _, prefix := range []string{".config/gcloud", "home/.config/gcloud"} {
if _, exit := fx.listed(t, prefix); exit != 126 {
t.Errorf("list of %q: exit %d, want 126", prefix, exit)
}
}
for _, p := range []string{".config/gcloud", ".config/gcloud/access_tokens.db",
"home/.config/gcloud/credentials.db", " .config/gcloud/access_tokens.db",
"docs/../.config/gcloud/access_tokens.db"} {
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": p}); exit != 126 {
t.Errorf("stat %q: exit %d %v, want 126", p, exit, obj)
}
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "TOKEN") {
t.Errorf("read_range %q: exit %d %q, want 126 and nothing", p, exit, got)
}
}
if got, why, exit := fx.read(t, ".config/gcloudx/ok.txt"); exit != 0 || got != "NEAR-MISS" {
t.Errorf("a folder only named like gcloud's must read: exit %d %q %s", exit, got, why)
}
if problems := dcnCheckPlan(fx.store, dcnDefaultExcludes, []dcnMove{
{From: ".config/gcloud/access_tokens.db", To: "out.db"}}); len(problems) == 0 {
t.Error("a move out of gcloud's folder must be refused")
}
if problems := dcnCheckPlan(fx.store, dcnDefaultExcludes, []dcnMove{
{From: "a.txt", To: "home/.config/gcloud/a.txt"}}); len(problems) == 0 {
t.Error("a move into gcloud's folder must be refused")
}
if err := os.Symlink(filepath.Join(".config", "gcloud"), filepath.Join(fx.store, "gc")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if got, _, exit := fx.read(t, "gc/access_tokens.db"); exit != 126 || got == "GCLOUD-TOKEN" {
t.Errorf("read through a link to gcloud's folder: exit %d %q, want 126", exit, got)
}
}
// The agent's folder is refused on every op a host sends: at the store's top,
// inside a folder another grant shares (where that store's agent keeps its
// own), through a link, and however it is spelled. The grant here excludes
// nothing, so the refusal is not the grant's to give or take.
func TestDcnTheAgentsFolderIsNeverServed(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.put(t, ".agent/extract/ab/report.txt", "PLAINTEXT-OF-A-FILE")
fx.put(t, ".agent/manifest.yaml", "manifest")
fx.put(t, "proj/.agent/extract/x.txt", "NESTED-PLAINTEXT")
fx.put(t, "proj/readme.md", "README")
fx.syncExcluding(t, nil)
paths, exit := fx.listed(t, "")
if exit != 0 {
t.Fatalf("list: exit %d", exit)
}
have := map[string]bool{}
for _, p := range paths {
have[p] = true
if strings.Contains("/"+p, "/.agent/") {
t.Errorf("list returned %q, inside an agent's folder", p)
}
}
if !have["a.txt"] || !have["proj/readme.md"] {
t.Errorf("list left out the folder's own files: %v", paths)
}
for _, prefix := range []string{".agent", ".agent/extract", "proj/.agent"} {
if _, exit := fx.listed(t, prefix); exit != 126 {
t.Errorf("list of %q: exit %d, want 126", prefix, exit)
}
}
for _, p := range []string{".agent", ".agent/extract/ab/report.txt", ".agent/manifest.yaml",
"proj/.agent/extract/x.txt", " .agent/manifest.yaml", "docs/../.agent/extract/ab/report.txt"} {
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": p}); exit != 126 {
t.Errorf("stat %q: exit %d %v, want 126", p, exit, obj)
}
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "PLAINTEXT") {
t.Errorf("read_range %q: exit %d %q, want 126 and nothing", p, exit, got)
}
}
for _, m := range []dcnMove{{From: ".agent/extract/ab/report.txt", To: "out.txt"},
{From: "a.txt", To: ".agent/a.txt"}, {From: "a.txt", To: "proj/.agent/a.txt"}} {
if problems := dcnCheckPlan(fx.store, nil, []dcnMove{m}); len(problems) == 0 {
t.Errorf("a move %q -> %q must be refused", m.From, m.To)
}
}
// The bastion's own report of a plan it applied still goes into
// .agent/plans, through the real plan op.
hn, _ := os.Hostname()
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_move_plan", "store_id": 1, "plan_id": 41,
"server": hn, "root": fx.store, "archive": "Archive",
"moves": []map[string]any{{"from": "a.txt", "to": "Archive/a.txt"}},
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
payload, _ := json.Marshal(map[string]any{"terms_canonical": rec.TermsCanonical,
"signature": rec.Signature, "mode": "apply"})
out, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
handleDcnPlanApply(ws, fx.dir, []string{"1", base64.StdEncoding.EncodeToString(payload)})
})
if exit != 0 || !strings.Contains(out, `"ok"`) {
t.Fatalf("plan apply: exit %d %s %s", exit, out, why)
}
if b, err := os.ReadFile(filepath.Join(fx.store, ".agent", "plans", "41.json")); err != nil ||
!strings.Contains(string(b), `"state": "ok"`) {
t.Fatalf("the plan's report was not written into .agent/plans: %q %v", b, err)
}
if err := os.Symlink(filepath.Join(".agent", "extract", "ab", "report.txt"), filepath.Join(fx.store, "notes.txt")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := os.Symlink(".agent", filepath.Join(fx.store, "ag")); err != nil {
t.Fatal(err)
}
for _, p := range []string{"notes.txt", "ag/extract/ab/report.txt", "ag/manifest.yaml"} {
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "PLAINTEXT") {
t.Errorf("read_range %q, a link into .agent: exit %d %q, want 126", p, exit, got)
}
}
if _, exit := fx.listed(t, "ag"); exit != 126 {
t.Errorf("list of a link to .agent: exit %d, want 126", exit)
}
}
// The agent's folder is named as the file system names it, and on every OS
// regardless of case, as the agent's own walker treats it: a ".Agent" left on
// a volume that ignores case is the agent's folder.
func TestDcnTheAgentsFolderIsMatchedHoweverTheNameIsCased(t *testing.T) {
for _, c := range []struct {
goos, path string
want bool
}{
{"linux", ".agent/extract/x", true},
{"linux", ".AGENT/extract/x", true},
{"darwin", ".Agent/vectors.db", true},
{"windows", ".AGENT/extract/x", true},
{"windows", ".agent./extract/x", true},
{"windows", ".agent /extract/x", true},
{"windows", ".agent::$INDEX_ALLOCATION/x", true},
{"linux", "deep/.agent", true},
{"linux", ".agentx/x", false},
{"linux", "agent/x", false},
{"linux", "notes/.agent-old/x", false},
{"darwin", "docs/readme.md", false},
} {
if got := dcnExcludedOn(c.goos, nil, c.path); got != c.want {
t.Errorf("%s: %q refused=%v, want %v", c.goos, c.path, got, c.want)
}
}
}
+322
View File
@@ -0,0 +1,322 @@
package main
// A machine store is the folder the person granted, for as long as the grant
// lives. The bastion often runs as root and the folder belongs to someone who
// is not, so whoever can write in it, or above it, must not be able to point
// a read or a move somewhere else: by turning the granted folder into a link
// after the grant (no race needed), or by swapping a file for a link between
// the check and the open.
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
)
// dcnRunOp runs one DCN handler on the far side of a real WebSocket and
// returns what it sent: the O payload, the R text and the exit code.
func dcnRunOp(t *testing.T, h func(ws *websocket.Conn)) (out, errText string, exit int) {
t.Helper()
up := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ws, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
h(ws)
}))
defer srv.Close()
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatal(err)
}
defer ws.Close()
_ = ws.SetReadDeadline(time.Now().Add(10 * time.Second))
exit = -1
for {
_, data, err := ws.ReadMessage()
if err != nil || len(data) == 0 {
return
}
switch data[0] {
case 'O':
out += string(data[1:])
case 'R':
errText += string(data[1:])
case 'X':
exit, _ = strconv.Atoi(string(data[1:]))
return
}
}
}
type dcnStoreFixture struct {
dir string // the pairing's dcn dir
base string
store string // the granted folder
other string // a folder the grant never named
priv ed25519.PrivateKey
pub ed25519.PublicKey
}
func newDcnStoreFixture(t *testing.T) *dcnStoreFixture {
t.Helper()
fx := &dcnStoreFixture{dir: t.TempDir(), base: t.TempDir()}
fx.store = filepath.Join(fx.base, "store")
fx.other = filepath.Join(fx.base, "other")
for _, d := range []string{fx.store, fx.other, filepath.Join(fx.store, "docs")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(fx.store, "a.txt"), []byte("MINE"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(fx.other, "a.txt"), []byte("OTHER"), 0o600); err != nil {
t.Fatal(err)
}
fx.pub, fx.priv, _ = ed25519.GenerateKey(nil)
return fx
}
// sync installs a read grant for the store through the real grant_sync op.
func (fx *dcnStoreFixture) sync(t *testing.T, mask ...string) {
t.Helper()
if len(mask) == 0 {
mask = []string{"read"}
}
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_store_grant", "class": "machine",
"root": fx.store, "mask": mask,
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)})
payload, _ := json.Marshal(map[string]any{"store_id": 1, "terms_canonical": rec.TermsCanonical,
"signature": rec.Signature, "verify_pubkey": base64.StdEncoding.EncodeToString(fx.pub)})
_, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
handleDcnGrantSync(ws, fx.dir, []string{base64.StdEncoding.EncodeToString(payload)})
})
if exit != 0 {
t.Fatalf("grant_sync: exit %d: %s", exit, why)
}
}
func (fx *dcnStoreFixture) op(t *testing.T, h func(*websocket.Conn, string, []string), args map[string]any) (map[string]any, string, int) {
t.Helper()
raw, _ := json.Marshal(args)
out, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
h(ws, fx.dir, []string{"1", string(raw)})
})
var obj map[string]any
_ = json.Unmarshal([]byte(out), &obj)
return obj, why, exit
}
func (fx *dcnStoreFixture) read(t *testing.T, path string) (string, string, int) {
t.Helper()
obj, why, exit := fx.op(t, handleDcnReadRange, map[string]any{"path": path})
b, _ := base64.StdEncoding.DecodeString(func() string { s, _ := obj["content_b64"].(string); return s }())
return string(b), why, exit
}
// repoint turns the granted folder into a link to the other one.
func (fx *dcnStoreFixture) repoint(t *testing.T) {
t.Helper()
if err := os.Rename(fx.store, fx.store+".was"); err != nil {
t.Fatal(err)
}
if err := os.Symlink(fx.other, fx.store); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
func TestDcnStoreRepointedAfterTheGrantIsRefused(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.sync(t)
if got, why, exit := fx.read(t, "a.txt"); exit != 0 || got != "MINE" {
t.Fatalf("the granted file must read: exit %d %q %s", exit, got, why)
}
fx.repoint(t)
if got, _, exit := fx.read(t, "a.txt"); exit == 0 || got == "OTHER" {
t.Fatalf("after the granted folder became a link elsewhere, read_range returned %q (exit %d); it must refuse", got, exit)
}
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": "a.txt"}); exit == 0 {
t.Fatalf("stat through the repointed folder must refuse, got %v", obj)
}
if obj, _, exit := fx.op(t, handleDcnList, map[string]any{}); exit == 0 {
t.Fatalf("list through the repointed folder must refuse, got %v", obj)
}
}
// A folder inside the store that becomes a link to somewhere else after the
// grant is the same escape one level down.
func TestDcnSubfolderRepointedIsRefused(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.sync(t)
if err := os.RemoveAll(filepath.Join(fx.store, "docs")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(fx.other, filepath.Join(fx.store, "docs")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if got, _, exit := fx.read(t, "docs/a.txt"); exit == 0 || got == "OTHER" {
t.Fatalf("a link inside the store to a folder outside it read %q (exit %d)", got, exit)
}
}
// The race: a file swapped for a link to a file outside the store between the
// check and the open. Run for long enough that the old check-then-open loses.
func TestDcnReadRangeSwapRaceNeverLeavesTheStore(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.sync(t)
target := filepath.Join(fx.store, "a.txt")
realTmp := filepath.Join(fx.store, ".real")
linkTmp := filepath.Join(fx.store, ".link")
var stop atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
for !stop.Load() {
_ = os.Symlink(filepath.Join(fx.other, "a.txt"), linkTmp)
_ = os.Rename(linkTmp, target)
_ = os.WriteFile(realTmp, []byte("MINE"), 0o644)
_ = os.Rename(realTmp, target)
}
}()
deadline := time.Now().Add(3 * time.Second)
n := 0
for time.Now().Before(deadline) {
n++
if got, _, _ := fx.read(t, "a.txt"); got == "OTHER" {
stop.Store(true)
<-done
t.Fatalf("read %d returned the file outside the store through a swapped link", n)
}
}
stop.Store(true)
<-done
}
// A move plan applies through the store too: a target folder that leads
// outside the store for an instant between the re-check and the rename must
// not receive the file.
func TestDcnPlanMoveRaceNeverLeavesTheStore(t *testing.T) {
fx := newDcnStoreFixture(t)
if err := os.Remove(filepath.Join(fx.other, "a.txt")); err != nil {
t.Fatal(err)
}
docs := filepath.Join(fx.store, "docs")
var stop atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
for !stop.Load() {
_ = os.Rename(docs, docs+".real")
_ = os.Symlink(fx.other, docs+".link")
_ = os.Rename(docs+".link", docs)
_ = os.Rename(docs, docs+".link")
_ = os.Remove(docs + ".link")
_ = os.Rename(docs+".real", docs)
}
}()
defer func() { stop.Store(true); <-done }()
deadline := time.Now().Add(3 * time.Second)
for i := 0; time.Now().Before(deadline); i++ {
_ = os.WriteFile(filepath.Join(fx.store, "a.txt"), []byte("MINE"), 0o644)
plan := &dcnPlanTerms{PlanID: int64(i), StoreID: 1, Root: fx.store,
Moves: []dcnMove{{From: "a.txt", To: "docs/a.txt"}}}
_ = dcnApplyPlan(fx.store, nil, plan)
if _, err := os.Lstat(filepath.Join(fx.other, "a.txt")); err == nil {
t.Fatalf("move %d landed outside the store, through a folder that was a link for an instant", i)
}
_ = os.Remove(filepath.Join(fx.store, "docs.real", "a.txt"))
_ = os.Remove(filepath.Join(docs, "a.txt"))
}
}
// The plan report is written beside its name and renamed into place. Whoever
// can write in the store could plant a link at that side name; the write must
// never follow it, or the root bastion truncates whatever file the link names
// (an excluded .ssh/authorized_keys included) and fills it with the report.
func TestDcnReportWriteNeverFollowsAPlantedSideName(t *testing.T) {
store := t.TempDir()
victim := filepath.Join(store, ".ssh", "authorized_keys")
if err := os.MkdirAll(filepath.Dir(victim), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(victim, []byte("ssh-ed25519 AAAAexample user@example\n"), 0o600); err != nil {
t.Fatal(err)
}
plans := filepath.Join(store, ".agent", "plans")
if err := os.MkdirAll(plans, 0o700); err != nil {
t.Fatal(err)
}
rel := filepath.Join(".agent", "plans", "7.json")
// Every side name a writer could guess: this process's pid, and a spread
// of others, all pointing at the victim.
for _, pid := range []int{os.Getpid(), 1, 2, 100, 1000} {
side := filepath.Join(store, rel+".tmp-"+strconv.Itoa(pid))
if err := os.Symlink(filepath.Join("..", "..", ".ssh", "authorized_keys"), side); err != nil {
t.Fatal(err)
}
}
r, err := os.OpenRoot(store)
if err != nil {
t.Fatal(err)
}
defer r.Close()
if err := dcnRootAtomicWrite(r, rel, []byte(`{"state":"ok"}`)); err != nil {
t.Fatalf("report write: %v", err)
}
if got, _ := os.ReadFile(victim); string(got) != "ssh-ed25519 AAAAexample user@example\n" {
t.Fatalf("the report write followed a planted link: the victim now holds %q", got)
}
if got, err := os.ReadFile(filepath.Join(store, rel)); err != nil || string(got) != `{"state":"ok"}` {
t.Fatalf("report not in place: %q %v", got, err)
}
if fi, err := os.Lstat(filepath.Join(store, rel)); err != nil || fi.Mode()&os.ModeSymlink != 0 || fi.Mode().Perm() != 0o600 {
t.Fatalf("report is not a 0600 regular file: %v %v", fi, err)
}
}
// A grant installed before pins existed has no root_real. It still works,
// pins where its folder leads at first use, keeps that pin, and a repoint
// after that is refused like any other.
func TestDcnGrantFromBeforePinsPinsAtFirstUse(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.sync(t)
f, err := dcnLoadFile(fx.dir)
if err != nil {
t.Fatal(err)
}
rec := f.Stores["1"]
rec.RootReal = ""
f.Stores["1"] = rec
if err := dcnSaveFile(fx.dir, f); err != nil {
t.Fatal(err)
}
if got, why, exit := fx.read(t, "a.txt"); exit != 0 || got != "MINE" {
t.Fatalf("a grant from before pins no longer reads: exit %d %q %s", exit, got, why)
}
f, err = dcnLoadFile(fx.dir)
if err != nil {
t.Fatal(err)
}
want, _ := filepath.EvalSymlinks(fx.store)
if got := f.Stores["1"].RootReal; got != want {
t.Fatalf("first use pinned %q, want %q", got, want)
}
fx.repoint(t)
if got, _, exit := fx.read(t, "a.txt"); exit == 0 || got == "OTHER" {
t.Fatalf("after the first-use pin a repointed folder was read: exit %d %q", exit, got)
}
}
+199
View File
@@ -0,0 +1,199 @@
package main
// dcn_store.go — every byte a machine-store op touches is reached through ONE
// handle on the folder the person granted.
//
// WHY. The bastion is often root (a networked install, the macOS and Windows
// system service), and the granted folder is often someone else's. Checking a
// path and then opening it by name leaves two ways out of the store for
// whoever can write in it or above it:
//
// - the granted folder itself turned into a link to another folder after the
// grant. The old check resolved the root again on every op and judged the
// path against wherever it led NOW, so no race was needed;
// - a file or folder inside the store swapped for a link between the check
// and the open (or the rename, for a move plan).
//
// So: the folder's real path is PINNED at grant sync (rec.RootReal, this box's
// own field, outside the signed bytes), an op whose root now leads elsewhere
// is refused, and the op opens the pinned folder as an os.Root, which never
// follows a link out of it, whatever happens to the names afterwards. That
// handle is then checked to be the folder at the pinned path reached with no
// link at all (dcnNoLinkStat: an open that refuses a link in every component
// on Linux and macOS), so a swap of a folder above the store between the two
// opens is refused too.
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
)
// errDcnRepointed is the refusal for a granted folder that leads elsewhere now.
var errDcnRepointed = errors.New("the granted folder now leads somewhere other than the folder that was granted; " +
"a new grant for it is the repair")
// dcnRealRoot is where the grant's root leads now: its links resolved.
func dcnRealRoot(root string) (string, error) {
real, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return "", err
}
if !filepath.IsAbs(real) {
return "", errors.New("the granted folder is not an absolute path")
}
return filepath.Clean(real), nil
}
// dcnPinFor is the real root a sync records: the one already pinned for the
// same root while the store is held (a re-sync, a renewed grant), else where
// the root leads now ("" when it cannot be resolved yet; pinned at first use).
func dcnPinFor(prev *dcnGrantRec, prevRoot, root string) string {
if prev != nil && prev.RootReal != "" && filepath.Clean(prevRoot) == filepath.Clean(root) {
return prev.RootReal
}
real, err := dcnRealRoot(root)
if err != nil {
return ""
}
return real
}
// dcnStorePin is the pinned real root of a held store, pinning it now (and
// saving it) for a grant synced before pins existed.
func dcnStorePin(dir string, storeID int64, root string) (string, error) {
grantsMu.Lock()
defer grantsMu.Unlock()
f, err := dcnLoadFile(dir)
if err != nil {
return "", err
}
key := strconv.FormatInt(storeID, 10)
rec, ok := f.Stores[key]
if !ok {
return "", errors.New("no grant for this store on this box")
}
if rec.RootReal != "" {
return rec.RootReal, nil
}
real, err := dcnRealRoot(root)
if err != nil {
return "", err
}
rec.RootReal = real
f.Stores[key] = rec
if err := dcnSaveFile(dir, f); err != nil {
return "", err
}
return real, nil
}
// dcnOpenStore opens the store a verified grant names, as the folder that was
// granted, or refuses.
func dcnOpenStore(dir string, storeID int64, terms *dcnTerms) (*os.Root, error) {
pin, err := dcnStorePin(dir, storeID, terms.Root)
if err != nil {
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
}
real, err := dcnRealRoot(terms.Root)
if err != nil {
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
}
if real != pin {
return nil, errDcnRepointed
}
return dcnOpenRootAt(pin)
}
// dcnOpenRootAt opens real (a resolved, absolute path) as an os.Root and
// checks that the handle is the folder reached at real with no link in any
// component, so nothing above the folder was swapped while it was opened.
func dcnOpenRootAt(real string) (*os.Root, error) {
r, err := os.OpenRoot(real)
if err != nil {
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
}
rfi, err := r.Stat(".")
if err != nil {
_ = r.Close()
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
}
nfi, err := dcnNoLinkStat(real)
if err != nil || !nfi.IsDir() || !os.SameFile(rfi, nfi) {
_ = r.Close()
return nil, errDcnRepointed
}
return r, nil
}
// dcnRel is a path the sandbox accepted (cleanRel/dcnResolvePath's joined
// form) as a name inside the root handle.
func dcnRel(root, joined string) (string, error) {
rel, err := filepath.Rel(filepath.Clean(root), joined)
if err != nil || rel == ".." || filepath.IsAbs(rel) || len(rel) >= 3 && rel[:3] == ".."+string(os.PathSeparator) {
return "", errors.New("path escapes the store root")
}
return rel, nil
}
// dcnEscaped: the root handle refused a name because it leads out of the store.
func dcnEscaped(err error) bool {
var pe *os.PathError
return errors.As(err, &pe) && pe.Err != nil && pe.Err.Error() == "path escapes from parent"
}
// dcnRootAtomicWrite is atomicWrite inside the store's handle: folders 0700,
// the file 0600, written beside its name and renamed into place.
func dcnRootAtomicWrite(r *os.Root, rel string, data []byte) error {
if d := filepath.Dir(rel); d != "." {
if err := r.MkdirAll(d, 0o700); err != nil {
return err
}
}
// The side name is unguessable and opened O_EXCL, as atomicWrite's
// CreateTemp is: whoever can write in the store could otherwise plant a
// link at a guessable name, and an open that follows it would truncate
// and fill whatever file in the store the link names. O_EXCL refuses any
// existing name, a link included, without following it.
var f *os.File
var tmp string
for try := 0; ; try++ {
var nb [12]byte
if _, err := rand.Read(nb[:]); err != nil {
return err
}
tmp = filepath.Join(filepath.Dir(rel), ".dcn-"+hex.EncodeToString(nb[:])+".tmp")
var err error
f, err = r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
break
}
if !errors.Is(err, fs.ErrExist) || try >= 8 {
return err
}
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
_ = r.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
_ = r.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = r.Remove(tmp)
return err
}
if err := r.Rename(tmp, rel); err != nil {
_ = r.Remove(tmp)
return err
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build darwin
package main
import (
"os"
"golang.org/x/sys/unix"
)
// dcnNoLinkStat describes the directory at p opened with O_NOFOLLOW_ANY, which
// fails on a link in any component (macOS 11 and later).
func dcnNoLinkStat(p string) (os.FileInfo, error) {
fd, err := unix.Open(p, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW_ANY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), p)
defer f.Close()
return f.Stat()
}
+48
View File
@@ -0,0 +1,48 @@
//go:build linux
package main
import (
"errors"
"os"
"strings"
"golang.org/x/sys/unix"
)
// dcnNoLinkStat describes the directory at p (absolute, clean) reached one
// component at a time with O_NOFOLLOW, so a link anywhere in the path fails
// instead of being followed. O_PATH: a component the bastion may traverse but
// not list (a home at 0711) still opens.
func dcnNoLinkStat(p string) (os.FileInfo, error) {
if !strings.HasPrefix(p, "/") {
return nil, errors.New("not an absolute path")
}
fd, err := unix.Open("/", unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
for _, c := range strings.Split(p, "/") {
if c == "" {
continue
}
if c == "." || c == ".." {
_ = unix.Close(fd)
return nil, errors.New("not a clean path")
}
next, err := unix.Openat(fd, c, unix.O_PATH|unix.O_NOFOLLOW|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
_ = unix.Close(fd)
if err != nil {
return nil, err
}
var st unix.Stat_t
if err := unix.Fstat(next, &st); err != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR {
_ = unix.Close(next)
return nil, errors.New("a component is not a directory")
}
fd = next
}
f := os.NewFile(uintptr(fd), p)
defer f.Close()
return f.Stat()
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !linux && !darwin
package main
import "os"
// dcnNoLinkStat on a platform with no open that refuses a link in every
// component (Windows): the final component is judged without following it.
// The store is still reached only through its os.Root and only while its real
// path matches the pin; what this leaves is a swap of a folder above the
// store, by someone who can rename it, timed between the two opens.
func dcnNoLinkStat(p string) (os.FileInfo, error) {
return os.Lstat(p)
}
+16 -19
View File
@@ -20,9 +20,9 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"sort" "sort"
"strings" "strings"
@@ -675,53 +675,50 @@ func runDeltaRefresh(ctx context.Context) {
// ── the operator's own eyes ───────────────────────────────────────────────── // ── the operator's own eyes ─────────────────────────────────────────────────
func cmdObserveDelta(asJSON bool) error { func cmdObserveDelta(asJSON bool) error {
if err := requireMachineAuthority("observe:delta"); err != nil { args := []string{"diff"}
return err
}
r := deltaPass(&digestCache{}, time.Now(), deltaRoleOperator)
if asJSON { if asJSON {
b, err := json.MarshalIndent(r, "", " ") args = append(args, "--json")
if err != nil {
return err
} }
fmt.Println(string(b)) return cmdBaseline(args)
return nil
} }
// printDelta is `baseline diff` in words.
func printDelta(w io.Writer, r deltaReport) error {
switch r.Baseline { switch r.Baseline {
case deltaBaselineNone: case deltaBaselineNone:
return errors.New("this machine has no accounting to compare against — " + return errors.New("this machine has no accounting to compare against — " +
"`privatae-bastion observe:baseline` takes one (as an administrator)") "`privatae-bastion observe:baseline` takes one (as an administrator)")
case deltaBaselineEdit: case deltaBaselineEdit:
fmt.Printf("WARNING: the accounting has been changed since it was taken — " + fmt.Fprintf(w, "WARNING: the accounting has been changed since it was taken — "+
"what follows is measured against an edited file\n") "what follows is measured against an edited file\n")
} }
fmt.Printf("%d findings against the accounting taken %s (%s), from %d processes in %s\n", fmt.Fprintf(w, "%d findings against the accounting taken %s (%s), from %d processes in %s\n",
len(r.Findings), r.BaselineTakenAt, r.Coverage, r.Scanned, r.Elapsed) len(r.Findings), r.BaselineTakenAt, r.Coverage, r.Scanned, r.Elapsed)
switch { switch {
case r.WalkFailed != "" && len(r.Findings) > 0: case r.WalkFailed != "" && len(r.Findings) > 0:
// The binary rung read the disk; everything else did not happen. Said // The binary rung read the disk; everything else did not happen. Said
// so that the finding below is not discounted by "nothing was compared". // so that the finding below is not discounted by "nothing was compared".
fmt.Printf("the process walk failed this pass (%s): only the bastion-binary check ran — "+ fmt.Fprintf(w, "the process walk failed this pass (%s): only the bastion-binary check ran — "+
"the finding(s) below are real; nothing else was compared\n", r.WalkFailed) "the finding(s) below are real; nothing else was compared\n", r.WalkFailed)
case r.WalkFailed != "": case r.WalkFailed != "":
// The one sentence a failed walk must never print is the all-clear. // The one sentence a failed walk must never print is the all-clear.
fmt.Printf("NOTHING WAS COMPARED: the process walk failed this pass (%s) — "+ fmt.Fprintf(w, "NOTHING WAS COMPARED: the process walk failed this pass (%s) — "+
"the zero above means nothing looked, not that nothing differs\n", r.WalkFailed) "the zero above means nothing looked, not that nothing differs\n", r.WalkFailed)
case len(r.Findings) == 0: case len(r.Findings) == 0:
fmt.Printf("nothing on this machine is outside what was accounted for\n") fmt.Fprintf(w, "nothing on this machine is outside what was accounted for\n")
} }
for _, ev := range r.Findings { for _, ev := range r.Findings {
fmt.Printf(" %-8s %-20s %s\n", ev.Level, ev.Kind, deltaOneLine(ev)) fmt.Fprintf(w, " %-8s %-20s %s\n", ev.Level, ev.Kind, deltaOneLine(ev))
for _, e := range ev.Evidence { for _, e := range ev.Evidence {
fmt.Printf(" %-12s %s\n", e.Tier, e.Signal+": "+e.Value) fmt.Fprintf(w, " %-12s %s\n", e.Tier, e.Signal+": "+e.Value)
} }
} }
if r.ShortLived > 0 || len(r.Filtered) > 0 { if r.ShortLived > 0 || len(r.Filtered) > 0 {
fmt.Printf("%d filtered as ordinary (%d came and went before they were old enough to report; %d since the accounting)\n", fmt.Fprintf(w, "%d filtered as ordinary (%d came and went before they were old enough to report; %d since the accounting)\n",
len(r.Filtered), r.ShortLived, r.ShortLivedTotal) len(r.Filtered), r.ShortLived, r.ShortLivedTotal)
} }
for _, n := range r.Notes { for _, n := range r.Notes {
fmt.Printf(" note: %s\n", n) fmt.Fprintf(w, " note: %s\n", n)
} }
return nil return nil
} }
+75 -30
View File
@@ -33,6 +33,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -296,13 +297,6 @@ func nonceSave(m map[string]string) error {
return atomicWrite(p, raw) return atomicWrite(p, raw)
} }
func nonceSeen(nonce string) bool {
deviceMu.Lock()
defer deviceMu.Unlock()
_, ok := nonceLoad()[nonce]
return ok
}
// burnNonce records the nonce until its approval's expiry (after which a // burnNonce records the nonce until its approval's expiry (after which a
// replay fails on expiry anyway). // replay fails on expiry anyway).
func burnNonce(a *deviceApproval) { func burnNonce(a *deviceApproval) {
@@ -331,17 +325,6 @@ func approvalOK(dir, approval, purpose string) error {
return stepupCheckApproval(dir, approval, purpose) return stepupCheckApproval(dir, approval, purpose)
} }
// approvalKind names how an approval was given, for the audit line.
func approvalKind(approval string) string {
switch {
case strings.TrimSpace(approval) == "":
return ""
case isDeviceApproval(approval):
return "device"
}
return "console"
}
// approvalRequired: does a change on this box need the person's approval? // approvalRequired: does a change on this box need the person's approval?
// Always on an anchored box (writes.go); on a local box only when the person // Always on an anchored box (writes.go); on a local box only when the person
// enrolled a factor here — the rule the DCN lanes have had since stepup.go. // enrolled a factor here — the rule the DCN lanes have had since stepup.go.
@@ -865,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, "") }
@@ -965,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) {
@@ -1013,6 +1053,11 @@ func cmdPinDevice(key string, unpin bool) error {
return err return err
} }
fmt.Println("device key unpinned; approvals signed by it are refused from now on") fmt.Println("device key unpinned; approvals signed by it are refused from now on")
// Every op grant it approved dies with it (ops_bounds.go), dropped
// now so pinning the key again does not bring one back.
if n := opsDropApprovedBy("device:" + strings.TrimSpace(key)); n > 0 {
fmt.Printf("%d op grant(s) it approved dropped\n", n)
}
return nil return nil
} }
if err := pinDeviceKey(key); err != nil { if err := pinDeviceKey(key); err != nil {
+369
View File
@@ -0,0 +1,369 @@
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 _, c := range doorFirewallChecks(s.Firewall) {
add(c)
}
if s.LLM != nil {
add(doctorCheck{Name: "model server", OK: true, Detail: sentenceCase(s.LLM.words()) + "."})
}
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.WaitingFor != "":
add(doctorCheck{Name: "host " + p.Host, OK: true, Detail: p.Host + " waits for " + p.WaitingFor +
" to admit it; " + p.WaitingFor + " asks you to approve it."})
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)
}
}
}
+1059
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More