bastion 0.55.3

This commit is contained in:
Privatae
2026-10-25 23:16:45 +00:00
parent 77b7853ae1
commit 099ec37c1d
136 changed files with 8638 additions and 1641 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
+13 -5
View File
@@ -16,7 +16,7 @@ on:
branches: [main]
env:
GO_IMAGE: golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59
GO_IMAGE: golang@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba
# The pull request's own head, or the pushed commit.
WANT: ${{ gitea.event.pull_request.head.sha || gitea.sha }}
@@ -58,7 +58,10 @@ jobs:
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 ./...'
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
run: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/deploy/release-sign \
@@ -80,9 +83,14 @@ jobs:
test "$(git rev-parse HEAD)" = "$WANT"
- name: no person's data in the tree
# 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
# the run says so; the entity-id, provenance, email and phone checks run.
run: python3 scripts/test_no_personal_data.py
# maintainers' key and the entity-id check the reserved ids, which a CI
# job never holds, so the job names files that do not exist: both
# 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
+241
View File
@@ -0,0 +1,241 @@
# 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: 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" || {
grep -h '"Action":"fail"' "$RUNNER_TEMP/claims.json" | head -40; 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"
+339 -7
View File
@@ -23,6 +23,338 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked.
## 0.55.3
- **A pairing change reaches every Host on the machine at once.** Each pairing
beats its Host every 60 seconds, and the beat is how every Host on the
machine learns that a pairing went active or was revoked. Approving or
revoking a pairing changed it on disk immediately but triggered no beat, so
every Host kept the old state for up to a minute: a connection request to a
Host the anchor had just approved was refused as not on a shared machine. Now
a grant that makes a pairing active, and a revoke, make every running
pairing beat right away. A refused grant, or a re-sync of a pairing that is
already active, beats nothing.
## 0.55.2
- **A search reply says how its text was sanitized.** Every successful reply
from the bastion agent (search, read range, catalog) was stamped
`sanitized: true`, while the agent the bastion deploys runs in `flag` mode,
which replaces e-mail addresses, phone numbers, card, bank and ID numbers,
IP and MAC addresses and keys, and leaves names, street addresses and
dates of birth in. A reply now carries `sanitize_mode` (the mode this
bastion wrote into the agent's manifest, or for an agent run by hand the
mode it reports, else `unknown`), `sanitized` true only for `sterilize`,
and `sanitize_note` saying what the mode leaves in. A reply cannot raise
the mode this bastion wrote.
- **The README says what the bastion needs.** It said "no Docker, no Python".
The bastion itself needs neither, and the README now names the optional
features that do: coder runs, the service slot and the memory mirror need a
container runtime, folder search needs Python 3.8 or later, Python ops need
`/usr/bin/python3`, and the Linux accessibility reader needs python3 with
AT-SPI. It also said Go 1.25; `client-go/go.mod` needs 1.26.
- **GitHub releases carry their files.** A new workflow,
`.github/workflows/release.yml`, runs on each release tag (and by hand for
an earlier one): `deploy/release-assets.sh` rebuilds the release from its
tag, writes the release manifest from the rebuilt binaries with
`deploy/manifest.py` (which `publish.sh` now uses too, so the two cannot
differ), and fetches the signature published for that manifest. Only when
the signature verifies are the six binaries, `version.json`, its signature
and `SHA256SUMS` attached to the release. Releases 0.53.2, 0.54.0 and
0.54.1 were rebuilt this way and each matched its signed manifest.
- **The lab harness is out of the release build.** `mode=lab` drove one test
lab's own cluster and database, and its layout was in the published source.
It is now built only with `-tags lab` and is not in the published source; a
release bastion answers a lab run with exit 126, as a bastion without a lab
always did.
- **staticcheck is clean** on linux, darwin and windows, for both Go modules,
and CI runs it (`deploy/staticcheck.sh`, a pinned staticcheck release, with
the per-OS runs merged so code used on one OS only is not reported unused).
Unused code is removed and error strings no longer end in punctuation; no
behaviour changes.
- **The claim tests a container cannot run now run on GitHub.** 25 tests the
claims table cites (the op and action-op sandboxes, the egress tests in
private namespaces, the root command watch) skipped in CI, because a CI
container has no systemd, no namespaces of its own and no nftables. A new
job runs every cited test as root on GitHub's discarded virtual machine and
fails if any skips; `deploy/check-claims.sh --skipped` names them.
## 0.55.1
- **A root op's tasks are capped.** An op's tasks were capped at 64 with
`RLIMIT_NPROC`, which the kernel never applies to root, so a root op could
start as many tasks as it liked until its time limit. A root op now runs
in a systemd scope of its own in `privatae_ops.slice` with `TasksMax=96`,
which counts every task in it, root's included: the op's 64 and the
bastion's two processes that start it. Those two run with one CPU's worth
of Go runtime threads (`GOMAXPROCS=1`), so they take the same share of the
cap on a machine of any size: with the runtime set for 64 CPUs they took
44 of the 96, and set for 256 no root op could start. The scope's process
reads its own cgroup's `pids.max` before the op starts, and the op's
report carries `tasks_capped` only when that is the cap. Where no scope
can be made (no systemd, no pids controller, or `systemd-run` failing),
the op runs as before and its report's `tasks_note` says its tasks were
not capped, and why. A user op's tasks are held by `RLIMIT_NPROC` as
before, and its report says so. A scope is killed with the run at the
time limit, when the bastion stops, and by the startup sweep. Two ops can
run at once, so an action op's memory watch now reads each other op's
unit or scope in the slice as another program: it yields when a root op
beside it makes the machine wait on memory, as it did when root ops ran
in the bastion's own cgroup, and its own unit still never counts.
## 0.55.0
- **A local machine's egress lane never reaches the machine itself.** Before,
on a machine with no anchor the lane vetted no destination, so a host could
dial this machine's own services through it: `POST
http://127.0.0.1:11434/api/pull` reached ollama whatever the `models` switch
said. It now refuses `localhost` names, loopback and unspecified addresses
(`0.0.0.0/8` and `::` included), link-local addresses (the cloud metadata
address included), the rest of `64:ff9b::/32`, the deprecated IPv6 forms
below, and any address this machine takes as its own, as the anchored lane
checks them, also inside an IPv4-mapped, NAT64 or 6to4 address. The LAN
stays reachable: a private, CGNAT or reserved address that is not this
machine's is dialled. Every address a name resolves to is checked, and only
those addresses are dialled, never the name again.
- **A container or VM on this machine is this machine, not the LAN.** On
Linux, an address the kernel routes straight onto a container's veth, or
onto a bridge whose every port is a veth, a tap or a dummy (the bridge
networks Docker, Podman, LXD and libvirt make for their containers and
VMs), is refused as this machine. A route through a gateway on such a link
leaves, and a link an IPv4 or IPv6 default route of the main table goes
out of (any nexthop's, for a multipath default) is never inside: a bastion
that is itself in a container reaches the internet through the machine it
runs on, and dials the network on its own veth, its LAN included, as
before. Before, the local egress lane dialled such an address as LAN,
so a host could reach a service in a container here, `172.17.0.2:11434`
for one, though the same service on loopback was refused. A bridge with a
network card, a bond or a VLAN among its ports is still the LAN. Every lane
reads the same check, so a coder run's proxy, the anchored lane and the
backup lanes now also refuse a public address on such a network; their
private addresses were refused already. macOS and Windows have no route
lookup here, so there such an address is still dialled.
- **Every lane refuses the deprecated IPv6 forms.** IPv4-compatible `::/96`,
IPv4-translated `::ffff:0:0/96` and site-local `fec0::/10` are no
destination of their own, and where each is delivered depends on the
platform. A coder run's proxy and the backup lanes already refused them; the
egress lane now does too, anchored or local, by address and in any answer a
name resolves to. The anchored lane refuses nothing else it did not refuse
before. All the lanes now read one address classifier.
- **Both egress lanes try each vetted address, as Happy Eyeballs does.** A
name with more than one address is dialled at each in order, the next
starting as soon as an attempt fails or when none has answered within
300 ms, and the first to answer is used; the others are cancelled, and one
that connects anyway is closed. Before, the anchored lane dialled only the
first address, and a coder run's proxy gave each address an even share of
the dial budget, so an address whose packets go nowhere held up the next
one for seconds. The next one now starts within 300 ms, about what the OS
dialler took when it was given the name.
- **`pair` says to enrol a second factor after every pairing that has none.**
It said so only after it anchored a networked machine, so a local pairing,
one over TCP or a unix socket, and a later host on an anchored machine were
told nothing, and on a local machine a folder grant then installs on the
host's signed grant alone. The line names `--host` when more than one host
is paired, and `sudo -H` only when `pair` itself ran with machine authority,
so a local install is told to enrol as its own user. A pairing that keeps
its factor (a `--replace`), or a machine with an approval device pinned, is
not told.
- **A root-command grant lasts at most 30 days.** It was a year. `root-exec
grant --for 31d` is refused and says the longest term; grant again before
it ends. A grant given under the longer term ends 30 days after it was
given, and `root-exec` shows that end. A grant file with no grant time is
refused, since it cannot be held to the term.
- **SECURITY.md, the README's license section and `docs/host-on-this-machine.md`
name each kind of Host.** They described only a Host on Privatae's platform.
They now say that a C-Series Host runs on your own machine in its own Linux
virtual machine, is open source (Privatae/cseries, AGPL-3.0-only), needs no
Privatae account, and that `npx cseries` sets the bastion up networked,
paired, anchored and with a second factor on every pairing (the commands are
shown); that C-Series Cloud and a network membership are optional and paid;
and that a Host on Privatae's own platform is the case where the Host's
software is not open source.
- **Security reports go to security@privatae.ai,** the address
privatae.ai/.well-known/security.txt names. SECURITY.md said info@.
- **`docs/models.md` says what stops a host changing your models on a local
machine.** It told you to scope the pairing on your host, which a local
machine never reads. There, switching `models` off for the host is what
filters the ollama forward, and the egress lane is no way around it. On an
anchored machine the forward filters for every pairing, which the page now
says too.
- **A tunnel endpoint the host sends must be one host and port.** The
pairing response's `endpoint` and `endpoints`, and the endpoints in a
heartbeat's rotate directive, were written into the WireGuard
configuration unchecked. That configuration is one setting per line, so an
endpoint with a newline in it added settings of its own:
`203.0.113.7:51820` followed by a newline and `allowed_ip=::/0` let the
tunnel carry every IPv6 address, not only the host's. An endpoint must now
be an IP address or a host name with a port from 1 to 65535, and nothing
else, no space or control character. A pairing response with any other is
refused and nothing is saved, and a rotate directive with one is not acted
on. A WireGuard key must be 32 bytes. The tunnel's peer is now configured in
one place, which checks every value it writes.
- **A pairing file that holds such an endpoint is listed but never served.**
An older build may have saved one. Its tunnel and lanes never come up, and
`run` names the file and says why. Every other pairing still loads and
serves, `status` shows the pairing as `invalid` with the same reason, and
`unpair` removes it, as does pairing that host again with `pair --replace`.
One such file no longer stops `run`, `status` and `unpair` for the whole
machine.
- **Such a pairing cannot hold the anchor or be approved, and no host is told
of it.** `claim-anchor` marked it active and anchored the machine to it; it
now refuses, says why, and changes nothing. An approval from the anchor
host marked it active and started it with the endpoint its file holds; it
is now refused. The heartbeat and the anchor host's pairing list leave it
out: a host knows only the states active, pending and revoked, and could
show a pairing in any other state as active.
- **A harness run's agent credential is readable only by the harness.** The
credential file was readable by every user on the machine (0644), in a
folder anyone could open and write (0777), for the length of the run. A
bastion running as root now hands the folder (0700) and the file (0600) to
the user the harness image runs its agent as (uid 10005). One that is not
root cannot hand them over, so it leaves them open for the agent, and the
folder sits inside one that only the bastion's own user can enter.
- **A harness run's tree is out of other users' reach.** In a worktree run the
tree mounted at `/work` was opened to every user (0777), its top folder
included, in the shared temporary folder. Any local user could read or
change the Host's tree during the run. It now sits in a folder only the
bastion's own user can enter, as a git run's tree already did, and a run
refuses a tree whose folder other users can enter. Opening the tree for the
agent no longer follows a link in it. Before, on a bastion running as root,
a link in the repository, to `/etc/shadow` for example, made the linked
file world-writable. A file is opened as 0666, or 0777 if it was
executable, so a git run no longer commits every file as executable.
- **A worktree run returns only what is in its tree.** A file swapped for a
link while the tree was being packed, or a folder swapped for a link, was
read through the link, so a bastion running as root could return any file
on the machine to the Host. Each name is now opened inside the folder
already open above it, never through a link, and goes in only if it is the
plain file or folder that was looked at. On Linux a device or pipe swapped
in for a file in that gap is not opened either: opening some devices does
something by itself. Links, pipes, devices and sockets are left out
(before, each came back as an empty entry of its kind), and the run's
result names them in `worktree_skipped`, with `worktree_skipped_count`.
- **A coding agent can no longer run code as the bastion through the
repository's git settings.** In a git run the bastion committed and pushed
in the agent's own folder, `.git` included, so a hook, a file system
monitor, a filter, a signing program or a changed remote the agent set ran
as the bastion's own user, which can be root, or sent the push, with its
token, to another server. The bastion now makes its own fresh clone, in a
folder the agent cannot reach, copies in the agent's plain files and
folders (never a link, never anything of its `.git`), and commits and
pushes only there. The branch gets one commit with the agent's final
files; the agent's own commits are not pushed one by one. A link the
agent made is left out and named in `worktree_skipped`; a link the
repository already holds stays as it is. Every git the bastion runs for a
harness or a mirror runs with no hooks, no file system monitor, no `ext::`
transport, and no system or user git settings of the machine.
- **The agent in a git run no longer holds the push token, and no process
argument shows it.** The agent's clone named the repository with the
run's token in it, so the agent could push straight to the repository
during the run, past the bastion's copy, and every git the bastion ran
for the run had the token in its arguments, which any local user can
read. Every git call now names the repository without the token and gets
it from its environment, as an `Authorization` header sent to that
repository's address only; no clone's config holds it, and a run is
refused if the token is in any file of the agent's clone's `.git`.
- **A user-privilege op is no longer refused because other programs run as
its user.** An op's tasks are capped at 64, and the cap counted every task
the machine ran as the op's user (`privatae-session`, or `nobody`, which
system daemons share), so on a busy machine a user op could not start or
could not fork at all. The op now runs in a user namespace of its own,
as the same user and seeing every file's real owner, where the cap counts
only its own tasks. Where the machine has no user namespaces, the op runs
as before.
- **A harness run's git calls carry the harness identity.** git made one
from this machine's user and host name instead, which cost a host name
lookup per call (seconds where there is no resolver) and wrote this
machine's name into the clone handed to the agent.
- **A run whose agent container cannot be removed uses nothing of its
tree.** A failed removal was ignored, so an agent still running could go
on writing the tree while the bastion read it. The run now fails, says
so, and copies, returns and pushes nothing.
- **A git run whose commit fails pushes nothing and fails.** A failed add or
commit was ignored and the branch pushed anyway, and the run read as
successful. It now ends the run with an error and no push. A run in which
the agent changed no file pushes no branch and reports `changed: false`.
- **Repositories that keep files in Git LFS work in git runs again.** No
user git settings are read any more, so the LFS filter `git lfs install`
sets there was lost. When `git-lfs` is installed, the bastion now supplies
the standard LFS filter itself on its own git commands, sends the large
files before the push, and sends them only to the repository's own LFS
address, whatever a `.lfsconfig` the agent wrote names. A filter of any
other name in a `.gitattributes` still runs nothing. Tested with git-lfs
3.7.1.
- **Names that open as `.git` on Windows or macOS are left out of a git
run's copy.** Besides `.git` in any letter case, the copy now leaves out
`.git` followed by dots or spaces or a `:`, the short name `GIT~1`, and
`.git` with the characters HFS+ ignores, the names git itself refuses, so
no file the agent made can be written into the clean clone's own `.git`.
- **A run's tree has a size limit, and a file is never read into memory
whole.** A file of any size in the tree, even one that takes no disk
space, was read whole into memory, so an agent could make the bastion run
out of memory. A worktree run now returns at most 9 MiB of tree, which is
what fits in one message to the Host beside the transcript; a larger tree
is refused with `worktree_too_large` and none of it is returned. A git run
copies at most 2 GiB, and refuses a larger tree with `tree_too_large`
before it pushes anything; each file and folder counts 512 bytes and its
name besides its content, so a tree of millions of empty files is refused
too, and the copy keeps nothing per file in memory. Of the entries left
out, the count is kept in full and the first 100 names. A folder is read
512 names at a time, each counted as it is read, so a folder of millions
of names is refused without being read whole first. A result too large for one message to the Host
is refused with `result_too_large` instead of being lost on the way.
- **The local console shows a pairing that is not served as "invalid".** A
pairing whose file holds an endpoint this build refuses showed as active
on the console when its stored state was empty, as it is in older files.
It now reads as the setup page and `status` read it.
- **Built with Go 1.26.10, golang.org/x/net 0.61.0, x/crypto 0.58.0 and x/sys
0.49.0.** `govulncheck` finds 13 known vulnerabilities this code reaches in
Go 1.25.14 and x/net 0.57.0, which 0.54.1 was built with: in crypto/x509
(intermediate certificates since found compromised were still trusted),
crypto/tls, net/http and HTTP/2, net/textproto, os and html/template. With
these versions it finds none. The
build image is still pinned by digest, and CI runs in the same one. The
module now needs Go 1.26, as x/net's fixed release does. A published release
is still rebuilt by `deploy/verify-release.sh` with the toolchain its own
tag pins.
- **`go test ./...` passes for a contributor who is not root.** A root
command now starts in `/` when the process cannot enter root's home, as it
already did when that home is missing; the bastion runs these commands only
as root, so on a machine nothing changes, and the root-command tests now run
as any user. `TestTreeCheckoutMovesOnlyForward`, which needs a tree root
owns, skips as another user, and `TestClaimAnchorWithNoToken` checks the
refusal without machine authority and then skips the rest, where it passed
having checked nothing more.
- **The tests name no folder of our own build machine.** The sandbox tests'
fixture folder is `/srv/privatae-optest` (`/tmp` cannot hold an op's
effects), and the action tests' run note asks for any folder outside
`/tmp`.
- **How this repository is published (docs only).** From this release on,
GitHub receives each change merged since the last release as its own commit,
titled as the change was, instead of one commit per release. The README,
CONTRIBUTING.md and SECURITY.md say so, and say that releases from 0.53.2 on
can be verified from a clone, since the earlier tags are not public.
- **The personal-data check reads what it used to miss.** A listed term
written right after a backslash escape (`\n`, `\t`, `\u00e9`) is found, and
so is any value inside a run of base64 or base64url that decodes to text.
The reserved entity ids are read from `/etc/privatae/reserved-entity-ids`
(one per line, `RESERVED_ENTITY_IDS_FILE` to point elsewhere) instead of
being written in the script; where that file is absent, as in public CI,
the entity-id check is skipped and the run prints a NOTE, as the term check
already does without its key. A file that holds no id, or a line that is
not one, fails the run.
- **The former-name check reads comments and Markdown.** A kept identifier
is set aside only in Go code; in a comment, this changelog or any other
file it is a hit like the bare word, and the two entries that spelled the
device-fingerprint prefix out now name `fpSANPrefix` instead.
- **CI on GitHub.** `.github/workflows/ci.yml` runs the jobs of
`.gitea/workflows/ci.yml` on GitHub Actions in the same Go image, pinned by
the same digest, with no action from the marketplace and a token that can
only read. A step fails if its digest stops matching `deploy/build.sh`.
- Tests use documentation addresses (`2001:db8::/32`, `203.0.113.0/24`) and
neutral paths, and comments state their reason instead of citing a pull
request by number; a test keeps published source free of such numbers.
## 0.54.1
- **No change to what the bastion does.** The README is now a short front
@@ -225,13 +557,13 @@ says which version is. Older entries are not marked.
- **The device fingerprint is sent under a second name to a Host with no
platform.** A pairing with such a Host (`--redeem-url unix://…` or
`https://…`) sends an operator certificate request that carries the
fingerprint as two URI SANs: first `urn:skema:hwfp:<fp>` (`fpSANPrefix`),
then `urn:privatae:hwfp:<fp>`, which that Host reads. A platform pairing's
request names it once, under `urn:skema:hwfp:`, as before: the platform
signs a request with that one name and refuses one with any other. A
signed accounting verifies with the fingerprint under either name, and a
signer certificate that names two different fingerprints is refused rather
than resolved.
fingerprint as two URI SANs: first under the original device-fingerprint
URN prefix (`fpSANPrefix` in pair.go), then `urn:privatae:hwfp:<fp>`, which
that Host reads. A platform pairing's request names it once, under
`fpSANPrefix`, as before: the platform signs a request with that one name
and refuses one with any other. A signed accounting verifies with the
fingerprint under either name, and a signer certificate that names two
different fingerprints is refused rather than resolved.
## 0.52.0
+10 -4
View File
@@ -9,10 +9,9 @@ their approval on that machine.
Issues and pull requests are welcome on GitHub, at
[github.com/Privatae/bastion](https://github.com/Privatae/bastion).
Development happens in a private repository, and the public repository
receives the source of each published release as one snapshot commit, so an
accepted change is carried into the development repository and arrives with
the next release. Sign-in on the git.privatae.ai forge is not reachable from
Development happens in a private repository, so an accepted change is carried
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
@@ -26,8 +25,15 @@ gofmt -l . # must print nothing
go vet ./...
CGO_ENABLED=0 go build .
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
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.
+55 -20
View File
@@ -1,14 +1,22 @@
# privatae-bastion
[![License: GPL v3+](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE)
[![Go 1.25+](https://img.shields.io/badge/go-1.25%2B-00ADD8.svg)](client-go/go.mod)
[![Go 1.26+](https://img.shields.io/badge/go-1.26%2B-00ADD8.svg)](client-go/go.mod)
[![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](#supported-platforms)
[![ci](https://git.privatae.ai/privatae/privatae-bastion/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.privatae.ai/privatae/privatae-bastion/actions)
**One static Go binary that gives your AI Host access to a machine without SSH keys: it dials out, checks every signed grant on the machine itself, and keeps its own audit log.**
No Docker, no Python, no database, no separate WireGuard install. You pair the
binary with a short token from your host, and from then on your host can reach
The bastion itself needs no Docker, no Python, no database and no separate
WireGuard install. A few optional features use software the machine must
already have: coder runs, the service slot and the memory mirror need a
container runtime (Docker, Podman or nerdctl); folder search runs the bastion
agent, which needs Python 3.8 or later; host-written ops in Python need
`/usr/bin/python3`; and reading an app's accessibility tree on Linux needs
python3 with its AT-SPI bindings. Without them, those features say what is
missing and the rest works.
You pair the binary with a short token from your host, and from then on your host can reach
this machine only through the tunnel this bastion dials out and the lanes it
gates. Your files stay where they are. Nothing on this machine drives the host:
there is no MCP endpoint here.
@@ -261,14 +269,15 @@ Each point in full: [docs/security-model.md](docs/security-model.md); the link a
- **Egress for someone else reaches the public internet, never this
machine**: a coder run's proxy and an anchored machine's egress lane refuse
internal addresses and this machine's own (a coder run reaches a LAN host
you named). A local machine's egress lane vets no destination, and an address
that reaches this machine only through a 1:1 NAT is not covered. ([full text](docs/security-model.md))
you named). A local machine's egress lane refuses this machine and keeps
your LAN. An address that reaches this machine only through a 1:1 NAT is
not covered. ([full text](docs/security-model.md))
- **A terminal is checked here and logged.** On a local machine it opens when
the host asks; on an anchored one each needs an approval for that session;
a root terminal needs a code entered here. ([session lane](docs/lanes.md#session-lane))
- **Root commands run only under a grant given here with the code**, to the
anchor host, until the time you chose; a revoke or expiry stops a running
command within 2 seconds. ([session lane](docs/lanes.md#session-lane))
anchor host, until the time you chose and at most 30 days; a revoke or
expiry stops a running command within 2 seconds. ([session lane](docs/lanes.md#session-lane))
- **Root only where there is no one else to ask**: claiming ownership or the
anchor. ([modes](#local-and-networked-modes))
- **On macOS and Windows, a networked bastion is the system's service**, which
@@ -295,15 +304,20 @@ To report a security issue, please do not open a public issue. See
Every security claim names the test that fails if it stops being true, and
says where there is none. The full table is one file,
[docs/claims.md](docs/claims.md); `deploy/check-claims.sh` checks that every
test it cites exists in `client-go`.
test it cites exists in `client-go`. On GitHub, CI also runs every cited test
as root on the runner's own virtual machine, where the sandbox and namespace
tests a container cannot run do run, and fails if any of them skips.
## How this repo is published
Development happens in a private repository. This repository receives the
source of each published release as one snapshot commit, tagged
`v<version>`, with the tree identical to the commit the signed release
manifest names as `built_from`. Every binary can be rebuilt from the tag and
compared with the published one: see [Verify a release](#verify-a-release).
Development happens in a private repository, and its history is not
published. When a release is published, each change merged since the last one
arrives on GitHub as a commit with the same files, titled as the change was
and dated when it was merged; the copy at git.privatae.ai receives the release
as one commit. In both, the release is tagged `v<version>` on a commit whose
tree is identical to the commit the signed release manifest names as
`built_from`. Every binary can be rebuilt from the tag and compared with the
published one: see [Verify a release](#verify-a-release).
Issues and pull requests are welcome on GitHub, at
[github.com/Privatae/bastion](https://github.com/Privatae/bastion). An
@@ -326,7 +340,7 @@ rather than faked.
## Build from source
Requires Go 1.25 or newer. The binary is pure Go and builds with `cgo` off.
Requires Go 1.26 or newer (`client-go/go.mod`). The binary is pure Go and builds with `cgo` off.
```sh
git clone https://git.privatae.ai/privatae/privatae-bastion.git
@@ -358,8 +372,8 @@ It checks the release manifest's signature against the keys pinned in
`client-go/release_sign.go`, checks out the release's source, rebuilds it, and
compares every binary's sha256 with the published one.
From this release on, each public snapshot in this repository carries the tag
`v<version>` (for example `v0.43.0`). The manifest's `built_from` names the
Each published release in this repository carries the tag `v<version>` (for
example `v0.54.1`). The manifest's `built_from` names the
commit in the repository the release was built in, which is not a commit here,
so the script checks out the tag `v<version>` for the version the manifest
names, and prints which source it used. The tag is annotated and its message
@@ -372,8 +386,26 @@ bytes.
If the published version has no tag in your clone, the script says so and
stops without building. If it cannot fetch tags, it says that instead.
Releases from 0.43.0 on can be checked this way from a clone of this
repository.
Releases from 0.53.2 on can be checked this way from a clone of this
repository; the tags of earlier releases are not in it.
Each release on GitHub carries the six binaries, the signed manifest
`version.json`, its signature `version.json.<first 16 hex of its sha256>.sig`
and `SHA256SUMS`. The release workflow (`.github/workflows/release.yml`)
attaches them only after `deploy/release-assets.sh` has rebuilt the binaries
from the tag and the published signature has verified against the manifest
written from them, so they are the same bytes the installer and the
self-updater fetch. To check a download yourself, compare its sha256 with the
manifest's, and check the manifest's signature against the keys this source
pins (needs Go):
```sh
cd deploy/release-sign
go run . verify bastion <dir>/version.json <dir>/version.json.<…>.sig $(go run . pinned ../../client-go/release_sign.go)
```
`deploy/release-assets.sh v<version> <dir>` does all of it from a clone,
rebuild included, for any release from 0.53.2 on.
## Contributing
@@ -397,8 +429,11 @@ version 3 or (at your option) any later version. See [LICENSE](LICENSE).
Copyright Privatae LLC.
You may run it, read it, change it and redistribute it under that license. The
Privatae platform the bastion connects to is separate, proprietary software and
is not covered by this license.
Host the bastion connects to is separate software and is not covered by this
license: a C-Series Host is [Privatae/cseries](https://github.com/Privatae/cseries),
under AGPL-3.0-only, and a Host on Privatae's own platform is proprietary
software. See [SECURITY.md](SECURITY.md#what-you-still-have-to-trust) for what
the bastion holds each to.
The local console vendors [htmx](https://htmx.org) 2.0.10
(`client-go/uiassets/htmx.min.js`), which is distributed under the Zero-Clause
+32 -10
View File
@@ -4,7 +4,7 @@
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,
- what you found and how to reproduce it,
@@ -27,9 +27,10 @@ notes if you wish.
## What is out of scope
- The Privatae platform the bastion connects to. It is separate software with
its own reporting path; reports about it sent to the address above are
forwarded.
- The Host the bastion connects to. It is separate software: a C-Series Host
is [Privatae/cseries](https://github.com/Privatae/cseries), and a Host on
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
assume the machine's administrator is trusted.
@@ -101,7 +102,7 @@ one installed.
**Checking our work.** Release builds are reproducible.
`deploy/verify-release.sh` fetches the published manifest, checks its
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, each published release in this repository
carries the annotated tag `v<version>`, whose message records the release's
`built_from`. 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
@@ -114,9 +115,30 @@ 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
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,
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
bastion keeps the host to your grant and your approvals whatever the platform
does. What the host does with what you share with it is governed by software
you cannot read here.
**The Host.** The bastion holds your Host to your grant and your approvals.
What the Host does with what you share with it is the Host's own software, and
which software that is depends on where the Host runs.
- **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.
+15 -3
View File
@@ -810,6 +810,11 @@ type pairingEntry struct {
// --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
// 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 {
cfgs, err := loadPairings()
if err != nil {
@@ -817,7 +822,7 @@ func pairingsReport() []pairingEntry {
}
out := make([]pairingEntry, 0, len(cfgs))
for _, c := range cfgs {
if c.HostName == "" {
if c.HostName == "" || c.invalid != "" {
continue
}
out = append(out, pairingEntry{HostName: c.HostName, Handle: handleOf(c.HostName),
@@ -849,7 +854,7 @@ func unapprovedAttempts(now time.Time) []peerAnnounce {
if err != nil || now.Sub(since) < unapprovedAfter {
continue
}
out = append(out, peerAnnounce{HostName: e.HostName, Handle: e.Handle, State: e.State, Since: e.Since})
out = append(out, peerAnnounce(e))
}
return out
}
@@ -1113,9 +1118,16 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error {
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")
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])
}
// 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
// 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),
+1 -1
View File
@@ -606,7 +606,7 @@ func TestClaimAnchorWithNoToken(t *testing.T) {
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)
}
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) {
-6
View File
@@ -32,9 +32,3 @@ func systemAuditWrite(line []byte) {
auditSyslog = nil
}
}
// registerAuditEventSource has nothing to register outside Windows: syslog
// takes any tag.
func registerAuditEventSource() error { return nil }
func removeAuditEventSource() {}
-43
View File
@@ -16,7 +16,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"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) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+3
View File
@@ -655,6 +655,9 @@ func cmdBackupDecide(host, prefix string, approve bool) error {
}
}
r, err = resealApprove(dir, who, id, code)
if err != nil {
return err
}
} else {
r, err = resealRefuse(dir, resealWho{Name: cfg.HostName}, id, "")
}
+34
View File
@@ -10,6 +10,7 @@ package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"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)
}
}
+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)
}
}
}
}
+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) {
for _, hostIP := range []string{"2001:db8::e:3", "2001:db8::e:ffff", "2001:db8::1"} {
cfg := &Config{
+8 -48
View File
@@ -10,8 +10,8 @@ package main
// over the machine's own connection.
//
// 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
// using it is the person's own. A coding agent is not the person: it runs
// (egress.go) keeps a local box's LAN reachable, because on a local box the
// 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
// 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
@@ -23,7 +23,6 @@ package main
import (
"bufio"
"context"
"errors"
"net"
"os"
"path/filepath"
@@ -50,35 +49,12 @@ var (
coderDialOut egressDialer = (&net.Dialer{Timeout: egressDialTimeout}).DialContext
)
// coderInternalIP is internalIP (egress.go) plus the IPv6 forms that carry an
// IPv4 address and are not covered there: IPv4-compatible ::a.b.c.d (::/96),
// IPv4-translated ::ffff:0:a.b.c.d, and the deprecated site-local fec0::/10.
// IPv4-mapped ::ffff:a.b.c.d is covered by internalIP through To4.
// coderInternalIP is internalIP (egress.go): a coder run's proxy and the
// backup lanes refuse the same address classes as the egress lane, from the
// one classifier (classifyIP), the IPv4-compatible, IPv4-translated and
// site-local fec0::/10 forms included.
func coderInternalIP(ip net.IP) bool {
if 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
return internalIP(ip)
}
// coderLANAllowed: the owner's allow_lan names this requested host (an entry
@@ -245,23 +221,7 @@ func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
coderRefuse(client, why)
return
}
var upstream net.Conn
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
}
}
upstream, err := dialEach(ctx, coderDialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
return
+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)
}
// 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 {
t.Fatalf("capped: count=%d trunc=%v", count, trunc)
}
+37 -22
View File
@@ -76,12 +76,20 @@ type Config struct {
// that its file does not hold yet (rotate.go, setUnsaved; guarded by
// rotation.mu). Unexported: never in the file.
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 (
pairingActive = "active"
pairingPending = "pending"
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 = "unix"
@@ -103,11 +111,15 @@ func (c *Config) localTransport() bool { return c.unixTransport() || c.tcpTransp
// 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.
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 {
if c.invalid != "" {
return pairingInvalid
}
if c.State == "" {
return pairingActive
}
@@ -316,17 +328,36 @@ func (c *Config) updateOnDiskIf(apply func(disk *Config) error) error {
// updatePairingOnDisk is updateOnDisk for a caller that only has the host
// name (the mgmt handlers). Returns the pairing as written, or nil with
// 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) {
pd, err := pairingsDir()
if err != nil {
return nil, err
}
c := &Config{HostName: host}
var out *Config
err := c.updateOnDisk(func(d *Config) {
err = c.updateOnDisk(func(d *Config) {
apply(d)
cp := *d
cp.invalid = invalidReason(&cp, filepath.Join(pd, host+".json"))
out = &cp
})
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.
// 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.
@@ -495,31 +526,15 @@ func loadPairings() ([]*Config, error) {
if err := json.Unmarshal(raw, &c); err != nil {
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)
}
sort.Slice(cfgs, func(i, j int) bool { return cfgs[i].HostName < cfgs[j].HostName })
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).
func hostNames(cfgs []*Config) []string {
names := make([]string, 0, len(cfgs))
+1 -1
View File
@@ -135,7 +135,7 @@ func maybeContain(pairing, dcnDir string, heartbeatResponse []byte) {
// already passed the agent checks above, and an agent whose own
// fingerprint is in the accounting (one running as a service, say)
// 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 := killTree(pid, func(p int) bool {
if p == root {
+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
// 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
// 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.
var containResultsLoaded bool
@@ -165,7 +165,7 @@ func containResultsSaveLocked() {
}
if b, err := json.Marshal(disk); err == nil {
// 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)
}
}
@@ -231,7 +231,7 @@ type findingView struct {
protected map[int]bool
// 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
// and the kill is never signalled (review of #100).
// and the kill is never signalled.
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
// (accountingPIDs), an app the user is running, anything this bastion started,
// init, the bastion. killTree does not signal a protected process or walk
// beneath it (review of #100: stopping a replaced sshd would otherwise have
// taken every ssh session with it). It also returns the bastion's ANCESTORS —
// its launch chain — which the finding path refuses by name: if any process in
// beneath it: without that, stopping a replaced sshd would take every ssh
// session with it. It also returns the bastion's ANCESTORS — its launch
// 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
// the named check always fires first and a tree-level check could never be
// 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
// started: those processes and everything beneath them are spared, the rest
// 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 machine replaced (review of #100: every other test replaced the whole
// function, so none of these was tested).
// the machine replaced. Every other test replaces the whole function, so
// this is the one that tests these refusals.
//
// MUTATION: drop the root check. RESULT: red (no_authority not returned).
// 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.
//
// 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
// deltaFilter the pass uses, and so is never containable; the same process
// started by anyone else is.
@@ -422,7 +422,7 @@ func TestContainFindingRunsAnOrderOnce(t *testing.T) {
func TestContainFilterIsTheDeltaPassFilter(t *testing.T) {
// 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
// namespace is seconds old and reads as short-lived (review of #100).
// namespace is seconds old and reads as short-lived.
self := os.Getpid()
prevTree, prevAge := processTree, deltaAgeFn
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.
//
// 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.
//
// 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
// of #100, round 3): the tree guard applies BENEATH the agent, never to the
// agent the host named. Before this, such an agent was spared in silence.
// AN AGENT WHOSE OWN FINGERPRINT IS IN THE ACCOUNTING IS STILL STOPPED: the
// tree guard applies BENEATH the agent, never to the agent the host named.
// Before this, such an agent was spared in silence.
//
// MUTATION: killTree(pid, guard) — the guard on the root too. RESULT: red.
func TestAgentContainStopsAnAgentThatIsItselfInTheAccounting(t *testing.T) {
+2 -2
View File
@@ -200,10 +200,10 @@ func contextAskViaSock(root, name string) (sent bool, host, note string, availab
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
// 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")
func contextSockFmtUser(uid uint32, name string) string {
+1 -1
View File
@@ -7,4 +7,4 @@ package main
// 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, contextSockUnsupported }
func contextPeerUID(uintptr) (uint32, error) { return 0, errContextSockUnsupported }
+2 -2
View File
@@ -33,11 +33,11 @@ const contextSockDir = `C:\ProgramData\privatae-bastion`
func contextSockDialable(string) string { return "" }
func contextSockListen(path string) (net.Listener, error) {
return nil, contextSockUnsupported
return nil, errContextSockUnsupported
}
func contextPeerUser(c net.Conn) (string, error) {
return "", contextSockUnsupported
return "", errContextSockUnsupported
}
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
// opening bracket must be closed only at the very end.
inner := t[1 : len(t)-1]
if strings.IndexByte(inner, closer) >= 0 {
return false
}
return true
return strings.IndexByte(inner, closer) < 0
}
type sttSegment struct {
+1 -3
View File
@@ -26,9 +26,7 @@ import (
"github.com/gorilla/websocket"
)
// uvicornWSMaxSize is the host's WebSocket frame ceiling: the server default,
// which nothing on the host overrides, so this is the live value.
const uvicornWSMaxSize = 16 << 20
// uvicornWSMaxSize (harness.go) is the host's WebSocket frame ceiling.
func TestMeasureCtxPutCapacity(t *testing.T) {
if os.Getenv("MEASURE_CTX_PUT") == "" {
-18
View File
@@ -296,13 +296,6 @@ func nonceSave(m map[string]string) error {
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
// replay fails on expiry anyway).
func burnNonce(a *deviceApproval) {
@@ -331,17 +324,6 @@ func approvalOK(dir, approval, purpose string) error {
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?
// 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.
+211 -56
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"errors"
"io"
"net"
"strings"
@@ -63,12 +64,12 @@ func handleEgressConn(client net.Conn, dialOut egressDialer, self string) {
defer cancel()
if strings.EqualFold(method, "CONNECT") {
dialAddr, why := egressVettedAddr(ctx, ensurePort(target, "443"))
dialAddrs, why := egressVettedAddrs(ctx, ensurePort(target, "443"))
if why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
}
upstream, err := dialOut(ctx, "tcp", dialAddr)
upstream, err := dialEach(ctx, dialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"))
return
@@ -94,12 +95,12 @@ func handleEgressConn(client net.Conn, dialOut egressDialer, self string) {
if host == "" {
return
}
dialAddr, why := egressVettedAddr(ctx, ensurePort(host, "80"))
dialAddrs, why := egressVettedAddrs(ctx, ensurePort(host, "80"))
if why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
}
upstream, err := dialOut(ctx, "tcp", dialAddr)
upstream, err := dialEach(ctx, dialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"))
return
@@ -145,47 +146,65 @@ func splice(clientReader io.Reader, client, upstream net.Conn) {
_ = client.Close()
}
// egressInternalRefusal: on an ANCHORED box the egress lane reaches the public
// internet and nothing else. Loopback, link-local and private addresses — this
// machine's own services, the LAN it sits on, the mesh — are refused before
// any dial, whether named by address or by a name that resolves to one. The
// lane is USE of the machine's connection (writes.go), and that holds only
// egressInternalRefusal is the refusal egressVettedAddrs gives, or "".
//
// The lane is USE of the machine's connection (writes.go), and that holds only
// while it cannot be turned inward: `POST http://127.0.0.1:11434/api/pull`
// down this lane would reach ollama around the forward's filter, and anything
// on the LAN around every other gate. So is any address the machine takes as
// its own (ownaddr.go): a public one on its NIC, or a range a `local` route
// delivers here. A local box keeps the lane it had.
// Resolution failures refuse too — a name this box cannot resolve is not one
// down this lane would reach ollama around the forward's filter and around the
// `models` switch, and this machine's other services around theirs. So on
// every box the lane refuses this machine: `localhost` names, loopback and
// unspecified addresses, link-local ones (the cloud metadata address
// included), and any address the machine takes as its own (ownaddr.go), such
// as a public one on its NIC or a range a `local` route delivers here.
//
// What differs is the network around it. On an ANCHORED box the lane reaches
// the public internet and nothing else: private, CGNAT and reserved addresses
// (the LAN, the mesh) are refused too. On a LOCAL box the LAN stays reachable:
// the host there is the one the person paired, and their own network is
// theirs to reach.
//
// Resolution failures refuse too: a name this box cannot resolve is not one
// it can vouch for.
//
// THE ADDRESS THE CHECK VETTED IS THE ADDRESS DIALLED. egressVettedAddr
// returns "ip:port" on an anchored box, and the dial takes that, never the
// name: a name resolved once for the check and again for the dial is a
// rebinding hole (a public answer for the check, 127.0.0.1 for the dial).
// THE ADDRESS THE CHECK VETTED IS THE ADDRESS DIALLED. egressVettedAddrs
// returns "ip:port" forms, and the dial takes those, never the name: a name
// resolved once for the check and again for the dial is a rebinding hole (a
// public answer for the check, 127.0.0.1 for the dial).
func egressInternalRefusal(ctx context.Context, hostport string) string {
_, why := egressVettedAddr(ctx, hostport)
_, why := egressVettedAddrs(ctx, hostport)
return why
}
// egressResolve is how the anchored lane resolves a name. A seam so a test can
// answer for a name without DNS.
// egressResolve is how the lane resolves a name. A seam so a test can answer
// for a name without DNS.
var egressResolve = net.DefaultResolver.LookupIPAddr
// egressVettedAddr: (the address to dial, "") or ("", the refusal). On a
// local box the input is returned as it came (the OS resolves at the dial,
// as before); on an anchored box every address the name resolves to is
// checked and the FIRST one is what the dial gets, as "ip:port".
// egressVettedAddr: (the first address to dial, "") or ("", the refusal).
func egressVettedAddr(ctx context.Context, hostport string) (string, string) {
if currentAnchor() == nil {
return hostport, ""
all, why := egressVettedAddrs(ctx, hostport)
if why != "" {
return "", why
}
return all[0], ""
}
// egressVettedAddrs: every address the name resolved to, as "ip:port" in the
// resolver's order, or (nil, the refusal). Any one refused address refuses the
// name. The dial tries them in order (dialEach), so a dual-stack site still
// answers from a machine whose IPv6 goes nowhere, as it did when the OS dialled
// the name.
func egressVettedAddrs(ctx context.Context, hostport string) ([]string, string) {
anchored := currentAnchor() != nil
host, port, err := net.SplitHostPort(hostport)
if err != nil {
host, port = hostport, ""
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if strings.EqualFold(host, "localhost") || strings.HasSuffix(strings.ToLower(host), ".localhost") {
return "", "refused: egress to " + host + " — this lane reaches the public internet only on an anchored machine"
if anchored {
return nil, "refused: egress to " + host + " — this lane reaches the public internet only on an anchored machine"
}
return nil, "refused: egress to " + host + " — this lane never reaches this machine"
}
var addrs []net.IP
if ip := net.ParseIP(host); ip != nil {
@@ -193,50 +212,150 @@ func egressVettedAddr(ctx context.Context, hostport string) (string, string) {
} else {
resolved, err := egressResolve(ctx, host)
if err != nil || len(resolved) == 0 {
return "", "refused: egress to " + host + " — could not resolve it on this machine"
return nil, "refused: egress to " + host + " — could not resolve it on this machine"
}
for _, r := range resolved {
addrs = append(addrs, r.IP)
}
}
for _, ip := range addrs {
if internalIP(ip) {
return "", "refused: egress to " + host + " (" + ip.String() + ") — this lane reaches the public " +
if anchored && internalIP(ip) {
return nil, "refused: egress to " + host + " (" + ip.String() + ") — this lane reaches the public " +
"internet only on an anchored machine, never this machine, its network or the mesh"
}
if !anchored && thisMachineOrLinkIP(ip) {
return nil, "refused: egress to " + host + " (" + ip.String() + ") — this lane never reaches " +
"this machine or a link-local address"
}
}
// This machine is not only loopback: a public address on its NIC, or a
// range the kernel delivers locally through a `local` route, reaches its
// 0.0.0.0 services too (ownaddr.go). This lane has no allow list, so the
// refusal has no exception; a local box returned above and is unchanged.
// This machine is not only loopback: a public address on its NIC, a LAN
// address on it, or a range the kernel delivers locally through a `local`
// route, reaches its 0.0.0.0 services too, and a container's or VM's
// address on a network made here reaches a service on it (ownaddr.go). This
// lane has no allow list, so the refusal has no exception on either kind of
// box.
own, err := ownDestination(addrs)
if err != nil {
return "", "refused: egress to " + host + " — this machine could not tell whether that is itself, so it cannot be vetted"
return nil, "refused: egress to " + host + " — this machine could not tell whether that is itself, so it cannot be vetted"
}
if own {
return "", "refused: egress to " + host + " — that is this machine; this lane reaches the public " +
"internet only on an anchored machine, never this machine, its network or the mesh"
if anchored {
return nil, "refused: egress to " + host + " — that is this machine; this lane reaches the public " +
"internet only on an anchored machine, never this machine, its network or the mesh"
}
return nil, "refused: egress to " + host + " — that is this machine; this lane never reaches this machine"
}
return net.JoinHostPort(addrs[0].String(), port), ""
out := make([]string, 0, len(addrs))
for _, ip := range addrs {
out = append(out, net.JoinHostPort(ip.String(), port))
}
return out, ""
}
// internalIP: loopback, link-local, private (RFC 1918, ULA), CGNAT, unspecified.
func internalIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {
return true
// dialEachStagger is how long an attempt has to answer before the next
// address is tried beside it: the connection attempt delay of Happy Eyeballs
// (RFC 8305 recommends 250 ms), which the OS dialler used when it was given
// the name.
var dialEachStagger = 300 * time.Millisecond
// dialEach dials vetted addresses, in order, and returns the first that
// answers. The next address starts as soon as an attempt fails, or when the
// attempts in flight have not answered within dialEachStagger, so an address
// whose packets go nowhere costs a fraction of a second, not the dial budget.
// Only these addresses are ever dialled. The attempts still in flight when one
// answers are cancelled, and any that connects anyway is closed.
func dialEach(ctx context.Context, dialOut egressDialer, addrs []string) (net.Conn, error) {
if len(addrs) == 0 {
return nil, errors.New("no address")
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type attempt struct {
c net.Conn
err error
}
results := make(chan attempt, len(addrs))
next, inFlight := 0, 0
start := func() {
a := addrs[next]
next++
inFlight++
go func() {
c, err := dialOut(ctx, "tcp", a)
results <- attempt{c, err}
}()
}
stagger := time.NewTimer(dialEachStagger)
defer stagger.Stop()
start()
var err error
for inFlight > 0 {
var tick <-chan time.Time
if next < len(addrs) {
tick = stagger.C
}
select {
case r := <-results:
inFlight--
if r.err == nil {
go func(n int) {
for ; n > 0; n-- {
if late := <-results; late.c != nil {
_ = late.c.Close()
}
}
}(inFlight)
return r.c, nil
}
err = r.err
if next < len(addrs) {
start()
stagger.Reset(dialEachStagger)
}
case <-tick:
start()
stagger.Reset(dialEachStagger)
}
}
return nil, err
}
// ipClass is what an address reaches, read by every lane that dials for
// someone else. The address rules live here once: internalIP is "anything but
// public", and the local egress lane refuses the classes that are this machine
// or its link.
type ipClass int
const (
ipPublic ipClass = iota // the public internet
ipThisMachine // loopback, unspecified, 0.0.0.0/8
ipLink // link-local unicast and multicast (the cloud metadata address)
ipNetwork // private (RFC 1918, ULA), CGNAT, multicast, benchmarking, reserved
ipUnreadable // a translated form whose IPv4 address this box cannot read
ipObsolete // IPv4-compatible ::/96, IPv4-translated ::ffff:0:0/96, site-local fec0::/10
)
func classifyIP(ip net.IP) ipClass {
switch {
case ip.IsLoopback(), ip.IsUnspecified():
return ipThisMachine
case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast():
return ipLink
case ip.IsPrivate(), ip.IsMulticast():
return ipNetwork
}
if v4 := ip.To4(); v4 != nil {
// 100.64.0.0/10 (carrier NAT) is as internal as 10/8 for this purpose.
if v4[0] == 100 && v4[1]&0xc0 == 64 {
return true
// 0.0.0.0/8 is "this host on this network".
if v4[0] == 0 {
return ipThisMachine
}
// 0.0.0.0/8 ("this network"), 198.18.0.0/15 (benchmarking) and
// 240.0.0.0/4 (reserved) are never a public host.
if v4[0] == 0 || (v4[0] == 198 && v4[1]&0xfe == 18) || v4[0] >= 240 {
return true
// 100.64.0.0/10 (carrier NAT) is as internal as 10/8 for this purpose;
// 198.18.0.0/15 (benchmarking) and 240.0.0.0/4 (reserved) are never a
// public host.
if (v4[0] == 100 && v4[1]&0xc0 == 64) || (v4[0] == 198 && v4[1]&0xfe == 18) || v4[0] >= 240 {
return ipNetwork
}
return false
return ipPublic
}
// IPv6 forms that carry an IPv4 address inside them reach that address, so
// they are judged by it: NAT64 64:ff9b::/96 (the last four bytes — how an
@@ -245,15 +364,51 @@ func internalIP(ip net.IP) bool {
if len(ip) == net.IPv6len {
if ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b {
if ip[4]|ip[5]|ip[6]|ip[7]|ip[8]|ip[9]|ip[10]|ip[11] == 0 {
return internalIP(net.IPv4(ip[12], ip[13], ip[14], ip[15]))
return classifyIP(net.IPv4(ip[12], ip[13], ip[14], ip[15]))
}
// The rest of 64:ff9b::/32 (local-use translators, RFC 8215) maps
// to IPv4 in ways this box cannot read: refused.
return true
// to IPv4 in ways this box cannot read.
return ipUnreadable
}
if ip[0] == 0x20 && ip[1] == 0x02 {
return internalIP(net.IPv4(ip[2], ip[3], ip[4], ip[5]))
return classifyIP(net.IPv4(ip[2], ip[3], ip[4], ip[5]))
}
// Deprecated forms that are no destination of their own: IPv4-compatible
// ::a.b.c.d (RFC 4291 2.5.5.1), IPv4-translated ::ffff:0:a.b.c.d (SIIT)
// and site-local fec0::/10 (RFC 3879). Where each is delivered depends on
// the platform, so no lane dials them. (:: and ::1 are matched above.)
if isZeros(ip[0:12]) ||
(isZeros(ip[0:8]) && ip[8] == 0xff && ip[9] == 0xff && ip[10] == 0 && ip[11] == 0) ||
(ip[0] == 0xfe && ip[1]&0xc0 == 0xc0) {
return ipObsolete
}
}
return ipPublic
}
func isZeros(b []byte) bool {
for _, c := range b {
if c != 0 {
return false
}
}
return true
}
// internalIP: loopback, link-local, private (RFC 1918, ULA), CGNAT,
// unspecified, multicast and reserved, also inside a NAT64 or 6to4 address,
// and the deprecated IPv4-compatible, IPv4-translated and site-local forms.
func internalIP(ip net.IP) bool {
return classifyIP(ip) != ipPublic
}
// thisMachineOrLinkIP: what the local egress lane refuses by address class.
// An address it cannot read is refused, since it could be either, and so is a
// deprecated form, which is no destination of its own.
func thisMachineOrLinkIP(ip net.IP) bool {
switch classifyIP(ip) {
case ipThisMachine, ipLink, ipUnreadable, ipObsolete:
return true
}
return false
}
+122
View File
@@ -0,0 +1,122 @@
package main
// dialEach, which both egress lanes dial through: an address whose packets go
// nowhere must not hold up the next one for its share of the dial budget, the
// way the OS dialler's Happy Eyeballs never did when it was given the name.
import (
"context"
"errors"
"net"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
type closeRecordingConn struct {
net.Conn
closed atomic.Bool
}
func (c *closeRecordingConn) Close() error {
c.closed.Store(true)
return c.Conn.Close()
}
// MUTATION: dial the addresses one after another, each with an even share of
// what is left of the budget.
// RESULT: red — the second address waits out the first one's share (2 s here).
func TestDialEachDoesNotWaitOutAnAddressThatGoesNowhere(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
var mu sync.Mutex
var dialled []string
abandoned := make(chan struct{})
dial := func(ctx context.Context, _, addr string) (net.Conn, error) {
mu.Lock()
dialled = append(dialled, addr)
mu.Unlock()
if addr == "[2001:db8::30]:443" {
<-ctx.Done() // no answer and no error: the packets go nowhere
close(abandoned)
return nil, ctx.Err()
}
a, b := net.Pipe()
go func() { _ = b.Close() }()
return a, nil
}
began := time.Now()
c, err := dialEach(ctx, dial, []string{"[2001:db8::30]:443", "198.51.100.30:443"})
took := time.Since(began)
if err != nil {
t.Fatalf("the second address answers: %v", err)
}
_ = c.Close()
if took > 1500*time.Millisecond {
t.Fatalf("the second address waited %v for the first", took)
}
select {
case <-abandoned:
case <-time.After(2 * time.Second):
t.Fatal("the attempt left in flight must be cancelled once another answers")
}
mu.Lock()
defer mu.Unlock()
if want := []string{"[2001:db8::30]:443", "198.51.100.30:443"}; !reflect.DeepEqual(dialled, want) {
t.Fatalf("only the vetted addresses, in order: got %v, want %v", dialled, want)
}
}
// A connection that answers after another already won is closed, not leaked;
// an address that fails at once hands over to the next without waiting; and
// when every address fails the last error is returned.
func TestDialEachClosesALateWinnerAndReturnsTheLastError(t *testing.T) {
late := make(chan *closeRecordingConn, 1)
dial := func(ctx context.Context, _, addr string) (net.Conn, error) {
switch addr {
case "198.51.100.31:443":
time.Sleep(2 * dialEachStagger) // answers, but after the second address did
a, b := net.Pipe()
go func() { _ = b.Close() }()
rc := &closeRecordingConn{Conn: a}
late <- rc
return rc, nil
case "198.51.100.32:443":
a, b := net.Pipe()
go func() { _ = b.Close() }()
return a, nil
}
return nil, errors.New("refused: " + addr)
}
c, err := dialEach(context.Background(), dial, []string{"198.51.100.31:443", "198.51.100.32:443"})
if err != nil {
t.Fatal(err)
}
_ = c.Close()
select {
case rc := <-late:
deadline := time.Now().Add(2 * time.Second)
for !rc.closed.Load() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !rc.closed.Load() {
t.Fatal("a connection that answered after the winner must be closed")
}
case <-time.After(3 * time.Second):
t.Fatal("the slow attempt never answered")
}
began := time.Now()
_, err = dialEach(context.Background(), dial, []string{"198.51.100.33:443", "198.51.100.34:443"})
if err == nil || err.Error() != "refused: 198.51.100.34:443" {
t.Fatalf("every address failed, so the last error: %v", err)
}
if took := time.Since(began); took >= dialEachStagger {
t.Fatalf("a failed address hands over at once, not after the stagger: %v", took)
}
if _, err := dialEach(context.Background(), dial, nil); err == nil {
t.Fatal("no address is an error")
}
}
+228
View File
@@ -0,0 +1,228 @@
//go:build !windows
// Tagged !windows: it borrows setupBox (anchor_test.go) and stubOwnAddrs
// (ownaddr_test.go).
package main
// The egress lane on a local (unanchored) box. It refuses this machine and
// its link-local addresses, so a host cannot reach this machine's services
// (ollama above all) around the switches that govern them, and it keeps the
// person's LAN. An anchored box is unchanged: the public internet only.
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
)
// answerEgressNames answers the egress lane's name lookups from m.
func answerEgressNames(t *testing.T, m map[string][]string) {
t.Helper()
old := egressResolve
t.Cleanup(func() { egressResolve = old })
egressResolve = func(_ context.Context, host string) ([]net.IPAddr, error) {
ips, ok := m[strings.ToLower(host)]
if !ok {
return nil, errors.New("no such host")
}
var out []net.IPAddr
for _, s := range ips {
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
}
return out, nil
}
}
// A machine with a LAN address of its own and an AnyIP range, and names that
// answer with this machine, with the LAN and with the public internet.
func localEgressFixture(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if currentAnchor() != nil || boxMode() != modeLocal {
t.Fatal("the fixture must be a local, unanchored box")
}
stubOwnAddrs(t, []string{"127.0.0.1/8", "::1/128", "192.168.50.7/24", "fe80::7/64"}, localRange("203.0.113.0/24"))
answerEgressNames(t, map[string][]string{
"ollama.example.test": {"127.0.0.1"},
"mixed.example.test": {"198.51.100.8", "127.0.0.1"},
"self.example.test": {"192.168.50.7"},
"nas.example.test": {"192.168.50.30"},
"dual.example.test": {"2001:db8::8", "198.51.100.8"},
})
}
// MUTATION: restore the early return for an unanchored box in egressVettedAddrs.
// RESULT: red — 127.0.0.1:11434 is handed to the dial, and a name is dialled
// as a name instead of the address that was vetted.
func TestLocalEgressRefusesThisMachineAndKeepsTheLAN(t *testing.T) {
localEgressFixture(t)
ctx := context.Background()
for _, hp := range []string{
"127.0.0.1:11434", "127.9.9.9:80", "localhost:11434", "ollama.localhost:80", "[::1]:11434",
"0.0.0.0:11434", "0.1.2.3:80", "[::]:80",
"[::ffff:127.0.0.1]:11434", "[64:ff9b::7f00:1]:11434", "[2002:7f00:1::]:80", "[64:ff9b:1::5]:80",
"169.254.169.254:80", "[fe80::1]:80",
"192.168.50.7:11434", // this machine's own LAN address
"203.0.113.5:80", // a range a `local` route delivers here
"ollama.example.test:11434", "mixed.example.test:443", "self.example.test:11434",
"unknown.example.test:443",
} {
if addr, why := egressVettedAddr(ctx, hp); why == "" || addr != "" {
t.Errorf("%s must be refused on a local box, got dial %q", hp, addr)
}
}
for hp, want := range map[string][]string{
"192.168.50.20:8080": {"192.168.50.20:8080"},
"10.1.2.3:443": {"10.1.2.3:443"},
"172.16.4.4:22": {"172.16.4.4:22"},
"[fd12:3456::9]:80": {"[fd12:3456::9]:80"},
"100.64.0.9:80": {"100.64.0.9:80"},
"198.51.100.8:443": {"198.51.100.8:443"},
"nas.example.test:443": {"192.168.50.30:443"},
"dual.example.test:443": {"[2001:db8::8]:443", "198.51.100.8:443"},
} {
got, why := egressVettedAddrs(ctx, hp)
if why != "" || !reflect.DeepEqual(got, want) {
t.Errorf("%s is the LAN or the internet, so a local box dials %v: got %v %q", hp, want, got, why)
}
}
}
// The anchored lane keeps its own wording and still refuses the LAN that a
// local box reaches.
func TestAnchoredEgressStillRefusesTheLAN(t *testing.T) {
setupBox(t)
stubOwnAddrs(t, []string{"127.0.0.1/8", "::1/128", "192.168.50.7/24"}, localRange("203.0.113.0/24"))
ctx := context.Background()
for _, hp := range []string{"192.168.50.20:8080", "10.1.2.3:443", "[fd12:3456::9]:80", "100.64.0.9:80", "127.0.0.1:11434"} {
if addr, why := egressVettedAddr(ctx, hp); addr != "" || !strings.Contains(why, "only on an anchored machine") {
t.Errorf("%s must be refused on an anchored box as before, got %q %q", hp, addr, why)
}
}
if addr, why := egressVettedAddr(ctx, "198.51.100.8:443"); why != "" || addr != "198.51.100.8:443" {
t.Fatalf("the public internet passes: %q %q", addr, why)
}
}
// Through the lane itself, with a real dialer: a request for this machine is
// answered 403 and never reaches the listener; a LAN request is dialled at its
// vetted address; a dual-stack name falls back to its second address.
func TestLocalEgressLaneNeverDialsThisMachine(t *testing.T) {
localEgressFixture(t)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
var hits atomic.Int32
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
hits.Add(1)
c.Close()
}
}()
ask := func(dial egressDialer, req string) string {
srv, cli := net.Pipe()
go handleEgressConn(srv, dial, "home.host.example")
_ = cli.SetDeadline(time.Now().Add(5 * time.Second))
_, _ = io.WriteString(cli, req)
line, _ := bufio.NewReader(cli).ReadString('\n')
cli.Close()
return line
}
netDial := (&net.Dialer{Timeout: 2 * time.Second}).DialContext
target := ln.Addr().String()
for _, req := range []string{
"CONNECT " + target + " HTTP/1.1\r\n\r\n",
"GET http://" + target + "/api/tags HTTP/1.1\r\nHost: " + target + "\r\n\r\n",
"CONNECT localhost:" + strings.Split(target, ":")[1] + " HTTP/1.1\r\n\r\n",
} {
if line := ask(netDial, req); !strings.HasPrefix(line, "HTTP/1.1 403") {
t.Errorf("a local box must refuse %q, answered %q", req, line)
}
}
time.Sleep(50 * time.Millisecond)
if n := hits.Load(); n != 0 {
t.Fatalf("the lane reached this machine's listener %d times", n)
}
var asked []string
recording := func(_ context.Context, _, addr string) (net.Conn, error) {
asked = append(asked, addr)
return nil, errors.New("stop here")
}
if line := ask(recording, "CONNECT nas.example.test:443 HTTP/1.1\r\n\r\n"); !strings.HasPrefix(line, "HTTP/1.1 502") {
t.Fatalf("a LAN destination is dialled (and this dial fails): %q", line)
}
if !reflect.DeepEqual(asked, []string{"192.168.50.30:443"}) {
t.Fatalf("the vetted LAN address, never the name: %v", asked)
}
asked = nil
fallback := func(_ context.Context, _, addr string) (net.Conn, error) {
asked = append(asked, addr)
if strings.HasPrefix(addr, "[") {
return nil, errors.New("network is unreachable")
}
a, b := net.Pipe()
go func() { _, _ = io.Copy(b, b) }()
return a, nil
}
if line := ask(fallback, "CONNECT dual.example.test:443 HTTP/1.1\r\n\r\n"); !strings.HasPrefix(line, "HTTP/1.1 200") {
t.Fatalf("a dual-stack name answers from its second address: %q", line)
}
if want := []string{"[2001:db8::8]:443", "198.51.100.8:443"}; !reflect.DeepEqual(asked, want) {
t.Fatalf("each vetted address in turn: got %v, want %v", asked, want)
}
}
// What docs/models.md tells a person with a local box to do: switch `models`
// off for the host. The forward then filters store-changing requests, and the
// egress lane is no way around it, since it never reaches this machine's
// ollama, on loopback or on the machine's own LAN address.
func TestLocalModelsSwitchLeavesNoEgressRouteToOllama(t *testing.T) {
localEgressFixture(t)
const host = "home.host.example"
writePairingState(t, host, pairingActive)
if inferenceFilterNeeded(host, laneOllama, pairingModelScope(host)) {
t.Fatal("an unscoped local pairing is a raw splice before the switch, so this test would prove nothing")
}
if err := localDenySet(host, "models", true); err != nil {
t.Fatal(err)
}
if !inferenceFilterNeeded(host, laneOllama, pairingModelScope(host)) {
t.Fatal("with `models` off the ollama forward must filter /api/pull")
}
var dialled []string
dial := func(_ context.Context, _, addr string) (net.Conn, error) {
dialled = append(dialled, addr)
return nil, errors.New("stop here")
}
for _, dest := range []string{"127.0.0.1:11434", "localhost:11434", "192.168.50.7:11434", "[::1]:11434"} {
srv, cli := net.Pipe()
go handleEgressConn(srv, dial, host)
_ = cli.SetDeadline(time.Now().Add(5 * time.Second))
fmt.Fprintf(cli, "POST http://%s/api/pull HTTP/1.1\r\nHost: %s\r\n\r\n", dest, dest)
line, _ := bufio.NewReader(cli).ReadString('\n')
cli.Close()
if !strings.HasPrefix(line, "HTTP/1.1 403") {
t.Errorf("egress to %s must be refused: %q", dest, line)
}
}
if len(dialled) != 0 {
t.Fatalf("nothing for this machine may be dialled: %v", dialled)
}
}
+80
View File
@@ -0,0 +1,80 @@
//go:build !windows
// Tagged !windows: it borrows setupBox (anchor_test.go) and stubOwnAddrs
// (ownaddr_test.go).
package main
// The deprecated IPv6 forms are no destination of their own, and where each
// is delivered depends on the platform, so every lane refuses them: the
// anchored egress lane, the local one, and a coder run's proxy, which refused
// them first.
import (
"context"
"testing"
)
var obsoleteForms = []string{
"[::c633:6408]:443", // IPv4-compatible ::198.51.100.8
"[::7f00:1]:11434", // IPv4-compatible ::127.0.0.1
"[::c0a8:3214]:80", // IPv4-compatible ::192.168.50.20, a LAN address
"[::ffff:0:c633:6408]:443", // IPv4-translated ::ffff:0:198.51.100.8
"[::ffff:0:7f00:1]:80", // IPv4-translated ::ffff:0:127.0.0.1
"[fec0::5]:80", // site-local
"[feff:1::9]:443", // the top of fec0::/10
"compat.example.test:443", // a name with one such answer among public ones
"sitelocal.example.test:80",
}
func answerObsoleteNames(t *testing.T) {
answerEgressNames(t, map[string][]string{
"compat.example.test": {"198.51.100.8", "::c633:6408"},
"sitelocal.example.test": {"fec0::5"},
"public.example.test": {"198.51.100.8", "2001:db8::8"},
})
}
// MUTATION: classify the three forms as public in classifyIP.
// RESULT: red — both egress lanes hand them to the dial.
func TestEveryLaneRefusesTheDeprecatedIPv6Forms(t *testing.T) {
ctx := context.Background()
refusedOn := func(t *testing.T, lane string) {
t.Helper()
for _, hp := range obsoleteForms {
if addrs, why := egressVettedAddrs(ctx, hp); why == "" || addrs != nil {
t.Errorf("%s must be refused on the %s egress lane, got dial %v", hp, lane, addrs)
}
}
for _, hp := range []string{"198.51.100.8:443", "public.example.test:443"} {
if _, why := egressVettedAddrs(ctx, hp); why != "" {
t.Errorf("%s is the public internet and passes the %s egress lane: %s", hp, lane, why)
}
}
}
t.Run("anchored", func(t *testing.T) {
setupBox(t)
stubOwnAddrs(t, loopbackOnly, localRange())
answerObsoleteNames(t)
refusedOn(t, "anchored")
})
t.Run("local", func(t *testing.T) {
localEgressFixture(t)
answerObsoleteNames(t)
refusedOn(t, "local")
})
t.Run("a coder run's proxy agrees", func(t *testing.T) {
stubOwnAddrs(t, loopbackOnly, localRange())
for _, hp := range obsoleteForms[:7] {
if addrs, why := coderVettedAddrs(ctx, hp, nil); why == "" || addrs != nil {
t.Errorf("%s must be refused on a coder run's proxy, got dial %v", hp, addrs)
}
}
if _, why := coderVettedAddrs(ctx, "198.51.100.8:443", nil); why != "" {
t.Errorf("the public internet passes a coder run's proxy: %s", why)
}
})
}
+23 -6
View File
@@ -38,6 +38,16 @@ func dialHandler(t *testing.T, dialOut egressDialer) (net.Conn, func()) {
return cli, func() { cli.Close(); ln.Close() }
}
// redirectDial stands in for the user's internet: the lane asks for a public
// destination, and this dials the loopback backend instead, recording what the
// lane asked for. The lane never reaches loopback itself.
func redirectDial(backend string, asked *[]string) egressDialer {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
*asked = append(*asked, addr)
return (&net.Dialer{}).DialContext(ctx, network, backend)
}
}
// CONNECT establishes a raw tunnel; bytes flow both ways through it.
func TestEgressConnConnectTunnel(t *testing.T) {
backend, err := net.Listen("tcp", "127.0.0.1:0")
@@ -54,9 +64,10 @@ func TestEgressConnConnectTunnel(t *testing.T) {
_, _ = io.Copy(c, c) // echo
}()
cli, cleanup := dialHandler(t, (&net.Dialer{}).DialContext)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(backend.Addr().String(), &asked))
defer cleanup()
fmt.Fprintf(cli, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", backend.Addr(), backend.Addr())
fmt.Fprint(cli, "CONNECT 198.51.100.20:443 HTTP/1.1\r\nHost: 198.51.100.20:443\r\n\r\n")
br := bufio.NewReader(cli)
status, err := br.ReadString('\n')
if err != nil || !strings.Contains(status, "200") {
@@ -82,6 +93,9 @@ func TestEgressConnConnectTunnel(t *testing.T) {
if string(buf) != want {
t.Fatalf("tunnel echo mismatch: got %q", string(buf))
}
if len(asked) != 1 || asked[0] != "198.51.100.20:443" {
t.Fatalf("the lane must dial the address it vetted: %v", asked)
}
}
func TestEgressConnPlainHTTP(t *testing.T) {
@@ -89,15 +103,18 @@ func TestEgressConnPlainHTTP(t *testing.T) {
fmt.Fprint(w, "plain-http-ok")
}))
defer backend.Close()
host := strings.TrimPrefix(backend.URL, "http://")
cli, cleanup := dialHandler(t, (&net.Dialer{}).DialContext)
var asked []string
cli, cleanup := dialHandler(t, redirectDial(strings.TrimPrefix(backend.URL, "http://"), &asked))
defer cleanup()
fmt.Fprintf(cli, "GET http://%s/ HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", host, host)
fmt.Fprint(cli, "GET http://198.51.100.21/ HTTP/1.1\r\nHost: 198.51.100.21\r\nConnection: close\r\n\r\n")
body, _ := io.ReadAll(cli)
if !strings.Contains(string(body), "plain-http-ok") {
t.Fatalf("plain HTTP forward: got %q", string(body))
}
if len(asked) != 1 || asked[0] != "198.51.100.21:80" {
t.Fatalf("the lane must dial the address it vetted: %v", asked)
}
}
func TestEgressConnDialFailure(t *testing.T) {
@@ -106,7 +123,7 @@ func TestEgressConnDialFailure(t *testing.T) {
}
cli, cleanup := dialHandler(t, failDial)
defer cleanup()
fmt.Fprint(cli, "CONNECT unreachable.example:443 HTTP/1.1\r\n\r\n")
fmt.Fprint(cli, "CONNECT 198.51.100.22:443 HTTP/1.1\r\n\r\n")
status, _ := bufio.NewReader(cli).ReadString('\n')
if !strings.Contains(status, "502") {
t.Fatalf("expected 502 on dial failure, got %q", status)
+230
View File
@@ -0,0 +1,230 @@
//go:build !windows
// Uses fakeToken from pairing_redeem_test.go and setupBox/driveOp from
// anchor_test.go, both unix-only.
package main
// A pairing whose file holds an endpoint this build refuses loads as invalid
// and is never served (endpoint_test.go). These are the paths that could
// still act on it: anchoring the box to it, an approval from the anchor host,
// what the hosts are told about it, and the two ways a person clears it.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// unservedEndpoint is one the endpoint check refuses (badTunnelEndpoints).
const unservedEndpoint = "203.0.113.7:51820\nallowed_ip=::/0"
// writeUnservedPairing writes a pairing file in the given state whose
// endpoint this build refuses, and returns its path.
func writeUnservedPairing(t *testing.T, host, state string) string {
t.Helper()
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(Config{HostName: host, WGPrivateKey: "k", BastionTunnelIP: "fd12:9::a:1",
State: state, Endpoint: unservedEndpoint})
p := filepath.Join(pd, host+".json")
if err := os.WriteFile(p, b, 0o600); err != nil {
t.Fatal(err)
}
return p
}
// fileState is the state field as written in host's pairing file.
func fileState(t *testing.T, host string) string {
t.Helper()
pd, _ := pairingsDir()
raw, err := os.ReadFile(filepath.Join(pd, host+".json"))
if err != nil {
t.Fatal(err)
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
return c.State
}
// claim-anchor names the reason and writes nothing: the pairing keeps its
// state, and the box is neither anchored nor claimed. As the sole pairing and
// named with --host among others, in the legacy active state and pending.
func TestClaimAnchorRefusesAPairingThatIsNotServed(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("claim-anchor needs machine authority, which needs root")
}
for _, c := range []struct {
name string
others []string
host string
}{{"sole pairing", nil, ""}, {"named with --host", []string{tAnchor}, tMember}} {
for _, st := range []string{"", pairingPending} {
t.Run(c.name+"/"+st, func(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
if err := setBoxMode(modeNetworked); err != nil {
t.Fatal(err)
}
for _, o := range c.others {
writePairing(t, o)
}
p := writeUnservedPairing(t, tMember, st)
err := cmdClaimAnchor("", false, "", c.host)
if err == nil || !contains(err.Error(), "not a host:port") || !contains(err.Error(), p) {
t.Fatalf("claim-anchor must refuse the pairing, naming its file and why: %v", err)
}
if a := currentAnchor(); a != nil {
t.Fatalf("a refused claim anchored the box: %+v", a)
}
if oc := currentOwnerClaim(); oc != nil {
t.Fatalf("a refused claim wrote the owner claim: %+v", oc)
}
if got := fileState(t, tMember); got != st {
t.Fatalf("a refused claim changed the pairing's state from %q to %q", st, got)
}
})
}
}
}
// The anchor host's approval of such a pairing is refused: it is not made
// active, no grant is kept, and the refusal names no file of this machine.
// The copy updatePairingOnDisk hands back carries the same mark, so a caller
// that starts a pairing from it starts nothing.
func TestAnApprovalOfAPairingThatIsNotServedIsRefused(t *testing.T) {
priv := setupBox(t)
writeUnservedPairing(t, tMember, pairingPending)
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
_, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig})
if code != 126 || !contains(errOut, "not served") {
t.Fatalf("the approval must be refused (126, not served): %d %s", code, errOut)
}
pd, _ := pairingsDir()
if contains(errOut, pd) {
t.Fatalf("the refusal sent to the host names a file of this machine: %s", errOut)
}
if got := fileState(t, tMember); got != pairingPending {
t.Fatalf("a refused approval changed the state to %q", got)
}
if _, err := loadPairingGrant(tMember); err == nil {
t.Fatal("a refused approval kept the grant")
}
c, err := updatePairingOnDisk(tMember, func(d *Config) { d.State = pairingActive })
if err != nil {
t.Fatal(err)
}
if c.invalid == "" || c.active() {
t.Fatalf("the copy written back must still be invalid and never active: invalid=%q active=%v", c.invalid, c.active())
}
if startPairing(c) {
t.Fatal("a pairing that is not served was started")
}
}
// What the hosts hear: neither the peer list every host gets, nor the
// pairing list the anchor host gets (in the heartbeat and from pairing_list),
// names such a pairing, and every state they do carry is one a host knows.
// The machine's own status still shows it invalid (endpoint_test.go).
func TestNoHostIsToldOfAPairingThatIsNotServed(t *testing.T) {
setupBox(t)
const beta, pend = "beta.host.privatae.ai", "pend.host.privatae.ai"
writePairing(t, beta)
writeUnservedPairing(t, tMember, "")
writeUnservedPairing(t, pend, pairingPending)
known := map[string]bool{pairingActive: true, pairingPending: true, pairingRevoked: true}
peers := peersFor(tAnchor)
if len(peers) != 1 || peers[0].HostName != beta {
t.Fatalf("the peers must be only %s, got %+v", beta, peers)
}
for _, p := range peers {
if !known[p.State] {
t.Fatalf("a peer was sent with a state a host does not know: %+v", p)
}
}
list := pairingsReport()
var names []string
for _, e := range list {
names = append(names, e.HostName)
if !known[e.State] {
t.Fatalf("a pairing was sent with a state a host does not know: %+v", e)
}
}
if got := strings.Join(names, ","); got != tAnchor+","+beta {
t.Fatalf("the anchor host's list must be %s,%s, got %s", tAnchor, beta, got)
}
out, errOut, code := driveOp(t, tAnchor, "pairing_list", nil)
if code != 0 || contains(out, tMember) || contains(out, pend) || contains(out, pairingInvalid) {
t.Fatalf("pairing_list must leave the pairing out: %d %s %s", code, out, errOut)
}
if u := unapprovedAttempts(time.Now().Add(24 * time.Hour)); len(u) != 0 {
t.Fatalf("a pairing that is not served is no unapproved attempt: %+v", u)
}
cfgs, err := loadPairings()
if err != nil {
t.Fatal(err)
}
if c := pairingFor(cfgs, tMember); c == nil || c.stateName() != pairingInvalid {
t.Fatalf("this machine still lists it invalid: %+v", c)
}
}
// pair --replace over such a pairing pairs it again, served; unpair removes
// one. Both through the commands a person runs.
func TestPairReplaceAndUnpairClearAPairingThatIsNotServed(t *testing.T) {
uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
prev := unpairNeedsRoot
unpairNeedsRoot = func() bool { return false }
t.Cleanup(func() { unpairNeedsRoot = prev })
const host, good = "acme.host.privatae.ai", "203.0.113.7:51820"
redeem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"operator_crt": "CERT", "ca_crt": "CA", "bearer": "b",
"endpoint": good, "endpoints": []string{good},
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
"host_tunnel_ip": "2001:db8::e:3", "host_name": host,
})
}))
defer redeem.Close()
old := redeemURLBase
redeemURLBase = redeem.URL
t.Cleanup(func() { redeemURLBase = old })
writeUnservedPairing(t, host, "")
if err := cmdPair(fakeToken("acme"), "", "", true); err != nil {
t.Fatalf("pair --replace over a pairing that is not served: %v", err)
}
cfgs, err := loadPairings()
if err != nil || len(cfgs) != 1 {
t.Fatalf("after pair --replace one pairing loads: %v %v", cfgs, err)
}
if c := cfgs[0]; c.HostName != host || c.invalid != "" || !c.active() || c.Endpoint != good {
t.Fatalf("pair --replace must leave a served pairing: %q invalid=%q active=%v endpoint=%q",
c.HostName, c.invalid, c.active(), c.Endpoint)
}
writeUnservedPairing(t, host, "")
if err := cmdUnpair(host); err != nil {
t.Fatalf("unpair of a pairing that is not served: %v", err)
}
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 0 {
t.Fatalf("after unpair nothing is left: %v %v", cfgs, err)
}
pd, _ := pairingsDir()
if _, err := os.Stat(filepath.Join(pd, host+".json")); !os.IsNotExist(err) {
t.Fatalf("the pairing file is still there: %v", err)
}
}
+207
View File
@@ -0,0 +1,207 @@
//go:build !windows
// Uses fakeToken from pairing_redeem_test.go, unix-only.
package main
// A tunnel endpoint arrives from the host three ways: in the pairing
// response, in the rotate directive on a heartbeat answer, and from the
// pairing file those were saved to. Each one refuses an endpoint that is
// not one host:port, the file by never serving that pairing (the
// device-level checks are in claims_tunnel_test.go).
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// A pairing response whose endpoint, or any entry of its endpoints list, is
// not one host:port is refused and nothing is saved. An IPv4, an IPv6 and a
// host name endpoint pair.
func TestAPairingResponseWithAnUnsafeEndpointSavesNothing(t *testing.T) {
cfgHome := uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
var endpoint, hostName string
var endpoints []string
redeem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"operator_crt": "CERT", "ca_crt": "CA", "bearer": "b",
"endpoint": endpoint, "endpoints": endpoints,
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
"host_tunnel_ip": "2001:db8::e:3", "host_name": hostName,
})
}))
defer redeem.Close()
old := redeemURLBase
redeemURLBase = redeem.URL
t.Cleanup(func() { redeemURLBase = old })
hostName = "acme.host.privatae.ai"
for _, bad := range badTunnelEndpoints {
if bad == "" {
continue // a missing endpoint has its own refusal
}
for _, c := range []struct {
endpoint string
list []string
}{{bad, nil}, {"203.0.113.7:51820", []string{"[2001:db8::7]:51820", bad}}} {
endpoint, endpoints = c.endpoint, c.list
if _, _, err := pairWithToken(fakeToken("acme"), false, false); err == nil || !strings.Contains(err.Error(), "not a host:port") {
t.Errorf("%q in %+v: the pairing must be refused for its endpoint, got %v", bad, c, err)
}
}
}
_ = filepath.Walk(cfgHome, func(path string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() {
t.Errorf("a refused pairing left a file behind: %s", path)
}
return nil
})
for _, good := range goodTunnelEndpoints {
uiTestConfigDir(t) // a local bastion serves one host: a fresh box each
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
endpoint, endpoints = good, []string{good}
cfg, _, err := pairWithToken(fakeToken("acme"), false, false)
if err != nil {
t.Fatalf("%q must pair: %v", good, err)
}
if cfg.Endpoint != good || len(cfg.Endpoints) != 1 || cfg.Endpoints[0] != good {
t.Fatalf("%q: saved %q %q", good, cfg.Endpoint, cfg.Endpoints)
}
}
}
// A pairing file that already holds an unsafe endpoint (written by an older
// build, or by hand) loads as invalid, naming the file and why: it is never
// served, while every other pairing still loads, status shows it, and unpair
// removes it. A machine that upgrades is never stopped by one such file.
func TestAPairingFileWithAnUnsafeEndpointLoadsButIsNeverServed(t *testing.T) {
uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
supervised(t)
prev := unpairNeedsRoot
unpairNeedsRoot = func() bool { return false }
t.Cleanup(func() { unpairNeedsRoot = prev })
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
write := func(c *Config) string {
t.Helper()
b, _ := json.Marshal(c)
p := filepath.Join(pd, c.HostName+".json")
if err := os.WriteFile(p, b, 0o600); err != nil {
t.Fatal(err)
}
return p
}
const host, other = "acme.host.privatae.ai", "other.host.privatae.ai"
write(&Config{HostName: other, Endpoint: goodTunnelEndpoints[0]})
for _, bad := range badTunnelEndpoints {
if bad == "" {
continue // a pairing over a socket has no endpoint
}
for _, c := range []*Config{
{HostName: host, Endpoint: bad},
{HostName: host, Endpoint: "203.0.113.7:51820", Endpoints: []string{bad}},
} {
p := write(c)
cfgs, err := loadPairings()
if err != nil || len(cfgs) != 2 {
t.Fatalf("%q: one bad file must not fail the load: %v %v", bad, cfgs, err)
}
got := pairingFor(cfgs, host)
if got.active() || got.stateName() != "invalid" {
t.Errorf("%q: the pairing must load invalid: active=%v %q", bad, got.active(), got.stateName())
}
if out := statusOutput(t); !strings.Contains(out, host+" [invalid]") ||
!strings.Contains(out, "not a host:port") || !strings.Contains(out, p) {
t.Errorf("%q: status must show the pairing invalid, naming its file and why:\n%s", bad, out)
}
if !pairingFor(cfgs, other).active() {
t.Errorf("%q: the other pairing must still be served", bad)
}
if startPairing(got) {
t.Errorf("%q: an invalid pairing was started", bad)
}
}
}
// The supervisor starts nothing for it either (the other pairing is
// removed first: starting it would raise a real tunnel).
if err := os.Remove(filepath.Join(pd, other+".json")); err != nil {
t.Fatal(err)
}
superviseOnce()
supervisor.Lock()
running := len(supervisor.runs)
supervisor.Unlock()
if running != 0 {
t.Fatalf("the supervisor started %d run(s) for an invalid pairing", running)
}
if h, err := unpairHost(host); err != nil || h != host {
t.Fatalf("unpair must remove an invalid pairing: %q %v", h, err)
}
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 0 {
t.Fatalf("after unpair nothing is left: %v %v", cfgs, err)
}
for _, good := range goodTunnelEndpoints {
write(&Config{HostName: host, Endpoint: good, Endpoints: goodTunnelEndpoints})
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 1 || cfgs[0].Endpoint != good || !cfgs[0].active() {
t.Fatalf("%q must load and be served: %v %v", good, cfgs, err)
}
}
write(&Config{HostName: host, Transport: transportUnix})
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 1 || !cfgs[0].active() {
t.Fatalf("a pairing with no endpoint must load and be served: %v %v", cfgs, err)
}
}
// statusOutput is what `status` prints.
func statusOutput(t *testing.T) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
stdout := os.Stdout
os.Stdout = w
serr := cmdStatus()
os.Stdout = stdout
_ = w.Close()
out, _ := io.ReadAll(r)
if serr != nil {
t.Fatalf("status: %v", serr)
}
return string(out)
}
// A rotate directive that names any unsafe endpoint is not acted on; one
// that names only host:port entries is.
func TestARotateDirectiveWithAnUnsafeEndpointIsIgnored(t *testing.T) {
for _, bad := range badTunnelEndpoints {
if bad == "" {
continue // an empty entry is no entry
}
for _, d := range [][]byte{
directive(bad, nil, "PK="),
directive("203.0.113.7:51820", []string{bad, "[2001:db8::7]:51820"}, "PK="),
} {
if c, pk := parseRotateTarget(d); len(c) != 0 || pk != "" {
t.Errorf("%q: the directive must be ignored, got %q %q", bad, c, pk)
}
}
}
c, pk := parseRotateTarget(directive("203.0.113.7:51820", goodTunnelEndpoints, "PK="))
if pk != "PK=" || !sameList(c, goodTunnelEndpoints) {
t.Fatalf("a directive of host:port entries must be taken: %q %q", c, pk)
}
}
+60 -6
View File
@@ -60,6 +60,27 @@ type freeagentConfig struct {
Cert string // PEM: the client identity this box presents
Key string
CA string // PEM: the root the local agent's server cert must chain to
// Mode is the sanitize mode this bastion wrote into the agent's manifest
// (faManagedSanitizeMode for every agent it deploys), or "" for an agent
// someone runs by hand, whose manifest this bastion did not write.
Mode string
}
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
// only: it replaces e-mail addresses, phone numbers, card, bank and ID
// numbers, IP and MAC addresses and keys, and does not find names, street
// addresses or dates of birth.
const faManagedSanitizeMode = "flag"
// faSanitizeNotes says, for each mode a reply can carry, what personal data
// may still be in the text, in the words the host relays. A mode with no entry
// here is reported as not known to remove personal data.
var faSanitizeNotes = map[string]string{
"flag": "rules only: e-mail addresses, phone numbers, card, bank and ID numbers, IP and MAC addresses " +
"and keys were replaced; names, street addresses and dates of birth were not",
"sterilize": "",
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
}
func freeagentEnv() (freeagentConfig, bool) {
@@ -252,6 +273,16 @@ func freeagentProbe() (bool, bool) {
// local agent's own 403 (grant/mask) and 503 (manifest not
// serving) are refusals with the reason it gave; 404 on read_range is gone.
func freeagentMap(op string, status int, body []byte, err error) (map[string]any, string, bool) {
return freeagentMapMode(op, "", status, body, err)
}
// freeagentMapMode is freeagentMap for an agent whose manifest this bastion
// wrote with sanitize mode `mode` ("" when it did not write it). A successful
// reply says how its text was sanitized: `sanitize_mode` is the mode this
// bastion wrote, else the one the agent reports, else "unknown"; `sanitized`
// is true only for "sterilize", the mode that looks for names; and
// `sanitize_note` says what the mode leaves in.
func freeagentMapMode(op, mode string, status int, body []byte, err error) (map[string]any, string, bool) {
if err != nil {
return map[string]any{"state": "offline", "detail": "freeagent: " + err.Error()}, "", true
}
@@ -267,7 +298,7 @@ func freeagentMap(op string, status int, body []byte, err error) (map[string]any
return nil, "freeagent: malformed reply", false
case status == http.StatusOK:
payload["state"] = "ok"
payload["sanitized"] = true
faStampSanitize(payload, mode)
return payload, "", true
case status == http.StatusNotFound && op == "read_range":
return map[string]any{"state": "gone", "detail": reason}, "", true
@@ -286,10 +317,33 @@ func freeagentMap(op string, status int, body []byte, err error) (map[string]any
}
}
// faStampSanitize writes the three sanitize fields onto a successful reply.
// The mode this bastion wrote wins over the one the reply carries: a reply
// cannot make text it never sterilized read as sterilized.
func faStampSanitize(payload map[string]any, mode string) {
if mode == "" {
mode, _ = payload["sanitize_mode"].(string)
}
if mode == "" {
mode = "unknown"
}
note, known := faSanitizeNotes[mode]
if !known {
note = "the local agent did not say how this text was sanitized; it may contain personal data"
}
payload["sanitize_mode"] = mode
payload["sanitized"] = mode == "sterilize"
delete(payload, "sanitize_note")
if note != "" {
payload["sanitize_note"] = note
}
}
// handleDcnSearch — op `dcn_search`: args = <store_id> <json {query, k}>. Gated
// by the synced grant with the 'search' mask, then forwarded to the
// local agent's /search. Hits are sanitized windows with document ids the host
// feeds back to read_range.
// local agent's /search. Hits are windows of the agent's sanitized text, with
// document ids the host feeds back to read_range; faStampSanitize says which
// sanitize mode produced them.
func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
storeID, a, ok := dcnParseArgs(ws, args)
if !ok {
@@ -319,7 +373,7 @@ func handleDcnSearch(ws *websocket.Conn, dir string, args []string) {
}
status, body, err := freeagentCall(c, http.MethodPost, "/search",
map[string]any{"query": q, "k": k})
payload, why, ok := freeagentMap("search", status, body, err)
payload, why, ok := freeagentMapMode("search", c.Mode, status, body, err)
if !ok {
dcnRefuse(ws, dir, "search", storeID, "", why)
return
@@ -394,7 +448,7 @@ func handleDcnCatalog(ws *websocket.Conn, dir string, args []string) {
"state": "offline", "detail": "no catalog yet — the local agent's first sweep has not finished; try again later"})
return
}
payload, why, ok := freeagentMap("catalog", status, body, err)
payload, why, ok := freeagentMapMode("catalog", c.Mode, status, body, err)
if !ok {
dcnRefuse(ws, dir, "catalog", storeID, "kind:"+kind, why)
return
@@ -427,7 +481,7 @@ func handleDcnReadRangeDoc(ws *websocket.Conn, dir string, storeID int64, a *dcn
}
status, body, err := freeagentCall(c, http.MethodPost, "/read_range",
map[string]any{"doc": a.Doc, "start": start, "end": end})
payload, why, ok := freeagentMap("read_range", status, body, err)
payload, why, ok := freeagentMapMode("read_range", c.Mode, status, body, err)
if !ok {
dcnRefuse(ws, dir, "read_range", storeID, "doc:"+a.Doc, why)
return
+9 -1
View File
@@ -81,11 +81,19 @@ type faOwner struct {
gid uint32
name string
home string
// plat is what this platform needs beyond the above: nothing on unix, the
// plat is what this platform needs beyond the above: nothing on unix (an
// owner there is fully described by its uid, gid, name and home), the
// owner's SID on Windows (freeagent_drop_windows.go).
plat faOwnerPlat
}
// faOwnerPlat holds the owner's SID, as text, and their bare user name. Only
// Windows fills it; it stays zero on unix.
type faOwnerPlat struct {
sid string
user string
}
// faResolveOwner decides the identity for a store's agent. Where this
// platform drops and the daemon holds machine authority it is the granted
// folder's owner, refused for the machine's own accounts and for anyone who
-3
View File
@@ -20,9 +20,6 @@ func faDropSupported() bool { return true }
// own 0700 leaf.
func faRootStateBase() string { return "/var/lib/privatae/freeagent" }
// faOwnerPlat: a unix owner is fully described by its uid, gid, name and home.
type faOwnerPlat struct{}
// faHandsOverByChown: on unix every file root writes for the child is handed
// over by changing its owner.
const faHandsOverByChown = true
+1 -14
View File
@@ -71,12 +71,6 @@ func faRootStateBase() string {
return filepath.Join(d, "freeagent-state")
}
// faOwnerPlat holds the owner's SID, as text, and their bare user name.
type faOwnerPlat struct {
sid string
user string
}
// faHandsOverByChown: on Windows the hand-over is a permission entry on the
// state leaf, inherited by everything later created in it.
const faHandsOverByChown = false
@@ -430,12 +424,6 @@ func (c *winChild) Wait() error {
return nil
}
func faPathOwner(string) (uint32, uint32, error) {
return 0, 0, errors.New("ownership is not read as a uid on windows")
}
func faChown(string, uint32, uint32) error { return nil }
// sysProcAttr: the Windows drop does not go through a credential; faStartChild
// starts the child under the owner's token itself.
func (o *faOwner) sysProcAttr() *syscall.SysProcAttr { return nil }
@@ -457,5 +445,4 @@ func faFdSane(f *os.File) error {
return nil
}
func faInfoOwner(os.FileInfo) (uint32, uint32, bool) { return 0, 0, false }
func faInfoLinks(os.FileInfo) (uint64, bool) { return 0, false }
func faInfoLinks(os.FileInfo) (uint64, bool) { return 0, false }
+3 -2
View File
@@ -500,7 +500,7 @@ func faManifestText(id, cn string, terms *dcnTerms, sweep, recipient, embedModel
fmt.Fprintf(&b, " exclude: [%s]\n", strings.Join(ex, ", "))
}
fmt.Fprintf(&b, "mask: [%s]\nstages: [%s]\n", strings.Join(mask, ", "), strings.Join(stages, ", "))
b.WriteString("sanitize:\n mode: flag\n")
fmt.Fprintf(&b, "sanitize:\n mode: %s\n", faManagedSanitizeMode)
if containsStr(stages, "embed") {
fmt.Fprintf(&b, "embed:\n model: %s\n dim: %d\n chunk: {size_tokens: 512, overlap_tokens: 64}\n", yamlStr(embedModel), embedDim)
}
@@ -677,7 +677,8 @@ func faManagedConfig(storeID int64) (freeagentConfig, bool) {
if err != nil || port <= 0 {
return freeagentConfig{}, false
}
return freeagentConfig{Addr: "127.0.0.1:" + strconv.Itoa(port), Cert: inst.Cert, Key: inst.Key, CA: inst.Cert}, true
return freeagentConfig{Addr: "127.0.0.1:" + strconv.Itoa(port), Cert: inst.Cert, Key: inst.Key, CA: inst.Cert,
Mode: faManagedSanitizeMode}, true
}
// ── ensure / reconcile ──────────────────────────────────────────────────────
+47 -2
View File
@@ -190,8 +190,11 @@ func TestFreeagentProbeAndEnv(t *testing.T) {
func TestFreeagentMap(t *testing.T) {
m, why, ok := freeagentMap("search", 200, []byte(`{"hits":[]}`), nil)
if !ok || m["state"] != "ok" || m["sanitized"] != true || why != "" {
t.Fatalf("200 → ok/sanitized, got %v %q", m, why)
if !ok || m["state"] != "ok" || why != "" {
t.Fatalf("200 → ok, got %v %q", m, why)
}
if m["sanitized"] != false || m["sanitize_mode"] != "unknown" || m["sanitize_note"] == nil {
t.Fatalf("a reply that names no mode is not called sanitized, got %v", m)
}
m, _, ok = freeagentMap("read_range", 404, []byte(`{"error":"no such document"}`), nil)
if !ok || m["state"] != "gone" {
@@ -298,3 +301,45 @@ func TestDcnOpArgsCarryFreeagentFields(t *testing.T) {
t.Fatalf("args not carried: %+v", a)
}
}
// The sanitize fields say what was done to the text. An agent this bastion
// deployed runs in flag mode, which leaves names in: its replies are never
// called sanitized, whatever the reply itself claims. An agent run by hand is
// reported in the mode it names, and only sterilize counts as sanitized.
func TestASanitizeStampNamesTheModeThatRan(t *testing.T) {
for _, c := range []struct {
name, mode, body, wantMode string
wantSanitized, wantNote bool
}{
{"deployed, reply silent", faManagedSanitizeMode, `{"hits":[]}`, "flag", false, true},
{"deployed, reply claims sterilize", faManagedSanitizeMode, `{"hits":[],"sanitize_mode":"sterilize","sanitized":true}`, "flag", false, true},
{"by hand, flag", "", `{"hits":[],"sanitize_mode":"flag"}`, "flag", false, true},
{"by hand, sterilize", "", `{"hits":[],"sanitize_mode":"sterilize","sanitize_note":"x"}`, "sterilize", true, false},
{"by hand, silent", "", `{"hits":[],"sanitized":true}`, "unknown", false, true},
{"by hand, no sanitize stage", "", `{"hits":[],"sanitize_mode":"none"}`, "none", false, true},
{"by hand, a mode this build does not know", "", `{"hits":[],"sanitize_mode":"other"}`, "other", false, true},
} {
for _, op := range []string{"search", "read_range", "catalog"} {
m, why, ok := freeagentMapMode(op, c.mode, 200, []byte(c.body), nil)
if !ok || why != "" || m["state"] != "ok" {
t.Fatalf("%s/%s: %v %q", c.name, op, m, why)
}
note, hasNote := m["sanitize_note"].(string)
if m["sanitize_mode"] != c.wantMode || m["sanitized"] != c.wantSanitized || hasNote != c.wantNote || (hasNote && note == "") {
t.Errorf("%s/%s: got mode %v sanitized %v note %q", c.name, op, m["sanitize_mode"], m["sanitized"], note)
}
}
}
if !strings.Contains(faSanitizeNotes["flag"], "names") {
t.Fatal("the flag note must say names are left in")
}
}
// The mode the stamp reports for a deployed agent is the mode the bastion
// writes into its manifest.
func TestTheDeployedManifestUsesTheStampedMode(t *testing.T) {
text := faManifestText("s1", "cn", &dcnTerms{Root: "/tmp/x", Mask: []string{"search"}}, "", "", "m", 8)
if !strings.Contains(text, "sanitize:\n mode: "+faManagedSanitizeMode+"\n") {
t.Fatalf("manifest sanitize block:\n%s", text)
}
}
+4 -4
View File
@@ -1,14 +1,14 @@
module git.privatae.ai/privatae/privatae-bastion
go 1.25.0
go 1.26.0
require (
filippo.io/age v1.3.2
fyne.io/systray v1.12.2
github.com/creack/pty v1.1.24
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.55.0
golang.org/x/sys v0.47.0
golang.org/x/crypto v0.58.0
golang.org/x/sys v0.49.0
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446
gopkg.in/yaml.v3 v3.0.1
rsc.io/qr v0.2.0
@@ -18,7 +18,7 @@ require (
filippo.io/hpke v0.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/btree v1.1.2 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
+8 -8
View File
@@ -14,14 +14,14 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
+848 -105
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
package main
// The agent's credential for a harness run is a file in a folder mounted into
// the container. No other local user may reach either: as root the bastion
// hands both to the container's user, owner-only; as anyone else it opens them
// for that user, inside a folder only the bastion can enter.
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestTheHarnessCredentialIsReadableOnlyByTheContainersUser(t *testing.T) {
// A stand-in runtime: on `run` it records the mode and owner of the folder
// the credential folder sits in, of that folder and of the file, then
// fails, so the run stops there.
bin := t.TempDir()
record := filepath.Join(bin, "record")
rt := filepath.Join(bin, "runtime")
script := "#!/bin/sh\n[ \"$1\" = run ] || exit 1\nfor a; do case \"$a\" in *:/home/privatae/.claude) d=${a%:/home/privatae/.claude};; esac; done\n" +
"[ -n \"$d\" ] && stat -c '%a %u %g' \"$(dirname \"$d\")\" \"$d\" \"$d/.credentials.json\" > " + record + "\nexit 1\n"
if err := os.WriteFile(rt, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
spec := harnessSpec{HarnessID: "h1", Image: "harness:test", AgentCreds: `{"claudeAiOauth":{}}`}
if _, err := driveHarnessAgent(rt, t.TempDir(), spec, 1); err == nil {
t.Fatal("the stand-in runtime fails the start; the run must report it")
}
got, err := os.ReadFile(record)
if err != nil {
t.Fatalf("the credential folder was not mounted: %v", err)
}
self := strconv.Itoa(os.Geteuid()) + " " + strconv.Itoa(os.Getegid())
want := "700 " + self + "\n777 " + self + "\n666 " + self
if os.Geteuid() == 0 {
want = "700 " + self + "\n700 10005 10005\n600 10005 10005" // the harness image's user
}
if strings.TrimSpace(string(got)) != want {
t.Fatalf("enclosing folder, folder, then file: want %q, got %q", want, strings.TrimSpace(string(got)))
}
}
@@ -0,0 +1,154 @@
package main
// A git run's copy is bounded by how many entries a tree holds as well as by
// its bytes; it never hands git the run's token in an argument or a config
// file; and it uses nothing of the agent's tree while the agent's container
// may still be running.
import (
"encoding/base64"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// Many empty files cost nothing in bytes; each still counts against the
// copy, so a tree of them is refused and nothing is pushed.
func TestAGitRunRefusesATreeOfTooManyEntries(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, `cd "$1"
i=0; while [ $i -lt 40 ]; do mkdir d$i; j=0; while [ $j -lt 100 ]; do : > d$i/f$j; j=$((j+1)); done; i=$((i+1)); done
`)
saved := maxCopiedTreeBytes
maxCopiedTreeBytes = 1 << 20 // 4000 entries at 512 bytes each are 2 MB
t.Cleanup(func() { maxCopiedTreeBytes = saved })
res, code := f.run(t)
if code == 0 || res["ok"] != false {
t.Fatalf("a tree of 4000 empty files was copied under a 1 MiB limit: %v", res)
}
if e, _ := res["error"].(string); !strings.Contains(e, "too large") {
t.Errorf("the error does not say the tree is too large: %q", e)
}
if branchPushed(f) {
t.Error("the branch was pushed")
}
}
// A container the runtime does not confirm removed fails the run before any
// of its tree is copied.
func TestAGitRunFailsWhenTheAgentsContainerIsNotRemoved(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
docker := filepath.Join(filepath.Dir(f.agentFile), "docker")
b, err := os.ReadFile(docker)
if err != nil {
t.Fatal(err)
}
failing := strings.Replace(string(b), "ps|pull|rm) exit 0;;", "ps|pull) exit 0;;\nrm) echo 'Error: removal stuck' >&2; exit 1;;", 1)
if failing == string(b) {
t.Fatal("the stand-in runtime changed shape")
}
if err := os.WriteFile(docker, []byte(failing), 0o755); err != nil {
t.Fatal(err)
}
res, code := f.run(t)
if code == 0 || res["ok"] != false {
t.Fatalf("a run whose container was not removed reads ok: %v", res)
}
if e, _ := res["error"].(string); !strings.Contains(e, "could not be removed") || !strings.Contains(e, "removal stuck") {
t.Errorf("the error does not say the container was not removed: %q", e)
}
if branchPushed(f) {
t.Error("the branch was pushed")
}
}
// Against a server that needs the token, the run clones and pushes with the
// token in no git argument and in no clone's config.
func TestAGitRunKeepsTheTokenOutOfEveryArgumentAndConfig(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
realGit, err := exec.LookPath("git")
if err != nil {
t.Skip("no git")
}
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("harness:"+gitRunToken))
backend := &cgi.Handler{Path: realGit, Args: []string{"http-backend"}, Root: "/git",
Env: []string{"GIT_PROJECT_ROOT=" + filepath.Dir(f.origin), "GIT_HTTP_EXPORT_ALL=1",
"REMOTE_USER=harness", "GIT_CONFIG_NOSYSTEM=1", "HOME=" + t.TempDir()}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != want {
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
http.Error(w, "auth", http.StatusUnauthorized)
return
}
backend.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
logs := t.TempDir()
argvLog, cfgLog := filepath.Join(logs, "argv"), filepath.Join(logs, "config")
bin := t.TempDir()
wrapper := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> " + argvLog + "\n" +
"prev=; for a; do if [ \"$prev\" = -C ] && [ -f \"$a/.git/config\" ]; then cat \"$a/.git/config\" >> " + cfgLog + "; fi; prev=$a; done\n" +
"exec " + realGit + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
repoURL := strings.Replace(srv.URL, "http://", "http://harness:"+gitRunToken+"@", 1) + "/git/" + filepath.Base(f.origin)
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: repoURL, Branch: "agent-work", MaxMinutes: 1})
if code != 0 {
t.Fatalf("the run failed: %s", b)
}
if !branchPushed(f) {
t.Fatal("the branch is not in the origin")
}
argv, _ := os.ReadFile(argvLog)
if !strings.Contains(string(argv), "push origin agent-work") {
t.Fatalf("the stand-in git did not see the push:\n%s", argv)
}
cfg, _ := os.ReadFile(cfgLog)
if len(cfg) == 0 {
t.Fatal("no clone's config was read")
}
for name, body := range map[string][]byte{"an argument": argv, "a clone's config": cfg} {
for _, secret := range []string{gitRunToken, strings.TrimPrefix(want, "Basic ")} {
if strings.Contains(string(body), secret) {
t.Errorf("the token is in %s:\n%s", name, body)
}
}
}
}
// Every git call carries the harness identity: nothing of this machine's user
// or host name reaches the clone the agent is handed, and the commit is the
// harness's.
func TestAGitRunsGitCarriesTheHarnessIdentity(t *testing.T) {
f := newGitRunFixture(t)
reflog := filepath.Join(t.TempDir(), "reflog")
f.setAgent(t, "cp \"$1/.git/logs/HEAD\" "+reflog+" && cd \"$1\" && echo new > edit.txt\n")
res, code := f.run(t)
if code != 0 {
t.Fatalf("the run failed: %v", res)
}
b, err := os.ReadFile(reflog)
if err != nil {
t.Fatal(err)
}
host, _ := os.Hostname()
if !strings.Contains(string(b), " harness <harness@privatae.ai> ") || (host != "" && strings.Contains(string(b), host)) {
t.Errorf("the agent's clone records another identity:\n%s", b)
}
who := strings.TrimSpace(testGit(t, f.origin, "log", "-1", "--format=%an <%ae> / %cn <%ce>", "agent-work"))
if who != "harness <harness@privatae.ai> / harness <harness@privatae.ai>" {
t.Errorf("the commit is by %q", who)
}
}
@@ -0,0 +1,360 @@
package main
// What a git run hands the agent, how it ends when its commit cannot be made,
// and the git-lfs filter it supplies itself.
import (
"bufio"
"bytes"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
const gitRunToken = "tok3n0f7hisrun5a1e"
// The agent's clone names the repository without the run's token, and the
// token is in no file of the tree it is handed; the run still pushes.
func TestAGitRunHandsTheAgentNoToken(t *testing.T) {
f := newGitRunFixture(t)
snap := filepath.Join(t.TempDir(), "agent-saw")
f.setAgent(t, "cp -a \"$1\" "+snap+" && cd \"$1\" && echo new > edit.txt\n")
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: "file://harness:" + gitRunToken + "@" + f.origin, Branch: "agent-work", MaxMinutes: 1})
if code != 0 {
t.Fatalf("the run failed: %s", b)
}
if bytes.Contains(b, []byte(gitRunToken)) {
t.Errorf("the result carries the token: %s", b)
}
n := 0
err := filepath.WalkDir(snap, func(p string, d fs.DirEntry, err error) error {
if err != nil || !d.Type().IsRegular() {
return err
}
n++
body, err := os.ReadFile(p)
if err == nil && bytes.Contains(body, []byte(gitRunToken)) {
t.Errorf("the token is in the agent's tree, in %s", p[len(snap):])
}
return err
})
if err != nil || n == 0 {
t.Fatalf("the agent's tree was not read (%d files): %v", n, err)
}
if got := testGit(t, f.origin, "show", "agent-work:edit.txt"); got != "new\n" {
t.Errorf("edit.txt = %q", got)
}
}
func TestRepoURLPartsAndLFSPins(t *testing.T) {
bare, secret, err := repoURLParts("https://host-user:" + gitRunToken + "@git.example.invalid/o/r.git")
if err != nil || bare != "https://git.example.invalid/o/r.git" || secret != gitRunToken {
t.Errorf("parts = %q, %q, %v", bare, secret, err)
}
if bare, secret, err := repoURLParts("/srv/x/origin.git"); err != nil || bare != "/srv/x/origin.git" || secret != "" {
t.Errorf("a path = %q, %q, %v", bare, secret, err)
}
pins := strings.Join(lfsEndpointPins("https://u:"+gitRunToken+"@git.example.invalid/o/r"), " ")
for _, k := range []string{"lfs.url", "lfs.pushurl", "remote.origin.lfsurl", "remote.origin.lfspushurl"} {
if !strings.Contains(pins, k+"=https://u:"+gitRunToken+"@git.example.invalid/o/r.git/info/lfs") {
t.Errorf("%s is not pinned to the repository's own LFS address: %s", k, pins)
}
}
if p := lfsEndpointPins("/srv/x/origin.git"); p != nil {
t.Errorf("a path got pins: %v", p)
}
}
// standInGit puts a git on PATH that fails on the subcommand fail and is the
// real git otherwise.
func standInGit(t *testing.T, fail string) {
t.Helper()
real, err := exec.LookPath("git")
if err != nil {
t.Skip("no git")
}
bin := t.TempDir()
script := "#!/bin/sh\nfor a; do [ \"$a\" = " + fail + " ] && { echo 'stand-in: " + fail + " failed' >&2; exit 128; }; done\nexec " + real + " \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func branchPushed(f gitRunFixture) bool {
return exec.Command("git", "--git-dir", f.origin, "rev-parse", "--verify", "-q", "agent-work").Run() == nil
}
// A commit that fails is the run failing: nothing is pushed.
func TestAGitRunWhoseCommitFailsPushesNothing(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
standInGit(t, "commit")
res, code := f.run(t)
if code == 0 || res["ok"] != false {
t.Errorf("a run whose commit failed reads ok: code %d, %v", code, res)
}
if e, _ := res["error"].(string); !strings.Contains(e, "commit") || !strings.Contains(e, "nothing was pushed") {
t.Errorf("the error does not say the commit failed: %q", e)
}
if branchPushed(f) {
t.Error("the branch was pushed")
}
}
// A run that changed nothing is ok, says so, and pushes no branch.
func TestAGitRunThatChangesNothingPushesNothing(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "true\n")
res, code := f.run(t)
if code != 0 || res["ok"] != true || res["changed"] != false {
t.Errorf("want ok with changed false: code %d, %v", code, res)
}
if _, ok := res["branch"]; ok {
t.Errorf("the result names a branch: %v", res["branch"])
}
if branchPushed(f) {
t.Error("a branch was pushed for a run that changed nothing")
}
}
// Names a file system may open as .git are left out of the copy, and the
// rest of the run goes on.
func TestAGitRunLeavesOutEveryNameThatOpensAsDotGit(t *testing.T) {
f := newGitRunFixture(t)
names := []string{".git.", ".GIT ", "git~1", "GIT~1. ", ".g\u200cit", ".git::$INDEX_ALLOCATION"}
script := "cd \"$1\" && echo new > edit.txt\n"
for _, n := range names {
script += "mkdir '" + n + "' && echo '[core]' > '" + n + "/config'\n"
}
f.setAgent(t, script)
res, code := f.run(t)
if code != 0 || res["ok"] != true {
t.Fatalf("the run failed: code %d, %v", code, res)
}
files := testGit(t, f.origin, "ls-tree", "-r", "--name-only", "-z", "agent-work")
for _, n := range names {
if strings.Contains(files, n+"/") {
t.Errorf("%q is in the commit", n)
}
}
skipped, _ := res["worktree_skipped"].([]any)
listed := map[string]bool{}
for _, s := range skipped {
listed[fmt.Sprint(s)] = true
}
for _, n := range names {
if !listed[n] {
t.Errorf("%q is not listed as left out: %v", n, skipped)
}
}
for _, n := range []string{".git", ".Git", ".git.", ".git . .", "GiT~1", ".gi\ufefft", ".git:x"} {
if !dotGitName(n) {
t.Errorf("dotGitName(%q) = false", n)
}
}
for _, n := range []string{".gitignore", ".gitattributes", "git", ".github", "git~2", "x.git"} {
if dotGitName(n) {
t.Errorf("dotGitName(%q) = true", n)
}
}
}
// With git-lfs installed, the bastion's own git calls clean and smudge
// through it and push its objects; the agent's .git cannot replace it, and a
// filter of another name the agent's .gitattributes names runs nothing.
func TestAGitRunUsesTheLFSFilterOnlyFromThisProcess(t *testing.T) {
f := newGitRunFixture(t)
seed := filepath.Join(t.TempDir(), "s")
testGit(t, "", "clone", "-q", f.origin, seed)
if err := os.WriteFile(filepath.Join(seed, ".gitattributes"), []byte("*.bin filter=lfs -text\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(seed, "big.bin"), []byte("v1\n"), 0o644); err != nil {
t.Fatal(err)
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "lfs")
testGit(t, seed, "push", "-q", "origin", "main")
bin := t.TempDir()
lfsLog := filepath.Join(t.TempDir(), "lfs.log")
wrapper := "#!/bin/sh\nGO_HELPER_GITLFS=1 GO_HELPER_GITLFS_LOG=" + lfsLog + " exec " + os.Args[0] +
" -test.run='^TestHelperGitLFS$' -- \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git-lfs"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
mark := func(name string) string { return "touch " + filepath.Join(f.marks, name) }
f.setAgent(t, `set -e
cd "$1"
git config filter.lfs.process '`+mark("agent-lfs-process")+`; exit 1'
git config filter.lfs.clean '`+mark("agent-lfs-clean")+`; cat'
git config filter.evil.clean '`+mark("evil")+`; cat'
printf '*.bin filter=lfs -text\n*.txt filter=evil\n' > .gitattributes
echo v2 > big.bin
echo new > edit.txt
`)
res, code := f.run(t)
if code != 0 || res["ok"] != true {
t.Fatalf("the run failed: code %d, %v", code, res)
}
ents, _ := os.ReadDir(f.marks)
for _, e := range ents {
t.Errorf("the agent's %s ran as this process", e.Name())
}
logged, _ := os.ReadFile(lfsLog)
for _, want := range []string{"smudge big.bin", "clean big.bin", "push origin agent-work"} {
if !strings.Contains(string(logged), want+"\n") {
t.Errorf("git-lfs did not %s; it logged:\n%s", want, logged)
}
}
for _, line := range strings.Split(string(logged), "\n") {
if strings.HasSuffix(line, ".txt") {
t.Errorf("git-lfs ran for a file of another filter: %s", line)
}
}
if got := testGit(t, f.origin, "show", "agent-work:big.bin"); got != "v2\n" {
t.Errorf("big.bin = %q", got)
}
}
// TestHelperGitLFS is the stand-in git-lfs: run by the wrapper above, it is
// an identity filter that logs what it was asked to do.
func TestHelperGitLFS(t *testing.T) {
if os.Getenv("GO_HELPER_GITLFS") != "1" {
return
}
args := os.Args
for i, a := range args {
if a == "--" {
args = args[i+1:]
break
}
}
logf, err := os.OpenFile(os.Getenv("GO_HELPER_GITLFS_LOG"), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o644)
if err != nil {
os.Exit(2)
}
switch {
case len(args) > 0 && args[0] == "filter-process":
if err := helperFilterProcess(logf); err != nil {
fmt.Fprintln(os.Stderr, "stand-in git-lfs:", err)
os.Exit(1)
}
case len(args) > 0 && (args[0] == "clean" || args[0] == "smudge"):
fmt.Fprintf(logf, "%s %s\n", args[0], args[len(args)-1])
_, _ = io.Copy(os.Stdout, os.Stdin)
default:
fmt.Fprintln(logf, strings.Join(args, " "))
}
logf.Close()
os.Exit(0)
}
func pktRead(r *bufio.Reader) ([]byte, bool, error) {
var h [4]byte
if _, err := io.ReadFull(r, h[:]); err != nil {
return nil, false, err
}
n, err := strconv.ParseUint(string(h[:]), 16, 16)
if err != nil {
return nil, false, err
}
if n == 0 {
return nil, true, nil
}
b := make([]byte, n-4)
_, err = io.ReadFull(r, b)
return b, false, err
}
func pktList(r *bufio.Reader) ([]string, error) {
var out []string
for {
b, flush, err := pktRead(r)
if err != nil || flush {
return out, err
}
out = append(out, strings.TrimSuffix(string(b), "\n"))
}
}
func pktWrite(w io.Writer, lines ...string) {
for _, l := range lines {
fmt.Fprintf(w, "%04x%s", len(l)+4, l)
}
_, _ = w.Write([]byte("0000"))
}
// helperFilterProcess speaks git's long-running filter protocol, version 2,
// returning every file as it came.
func helperFilterProcess(logf io.Writer) error {
r, w := bufio.NewReader(os.Stdin), bufio.NewWriter(os.Stdout)
if _, err := pktList(r); err != nil {
return err
}
pktWrite(w, "git-filter-server\n", "version=2\n")
if err := w.Flush(); err != nil {
return err
}
if _, err := pktList(r); err != nil {
return err
}
pktWrite(w, "capability=clean\n", "capability=smudge\n")
if err := w.Flush(); err != nil {
return err
}
for {
head, err := pktList(r)
if err == io.EOF {
return nil
}
if err != nil {
return err
}
cmd, path := "", ""
for _, kv := range head {
k, v, _ := strings.Cut(kv, "=")
switch k {
case "command":
cmd = v
case "pathname":
path = v
}
}
var body bytes.Buffer
for {
b, flush, err := pktRead(r)
if err != nil {
return err
}
if flush {
break
}
body.Write(b)
}
fmt.Fprintf(logf, "%s %s\n", cmd, path)
pktWrite(w, "status=success\n")
data := body.Bytes()
for len(data) > 0 {
n := min(len(data), 65516)
fmt.Fprintf(w, "%04x", n+4)
_, _ = w.Write(data[:n])
data = data[n:]
}
_, _ = w.Write([]byte("0000"))
pktWrite(w)
if err := w.Flush(); err != nil {
return err
}
}
}
+355
View File
@@ -0,0 +1,355 @@
package main
// A git run's tree is the agent's, .git included. These tests stand in for an
// agent that uses that .git against the bastion: hooks, a file system
// monitor, a filter, a signing program, a changed remote and a link to a file
// outside the tree, besides ordinary edits. The container runtime is a
// stand-in script that does the agent's work on the mounted tree; the sidecar
// it would start is a local HTTP server on the sidecar's port.
import (
"encoding/json"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
const gitRunOutsideSecret = "outside-the-tree-7c41"
// gitRunFixture is an origin with one commit, a decoy repository, a file
// outside the tree, a folder the agent's code marks when it runs, and a
// stand-in runtime that runs agent (a shell script, given the tree's path as
// $1) when the run starts its container.
type gitRunFixture struct {
origin, decoy, outside, marks, agentFile string
}
// setAgent writes the script the stand-in runtime runs as the agent.
func (f gitRunFixture) setAgent(t *testing.T, script string) {
t.Helper()
if err := os.WriteFile(f.agentFile, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
func testGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
return string(out)
}
func newGitRunFixture(t *testing.T) gitRunFixture {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
tmp := t.TempDir()
// The test's own git, and git as the code before this change ran it, read
// no config of this machine's.
home := filepath.Join(tmp, "home")
if err := os.Mkdir(home, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
t.Setenv("GIT_AUTHOR_NAME", "seed")
t.Setenv("GIT_AUTHOR_EMAIL", "seed@example.invalid")
t.Setenv("GIT_COMMITTER_NAME", "seed")
t.Setenv("GIT_COMMITTER_EMAIL", "seed@example.invalid")
f := gitRunFixture{
origin: filepath.Join(tmp, "origin.git"),
decoy: filepath.Join(tmp, "decoy.git"),
outside: filepath.Join(tmp, "outside", "secret"),
marks: filepath.Join(tmp, "marks"),
}
for _, d := range []string{filepath.Dir(f.outside), f.marks} {
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(f.outside, []byte(gitRunOutsideSecret), 0o600); err != nil {
t.Fatal(err)
}
testGit(t, tmp, "init", "-q", "--bare", "-b", "main", f.origin)
testGit(t, tmp, "init", "-q", "--bare", "-b", "main", f.decoy)
seed := filepath.Join(tmp, "seed")
testGit(t, tmp, "init", "-q", "-b", "main", seed)
for name, body := range map[string]string{"keep.txt": "keep\n", "edit.txt": "old\n", "gone.txt": "bye\n"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(seed, "tool.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "seed")
testGit(t, seed, "push", "-q", f.origin, "main")
bin := t.TempDir()
f.agentFile = filepath.Join(bin, "agent.sh")
agentFile := f.agentFile
runtime := "#!/bin/sh\ncase \"$1\" in\nps|pull|rm) exit 0;;\ninspect) echo 127.0.0.1; exit 0;;\n" +
"run) for a; do case \"$a\" in *:/work) d=${a%:/work};; esac; done\n" +
" /bin/sh " + agentFile + " \"$d\" > " + filepath.Join(bin, "agent.log") + " 2>&1 || exit 1\n" +
" echo standin0123456789; exit 0;;\nesac\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(runtime), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Cleanup(func() {
if t.Failed() {
if b, err := os.ReadFile(filepath.Join(bin, "agent.log")); err == nil {
t.Logf("agent log:\n%s", b)
}
}
})
// The sidecar the run drives: it answers, and /run reports success.
ln, err := net.Listen("tcp", "127.0.0.1:8844")
if err != nil {
t.Fatalf("the sidecar's port is taken: %v", err)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/run" {
_, _ = w.Write([]byte(`{"ok":true,"transcript":"done"}`))
}
})}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })
return f
}
func (f gitRunFixture) run(t *testing.T) (map[string]any, int) {
t.Helper()
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: f.origin, Branch: "agent-work", MaxMinutes: 1})
var res map[string]any
if err := json.Unmarshal(b, &res); err != nil {
t.Fatalf("result: %v: %s", err, b)
}
return res, code
}
// theAgent turns its tree's .git against whoever runs git in it next, and
// makes ordinary changes to the files.
func theAgent(f gitRunFixture) string {
mark := func(name string) string { return "touch " + filepath.Join(f.marks, name) }
return `set -e
d="$1"; cd "$d"
g="$d/.git"
printf '#!/bin/sh\n` + mark("pre-commit") + `\n' > "$g/hooks/pre-commit"
printf '#!/bin/sh\n` + mark("post-commit") + `\n' > "$g/hooks/post-commit"
chmod +x "$g/hooks/pre-commit" "$g/hooks/post-commit"
printf '#!/bin/sh\n` + mark("fsmonitor") + `\nexit 1\n' > "$g/fsmon.sh"; chmod +x "$g/fsmon.sh"
git config core.fsmonitor "$g/fsmon.sh"
printf '* filter=evil\n' > .gitattributes
git config filter.evil.clean '` + mark("filter") + `; cat ` + f.outside + `'
git config filter.evil.smudge cat
printf '#!/bin/sh\n` + mark("gpg") + `\necho >&2; echo "[GNUPG:] SIG_CREATED D 1 8 00 0 X" >&2\necho "-----BEGIN PGP SIGNATURE-----"; echo x; echo "-----END PGP SIGNATURE-----"\n' > "$g/gpg.sh"
chmod +x "$g/gpg.sh"
git config commit.gpgsign true
git config gpg.program "$g/gpg.sh"
git config remote.origin.url ` + f.decoy + `
ln -s ` + f.outside + ` link-to-outside
echo new > edit.txt
echo added > added.txt
rm gone.txt
mkdir sub; echo deep > sub/deep.txt
`
}
func TestAGitRunNeverRunsGitInTheAgentsTree(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, theAgent(f))
res, code := f.run(t)
if code != 0 || res["ok"] != true {
t.Errorf("the run failed: code %d, %v", code, res)
}
// Nothing the agent set in its .git ran.
ents, _ := os.ReadDir(f.marks)
for _, e := range ents {
t.Errorf("the agent's %s ran as this process", e.Name())
}
// The push went to the repository the run was given, not the one the
// agent named.
if refs := strings.TrimSpace(testGit(t, f.decoy, "for-each-ref")); refs != "" {
t.Errorf("the push went to the agent's remote: %s", refs)
}
out, err := exec.Command("git", "--git-dir", f.origin, "rev-parse", "--verify", "-q", "agent-work").Output()
if err != nil {
t.Fatalf("the branch is not in the origin: %v", err)
}
tip := strings.TrimSpace(string(out))
if n := strings.TrimSpace(testGit(t, f.origin, "rev-list", "--count", "main.."+tip)); n != "1" {
t.Errorf("the branch carries %s commits on main, want 1", n)
}
// Exactly the agent's file changes: the link is not there, and the outside
// file's content is in no file of the commit.
files := strings.Fields(testGit(t, f.origin, "ls-tree", "-r", "--format=%(objectmode) %(path)", tip))
got := map[string]string{}
for i := 0; i+1 < len(files); i += 2 {
got[files[i+1]] = files[i]
}
want := map[string]string{".gitattributes": "100644", "added.txt": "100644", "edit.txt": "100644",
"keep.txt": "100644", "sub/deep.txt": "100644", "tool.sh": "100755"}
if !sameMap(got, want) {
t.Errorf("the commit holds %v, want %v", got, want)
}
for path := range got {
body := testGit(t, f.origin, "show", tip+":"+path)
if strings.Contains(body, gitRunOutsideSecret) {
t.Errorf("%s in the commit holds the file outside the tree", path)
}
}
for path, body := range map[string]string{"edit.txt": "new\n", "added.txt": "added\n", "keep.txt": "keep\n"} {
if _, ok := got[path]; ok {
if b := testGit(t, f.origin, "show", tip+":"+path); b != body {
t.Errorf("%s = %q, want %q", path, b, body)
}
}
}
if s, _ := res["worktree_skipped"].([]any); len(s) != 1 || s[0] != "link-to-outside" {
t.Errorf("left out = %v, want [link-to-outside]", res["worktree_skipped"])
}
for _, k := range []string{"branch", "commit", "commit_out"} {
if _, ok := res[k]; !ok {
t.Errorf("the result has no %s", k)
}
}
if res["commit"] != tip {
t.Errorf("the result names commit %v, the branch is at %s", res["commit"], tip)
}
}
// A link the repository itself holds stays as it is when the agent leaves it
// alone: a run that cannot copy a link must not delete it.
func TestAGitRunKeepsALinkTheRepositoryHolds(t *testing.T) {
f := newGitRunFixture(t)
seed := filepath.Join(t.TempDir(), "s")
testGit(t, "", "clone", "-q", f.origin, seed)
if err := os.Symlink("keep.txt", filepath.Join(seed, "alias")); err != nil {
t.Fatal(err)
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "a link")
testGit(t, seed, "push", "-q", "origin", "main")
f.setAgent(t, "cd \"$1\" && echo new > edit.txt\n")
res, code := f.run(t)
if code != 0 {
t.Fatalf("the run failed: %v", res)
}
if mode := strings.Fields(testGit(t, f.origin, "ls-tree", "agent-work", "alias")); len(mode) == 0 || mode[0] != "120000" {
t.Errorf("the repository's link is gone from the branch: %v", mode)
}
if diff := strings.Fields(testGit(t, f.origin, "diff", "--name-only", "main", "agent-work")); len(diff) != 1 || diff[0] != "edit.txt" {
t.Errorf("the branch changes %v, want [edit.txt]", diff)
}
}
// A file far larger than the copy may take (sparse, so it costs nothing to
// make) is refused from its size, before a byte of it is read: the run ends
// at once, pushes nothing, and says why.
func TestAGitRunRefusesATreeLargerThanItCopies(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && truncate -s 64G huge.bin && echo new > edit.txt\n")
type out struct {
res map[string]any
code int
}
ch := make(chan out, 1)
start := time.Now()
go func() { r, c := f.run(t); ch <- out{r, c} }()
var o out
select {
case o = <-ch:
case <-time.After(30 * time.Second):
t.Fatal("a run with a 64 GiB file was still going after 30 s")
}
t.Logf("returned in %v", time.Since(start))
if o.code == 0 || o.res["ok"] != false {
t.Fatalf("a tree over the cap was pushed: %v", o.res)
}
if e, _ := o.res["error"].(string); !strings.Contains(e, "huge.bin") || !strings.Contains(e, "too large") {
t.Errorf("the error does not say what was too large: %q", e)
}
if err := exec.Command("git", "--git-dir", f.origin, "rev-parse", "--verify", "-q", "agent-work").Run(); err == nil {
t.Error("the branch was pushed")
}
}
// The tree a worktree run returns is refused the same way, and no part of
// it is returned.
func TestAWorktreeRunRefusesATreeLargerThanTheLaneCarries(t *testing.T) {
f := newGitRunFixture(t)
f.setAgent(t, "cd \"$1\" && truncate -s 64G huge.bin\n")
ch := make(chan []byte, 1)
start := time.Now()
go func() {
b, _ := runWorktree(harnessSpec{Mode: "worktree", HarnessID: "h1", Image: "harness:test", MaxMinutes: 1})
ch <- b
}()
var b []byte
select {
case b = <-ch:
case <-time.After(30 * time.Second):
t.Fatal("a run with a 64 GiB file was still going after 30 s")
}
t.Logf("returned in %v", time.Since(start))
var res map[string]any
if err := json.Unmarshal(b, &res); err != nil {
t.Fatal(err)
}
if res["ok"] != false || res["worktree_b64"] != nil {
t.Fatalf("a tree over the cap was returned: ok=%v, %d bytes of tree", res["ok"], len(b))
}
if e, _ := res["error"].(string); !strings.Contains(e, "huge.bin") || !strings.Contains(e, "too large") {
t.Errorf("the error does not say what was too large: %q", e)
}
}
func sameMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
ka := make([]string, 0, len(a))
for k := range a {
ka = append(ka, k)
}
sort.Strings(ka)
for _, k := range ka {
if b[k] != a[k] {
return false
}
}
return true
}
// A repository with no commit yet gets the run's commit as its first.
func TestAGitRunIntoAnEmptyRepositoryPushesItsFirstCommit(t *testing.T) {
f := newGitRunFixture(t)
empty := filepath.Join(t.TempDir(), "empty.git")
testGit(t, "", "init", "-q", "--bare", "-b", "main", empty)
f.setAgent(t, "cd \"$1\" && echo first > first.txt\n")
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: empty, Branch: "agent-work", MaxMinutes: 1})
if code != 0 {
t.Fatalf("the run failed: %s", b)
}
if got := testGit(t, empty, "show", "agent-work:first.txt"); got != "first\n" {
t.Errorf("first.txt = %q", got)
}
}
@@ -0,0 +1,143 @@
package main
// A git run against a repository that keeps a file in Git LFS, with the real
// git-lfs. It runs only where git-lfs is installed; every server it talks to
// is in this test, on loopback.
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
)
func lfsPointer(body string) (oid, pointer string) {
sum := sha256.Sum256([]byte(body))
oid = hex.EncodeToString(sum[:])
return oid, fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", oid, len(body))
}
func TestAGitRunWithTheRealGitLFS(t *testing.T) {
if out, err := exec.Command("git-lfs", "version").Output(); err != nil || !strings.HasPrefix(string(out), "git-lfs/") {
t.Skip("git-lfs is not installed")
}
f := newGitRunFixture(t)
realGit, _ := exec.LookPath("git")
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("harness:"+gitRunToken))
var mu sync.Mutex
store := map[string][]byte{}
v1oid, v1ptr := lfsPointer("v1\n")
store[v1oid] = []byte("v1\n")
var srvURL string
backend := &cgi.Handler{Path: realGit, Args: []string{"http-backend"}, Root: "/git",
Env: []string{"GIT_PROJECT_ROOT=" + filepath.Dir(f.origin), "GIT_HTTP_EXPORT_ALL=1",
"REMOTE_USER=harness", "GIT_CONFIG_NOSYSTEM=1", "HOME=" + t.TempDir()}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/store/") { // the object hrefs the batch answer gives
oid := strings.TrimPrefix(r.URL.Path, "/store/")
mu.Lock()
defer mu.Unlock()
if r.Method == http.MethodPut {
b, _ := io.ReadAll(r.Body)
store[oid] = b
return
}
_, _ = w.Write(store[oid])
return
}
if r.Header.Get("Authorization") != want {
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
http.Error(w, "auth", http.StatusUnauthorized)
return
}
if !strings.Contains(r.URL.Path, "/info/lfs/") {
backend.ServeHTTP(w, r)
return
}
if !strings.HasSuffix(r.URL.Path, "/objects/batch") {
http.NotFound(w, r) // locks: not supported here
return
}
var req struct {
Operation string `json:"operation"`
Objects []struct {
Oid string `json:"oid"`
Size int64 `json:"size"`
} `json:"objects"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
var objs []map[string]any
mu.Lock()
for _, o := range req.Objects {
obj := map[string]any{"oid": o.Oid, "size": o.Size, "authenticated": true}
_, have := store[o.Oid]
if req.Operation == "download" || !have {
obj["actions"] = map[string]any{req.Operation: map[string]any{"href": srvURL + "/store/" + o.Oid}}
}
objs = append(objs, obj)
}
mu.Unlock()
w.Header().Set("Content-Type", "application/vnd.git-lfs+json")
_ = json.NewEncoder(w).Encode(map[string]any{"transfer": "basic", "objects": objs})
}))
t.Cleanup(srv.Close)
srvURL = srv.URL
var foreignHits atomic.Int64
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreignHits.Add(1)
http.Error(w, "no", http.StatusForbidden)
}))
t.Cleanup(foreign.Close)
seed := filepath.Join(t.TempDir(), "s")
testGit(t, "", "clone", "-q", f.origin, seed)
for name, body := range map[string]string{".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n", "big.bin": v1ptr} {
if err := os.WriteFile(filepath.Join(seed, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
testGit(t, seed, "add", "-A")
testGit(t, seed, "commit", "-q", "-m", "an lfs file")
testGit(t, seed, "push", "-q", "origin", "main")
// The agent changes the large file and names a foreign LFS server.
f.setAgent(t, `set -e
cd "$1"
grep -qx v1 big.bin
echo v2 > big.bin
printf '[lfs]\n\turl = `+foreign.URL+`/x.git/info/lfs\n\tpushurl = `+foreign.URL+`/x.git/info/lfs\n[remote "origin"]\n\tlfsurl = `+foreign.URL+`/y\n' > .lfsconfig
`)
repoURL := strings.Replace(srv.URL, "http://", "http://harness:"+gitRunToken+"@", 1) + "/git/" + filepath.Base(f.origin)
b, code := runGit(harnessSpec{Mode: "git", HarnessID: "h1", Image: "harness:test",
RepoURL: repoURL, Branch: "agent-work", MaxMinutes: 1})
t.Logf("result: %s", b)
if code != 0 {
t.Fatalf("the run failed")
}
v2oid, v2ptr := lfsPointer("v2\n")
if got := testGit(t, f.origin, "show", "agent-work:big.bin"); got != v2ptr {
t.Errorf("big.bin on the branch = %q, want the pointer to v2", got)
}
mu.Lock()
got := string(store[v2oid])
mu.Unlock()
if got != "v2\n" {
t.Errorf("the LFS object for v2 was not sent to the repository's own LFS server (%q)", got)
}
if n := foreignHits.Load(); n != 0 {
t.Errorf("the foreign LFS server named in .lfsconfig got %d requests", n)
}
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"errors"
"strconv"
"golang.org/x/sys/unix"
)
// openTreeEntry opens the entry name in the folder dfd with flags, for
// packFolder.
//
// A folder is opened directly: with O_DIRECTORY the kernel refuses anything
// that is not a folder before it opens it. Anything else is first opened as a
// path only (O_PATH), which runs no driver code, so a device, pipe or terminal
// swapped in since the look is never opened: opening some devices does
// something by itself (a watchdog arms, a tape rewinds on close). Only a plain
// file is then opened for reading, through /proc/self/fd, which reaches the
// file the path fd holds and no other.
func openTreeEntry(dfd int, name string, flags int) (int, error) {
if flags&unix.O_DIRECTORY != 0 {
return unix.Openat(dfd, name, flags, 0)
}
pfd, err := unix.Openat(dfd, name, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return -1, err
}
defer unix.Close(pfd)
var st unix.Stat_t
if err := unix.Fstat(pfd, &st); err != nil {
return -1, err
}
if st.Mode&unix.S_IFMT != unix.S_IFREG {
return -1, errNotPlainFile
}
fd, err := unix.Open("/proc/self/fd/"+strconv.Itoa(pfd), flags&^unix.O_NOFOLLOW, 0)
if errors.Is(err, unix.ENOENT) {
// No /proc mounted: open by name as other systems do. packOpened
// still checks that what was opened is the file that was looked at.
return unix.Openat(dfd, name, flags, 0)
}
return fd, err
}
+70
View File
@@ -0,0 +1,70 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/sys/unix"
)
// A name swapped for a special file between the walk's look and its open must
// never be opened: opening a device runs its driver (a watchdog arms, a tape
// rewinds on close, a serial line raises DTR), whatever is read after. A named
// pipe stands in for any of them, since anyone can make one, and inotify
// reports every open of it except one made only as a path (O_PATH), which runs
// no driver code. Both a name looked at as a plain file and one looked at as
// a folder are swapped.
func TestMakeTarNeverOpensASpecialFileSwappedInBeforeTheOpen(t *testing.T) {
for _, swap := range []string{"a.txt", "sub"} {
t.Run(swap, func(t *testing.T) {
_, _, tree := outsideAndTree(t)
in, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC)
if err != nil {
t.Skipf("no inotify: %v", err)
}
defer unix.Close(in)
watched := false
packTreeBeforeOpen = func(rel string) {
if rel != swap {
return
}
p := filepath.Join(tree, swap)
if err := os.RemoveAll(p); err != nil {
t.Errorf("remove %s: %v", p, err)
return
}
if err := unix.Mkfifo(p, 0o644); err != nil {
t.Errorf("mkfifo %s: %v", p, err)
return
}
if _, err := unix.InotifyAddWatch(in, p, unix.IN_OPEN); err != nil {
t.Errorf("watch %s: %v", p, err)
return
}
watched = true
}
defer func() { packTreeBeforeOpen = nil }()
b, skipped, err := packWorktree(tree)
if err != nil {
t.Fatalf("packWorktree: %v", err)
}
if !watched {
t.Fatal("the swap never ran")
}
buf := make([]byte, 4096)
if n, err := unix.Read(in, buf); n > 0 {
t.Fatalf("the special file swapped in for %s was opened (%d bytes of inotify events)", swap, n)
} else if err != unix.EAGAIN {
t.Fatalf("read inotify: %d %v", n, err)
}
if _, ok := tarEntries(t, b)[swap]; ok {
t.Fatalf("%s was a named pipe when it was opened and must be left out", swap)
}
if !strings.Contains(","+strings.Join(skipped, ",")+",", ","+swap+",") {
t.Fatalf("%s must be named as left out, got %v", swap, skipped)
}
})
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !linux
package main
import "golang.org/x/sys/unix"
// openTreeEntry opens the entry name in the folder dfd with flags, for
// packFolder. These systems have no open that runs no driver code, so a
// special file swapped in since the look is opened (O_NONBLOCK, O_NOCTTY),
// then closed unread by packOpened.
func openTreeEntry(dfd int, name string, flags int) (int, error) {
return unix.Openat(dfd, name, flags, 0)
}
+114
View File
@@ -0,0 +1,114 @@
//go:build !windows
package main
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
// errNotPlainFile is openTreeEntry's refusal of a name that is no longer a
// plain file when it is opened.
var errNotPlainFile = errors.New("not a plain file")
// packTree hands the tree under root to sink, walking it by open folder
// (packWorktree says why). Each name is looked at with fstatat and opened
// relative to the folder above it (openTreeEntry, per system), never
// following a link, and it goes in only if the opened file is still the plain
// file or folder that was looked at (same type, device and inode). A pipe or
// device seen as one is never opened, so the walk cannot wait on one; on
// Linux one swapped in between is not opened either, and elsewhere O_NONBLOCK
// and O_NOCTTY cover it. Each left-out entry goes to sink.skip.
func packTree(sink treeSink, root string) error {
fd, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return &os.PathError{Op: "open", Path: root, Err: err}
}
d := os.NewFile(uintptr(fd), root)
defer d.Close()
return packFolder(sink, d, fd, "", 0)
}
func packFolder(sink treeSink, d *os.File, dfd int, rel string, depth int) error {
names, err := readTreeNames(sink, d, rel)
if err != nil {
return err
}
if err := sink.folder(rel, names); err != nil {
return err
}
for _, name := range names {
p := name
if rel != "" {
p = rel + "/" + name
}
var st unix.Stat_t
if err := unix.Fstatat(dfd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
sink.skip(p) // gone since the folder was read
continue
}
kind := st.Mode & unix.S_IFMT
if kind == unix.S_IFDIR && name == ".git" {
continue
}
if (kind != unix.S_IFREG && kind != unix.S_IFDIR) || (kind == unix.S_IFDIR && depth >= maxTreeDepth) {
sink.skip(p)
continue
}
if packTreeBeforeOpen != nil {
packTreeBeforeOpen(p)
}
flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NONBLOCK | unix.O_NOCTTY
if kind == unix.S_IFDIR {
flags |= unix.O_DIRECTORY
}
cfd, err := openTreeEntry(dfd, name, flags)
if err != nil {
sink.skip(p) // a link or special file now, or gone
continue
}
err = packOpened(sink, cfd, name, st, p, depth)
if err != nil {
return err
}
}
return nil
}
// packOpened hands sink the entry opened as fd, and for a folder what is in it,
// if it is still the entry fstatat saw; it closes fd.
func packOpened(sink treeSink, fd int, name string, seen unix.Stat_t, p string, depth int) error {
var got unix.Stat_t
if err := unix.Fstat(fd, &got); err != nil {
unix.Close(fd)
return fmt.Errorf("stat %q: %w", p, err)
}
kind := got.Mode & unix.S_IFMT
if kind != seen.Mode&unix.S_IFMT || got.Dev != seen.Dev || got.Ino != seen.Ino {
unix.Close(fd)
sink.skip(p) // swapped between the look and the open
return nil
}
// A plain file or folder now: back to blocking reads, so os.File reads it
// as it reads any file it opened itself.
if err := unix.SetNonblock(fd, false); err != nil {
unix.Close(fd)
return fmt.Errorf("open %q: %w", p, err)
}
f := os.NewFile(uintptr(fd), name)
defer f.Close()
fi, err := f.Stat()
if err != nil {
return fmt.Errorf("stat %q: %w", p, err)
}
if err := sink.put(f, fi, p); err != nil {
return err
}
if kind == unix.S_IFDIR {
return packFolder(sink, f, fd, p, depth+1)
}
return nil
}
+204
View File
@@ -0,0 +1,204 @@
//go:build !windows
package main
import (
"archive/tar"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/sys/unix"
)
const tarOutsideSecret = "outside-the-tree-3f9a"
// tarInsideA is as long as the outside file: a writer who knows a file's size
// can make the one it swaps out the same size, so the tar's header fits.
const tarInsideA = "inside-the-tree-a-000"
// tarEntries reads back what makeTar produced: name -> content.
func tarEntries(t *testing.T, b []byte) map[string]string {
t.Helper()
out := map[string]string{}
tr := tar.NewReader(bytes.NewReader(b))
for {
hdr, err := tr.Next()
if err == io.EOF {
return out
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
data, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("read %s: %v", hdr.Name, err)
}
out[hdr.Name] = string(data)
}
}
// outsideAndTree makes a file outside the tree that a run must never return,
// and a tree with one file and one folder.
func outsideAndTree(t *testing.T) (outsideDir, secret, tree string) {
t.Helper()
outsideDir = t.TempDir()
secret = filepath.Join(outsideDir, "secret")
if err := os.WriteFile(secret, []byte(tarOutsideSecret), 0o600); err != nil {
t.Fatal(err)
}
tree = t.TempDir()
if err := os.WriteFile(filepath.Join(tree, "a.txt"), []byte(tarInsideA), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tree, "sub"), 0o755); err != nil {
t.Fatal(err)
}
// Named as the outside file is: a writer who knows the names in a folder
// outside can fill a folder of the tree with the same names, then swap it.
if err := os.WriteFile(filepath.Join(tree, "sub", "secret"), []byte("inside b"), 0o644); err != nil {
t.Fatal(err)
}
return outsideDir, secret, tree
}
func assertNoOutside(t *testing.T, ents map[string]string) {
t.Helper()
for name, data := range ents {
if strings.Contains(data, tarOutsideSecret) {
t.Fatalf("the tar holds a file from outside the tree, as %q", name)
}
}
}
// The tree's writer swaps a file for a link, and a folder for a link, after
// the walk has seen each one as a plain file or folder and before it opens it.
// The hook stands exactly in that gap, so the swap lands there every time.
func TestMakeTarNeverReadsThroughALinkSwappedInBeforeTheOpen(t *testing.T) {
for _, swap := range []string{"a.txt", "sub"} {
t.Run(swap, func(t *testing.T) {
outsideDir, secret, tree := outsideAndTree(t)
packTreeBeforeOpen = func(rel string) {
if rel != swap {
return
}
p := filepath.Join(tree, swap)
_ = os.RemoveAll(p)
if swap == "sub" {
_ = os.Symlink(outsideDir, p)
} else {
_ = os.Symlink(secret, p)
}
}
defer func() { packTreeBeforeOpen = nil }()
b, err := makeTar(tree)
if err != nil {
t.Fatalf("makeTar: %v", err)
}
ents := tarEntries(t, b)
assertNoOutside(t, ents)
if swap == "a.txt" {
if _, ok := ents["a.txt"]; ok {
t.Fatalf("a.txt was a link when it was opened and must be left out, got it in the tar")
}
}
})
}
}
// No hook: a writer flips one name between a plain file and a link to a file
// outside the tree as fast as it can while the tree is packed again and again.
func TestMakeTarNeverReadsThroughALinkUnderAConcurrentWriter(t *testing.T) {
_, secret, tree := outsideAndTree(t)
name := filepath.Join(tree, "a.txt")
tmpLink, tmpFile := filepath.Join(tree, ".l"), filepath.Join(tree, ".f")
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-stop:
return
default:
}
_ = os.WriteFile(tmpFile, []byte(tarInsideA), 0o644)
_ = os.Rename(tmpFile, name)
_ = os.Symlink(secret, tmpLink)
_ = os.Rename(tmpLink, name)
}
}()
defer func() { close(stop); <-done }()
deadline := time.Now().Add(3 * time.Second)
passes := 0
defer func() { t.Logf("%d passes", passes) }()
for i := 0; time.Now().Before(deadline); i++ {
passes = i + 1
b, err := makeTar(tree)
if err != nil {
continue // a name that vanished mid-walk; the next pass tries again
}
for n, data := range tarEntries(t, b) {
if strings.Contains(data, tarOutsideSecret) {
t.Fatalf("pass %d: the tar holds the file outside the tree, as %q", i, n)
}
}
}
}
// A link, a named pipe and a socket in the tree are left out and named as
// left out; the pipe is never opened, so the walk does not wait on it.
func TestMakeTarLeavesOutAndNamesWhatIsNotAPlainFileOrFolder(t *testing.T) {
_, secret, tree := outsideAndTree(t)
if err := os.Symlink(secret, filepath.Join(tree, "link")); err != nil {
t.Fatal(err)
}
if err := unix.Mkfifo(filepath.Join(tree, "sub", "pipe"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tree, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tree, ".git", "config"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
type res struct {
b []byte
skipped []string
err error
}
ch := make(chan res, 1)
go func() { b, s, err := packWorktree(tree); ch <- res{b, s, err} }()
var r res
select {
case r = <-ch:
case <-time.After(10 * time.Second):
t.Fatal("packing the tree waited on the named pipe")
}
if r.err != nil {
t.Fatalf("packWorktree: %v", r.err)
}
ents := tarEntries(t, r.b)
assertNoOutside(t, ents)
for _, want := range []string{"a.txt", "sub", "sub/secret"} {
if _, ok := ents[want]; !ok {
t.Errorf("%s missing from the tar (got %v)", want, ents)
}
}
if ents["a.txt"] != tarInsideA || ents["sub/secret"] != "inside b" {
t.Errorf("file contents changed in the tar: %v", ents)
}
for _, gone := range []string{"link", "sub/pipe", ".git", ".git/config"} {
if _, ok := ents[gone]; ok {
t.Errorf("%s is in the tar and must not be", gone)
}
}
got := strings.Join(r.skipped, ",")
if got != "link,sub/pipe" {
t.Errorf("left out = %q, want link,sub/pipe", got)
}
}
+83
View File
@@ -0,0 +1,83 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
)
// packTree hands the tree under root to sink (packWorktree says why it is
// walked this way). Every name is opened through an os.Root at the tree, so
// no link, junction or name swapped in while it is packed can open a file
// outside the tree; an entry goes in only if the opened file is the plain
// file or folder that was looked at. Each left-out entry goes to sink.skip.
func packTree(sink treeSink, root string) error {
r, err := os.OpenRoot(root)
if err != nil {
return err
}
defer r.Close()
return packRootFolder(sink, r, "", 0)
}
func packRootFolder(sink treeSink, r *os.Root, rel string, depth int) error {
open := rel
if open == "" {
open = "."
}
d, err := r.Open(open)
if err != nil {
return fmt.Errorf("open folder %q: %w", rel, err)
}
names, err := readTreeNames(sink, d, filepath.ToSlash(rel))
d.Close()
if err != nil {
return err
}
if err := sink.folder(filepath.ToSlash(rel), names); err != nil {
return err
}
for _, name := range names {
p := filepath.Join(rel, name)
tp := filepath.ToSlash(p)
seen, err := r.Lstat(p)
if err != nil {
sink.skip(tp)
continue
}
if seen.IsDir() && name == ".git" {
continue
}
if (!seen.Mode().IsRegular() && !seen.IsDir()) || (seen.IsDir() && depth >= maxTreeDepth) {
sink.skip(tp)
continue
}
if packTreeBeforeOpen != nil {
packTreeBeforeOpen(tp)
}
f, err := r.Open(p)
if err != nil {
sink.skip(tp)
continue
}
fi, err := f.Stat()
if err != nil || fi.Mode().Type() != seen.Mode().Type() {
f.Close()
sink.skip(tp)
continue
}
err = sink.put(f, fi, tp)
f.Close()
if err != nil {
return err
}
if fi.IsDir() {
if err := packRootFolder(sink, r, p, depth+1); err != nil {
return err
}
}
}
return nil
}
+125
View File
@@ -0,0 +1,125 @@
package main
// A harness run's tree is bind-mounted into the container at /work and opened
// to every user, because the agent runs as a uid of its own. What keeps the
// other users of this machine out of it is the folder it sits in, which only
// this process can enter. Opening the tree follows no link in it.
import (
"archive/tar"
"bytes"
"encoding/base64"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
// harnessStandIn writes a stand-in container runtime named name into a new
// folder. On `ps` and `pull` it succeeds; on `run` it records, into the
// returned file, the mode and owner of the folder the /work tree sits in, then
// fails, so the run stops there.
func harnessStandIn(t *testing.T, name string) (bin, record string) {
t.Helper()
bin = t.TempDir()
record = filepath.Join(bin, "record")
script := "#!/bin/sh\ncase \"$1\" in ps|pull) exit 0;; run) ;; *) exit 1;; esac\n" +
"for a; do case \"$a\" in *:/work) d=${a%:/work};; esac; done\n" +
"[ -n \"$d\" ] && stat -c '%a %u' \"$(dirname \"$d\")\" > " + record + "\nexit 1\n"
if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return bin, record
}
func TestAWorktreeRunsTreeSitsInAFolderOnlyTheBastionCanEnter(t *testing.T) {
bin, record := harnessStandIn(t, "docker")
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
// The temporary folder every user may write in, as /tmp is.
shared := t.TempDir()
if err := os.Chmod(shared, os.ModeSticky|0o777); err != nil {
t.Fatal(err)
}
t.Setenv("TMPDIR", shared)
var tb bytes.Buffer
tw := tar.NewWriter(&tb)
_ = tw.WriteHeader(&tar.Header{Name: "src/", Typeflag: tar.TypeDir, Mode: 0o755})
_ = tw.WriteHeader(&tar.Header{Name: "src/a.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 2})
_, _ = tw.Write([]byte("a\n"))
_ = tw.Close()
spec := harnessSpec{Mode: "worktree", HarnessID: "h1", Image: "harness:test",
WorktreeB64: base64.StdEncoding.EncodeToString(tb.Bytes())}
if _, code := runWorktree(spec); code == 0 {
t.Fatal("the stand-in runtime fails the start; the run must report it")
}
got, err := os.ReadFile(record)
if err != nil {
t.Fatalf("the tree was not mounted: %v", err)
}
if want := "700 " + strconv.Itoa(os.Geteuid()); strings.TrimSpace(string(got)) != want {
t.Fatalf("the folder the tree sits in: want %q, got %q", want, strings.TrimSpace(string(got)))
}
}
func TestOpeningARunsTreeFollowsNoLinkAndKeepsWhatIsExecutable(t *testing.T) {
bin, _ := harnessStandIn(t, "runtime")
rt := filepath.Join(bin, "runtime")
outside := filepath.Join(t.TempDir(), "shadow")
if err := os.WriteFile(outside, []byte("x\n"), 0o600); err != nil {
t.Fatal(err)
}
tree := t.TempDir()
files := map[string]os.FileMode{"run.sh": 0o755, "notes.txt": 0o644}
for name, mode := range files {
if err := os.WriteFile(filepath.Join(tree, name), nil, mode); err != nil {
t.Fatal(err)
}
}
if err := os.Mkdir(filepath.Join(tree, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(tree, "sub", "link")); err != nil {
t.Fatal(err)
}
if _, err := driveHarnessAgent(rt, tree, harnessSpec{HarnessID: "h1", Image: "harness:test"}, 1); err == nil {
t.Fatal("the stand-in runtime fails the start; the run must report it")
}
for p, want := range map[string]os.FileMode{
tree: 0o777, filepath.Join(tree, "sub"): 0o777,
filepath.Join(tree, "run.sh"): 0o777, filepath.Join(tree, "notes.txt"): 0o666,
outside: 0o600,
} {
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != want {
t.Errorf("%s: want %04o, got %04o", p, want, fi.Mode().Perm())
}
}
}
func TestARunsTreeInAFolderOtherUsersCanEnterIsRefused(t *testing.T) {
bin, record := harnessStandIn(t, "runtime")
rt := filepath.Join(bin, "runtime")
open := t.TempDir()
if err := os.Chmod(open, 0o755); err != nil {
t.Fatal(err)
}
tree := filepath.Join(open, "tree")
if err := os.Mkdir(tree, 0o755); err != nil {
t.Fatal(err)
}
_, err := driveHarnessAgent(rt, tree, harnessSpec{HarnessID: "h1", Image: "harness:test"}, 1)
if err == nil || !strings.Contains(err.Error(), "open to other users") {
t.Fatalf("want the tree refused for its folder, got %v", err)
}
if _, err := os.Stat(record); err == nil {
t.Fatal("a refused tree must start no container")
}
if fi, _ := os.Stat(tree); fi.Mode().Perm() != 0o755 {
t.Fatalf("a refused tree must not be opened: %04o", fi.Mode().Perm())
}
}
+119
View File
@@ -0,0 +1,119 @@
//go:build !windows
package main
import (
"errors"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
// A copy of a tree of very many empty files stops at its limit, and what it
// holds in memory does not grow with the number of files.
func TestACopyOfVeryManyEmptyFilesIsRefusedWithBoundedMemory(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
for i := 0; i < 50; i++ {
d := filepath.Join(src, "d"+strconv.Itoa(i))
if err := os.Mkdir(d, 0o755); err != nil {
t.Fatal(err)
}
for j := 0; j < 400; j++ {
if err := os.WriteFile(filepath.Join(d, "f"+strconv.Itoa(j)), nil, 0o644); err != nil {
t.Fatal(err)
}
}
}
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
_, err := mirrorTree(src, dst, 1<<20)
runtime.ReadMemStats(&after)
if !errors.Is(err, errTreeTooLarge) {
t.Fatalf("20000 empty files under a 1 MiB limit: %v", err)
}
got := after.TotalAlloc - before.TotalAlloc
t.Logf("refused: %v; %d KiB allocated", err, got>>10)
if got > 16<<20 {
t.Errorf("the copy allocated %d MiB", got>>20)
}
n := 0
_ = filepath.WalkDir(dst, func(string, os.DirEntry, error) error { n++; return nil })
if n > (1<<20)/512+1 {
t.Errorf("%d entries were written under a limit of %d", n, (1<<20)/512)
}
}
// Left-out entries are counted in full, and only the first
// maxSkippedReported names are kept.
func TestLeftOutEntriesAreCountedAndTheirNamesBounded(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
for i := 0; i < 3*maxSkippedReported; i++ {
if err := os.Symlink("x", filepath.Join(src, "l"+strconv.Itoa(i))); err != nil {
t.Fatal(err)
}
}
skips, err := mirrorTree(src, dst, maxCopiedTreeBytes)
if err != nil {
t.Fatal(err)
}
if skips.count != 3*maxSkippedReported || len(skips.names) != maxSkippedReported {
t.Errorf("count %d, %d names kept", skips.count, len(skips.names))
}
res := map[string]interface{}{}
skips.report(res)
if res["worktree_skipped_count"] != 3*maxSkippedReported {
t.Errorf("reported %v", res)
}
}
func TestRepoGitAuthPutsTheTokenOnlyInTheEnvironment(t *testing.T) {
bare, env, err := repoGitAuth("https://host-user:tok3n0f7hisrun5a1e@git.example.invalid/o/r.git")
if err != nil || bare != "https://git.example.invalid/o/r.git" {
t.Fatalf("bare = %q, %v", bare, err)
}
joined := strings.Join(env, "\n")
if !strings.Contains(joined, "GIT_CONFIG_KEY_0=http.https://git.example.invalid/o/r.git.extraHeader") ||
!strings.Contains(joined, "GIT_CONFIG_VALUE_0=Authorization: Basic ") {
t.Errorf("env = %q", env)
}
if _, env, _ := repoGitAuth("/srv/x/origin.git"); env != nil {
t.Errorf("a path got credentials: %v", env)
}
}
// One folder holding very many names is refused once the names read pass
// the limit, without the whole folder being read first.
func TestAFolderOfVeryManyNamesIsRefusedWithoutReadingItWhole(t *testing.T) {
src := t.TempDir()
long := strings.Repeat("n", 190)
for i := 0; i < 30000; i++ {
if err := os.WriteFile(filepath.Join(src, long+strconv.Itoa(i)), nil, 0o644); err != nil {
t.Fatal(err)
}
}
saved := maxReturnedTreeBytes
maxReturnedTreeBytes = 1 << 20
t.Cleanup(func() { maxReturnedTreeBytes = saved })
for name, walk := range map[string]func() error{
"copy": func() error { _, err := mirrorTree(src, t.TempDir(), 1<<20); return err },
"tar": func() error { _, _, err := packWorktree(src); return err },
} {
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
err := walk()
runtime.ReadMemStats(&after)
got := after.TotalAlloc - before.TotalAlloc
t.Logf("%s: %v; %d KiB allocated", name, err, got>>10)
if !errors.Is(err, errTreeTooLarge) {
t.Errorf("%s: a folder of 30000 names under a 1 MiB limit: %v", name, err)
}
if got > 4<<20 {
t.Errorf("%s: %d KiB allocated; the 30000 names alone are about 6 MiB", name, got>>10)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//go:build !windows
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// A file far larger than the tree a run may return (sparse, so it costs
// nothing to make) is refused from its size: packing returns at once, with
// an error and no tar, having read none of it.
func TestPackingRefusesAFileLargerThanTheLaneCarriesWithoutReadingIt(t *testing.T) {
tree := t.TempDir()
if err := os.WriteFile(filepath.Join(tree, "a.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(tree, "huge.bin"))
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(64 << 30); err != nil {
t.Skipf("no sparse file here: %v", err)
}
f.Close()
type res struct {
b []byte
err error
}
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
ch := make(chan res, 1)
start := time.Now()
go func() { b, _, err := packWorktree(tree); ch <- res{b, err} }()
var r res
select {
case r = <-ch:
case <-time.After(20 * time.Second):
t.Fatal("packing a tree with a 64 GiB file was still going after 20 s")
}
runtime.ReadMemStats(&after)
t.Logf("returned in %v, %d MiB allocated", time.Since(start), (after.TotalAlloc-before.TotalAlloc)>>20)
if r.err == nil || !strings.Contains(r.err.Error(), "huge.bin") || !strings.Contains(r.err.Error(), "too large") {
t.Fatalf("want the tree refused for huge.bin, got %v", r.err)
}
if r.b != nil {
t.Fatal("a tar came back with the refusal")
}
if got := after.TotalAlloc - before.TotalAlloc; got > 64<<20 {
t.Fatalf("packing allocated %d MiB", got>>20)
}
}
// A result larger than one message on the lane is replaced by a refusal that
// says so, never sent to be dropped by the host; one that fits goes as it is.
func TestAResultTheLaneCannotCarryIsRefusedNotSent(t *testing.T) {
small := []byte(`{"ok":true,"mode":"worktree"}`)
if b, code := fitLane(small, 0); string(b) != string(small) || code != 0 {
t.Fatalf("a result that fits was changed: %s %d", b, code)
}
big := append([]byte(`{"ok":true,"mode":"worktree","worktree_b64":"`), make([]byte, uvicornWSMaxSize)...)
b, code := fitLane(big, 0)
if code == 0 || len(b) > 1024 || !strings.Contains(string(b), `"result_too_large":true`) ||
!strings.Contains(string(b), `"ok":false`) {
t.Fatalf("an oversized result was not refused: %d bytes, code %d: %.200s", len(b), code, b)
}
}
+9 -6
View File
@@ -484,7 +484,7 @@ func TestRepeatedRefusedWritesQuarantineThePairing(t *testing.T) {
for i := 0; i < quarantineStrikes; i++ {
driveJob(t, tAnchor, putJob(""), ctx, "")
}
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || u.Sub(time.Now()) > quarantineBase+time.Minute {
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || time.Until(u) > quarantineBase+time.Minute {
t.Fatalf("a cleared hold is forgiven — the next one is the base length again: %v", u)
}
// Strikes double when the hold lapses on its own.
@@ -494,7 +494,7 @@ func TestRepeatedRefusedWritesQuarantineThePairing(t *testing.T) {
for i := 0; i < quarantineStrikes; i++ {
driveJob(t, tAnchor, putJob(""), ctx, "")
}
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || u.Sub(time.Now()) < 2*quarantineBase-time.Minute {
if u := quarantinedUntil(tAnchor, time.Now()); u.IsZero() || time.Until(u) < 2*quarantineBase-time.Minute {
t.Fatalf("a repeat hold doubles: %v", u)
}
}
@@ -1010,12 +1010,15 @@ func TestAnchoredEgressRefusesLoopbackAndPrivateDestinations(t *testing.T) {
if addr, why := egressVettedAddr(ctx, "localhost:80"); why == "" || addr != "" {
t.Fatalf("a refused name yields no address: %q %q", addr, why)
}
// A local box refuses this machine too, and keeps its LAN
// (egress_local_test.go has the rest).
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if why := egressInternalRefusal(ctx, "127.0.0.1:11434"); why != "" {
t.Fatalf("a local box keeps its lane: %s", why)
stubOwnAddrs(t, loopbackOnly, localRange())
if why := egressInternalRefusal(ctx, "127.0.0.1:11434"); why == "" {
t.Fatal("a local box must refuse its own ollama on the egress lane")
}
if addr, _ := egressVettedAddr(ctx, "example.com:443"); addr != "example.com:443" {
t.Fatalf("a local box dials the name it was given: %q", addr)
if addr, why := egressVettedAddr(ctx, "192.168.1.1:80"); why != "" || addr != "192.168.1.1:80" {
t.Fatalf("a local box keeps its LAN: %q %q", addr, why)
}
}
+2 -2
View File
@@ -90,7 +90,7 @@ var procIdentity = osProcIdentity
// walked. The tree kill used to test only the root, so stopping a finding
// took everything under it — a replaced sshd took every ssh session with it,
// accounting entries included, which is exactly what the guards on the root
// exist to refuse (review of #100). Killing a parent does not kill its
// exist to refuse. Killing a parent does not kill its
// children, so a spared subtree simply survives, re-parented.
//
// A PID IS NOT A PROCESS. Each pid's identity is read before it is stopped and
@@ -111,7 +111,7 @@ func killTree(root int, spare func(int) bool) int { return killTreeExpect(root,
// killTreeExpect is killTree for a root whose identity was read when it was
// chosen (the finding walk): if the pid no longer carries that identity, the
// number has changed hands since and nothing is signalled. That closes the gap
// between choosing the root and the first read inside the kill (review of #100).
// between choosing the root and the first read inside the kill.
func killTreeExpect(root int, spare func(int) bool, rootID string) int {
if spare(root) {
return 0
+3 -3
View File
@@ -208,7 +208,7 @@ func identityRig(t *testing.T, script map[int][]string, stopped map[int]bool) {
}
}
// A PID IS NOT A PROCESS (review of #100). 202's number changes hands between
// A PID IS NOT A PROCESS. 202's number changes hands between
// the read and the stop: the stranger is resumed and never killed. 203 dies
// while frozen and its number is reused before the kill: not killed, and not
// resumed either (the frozen process is gone; the new one was never stopped).
@@ -239,7 +239,7 @@ func TestKillTreeNeverSignalsAStrangerUnderARecycledPid(t *testing.T) {
}
// A PROCESS SOMEONE ELSE HAD STOPPED is killed with the tree but never resumed
// by us when the kill fails (review of #100, N4): its stopped state was not
// by us when the kill fails: its stopped state was not
// ours to undo.
//
// MUTATION: resume on kill failure regardless of wasStopped. RESULT: red.
@@ -311,7 +311,7 @@ func TestRecoverFrozenTreesThawsOnlyWhatItFroze(t *testing.T) {
}
}
// THE FROZEN SET IS JOURNALLED AS IT IS FROZEN (review of #100): a bastion that
// THE FROZEN SET IS JOURNALLED AS IT IS FROZEN: a bastion that
// dies during the freeze rounds still leaves a record of what it stopped.
//
// MUTATION: write the journal only after the freeze rounds. RESULT: red — the
-849
View File
@@ -1,849 +0,0 @@
package main
// lab.go — mode=lab: the Lab Harness.
//
// A lab run builds a sealed lab of N hardened hosts on THIS box's own k3s cluster (the
// containment lab), puts the application under test into every host, places ONE coding
// harness pod inside the lab and drives the test runbook through it, collects the
// transcript + per-step verdicts + the runbook worktree, and tears everything down.
//
// THREE STRUCTURAL WALLS (the product claim "spun up in the lab, nowhere else"):
// 1. This mode only answers on a LAB bastion: the lab marker, the inert egress wall file
// and the lab operator on loopback must all be present (labAvailable). A user's home
// bastion returns exit 126 in the same shape as an ungranted op.
// 2. The host-side grant carries a `mode:lab` token, so a grant for a home
// box never covers a lab run and a lab grant never covers a home box.
// 3. The lab cluster has no path out (cloud firewall + labguard per run + tripwire).
//
// What runs where: the lab HOSTS do not think — they hold and run the app under test. The
// coding harness pod thinks, with the per-run credential the host delivered, and its
// tools reach the lab hosts in-cluster. Nothing here talks to the platform.
//
// Gate: only gate="auto" is supported today (typed deliberately, exactly like the old bench's
// --auto). gate="ask" is the App lane (per-call y/n from outside the lab) and is not built
// yet; until then it is refused, never silently downgraded to auto.
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
const (
labMarkerPath = "/etc/privatae-lab/marker"
labGuardPath = "/etc/nftables-labguard.nft"
labOperatorURL = "http://127.0.0.1:8011/healthz"
labStorageRoot = "/var/lib/rancher/k3s/storage"
labWorkspace = "/var/lib/skema/projects" // the harness image's workspace root (same image, same root)
labHarnessPort = 8844
labHostIDFloor = 9001 // lab host entity ids start here; 900 is the bench's own test entity
labHostIDCeil = 9899
labTemplateEntity = 900 // the lab's bench entity; its registry row is the template for lab hosts
labDefaultHosts = 1
labMaxHostsCap = 8 // per-box cap — protects the LAB BOX, never a user's workflow; marker max_hosts= raises it
)
// labMarker holds the key=value lines of /etc/privatae-lab/marker. Written by the lab
// rebuild, never by this binary.
type labMarker struct {
present bool
hostImage string // host_image= — the host image lab hosts run (must already be on the node)
maxHosts int // max_hosts= — optional
}
func readLabMarker() labMarker {
m := labMarker{}
b, err := os.ReadFile(labMarkerPath)
if err != nil {
return m
}
m.present = true
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "host_image":
m.hostImage = strings.TrimSpace(v)
case "max_hosts":
m.maxHosts, _ = strconv.Atoi(strings.TrimSpace(v))
}
}
return m
}
// labAvailable is wall #1. Every check is a thing only the lab box has.
func labAvailable() (labMarker, error) {
m := readLabMarker()
if !m.present {
return m, fmt.Errorf("no lab marker")
}
if _, err := os.Stat(labGuardPath); err != nil {
return m, fmt.Errorf("no egress wall file")
}
c := &http.Client{Timeout: 3 * time.Second}
resp, err := c.Get(labOperatorURL)
if err != nil {
return m, fmt.Errorf("lab operator not on loopback")
}
resp.Body.Close()
if resp.StatusCode != 200 {
return m, fmt.Errorf("lab operator unhealthy (%d)", resp.StatusCode)
}
if _, err := runCmd(10*time.Second, "k3s", "kubectl", "version", "--client=true"); err != nil {
return m, fmt.Errorf("no cluster tooling")
}
if m.hostImage == "" {
return m, fmt.Errorf("marker has no host_image=")
}
return m, nil
}
// kc runs `k3s kubectl …` with a timeout.
func kc(timeout time.Duration, args ...string) (string, error) {
return runCmd(timeout, "k3s", append([]string{"kubectl"}, args...)...)
}
// kcApply applies a manifest from a temp file (runCmd has no stdin).
func kcApply(manifest string) (string, error) {
f, err := os.CreateTemp("", "lab-manifest-*.yaml")
if err != nil {
return "", err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(manifest); err != nil {
f.Close()
return "", err
}
f.Close()
return kc(60*time.Second, "apply", "-f", f.Name())
}
func labFail(res map[string]interface{}, code int, msg string) int {
res["ok"], res["error"] = false, msg
return code
}
// runLab is the mode=lab entry point (harness.go runHarness). The result is marshalled
// AFTER the inner run's deferred teardown has recorded its log, so the caller always sees
// what happened including the cleanup.
func runLab(spec harnessSpec) ([]byte, int) {
res := map[string]interface{}{"mode": "lab", "ran_on": orUnknown(hostnameSafe())}
code := runLabInner(spec, res)
b, _ := json.Marshal(res)
return b, code
}
func runLabInner(spec harnessSpec, res map[string]interface{}) int {
marker, err := labAvailable()
if err != nil {
// Same shape + exit as an ungranted op on the host side (exit 126).
return labFail(res, 126, "mode 'lab' not available on this server — "+err.Error())
}
// ── validate the spec ─────────────────────────────────────────────────────
if spec.Gate != "auto" {
return labFail(res, 1, "gate='ask' (per-call y/n from the App) is not available yet — pass gate='auto', typed deliberately")
}
if spec.Image == "" {
return labFail(res, 1, "image required for mode=lab (the coding harness that drives the tests)")
}
if len(spec.Tests) == 0 {
return labFail(res, 1, "tests required for mode=lab (the runbook: ordered steps)")
}
hosts := spec.Hosts
if hosts <= 0 {
hosts = labDefaultHosts
}
maxHosts := labMaxHostsCap
if marker.maxHosts > 0 {
maxHosts = marker.maxHosts
}
if hosts > maxHosts {
return labFail(res, 1, fmt.Sprintf("hosts=%d exceeds this lab box's max_hosts=%d", hosts, maxHosts))
}
app := spec.App
if app == "" {
app = "app"
}
if !validWSName(app) {
return labFail(res, 1, "app must be a plain directory name")
}
engine := spec.Engine
if engine == "" {
engine = spec.HarnessID
}
maxMin := spec.MaxMinutes
if maxMin <= 0 {
maxMin = 10
}
var rb [4]byte
_, _ = rand.Read(rb[:])
runID := hex.EncodeToString(rb[:])
res["run_id"] = runID
start := time.Now()
var log []string
note := func(f string, a ...interface{}) {
log = append(log, time.Now().UTC().Format("15:04:05 ")+fmt.Sprintf(f, a...))
}
// ── 0. one run at a time, and reap whatever a previous run left ──────────
// The teardown below is a defer, so it covers every ordinary exit — but NOT a
// SIGKILL or a power loss (measured: a killed self-test left its two
// entity_registry rows behind). "The lab keeps nothing" has to hold after a crash
// too, so a run starts by removing anything in the lab's own id range / name
// prefixes. The lock is what makes that safe: without it a second concurrent run
// would reap the first one's hosts.
unlock, err := labLock()
if err != nil {
return labFail(res, 1, err.Error())
}
defer unlock()
if reaped := labReapOrphans(); reaped != "" {
note("reaped a previous run's leftovers: %s", reaped)
res["reaped"] = reaped
}
// ── 1. lab hosts: N hardened Entity CRs on the lab cluster ────────────────
ids, err := labAllocateIDs(hosts)
if err != nil {
return labFail(res, 1, "allocate host ids: "+err.Error())
}
res["hosts"] = ids
// ALWAYS tear down, whatever happens below — the lab keeps nothing between runs.
defer func() {
note("teardown: %d hosts + namespace lab-%s", len(ids), runID)
labTeardown(runID, ids)
res["log"] = strings.Join(log, "\n")
}()
for i, id := range ids {
name := fmt.Sprintf("lab-%s-%d", runID, i+1)
if err := labRegistryRow(id, name); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d registry row: %v", id, err))
}
manifest := fmt.Sprintf(`apiVersion: privatae.ai/v1
kind: Entity
metadata:
name: entity-%d
namespace: default
labels:
privatae.ai/lab-run: "%s"
spec:
entityId: %d
lifecycleTier: always_on
image: %s
hardened: true
`, id, runID, id, marker.hostImage)
if out, err := kcApply(manifest); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d apply: %s", id, truncate(out, 200)))
}
note("host %d (%s) applied", id, name)
}
for _, id := range ids {
// The operator builds the namespace + StatefulSet asynchronously after the CR lands;
// `kubectl wait` on a pod that does not exist yet fails at once, so poll for the pod
// first, then wait for Ready.
ns, pod := fmt.Sprintf("entity-%d", id), fmt.Sprintf("pod/entity-%d-0", id)
appeared := false
for i := 0; i < 60; i++ {
if _, err := kc(15*time.Second, "-n", ns, "get", pod); err == nil {
appeared = true
break
}
time.Sleep(3 * time.Second)
}
if !appeared {
ev, _ := kc(20*time.Second, "-n", ns, "get", "events", "--sort-by=.lastTimestamp")
return labFail(res, 1, fmt.Sprintf("host %d: the operator never created its pod (3 min): %s", id, truncate(ev, 300)))
}
if out, err := kc(300*time.Second, "-n", ns, "wait", "--for=condition=Ready", pod, "--timeout=270s"); err != nil {
ev, _ := kc(20*time.Second, "-n", ns, "get", "events", "--sort-by=.lastTimestamp")
return labFail(res, 1, fmt.Sprintf("host %d never became Ready: %s | %s", id, truncate(out, 160), truncate(ev, 300)))
}
}
hostIPs := map[int]string{}
for _, id := range ids {
ip, _ := kc(20*time.Second, "-n", fmt.Sprintf("entity-%d", id), "get", "pod",
fmt.Sprintf("entity-%d-0", id), "-o", "jsonpath={.status.podIP}")
hostIPs[id] = strings.TrimSpace(ip)
}
res["host_ips"] = hostIPs
note("%d hosts Ready", len(ids))
// ── 2. the app under test + its settings into every host ─────────────────
if spec.WorktreeB64 != "" {
tarIn, err := base64.StdEncoding.DecodeString(spec.WorktreeB64)
if err != nil {
return labFail(res, 1, "bad worktree_b64: "+err.Error())
}
for _, id := range ids {
dir, err := labHostProjectDir(id, app)
if err != nil {
return labFail(res, 1, fmt.Sprintf("host %d projects dir: %v", id, err))
}
if err := extractTar(tarIn, dir); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d app extract: %v", id, err))
}
if err := labWriteEnv(dir, spec.Envs); err != nil {
return labFail(res, 1, fmt.Sprintf("host %d envs: %v", id, err))
}
_ = filepath.Walk(dir, func(p string, _ os.FileInfo, werr error) error {
if werr == nil {
_ = os.Chown(p, 1000, 1000) // the entity uid — the host owns its own project
}
return nil
})
}
note("app %q + %d settings placed in every host", app, len(spec.Envs))
}
// ── 3. the ONE harness pod inside the lab (same image + env as the pool) ──
ns := "lab-" + runID
if out, err := kc(30*time.Second, "create", "namespace", ns); err != nil {
return labFail(res, 1, "namespace: "+truncate(out, 200))
}
if spec.AgentCreds != "" {
f, err := os.CreateTemp("", "lab-creds-*")
if err != nil {
return labFail(res, 1, "creds temp: "+err.Error())
}
_ = os.WriteFile(f.Name(), []byte(spec.AgentCreds), 0o600)
f.Close()
out, err := kc(30*time.Second, "-n", ns, "create", "secret", "generic", "harness-creds",
"--from-file=.credentials.json="+f.Name())
_ = os.Remove(f.Name()) // per-run: nothing durable on the box
if err != nil {
return labFail(res, 1, "creds secret: "+truncate(out, 200))
}
}
if out, err := kcApply(labHarnessPodManifest(ns, spec.Image, engine, spec.AgentCreds != "")); err != nil {
return labFail(res, 1, "harness pod: "+truncate(out, 200))
}
if out, err := kc(240*time.Second, "-n", ns, "wait", "--for=condition=Ready", "pod/harness", "--timeout=210s"); err != nil {
ev, _ := kc(20*time.Second, "-n", ns, "get", "events", "--sort-by=.lastTimestamp")
return labFail(res, 1, "harness pod never Ready: "+truncate(out, 160)+" | "+truncate(ev, 400))
}
hip, _ := kc(20*time.Second, "-n", ns, "get", "pod", "harness", "-o", "jsonpath={.status.podIP}")
hip = strings.TrimSpace(hip)
if hip == "" {
return labFail(res, 1, "harness pod has no IP")
}
base := fmt.Sprintf("http://[%s]:%d", hip, labHarnessPort)
if !strings.Contains(hip, ":") {
base = fmt.Sprintf("http://%s:%d", hip, labHarnessPort)
}
if ok, _ := waitReachable(base+"/", 60*time.Second); !ok {
return labFail(res, 1, "harness sidecar not reachable on :"+strconv.Itoa(labHarnessPort))
}
note("harness pod %s Ready at %s", engine, hip)
// ── 4. the runbook: one governed /run per step ────────────────────────────
// The workspace the harness works in carries the runbook + a hosts file, so the
// agent knows what it is testing and where each lab host answers.
wsName := "lab-run"
wsTar, err := labRunbookTar(app, spec.Tests, ids, hostIPs, spec.Envs)
if err != nil {
return labFail(res, 1, "runbook tar: "+err.Error())
}
if code, body := labPost(base+"/workspace/load?dir="+wsName, "application/x-tar", wsTar, 60*time.Second); code != 200 {
return labFail(res, 1, fmt.Sprintf("workspace/load %d: %s", code, truncate(body, 200)))
}
projectDir := labWorkspace + "/" + wsName
client := &http.Client{Timeout: time.Duration(maxMin+2) * time.Minute}
steps := make([]map[string]interface{}, 0, len(spec.Tests))
allOK := true
for i, step := range spec.Tests {
prompt := labStepPrompt(i+1, len(spec.Tests), step, app, ids, hostIPs)
reqBody, _ := json.Marshal(map[string]interface{}{
"prompt": prompt, "project_dir": projectDir, "max_minutes": maxMin,
})
t0 := time.Now()
resp, err := client.Post(base+"/run", "application/json", bytes.NewReader(reqBody))
st := map[string]interface{}{"step": i + 1, "test": step}
if err != nil {
st["ok"], st["error"] = false, "harness /run: "+err.Error()
steps = append(steps, st)
allOK = false
note("step %d: /run failed: %v", i+1, err)
break
}
rbody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var r map[string]interface{}
_ = json.Unmarshal(rbody, &r)
okv, _ := r["ok"].(bool)
st["ok"] = okv
st["exit_code"] = r["exit_code"]
st["elapsed_s"] = round2(time.Since(t0).Seconds())
if tr, _ := r["transcript"].(string); tr != "" {
st["transcript"] = tr
}
if e, _ := r["error"].(string); e != "" {
st["error"] = e
}
steps = append(steps, st)
if !okv {
allOK = false
}
note("step %d: ok=%v (%.0fs)", i+1, okv, time.Since(t0).Seconds())
}
res["steps"] = steps
// ── 5. collect: the runbook worktree after (the agent's notes/results live there) ──
if code, body := labGet(base+"/workspace/export?dir="+wsName, 60*time.Second); code == 200 {
res["worktree_b64"] = base64.StdEncoding.EncodeToString([]byte(body))
}
res["meter_s"] = round2(time.Since(start).Seconds())
res["ok"] = allOK
if allOK {
return 0
}
return 1
}
// ── helpers ──────────────────────────────────────────────────────────────────
func validWSName(s string) bool {
if s == "" || len(s) > 64 || strings.ContainsAny(s, "/\\ ") || strings.HasPrefix(s, ".") {
return false
}
return true
}
// labDeleteRows removes the lab's OWN entity_registry rows and reports how many.
// Three independent scopes, all required, so this can never reach a real entity:
// the `lab-` display-name prefix, the lab id range, and (for a run's own teardown)
// that run's id list. runID=="" + ids==nil is the orphan reap: every lab row in the range.
// Returns "" when it removed nothing.
func labDeleteRows(runID string, ids []int) string {
where := fmt.Sprintf("display_name LIKE 'lab-%s%%' AND id BETWEEN %d AND %d",
runID+map[bool]string{true: "-", false: ""}[runID != ""], labHostIDFloor, labHostIDCeil)
if len(ids) > 0 {
var in []string
for _, id := range ids {
in = append(in, strconv.Itoa(id)) // ints: nothing quotable reaches the SQL
}
where += " AND id IN (" + strings.Join(in, ",") + ")"
} else if runID != "" {
return "" // a run's teardown with no ids has nothing of its own to remove
}
sql := "DELETE FROM entity_registry WHERE " + where + ";"
f, err := os.CreateTemp("", "lab-del-*.sql")
if err != nil {
return ""
}
defer os.Remove(f.Name())
_ = os.Chmod(f.Name(), 0o644) // the postgres user reads it
_, _ = f.WriteString(sql)
f.Close()
out, err := runCmd(20*time.Second, "sudo", "-u", "postgres", "psql", "-d", "parallax", "-q",
"-v", "ON_ERROR_STOP=1", "-c", "\\set QUIET on", "-f", f.Name())
if err != nil {
return ""
}
out = strings.TrimSpace(out)
if out == "" || strings.HasSuffix(out, "DELETE 0") {
return ""
}
return out
}
// labLock serialises lab runs on this box. Returns the release func. An ordinary exit
// releases it; a killed run leaves a stale lock, so a lock whose pid is gone is taken over
// (and the takeover is what makes the reap below safe rather than racy).
func labLock() (func(), error) {
const path = "/var/tmp/lab-run.lock"
take := func() error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return err
}
fmt.Fprintf(f, "%d\n", os.Getpid())
return f.Close()
}
if err := take(); err != nil {
if b, rerr := os.ReadFile(path); rerr == nil {
if pid, perr := strconv.Atoi(strings.TrimSpace(string(b))); perr == nil {
if p, ferr := os.FindProcess(pid); ferr != nil || p.Signal(syscall.Signal(0)) != nil {
_ = os.Remove(path) // stale: the holder is gone
if err2 := take(); err2 == nil {
return func() { _ = os.Remove(path) }, nil
}
} else {
return nil, fmt.Errorf("another lab run is in flight on this box (pid %d) — labs are one at a time", pid)
}
}
}
return nil, fmt.Errorf("could not take the lab run lock: %v", err)
}
return func() { _ = os.Remove(path) }, nil
}
// labReapOrphans removes anything a PREVIOUS lab run left behind. Scoped three ways so it
// can only ever touch lab leavings: the `lab-` namespace prefix, the lab entity id range
// (the bench's own entity 900 is deliberately below it), and the `lab-` display-name prefix.
// Returns a short description of what it removed, or "".
func labReapOrphans() string {
var did []string
if out, err := kc(30*time.Second, "get", "ns", "-o", "name"); err == nil {
for _, l := range strings.Split(out, "\n") {
ns := strings.TrimPrefix(strings.TrimSpace(l), "namespace/")
if strings.HasPrefix(ns, "lab-") {
_, _ = kc(60*time.Second, "delete", "namespace", ns, "--ignore-not-found", "--wait=false")
did = append(did, "ns "+ns)
}
}
}
var ids []int
if out, err := kc(30*time.Second, "get", "entity", "-A",
"-o", "jsonpath={range .items[*]}{.spec.entityId}{\"\\n\"}{end}"); err == nil {
for _, l := range strings.Split(out, "\n") {
if v, e := strconv.Atoi(strings.TrimSpace(l)); e == nil && v >= labHostIDFloor && v <= labHostIDCeil {
ids = append(ids, v)
}
}
}
if len(ids) > 0 {
did = append(did, fmt.Sprintf("%d host(s) %v", len(ids), ids))
}
// Volumes + registry rows are cleaned for the id range even when no CR remains
// (a crash can leave either half).
labTeardown("", ids)
orphans, _ := filepath.Glob(fmt.Sprintf("%s/pvc-*_entity-9[0-9][0-9][0-9]_*", labStorageRoot))
for _, p := range orphans {
_ = os.RemoveAll(p)
did = append(did, "volume "+filepath.Base(p))
}
if n := labDeleteRows("", nil); n != "" {
did = append(did, "registry rows "+n)
}
if len(did) == 0 {
return ""
}
return strings.Join(did, ", ")
}
// labAllocateIDs picks N free entity ids in the lab range by reading the Entity CRs that exist.
func labAllocateIDs(n int) ([]int, error) {
out, err := kc(30*time.Second, "get", "entity", "-A", "-o", "jsonpath={range .items[*]}{.spec.entityId}{\"\\n\"}{end}")
if err != nil {
return nil, fmt.Errorf("list entities: %s", truncate(out, 160))
}
used := map[int]bool{}
for _, l := range strings.Split(out, "\n") {
if v, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
used[v] = true
}
}
ids := []int{}
for id := labHostIDFloor; id <= labHostIDCeil && len(ids) < n; id++ {
if !used[id] {
ids = append(ids, id)
}
}
if len(ids) < n {
return nil, fmt.Errorf("only %d free lab host ids", len(ids))
}
return ids, nil
}
// labRegistryRow gives the lab operator the registry row it resolves names from
// (the operator reads display_name off entity_registry). Lab-local postgres. entity_type is
// CHECK-constrained to parallax_native|operator, so lab rows are marked by their display_name
// prefix "lab-<run>-" (and confined to the lab id range) — teardown deletes exactly those.
func labRegistryRow(id int, name string) error {
// Clone the lab's TEMPLATE entity row (entity 900, the bench's own test entity, restored
// with the lab DB) rather than invent values: the table carries CHECKs and FKs
// (mastercode_version → mastercode_versions) that a hand-written row keeps tripping.
sql := fmt.Sprintf(`INSERT INTO entity_registry (id, entity_type, display_name, master_directive, license_tier,
seed_weights, current_weights, envelope_bounds, mastercode_version, status)
SELECT %d, entity_type, '%s', 'lab host under test', 'standard',
seed_weights, current_weights, envelope_bounds, mastercode_version, 'active'
FROM entity_registry WHERE id = %d
ON CONFLICT (id) DO NOTHING;`, id, name, labTemplateEntity)
f, err := os.CreateTemp("", "lab-row-*.sql")
if err != nil {
return err
}
defer os.Remove(f.Name())
_ = os.Chmod(f.Name(), 0o644)
if _, err := f.WriteString(sql); err != nil {
f.Close()
return err
}
f.Close()
if out, err := runCmd(20*time.Second, "sudo", "-u", "postgres", "psql", "-d", "parallax", "-v", "ON_ERROR_STOP=1", "-q", "-f", f.Name()); err != nil {
return fmt.Errorf("%s", truncate(out, 200))
}
return nil
}
// labHostProjectDir resolves the host's entity-apps PVC on the node's local-path storage and
// returns <pvc>/projects/<app>, created. The node is root over its own storage — the lab box's
// privilege, and exactly why this mode is lab-only.
func labHostProjectDir(id int, app string) (string, error) {
matches, _ := filepath.Glob(fmt.Sprintf("%s/pvc-*_entity-%d_entity-apps-entity-%d-0", labStorageRoot, id, id))
if len(matches) != 1 {
return "", fmt.Errorf("expected one entity-apps volume, found %d", len(matches))
}
dir := filepath.Join(matches[0], "projects", app)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// labWriteEnv writes the app settings as <dir>/.env (KEY=value, sorted, values single-quoted).
func labWriteEnv(dir string, envs map[string]string) error {
if len(envs) == 0 {
return nil
}
keys := make([]string, 0, len(envs))
for k := range envs {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
if !validEnvKey(k) {
return fmt.Errorf("bad env key %q", k)
}
b.WriteString(k + "='" + strings.ReplaceAll(envs[k], "'", "'\\''") + "'\n")
}
return os.WriteFile(filepath.Join(dir, ".env"), []byte(b.String()), 0o640)
}
func validEnvKey(k string) bool {
if k == "" || len(k) > 128 {
return false
}
for i, r := range k {
ok := r == '_' || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (i > 0 && r >= '0' && r <= '9')
if !ok {
return false
}
}
return true
}
// labHarnessPodManifest mirrors the pool's harness pod (same image, same env, same floor:
// non-root uid, drop ALL, no privilege escalation, RO rootfs, the personal seccomp profile)
// with emptyDirs where the harness must write.
func labHarnessPodManifest(ns, image, engine string, withCreds bool) string {
credsVol, credsMount, credsInit := "", "", ""
if withCreds {
credsVol = ` - name: creds-src
secret:
secretName: harness-creds
`
credsMount = ""
credsInit = ` initContainers:
- name: creds
image: busybox:1.36
command: ["sh", "-c", "cp /src/.credentials.json /dst/.credentials.json && chown 10005:10005 /dst/.credentials.json && chmod 0644 /dst/.credentials.json"]
securityContext:
runAsUser: 0
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"], add: ["CHOWN", "FOWNER", "DAC_OVERRIDE"]}
volumeMounts:
- {name: creds-src, mountPath: /src, readOnly: true}
- {name: home-claude, mountPath: /dst}
`
}
return fmt.Sprintf(`apiVersion: v1
kind: Pod
metadata:
name: harness
namespace: %s
labels: {app: lab-harness, privatae.ai/lab-run: "%s"}
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
seccompProfile: {type: Localhost, localhostProfile: seccomp-personal.json}
%s containers:
- name: harness
image: %s
imagePullPolicy: IfNotPresent
env:
- {name: PORT, value: "%d"}
- {name: PRIVATAE_SIDECAR_BIND, value: "::"}
- {name: PRIVATAE_APP_ID, value: "%s"}
- {name: PRIVATAE_WORKSPACE, value: "%s"}
# Both spellings, on purpose: the harness image is published on its own
# schedule, so a lab run must work against one that reads either name.
# The older three come out once no published image reads them.
- {name: SKEMA_SIDECAR_BIND, value: "::"}
- {name: SKEMA_APP_ID, value: "%s"}
- {name: SKEMA_WORKSPACE, value: "%s"}
- {name: HOME, value: "/home/privatae"}
securityContext:
runAsUser: 10005
runAsGroup: 10005
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 250m, memory: 512Mi}
limits: {memory: 2Gi}
volumeMounts:
- {name: workspace, mountPath: %s}
- {name: home-claude, mountPath: /home/privatae/.claude}
- {name: tmp, mountPath: /tmp}
%s volumes:
- {name: workspace, emptyDir: {}}
- {name: home-claude, emptyDir: {}}
- {name: tmp, emptyDir: {}}
%s`, ns, strings.TrimPrefix(ns, "lab-"), credsInit, image, labHarnessPort,
engine, labWorkspace, engine, labWorkspace,
labWorkspace, credsMount, credsVol)
}
// labRunbookTar packs the runbook the harness works from: a README naming the app, the hosts
// and their addresses, the settings, and the ordered tests; plus a results/ dir for the agent.
func labRunbookTar(app string, tests []string, ids []int, ips map[int]string, envs map[string]string) ([]byte, error) {
dir, err := os.MkdirTemp("", "lab-runbook-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
var b strings.Builder
b.WriteString("# Lab run\n\nYou are the test harness inside a sealed lab. Nothing here can reach the internet or the platform.\n\n")
b.WriteString("## Application under test\n\n`" + app + "` — placed in every lab host under " + labWorkspace + "/" + app + "\n\n")
b.WriteString("## Lab hosts\n\n| host | pod address | kernel API |\n|---|---|---|\n")
for i, id := range ids {
b.WriteString(fmt.Sprintf("| lab host %d (entity-%d) | %s | http://[%s]:8800 |\n", i+1, id, ips[id], ips[id]))
}
if len(envs) > 0 {
b.WriteString("\n## Settings (also written to every host's " + app + "/.env)\n\n")
keys := make([]string, 0, len(envs))
for k := range envs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString("- " + k + "\n")
}
}
b.WriteString("\n## Tests (run in order; write each verdict to results/<n>.md)\n\n")
for i, t := range tests {
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, t))
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte(b.String()), 0o644); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(dir, "results"), 0o755); err != nil {
return nil, err
}
_ = os.WriteFile(filepath.Join(dir, "results", ".keep"), nil, 0o644)
return makeTar(dir)
}
func labStepPrompt(n, total int, step, app string, ids []int, ips map[int]string) string {
var hosts []string
for i, id := range ids {
hosts = append(hosts, fmt.Sprintf("lab host %d = entity-%d at %s", i+1, id, ips[id]))
}
return fmt.Sprintf("Lab test step %d of %d for the application `%s`.\n\nTEST: %s\n\nLab hosts: %s.\n\n"+
"Read README.md first. Run the test against the lab hosts, then write a short verdict to results/%d.md "+
"(first line PASS or FAIL, then the evidence). Do not modify the application; only test it.",
n, total, app, step, strings.Join(hosts, "; "), n)
}
func labPost(url, ctype string, body []byte, timeout time.Duration) (int, string) {
c := &http.Client{Timeout: timeout}
resp, err := c.Post(url, ctype, bytes.NewReader(body))
if err != nil {
return 0, err.Error()
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(rb)
}
func labGet(url string, timeout time.Duration) (int, string) {
c := &http.Client{Timeout: timeout}
resp, err := c.Get(url)
if err != nil {
return 0, err.Error()
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(rb)
}
// labTeardown removes everything a run created: the harness namespace, the lab hosts (Entity
// CRs → the operator deletes their namespaces), their volumes on local-path storage, and the
// lab-only registry rows. Best-effort, always runs, never raises.
func labTeardown(runID string, ids []int) {
if runID != "" { // "" = the orphan reap, which deletes lab- namespaces itself
_, _ = kc(120*time.Second, "delete", "namespace", "lab-"+runID, "--ignore-not-found", "--wait=false")
}
for _, id := range ids {
_, _ = kc(60*time.Second, "-n", "default", "delete", "entity", fmt.Sprintf("entity-%d", id), "--ignore-not-found", "--wait=false")
}
// wait for the CR to go (finalizers) and the operator to drop the namespace before wiping storage
for _, id := range ids {
for i := 0; i < 40; i++ {
if _, err := kc(15*time.Second, "-n", "default", "get", "entity", fmt.Sprintf("entity-%d", id)); err != nil {
break
}
time.Sleep(3 * time.Second)
}
for i := 0; i < 40; i++ {
if _, err := kc(15*time.Second, "get", "namespace", fmt.Sprintf("entity-%d", id)); err != nil {
break
}
time.Sleep(3 * time.Second)
}
for _, m := range []string{"entity-apps", "entity-data"} {
matches, _ := filepath.Glob(fmt.Sprintf("%s/pvc-*_entity-%d_%s-entity-%d-0", labStorageRoot, id, m, id))
for _, p := range matches {
_ = os.RemoveAll(p)
}
}
}
_ = labDeleteRows(runID, ids)
}
// cmdLabSelftest — `privatae-bastion lab-selftest <spec.json>`: run mode=lab from a spec file on
// THIS box without the host relay, print the result. Operator tooling for the lab box; on any
// other box it prints the same refusal the relay would (wall #1).
func cmdLabSelftest(path string) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var spec harnessSpec
if err := json.Unmarshal(raw, &spec); err != nil {
return fmt.Errorf("spec: %w", err)
}
spec.Mode = "lab"
if spec.HarnessID == "" {
spec.HarnessID = "lab"
}
out, code := runLab(spec)
var pretty bytes.Buffer
if json.Indent(&pretty, out, "", " ") == nil {
fmt.Println(pretty.String())
} else {
fmt.Println(string(out))
}
if code != 0 {
return fmt.Errorf("lab run exit %d", code)
}
return nil
}
+31
View File
@@ -0,0 +1,31 @@
//go:build !lab
package main
// lab_off.go: mode=lab and `lab-selftest` in every release build. The lab
// harness is test tooling for one test lab and is built only with -tags lab,
// so a release bastion answers a lab run the way any bastion without a lab
// answers it: exit 126, in the same shape as an ungranted op.
import (
"encoding/json"
"errors"
"fmt"
"os"
)
const labUnavailable = "mode 'lab' not available on this server — this build has no lab harness"
func runLab(_ harnessSpec) ([]byte, int) {
b, _ := json.Marshal(map[string]any{"mode": "lab", "ran_on": orUnknown(hostnameSafe()), "ok": false, "error": labUnavailable})
return b, 126
}
// cmdLabSelftest prints the same refusal the relay would give.
func cmdLabSelftest(path string) error {
if _, err := os.Stat(path); err != nil {
return err
}
fmt.Fprintln(os.Stderr, labUnavailable)
return errors.New("lab run exit 126")
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !lab
package main
import (
"encoding/json"
"testing"
)
// A release build has no lab harness: a lab run is refused with exit 126, the
// shape of an ungranted op, and touches nothing.
func TestAReleaseBuildRefusesALabRun(t *testing.T) {
out, code := runHarness(harnessSpec{Mode: "lab", HarnessID: "lab-1", Gate: "auto", Image: "x", Tests: []string{"a"}})
if code != 126 {
t.Fatalf("exit %d, want 126", code)
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil || m["ok"] != false || m["error"] != labUnavailable {
t.Fatalf("reply %s", out)
}
}
+7
View File
@@ -566,6 +566,13 @@ func main() {
os.Exit(125)
}
os.Exit(opSandboxChildMain(os.Args[2]))
case opSandboxScopeSubcommand:
// A root op's scope (ops_taskcap_linux.go): never typed by a person;
// systemd-run starts it for the daemon.
if len(os.Args) < 3 {
os.Exit(125)
}
os.Exit(opSandboxScopeMain(os.Args[2]))
case opActionSubcommand, opActionChildSubcommand, opActionProbeSubcommand:
// An action op's unit and its sandbox's pid 1 (ops_action_linux.go):
// never typed by a person; the daemon starts them.
+8
View File
@@ -45,6 +45,9 @@ func TestMain(m *testing.M) {
if len(os.Args) >= 3 && os.Args[1] == opSandboxSubcommand {
os.Exit(opSandboxChildMain(os.Args[2]))
}
if len(os.Args) >= 3 && os.Args[1] == opSandboxScopeSubcommand {
os.Exit(opSandboxScopeMain(os.Args[2]))
}
if len(os.Args) >= 3 && os.Args[1] == opActionSubcommand {
os.Exit(opActionOuterMain(os.Args[2]))
}
@@ -115,8 +118,13 @@ func TestMain(m *testing.M) {
// /proc/101 on the machine running the suite and the decision would depend
// on whatever process holds that number. The real-signal test opts back in.
procIdentity = func(int) (string, bool, bool) { return "", false, false }
// THE SWEEP IS ONE TOO. A test runs the ops' startup sweep for real, and it
// stops every op unit its names match: the suite's names are its own, so
// it never stops an op the machine's own bastion is running.
opIsolateOpsUnits()
code := m.Run()
opIsolatedOpsUnitsDone()
_ = os.RemoveAll(dir)
os.Exit(code)
}
+1 -1
View File
@@ -446,7 +446,7 @@ func effectOK(f string) error {
return errors.New("must be absolute")
}
if strings.Contains(f, "{") || strings.Contains(f, "..") {
return errors.New("no placeholders, no ..")
return errors.New("no placeholders, no \"..\"")
}
if strings.Contains(f, "\\") {
// A backslash is filepath.Match's escape: `\n` is a pattern the
+13 -4
View File
@@ -72,7 +72,6 @@ const (
opUIDFile = "op_uid_next"
opActionRunTmpfsBytes = 16 << 20
opBuilderInSandbox = "/run/privatae-builder/buildkitd.sock"
opActionUnitPrefix = "privatae-op-"
landlockRuleNetPort = 2 // LANDLOCK_RULE_NET_PORT
// opActionRetryAfter: a machine that could not run action ops is asked
// again after this long, not only at the next restart.
@@ -817,13 +816,22 @@ type landlockNetPortAttr struct {
Port uint64
}
// landlockRulesetAttr is the kernel's struct landlock_ruleset_attr up to
// "scoped", declared here like landlockNetPortAttr so the call does not move
// with x/sys's naming of it.
type landlockRulesetAttr struct {
HandledAccessFS uint64
HandledAccessNet uint64
Scoped uint64
}
// opLandlock: TCP connects to the declared ports only, no TCP bind, and no
// abstract unix socket or signal that leaves the sandbox. The addresses are
// the unit's allowlist; the two together are the destinations.
func opLandlock(ports []uint16) error {
attr := unix.LandlockRulesetAttr{
Access_net: unix.LANDLOCK_ACCESS_NET_BIND_TCP | unix.LANDLOCK_ACCESS_NET_CONNECT_TCP,
Scoped: unix.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | unix.LANDLOCK_SCOPE_SIGNAL,
attr := landlockRulesetAttr{
HandledAccessNet: unix.LANDLOCK_ACCESS_NET_BIND_TCP | unix.LANDLOCK_ACCESS_NET_CONNECT_TCP,
Scoped: unix.LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | unix.LANDLOCK_SCOPE_SIGNAL,
}
fd, _, e := unix.Syscall(unix.SYS_LANDLOCK_CREATE_RULESET, uintptr(unsafe.Pointer(&attr)), unsafe.Sizeof(attr), 0)
if e != 0 {
@@ -861,6 +869,7 @@ func opStartupSweep() {
opKillUnit(u)
_ = exec.Command("systemctl", "stop", u).Run()
}
opSweepScopes()
entries, _ := os.ReadDir(root)
for _, e := range entries {
if !strings.HasPrefix(e.Name(), "run-") {
+4 -3
View File
@@ -7,9 +7,10 @@ package main
//
// Run them in a private PID namespace but NOT a private mount namespace: the
// unit is started by the machine's systemd, in the machine's mounts, and must
// see the run's tmpfs the test mounted:
// see the run's tmpfs the test mounted. GOTMPDIR and TMPDIR name a folder
// on a disk, not under /tmp (a run gets its own /tmp):
//
// GOTMPDIR=/srv/work/tmp-gotest TMPDIR=/srv/work/tmp-gotest unshare --pid --fork go test -run Action .
// GOTMPDIR=<dir> TMPDIR=<dir> unshare --pid --fork go test -run Action .
import (
"bytes"
@@ -725,7 +726,7 @@ func TestMemoryFloorSitsAboveTheKubeletsEviction(t *testing.T) {
// A kill that does not land (no such unit, no cgroup yet) is never reported
// as the op stopped.
func TestTheMemoryWatchReportsOnlyAKillThatLanded(t *testing.T) {
stop := opMemoryWatch("privatae-op-nonexistent-optest", 1<<62)
stop := opMemoryWatch(opActionUnitPrefix+"nonexistent", 1<<62)
time.Sleep(4 * opMemoryWatchPeriod)
v := stop()
if v.Stopped {
+1 -1
View File
@@ -131,7 +131,7 @@ func TestActionManifestRefusesWhatIsNotDeclaredOrNotBounded(t *testing.T) {
}
// A files op carrying an action op's fields is refused, not read as one.
files := strings.Replace(opTestManifest, "modes:", "verbs:\n - {verb: unit.restart, unit: a.service}\nmodes:", 1)
if _, err := parseOpManifest([]byte(strings.Replace(files, "%s/", "/srv/work/optest/x/", 1))); err == nil || !strings.Contains(err.Error(), "belong to an action op") {
if _, err := parseOpManifest([]byte(strings.Replace(files, "%s/", "/srv/privatae-optest/x/", 1))); err == nil || !strings.Contains(err.Error(), "belong to an action op") {
t.Fatalf("a files op with verbs: %v", err)
}
}
+95 -27
View File
@@ -224,6 +224,11 @@ type opReport struct {
// per change, in path order. An op_run apply is bound to it (ops.go opTerms.Changes).
ChangesSHA256 string `json:"changes_sha256"`
Committed []string `json:"committed,omitempty"`
// TasksCapped: a cap held the run's tasks, root's included, at
// opSandboxTasksMax. A root op where none could be held runs uncapped,
// and TasksNote says so and why.
TasksCapped bool `json:"tasks_capped,omitempty"`
TasksNote string `json:"tasks_note,omitempty"`
// An action op's apply (ops_verbs_linux.go): each verb as it ran, each
// check after it, and the receipts for what its program pushed.
Verbs []opVerbResult `json:"verbs,omitempty"`
@@ -421,18 +426,49 @@ func opRunSandboxed(opDir string, m *opManifest, mode, params string) (*opReport
limit := time.Duration(m.TimeLimit) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()
cmd := exec.CommandContext(ctx, self, opSandboxSubcommand, specPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS,
Setpgid: true,
// A user op's tasks are held by RLIMIT_NPROC. Root is exempt from it, so
// a root op's are held by a scope of its own (ops_taskcap_linux.go), or
// by nothing where none can be made, and the report says which.
unit := ""
if spec.AsUser {
rep.TasksCapped = true
} else if why := opTasksScopeWhyNot(); why != "" {
rep.TasksNote = opTasksNotCapped(why)
} else {
unit = opScopeUnit(run)
}
var out *cappedBuffer
launch := func(unit string) error {
cmd := opSandboxCmd(ctx, self, specPath, unit)
cmd.Stdin = strings.NewReader(params)
out = &cappedBuffer{max: opOutputMaxBytes}
cmd.Stdout, cmd.Stderr = out, out
// systemd-run hands this environment on to the scope's process.
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", opSandboxHelperEnv}
cmd.WaitDelay = 2 * time.Second // the pipes close when the namespace is gone; never wait past that
return opRunSandboxCmd(cmd)
}
cmd.Stdin = strings.NewReader(params)
out := &cappedBuffer{max: opOutputMaxBytes}
cmd.Stdout, cmd.Stderr = out, out
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
cmd.WaitDelay = 2 * time.Second // the pipes close when the namespace is gone; never wait past that
start := time.Now()
runErr := cmd.Run()
runErr := launch(unit)
if unit != "" {
if ctx.Err() == context.DeadlineExceeded {
opKillScope(unit)
}
switch got, ran := opScopeTasks(run); {
case ran && got == strconv.Itoa(opSandboxScopeTasks):
rep.TasksCapped = true
case ran:
rep.TasksNote = opTasksNotCapped(fmt.Sprintf("its scope's pids.max read %q", truncate(got, 200)))
case ctx.Err() != nil:
rep.TasksNote = opTasksNotCapped("its scope did not start inside the time limit")
default:
// systemd-run could not make the scope, or the scope's process
// died before it recorded the cap: either way nothing ran, and the
// op runs as it does where there is no systemd.
rep.TasksNote = opTasksNotCapped("its scope never started the op: " + truncate(opLastLine(out.String()), 200))
runErr = launch("")
}
}
rep.Seconds = time.Since(start).Seconds()
rep.Output = out.String()
if ctx.Err() == context.DeadlineExceeded {
@@ -506,9 +542,16 @@ func opMatchDir(dir, like string) error {
return nil
}
// opUnprivilegedIdentityHook, when set, is the identity a user-privilege op
// runs as. Only tests set it.
var opUnprivilegedIdentityHook func() (uint32, uint32, error)
// opUnprivilegedIdentity is the session lane's user (session_unix.go): the
// dedicated account if it exists, else nobody.
func opUnprivilegedIdentity() (uint32, uint32, error) {
if opUnprivilegedIdentityHook != nil {
return opUnprivilegedIdentityHook()
}
var u *user.User
var err error
for _, name := range sessionUnprivUsers {
@@ -869,8 +912,10 @@ func opSandboxChildMain(arg string) int {
return 125
}
// Rlimits are inherited by the grandchild: no fork bombs, no giant files,
// a CPU cap beside the wall-clock one the parent enforces.
_ = unix.Setrlimit(unix.RLIMIT_NPROC, &unix.Rlimit{Cur: 64, Max: 64})
// a CPU cap beside the wall-clock one the parent enforces. The kernel
// does not hold root to RLIMIT_NPROC; a root op's tasks are capped by
// the scope it runs in (ops_taskcap_linux.go).
_ = unix.Setrlimit(unix.RLIMIT_NPROC, &unix.Rlimit{Cur: opSandboxTasksMax, Max: opSandboxTasksMax})
_ = unix.Setrlimit(unix.RLIMIT_FSIZE, &unix.Rlimit{Cur: opUpperMaxBytes, Max: opUpperMaxBytes})
_ = unix.Setrlimit(unix.RLIMIT_NOFILE, &unix.Rlimit{Cur: 256, Max: 256})
_ = unix.Setrlimit(unix.RLIMIT_CPU, &unix.Rlimit{Cur: uint64(spec.TimeLimit), Max: uint64(spec.TimeLimit)})
@@ -884,23 +929,46 @@ func opSandboxChildMain(arg string) int {
fmt.Fprintln(os.Stderr, "sandbox: no_new_privs:", err)
return 125
}
cmd := exec.Command(spec.Interpreter, opSandboxMount+"/run", "--mode", spec.Mode)
cmd.Dir = "/"
cmd.Env = []string{
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/tmp",
"TMPDIR=/tmp",
"LANG=C.UTF-8",
"PRIVATAE_OP_MODE=" + spec.Mode,
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if spec.AsUser {
newCmd := func(ownUserNS bool) *exec.Cmd {
cmd := exec.Command(spec.Interpreter, opSandboxMount+"/run", "--mode", spec.Mode)
cmd.Dir = "/"
cmd.Env = []string{
"PATH=/usr/sbin:/usr/bin:/sbin:/bin",
"HOME=/tmp",
"TMPDIR=/tmp",
"LANG=C.UTF-8",
"PRIVATAE_OP_MODE=" + spec.Mode,
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if !spec.AsUser {
cmd.Env = append(cmd.Env, "USER=root")
return cmd
}
cmd.Env = append(cmd.Env, "USER=privatae-op")
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: spec.UID, Gid: spec.GID, NoSetGroups: true}}
} else {
cmd.Env = append(cmd.Env, "USER=root")
attr := &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: spec.UID, Gid: spec.GID, NoSetGroups: true}}
if ownUserNS {
// RLIMIT_NPROC counts every task of a user in its user namespace,
// so in the machine's own one the cap above would count whatever
// else runs as this user (nobody is shared with system daemons)
// and refuse the op on a busy machine. In a user namespace of its
// own the count is the op's tasks alone. The map is the identity:
// the op is the same user and sees every file's real owner.
attr.Cloneflags = syscall.CLONE_NEWUSER
attr.UidMappings = []syscall.SysProcIDMap{{ContainerID: 0, HostID: 0, Size: 1<<31 - 1}}
attr.GidMappings = []syscall.SysProcIDMap{{ContainerID: 0, HostID: 0, Size: 1<<31 - 1}}
}
cmd.SysProcAttr = attr
return cmd
}
if err := cmd.Run(); err != nil {
cmd := newCmd(true)
err = cmd.Run()
if spec.AsUser && err != nil && cmd.Process == nil {
// No user namespace here (not built in, or turned off): run the op
// as before, with the cap counting the user's other tasks too.
cmd = newCmd(false)
err = cmd.Run()
}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
+6 -4
View File
@@ -20,15 +20,14 @@ type opChange struct {
Digest string `json:"sha256,omitempty"`
Diff string `json:"diff,omitempty"`
Note string `json:"note,omitempty"`
src string
from string
}
type opReport struct {
Mode string `json:"mode"`
Exit int `json:"exit"`
TimedOut bool `json:"timed_out,omitempty"`
TasksCapped bool `json:"tasks_capped,omitempty"`
TasksNote string `json:"tasks_note,omitempty"`
Seconds float64 `json:"seconds"`
Output string `json:"output"`
Changes []opChange `json:"changes"`
@@ -39,7 +38,6 @@ type opReport struct {
Checks []opVerbResult `json:"checks,omitempty"`
Receipts []opSignedReceipt `json:"receipts,omitempty"`
run string
cleanup func()
}
@@ -60,6 +58,10 @@ func opCommitUpper(_ *opReport) ([]string, error) {
func opSandboxChildMain(_ string) int { return 125 }
const opSandboxScopeSubcommand = "__op-sandbox-scope"
func opSandboxScopeMain(_ string) int { return 125 }
func opMountsUnder(_ string) []string { return nil }
const (
+57 -18
View File
@@ -56,8 +56,21 @@ import (
"time"
)
// The names of the ops' slice and of the units in it. The startup sweep stops
// every unit these name, so they are variables only for the tests, which give
// themselves names no running bastion uses (ops_names_linux_test.go): a suite
// run on a machine whose own bastion is running an op never touches it.
// Nothing else sets them.
var (
opActionSlice = "privatae_ops.slice"
// opActionUnitPrefix names an action op's unit.
opActionUnitPrefix = "privatae-op-"
// opScopeUnitPrefix names a files op's scope. It is not the action ops'
// prefix, so their sweep never takes one for an action unit.
opScopeUnitPrefix = "privatae-opfiles-"
)
const (
opActionSlice = "privatae_ops.slice"
opMemoryFloorMin = 1 << 30
opMemoryKubeletPad = 512 << 20
opMemoryWatchPeriod = 250 * time.Millisecond
@@ -85,8 +98,13 @@ func opKillUnit(unit string) bool {
return exec.Command("systemctl", "kill", "--signal=SIGKILL", unit).Run() == nil
}
// opOthersStalls is each top-level cgroup's memory stall (PSI some avg10,
// percent), the ops' own slice left out.
// opOthersStalls is the memory stall (PSI some avg10, percent) of each
// top-level cgroup, and of each op's unit or scope in the ops' slice. The
// slice's own figure is left out, since it holds the op that is measuring;
// that op's own unit is taken out by opStallsBut. Two ops can run at once
// (the management lane has more than one worker, and a ship plan runs its
// own), and a root op runs in a scope in this slice: an action op beside it
// must still yield when the root op's memory makes the machine wait.
func opOthersStalls() map[string]float64 {
out := map[string]float64{}
entries, err := os.ReadDir(opCgroupRoot)
@@ -94,29 +112,50 @@ func opOthersStalls() map[string]float64 {
return out
}
for _, e := range entries {
if !e.IsDir() || e.Name() == opActionSlice {
if !e.IsDir() {
continue
}
raw, err := os.ReadFile(filepath.Join(opCgroupRoot, e.Name(), "memory.pressure"))
if err != nil {
if e.Name() != opActionSlice {
opReadStall(out, e.Name())
continue
}
for _, line := range strings.Split(string(raw), "\n") {
if !strings.HasPrefix(line, "some ") {
continue
}
for _, f := range strings.Fields(line) {
if v, ok := strings.CutPrefix(f, "avg10="); ok {
if x, err := strconv.ParseFloat(v, 64); err == nil {
out[e.Name()] = x
}
}
ops, _ := os.ReadDir(filepath.Join(opCgroupRoot, opActionSlice))
for _, o := range ops {
if o.IsDir() {
opReadStall(out, opActionSlice+"/"+o.Name())
}
}
}
return out
}
// opReadStall puts cgroup cg's PSI some avg10 into out, when it can be read.
func opReadStall(out map[string]float64, cg string) {
raw, err := os.ReadFile(filepath.Join(opCgroupRoot, cg, "memory.pressure"))
if err != nil {
return
}
for _, line := range strings.Split(string(raw), "\n") {
if !strings.HasPrefix(line, "some ") {
continue
}
for _, f := range strings.Fields(line) {
if v, ok := strings.CutPrefix(f, "avg10="); ok {
if x, err := strconv.ParseFloat(v, 64); err == nil {
out[cg] = x
}
}
}
}
}
// opStallsBut is stalls without the action unit's own cgroup: an op's own
// stalls never stop it.
func opStallsBut(stalls map[string]float64, unit string) map[string]float64 {
delete(stalls, opActionSlice+"/"+unit+".service")
return stalls
}
// opStallRise is the cgroup whose stall rose most above its lowest reading
// so far in the run, and by how much. A group stalling throughout (a pod
// reclaiming against its own limit, a service at its MemoryMax) is that
@@ -355,7 +394,7 @@ func opMemoryWatch(unit string, floor uint64) (stop func() opMemoryVerdict) {
// Each group's lowest reading during the run: a group that spiked as
// the run began must not hide a rise the run causes later, and one
// held at its own limit throughout still never counts.
low := opOthersStalls()
low := opStallsBut(opOthersStalls(), unit)
for {
if raw, err := os.ReadFile(cur); err == nil {
if n, err := strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64); err == nil {
@@ -368,7 +407,7 @@ func opMemoryWatch(unit string, floor uint64) (stop func() opMemoryVerdict) {
}
_, avail, ok := opMemInfo()
short := ok && avail <= floor
now := opOthersStalls()
now := opStallsBut(opOthersStalls(), unit)
rise, who := opStallRise(low, now)
for cg, x := range now {
if l, ok := low[cg]; !ok || x < l {
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// opFakeStalls points the stall readings at a folder of stand-in cgroups and
// returns how to set one's PSI some avg10.
func opFakeStalls(t *testing.T) func(cg string, some float64) {
t.Helper()
fake := t.TempDir()
prev := opCgroupRoot
opCgroupRoot = fake
t.Cleanup(func() { opCgroupRoot = prev })
return func(cg string, some float64) {
_ = os.MkdirAll(filepath.Join(fake, cg), 0o755)
_ = os.WriteFile(filepath.Join(fake, cg, "memory.pressure"),
[]byte(fmt.Sprintf("some avg10=%.2f avg60=0.00 avg300=0.00 total=1\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=1\n", some)), 0o644)
}
}
// Two ops can run at once: the management lane has more than one worker
// (mgmtWorkerCount) and a ship plan runs its own, and a root op runs in a
// scope in the ops' slice. Another op's scope or unit there is another
// program whose memory can make the machine wait, and its stalls are read
// beside every top-level group's.
func TestAnotherOpsStallsAreReadBesideTheMachinesOwn(t *testing.T) {
write := opFakeStalls(t)
rootOp := opActionSlice + "/" + opScopeUnitPrefix + "4242.scope"
otherAction := opActionSlice + "/" + opActionUnitPrefix + "run2-apply.service"
write("system.slice", 0.10)
write(opActionSlice, 0.10)
write(rootOp, 3.5)
write(otherAction, 1.25)
got := opOthersStalls()
if got["system.slice"] != 0.10 {
t.Errorf("a top-level group's stall: %v", got)
}
if got[rootOp] != 3.5 || got[otherAction] != 1.25 {
t.Errorf("a root op's scope and another action op's unit are read as other programs: %v", got)
}
if _, ok := got[opActionSlice]; ok {
t.Errorf("the ops' slice's own figure holds the op that is measuring, and is never read: %v", got)
}
}
// What an action op's watch compares: a root op's scope rising beside it
// stops it, while its own unit's stalls and the slice's figure never do.
func TestAnActionOpYieldsToARootOpBesideItNeverToItself(t *testing.T) {
write := opFakeStalls(t)
own := opActionUnitPrefix + "run1-apply"
rootOp := opActionSlice + "/" + opScopeUnitPrefix + "4242.scope"
write("system.slice", 0.10)
write(opActionSlice, 0.10)
write(opActionSlice+"/"+own+".service", 0.10)
write(rootOp, 0.10)
base := opStallsBut(opOthersStalls(), own)
write(opActionSlice, 60)
write(opActionSlice+"/"+own+".service", 60)
if rise, who := opStallRise(base, opStallsBut(opOthersStalls(), own)); rise != 0 {
t.Errorf("the op's own stalls rose %.2f in %s and counted", rise, who)
}
write(rootOp, 12.5)
if rise, who := opStallRise(base, opStallsBut(opOthersStalls(), own)); who != rootOp || rise < 12.3 || rise > 12.5 {
t.Errorf("a root op's scope beside it began waiting 12.4%% more: got %.2f in %q", rise, who)
}
}
+79
View File
@@ -0,0 +1,79 @@
package main
// THE SUITE NEVER NAMES THE UNITS A RUNNING BASTION OWNS.
//
// The startup sweep (ops_action_linux.go opStartupSweep) stops every unit in
// the ops' slice whose name has an op's prefix, and a test runs it for real.
// Under the production names, a suite run on a machine whose own bastion is
// running an op would stop that op; under names shared between runs, two
// suites at once would stop each other's. So before any test runs (TestMain),
// this run takes a slice and prefixes of its own, with its pid and a random
// number in each.
import (
"crypto/rand"
"encoding/binary"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
// The names every bastion so far gives its ops' slice and units. Written out
// rather than read from the code: they are what a bastion already installed
// on the machine uses, whatever this tree says.
const (
opBastionSlice = "privatae_ops.slice"
opBastionActionUnits = "privatae-op-"
opBastionScopeUnits = "privatae-opfiles-"
)
// opIsolateOpsUnits gives this run its own ops' slice and unit prefixes. A
// slice name has no "-", which systemd would read as a slice inside another.
func opIsolateOpsUnits() {
var b [4]byte
_, _ = rand.Read(b[:])
run := fmt.Sprintf("%dx%08x", os.Getpid(), binary.BigEndian.Uint32(b[:]))
opActionSlice = "privatae_optest" + run + ".slice"
opActionUnitPrefix = "privatae-optest" + run + "-"
opScopeUnitPrefix = "privatae-optest" + run + "files-"
}
// opIsolatedOpsUnitsDone stops this run's own slice, which systemd keeps
// after its last unit ends. Never a slice a bastion uses.
func opIsolatedOpsUnitsDone() {
if opActionSlice == opBastionSlice || !strings.HasPrefix(opActionSlice, "privatae_optest") {
return
}
if _, err := os.Stat(filepath.Join("/sys/fs/cgroup", opActionSlice)); err == nil {
_ = exec.Command("systemctl", "stop", opActionSlice).Run()
}
}
// No name this run gives the ops' slice or units is a bastion's, sits in a
// bastion's slice, or shares a prefix with a bastion's units either way (so
// neither sweep takes the other's), and each holds this run's pid.
func TestTheSuiteNeverNamesTheUnitsABastionOwns(t *testing.T) {
if opActionSlice == opBastionSlice || strings.HasPrefix(opActionSlice, strings.TrimSuffix(opBastionSlice, ".slice")+"-") {
t.Errorf("the suite's ops' slice is %q: a bastion's own, or inside it", opActionSlice)
}
for _, mine := range []string{opActionUnitPrefix, opScopeUnitPrefix} {
for _, theirs := range []string{opBastionActionUnits, opBastionScopeUnits} {
if strings.HasPrefix(mine, theirs) || strings.HasPrefix(theirs, mine) {
t.Errorf("the suite's unit prefix %q and a bastion's %q: a sweep under one takes the other's units", mine, theirs)
}
}
}
if strings.HasPrefix(opScopeUnitPrefix, opActionUnitPrefix) {
t.Errorf("the suite's scope prefix %q starts with its action prefix %q: the action sweep would take a scope for its own", opScopeUnitPrefix, opActionUnitPrefix)
}
pid := strconv.Itoa(os.Getpid())
for _, n := range []string{opActionSlice, opActionUnitPrefix, opScopeUnitPrefix} {
if !strings.Contains(n, pid) {
t.Errorf("%q does not hold this run's pid %s", n, pid)
}
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package main
// Ops run only on Linux (ops_names_linux_test.go): elsewhere there are no
// units to keep apart.
func opIsolateOpsUnits() {}
func opIsolatedOpsUnitsDone() {}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// opNprocTestUID is a uid no account on a test machine uses.
const opNprocTestUID = 61999
// A user-privilege op starts however many tasks other programs already run
// as its user (a busy machine's daemons running as nobody), and the cap on
// its tasks still stops the op's own fork bomb.
func TestAUserOpStartsWhateverElseRunsAsItsUser(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("needs root")
}
if ok, why := opSandboxAvailable(); !ok {
t.Skip(why)
}
opUnprivilegedIdentityHook = func() (uint32, uint32, error) { return opNprocTestUID, opNprocTestUID, nil }
t.Cleanup(func() { opUnprivilegedIdentityHook = nil })
var others []*exec.Cmd
t.Cleanup(func() {
for _, c := range others {
_ = c.Process.Kill()
_ = c.Wait()
}
})
for i := 0; i < 64; i++ { // the sandbox's cap
c := exec.Command("sleep", "60")
c.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: opNprocTestUID, Gid: opNprocTestUID, NoSetGroups: true}}
if err := c.Start(); err != nil {
t.Fatal(err)
}
others = append(others, c)
}
runOp := func(extra string) (*opReport, error) {
n := time.Now().UnixNano()
root := filepath.Join(opTestRoot, fmt.Sprintf("np%d", n))
opDir := filepath.Join(opTestRoot, fmt.Sprintf("npop%d", n))
for _, p := range []string{root, opDir} {
p := p
t.Cleanup(func() { _ = os.RemoveAll(p) })
}
_ = os.MkdirAll(root, 0o777)
_ = os.Chmod(root, 0o777)
_ = os.MkdirAll(opDir, 0o755)
for _, f := range opFixture(root, extra) {
raw, _ := base64.StdEncoding.DecodeString(f.B64)
_ = os.WriteFile(filepath.Join(opDir, f.Path), raw, os.FileMode(f.Mode))
}
man := strings.Replace(fmt.Sprintf(opTestManifest, root), "privilege: root", "privilege: user", 1)
m, err := parseOpManifest([]byte(man))
if err != nil {
t.Fatal(err)
}
rep, err := opRunSandboxed(opDir, m, opModeDryRun, `{"name":"x","count":1,"kind":"a"}`)
if rep != nil {
t.Cleanup(rep.cleanup)
}
return rep, err
}
rep, err := runOp(`echo "RAN-AS=$(id -u)"; true`)
if err != nil {
t.Fatalf("a user op with 64 other tasks of its user on the machine: %v", err)
}
if !strings.Contains(rep.Output, fmt.Sprintf("RAN-AS=%d", opNprocTestUID)) {
t.Errorf("the op did not run as its user: %q", rep.Output)
}
// The op's own tasks are still capped: a loop starting 100 is stopped,
// and only after about as many as the cap, its own, not the others'.
rep, err = runOp(`i=0; while [ $i -lt 100 ]; do echo "STARTING=$i"; sleep 5 & i=$((i+1)); done; echo DONE; true`)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "fork") || strings.Contains(rep.Output, "DONE") {
t.Fatalf("100 background tasks were not stopped by the cap: %v, %q", err, rep.Output)
}
last := ""
for _, line := range strings.Split(rep.Output, "\n") {
if strings.HasPrefix(line, "STARTING=") {
last = strings.TrimPrefix(line, "STARTING=")
}
}
if n, _ := strconv.Atoi(last); n < 50 {
t.Errorf("the cap stopped the op after %s of its own tasks: it counts other tasks too", last)
}
t.Logf("the op's fork loop was stopped at task %s", last)
}
+200
View File
@@ -0,0 +1,200 @@
//go:build linux
package main
// ops_taskcap_linux.go — a root op's tasks are capped by a cgroup.
//
// A files op's tasks are capped at opSandboxTasksMax by RLIMIT_NPROC, and the
// kernel never applies that limit to root: a task whose real user is root,
// or that holds CAP_SYS_RESOURCE or CAP_SYS_ADMIN, forks past it. A user op
// runs as an unprivileged user and the limit holds. A root op's runaway fork
// was held by nothing but its time limit.
//
// A cgroup's pids.max counts every task in the cgroup, root's included. So a
// root op runs in a scope of its own, in the ops' slice, with TasksMax set,
// started by systemd-run as an action op's unit is. The scope's process is
// this binary as `__op-sandbox-scope <spec>`: it reads its own cgroup's
// pids.max, writes what it read beside the spec, and only then starts the
// sandbox's pid 1 exactly as the bastion does without a scope. The report
// says the tasks were capped only when that value is the cap.
//
// Where no scope can be made (no systemd, no pids controller, systemd-run
// failing), the op runs as before, and its report says its tasks were not
// capped and why. A scope that could not start ran nothing: the value is
// written before the sandbox starts, so with no value the op never ran.
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
)
const (
// opSandboxTasksMax is the most tasks a files op's sandbox holds at once,
// threads included: RLIMIT_NPROC for a user op, pids.max for a root op.
opSandboxTasksMax = 64
// opSandboxScopeTasks is the scope's TasksMax: the op's own cap plus room
// for the two bastion processes that start it inside the scope (this
// binary, twice, each with its runtime's threads; 8 tasks between them
// where measured, held there by opSandboxHelperEnv), so a root op may
// start at least as many tasks as a user op.
opSandboxScopeTasks = opSandboxTasksMax + 32
// opSandboxHelperEnv is in the environment of both bastion processes that
// start the sandbox. The Go runtime starts threads by the CPUs it may use,
// and in a scope each one counts against the cap: measured, the two held
// 16 tasks with 12 CPUs, 44 with the runtime set for 64, and set for 256
// the sandbox's pid 1 could not start a thread and the op never ran. With
// one, they hold 8 on a machine of any size. Neither process does more
// than start its child and wait for it. The op never sees this: pid 1
// sets the op's environment itself.
opSandboxHelperEnv = "GOMAXPROCS=1"
opSandboxScopeSubcommand = "__op-sandbox-scope"
// opScopeTasksFile, beside the spec on the run's tmpfs, holds the
// pids.max the scope's process read in its own cgroup.
opScopeTasksFile = "scope_pids_max"
)
// opTasksScopeWhyNot says why this machine cannot hold an op's tasks in a
// scope of their own, "" when it can.
func opTasksScopeWhyNot() string {
if _, err := exec.LookPath("systemd-run"); err != nil {
return "systemd-run is not installed"
}
if st, err := os.Stat("/run/systemd/system"); err != nil || !st.IsDir() {
return "systemd is not this machine's init"
}
raw, err := os.ReadFile("/sys/fs/cgroup/cgroup.controllers")
if err != nil {
return "this machine does not run the unified cgroup hierarchy"
}
for _, c := range strings.Fields(string(raw)) {
if c == "pids" {
return ""
}
}
return "the kernel offers no pids cgroup controller"
}
// opTasksScopeArgs is the systemd-run command line for a root op's run.
func opTasksScopeArgs(unit, self, specPath string) []string {
return []string{"--scope", "--quiet", "--collect", "--unit=" + unit, "--slice=" + opActionSlice,
"-p", "TasksMax=" + strconv.Itoa(opSandboxScopeTasks),
"--", self, opSandboxScopeSubcommand, specPath}
}
// opScopeUnit names the scope of the run whose tmpfs is run.
func opScopeUnit(run string) string {
return opScopeUnitPrefix + strings.TrimPrefix(filepath.Base(run), "run-")
}
// opKillScope kills every task in a files op's scope through the kernel.
func opKillScope(unit string) {
_ = os.WriteFile(filepath.Join("/sys/fs/cgroup", opActionSlice, unit+".scope", "cgroup.kill"), []byte("1"), 0o600)
}
// opSweepScopes kills what a files op's scope still holds when the bastion
// died under it.
func opSweepScopes() {
entries, _ := os.ReadDir(filepath.Join("/sys/fs/cgroup", opActionSlice))
for _, e := range entries {
if n := e.Name(); e.IsDir() && strings.HasPrefix(n, opScopeUnitPrefix) && strings.HasSuffix(n, ".scope") {
opKillScope(strings.TrimSuffix(n, ".scope"))
}
}
}
// opSandboxCmd is the command that starts the sandbox: the sandbox's pid 1
// directly, or systemd-run making the scope it runs in.
func opSandboxCmd(ctx context.Context, self, specPath, unit string) *exec.Cmd {
if unit == "" {
cmd := exec.CommandContext(ctx, self, opSandboxSubcommand, specPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS,
Setpgid: true,
}
return cmd
}
cmd := exec.CommandContext(ctx, "systemd-run", opTasksScopeArgs(unit, self, specPath)...)
// The scope is outside the bastion's own cgroup, so a bastion stopped or
// restarted does not take it down: the scope's process dies with the
// bastion instead (systemd-run execs it, which keeps the signal), and the
// sandbox's pid 1 dies with that process.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
return cmd
}
// opRunSandboxCmd runs cmd on a thread held for the whole run: a parent-
// death signal is sent when the thread that started the child exits.
func opRunSandboxCmd(cmd *exec.Cmd) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
return cmd.Run()
}
// opScopeTasks reads what the scope's process found its cgroup's pids.max to
// be: ok is false when the scope's process never ran.
func opScopeTasks(run string) (string, bool) {
raw, err := os.ReadFile(filepath.Join(run, opScopeTasksFile))
if err != nil {
return "", false
}
return strings.TrimSpace(string(raw)), true
}
// opOwnPidsMax reads pids.max of the cgroup this process is in.
func opOwnPidsMax() string {
raw, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return "unreadable: " + err.Error()
}
for _, line := range strings.Split(string(raw), "\n") {
if p, ok := strings.CutPrefix(line, "0::"); ok {
v, err := os.ReadFile(filepath.Join("/sys/fs/cgroup", filepath.Clean("/"+p), "pids.max"))
if err != nil {
return "unreadable: " + err.Error()
}
return strings.TrimSpace(string(v))
}
}
return "unreadable: no unified cgroup"
}
// opSandboxScopeMain is the scope's process: it records its cgroup's
// pids.max, then starts the sandbox's pid 1 and returns its exit.
func opSandboxScopeMain(specPath string) int {
if err := os.WriteFile(filepath.Join(filepath.Dir(specPath), opScopeTasksFile), []byte(opOwnPidsMax()), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "sandbox: cannot record the scope's task cap:", err)
return 125
}
self, err := os.Executable()
if err != nil {
fmt.Fprintln(os.Stderr, "sandbox:", err)
return 125
}
cmd := exec.Command(self, opSandboxSubcommand, specPath)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC | syscall.CLONE_NEWUTS,
Pdeathsig: syscall.SIGKILL,
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", opSandboxHelperEnv}
return opExitOf(cmd.Run())
}
// opTasksNotCapped is the report's note for a root op whose tasks no cgroup
// held.
func opTasksNotCapped(why string) string {
return "this root op's tasks were not capped: " + why
}
// opLastLine is the last line s holds, "" for none.
func opLastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return lines[len(lines)-1]
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/sys/unix"
)
// opTaskCapRun runs one dry run of the op fixture with the given run body
// (@ROOT@ in it is the op's effects folder), at the given privilege, and
// returns its report and its report's JSON (what the host receives). A
// timeLimit above 0 replaces the fixture's.
func opTaskCapRun(t *testing.T, privilege, body string, timeLimit int) (*opReport, string, error) {
t.Helper()
n := time.Now().UnixNano()
root := filepath.Join(opTestRoot, fmt.Sprintf("tc%d", n))
opDir := filepath.Join(opTestRoot, fmt.Sprintf("tcop%d", n))
for _, p := range []string{root, opDir} {
p := p
t.Cleanup(func() { _ = os.RemoveAll(p) })
}
_ = os.MkdirAll(root, 0o777)
_ = os.Chmod(root, 0o777)
_ = os.MkdirAll(opDir, 0o755)
for _, f := range opFixture(root, strings.ReplaceAll(body, "@ROOT@", root)) {
raw, _ := base64.StdEncoding.DecodeString(f.B64)
_ = os.WriteFile(filepath.Join(opDir, f.Path), raw, os.FileMode(f.Mode))
}
man := strings.Replace(fmt.Sprintf(opTestManifest, root), "privilege: root", "privilege: "+privilege, 1)
if timeLimit > 0 {
man = strings.Replace(man, "time_limit: 30", fmt.Sprintf("time_limit: %d", timeLimit), 1)
}
m, err := parseOpManifest([]byte(man))
if err != nil {
t.Fatal(err)
}
rep, err := opRunSandboxed(opDir, m, opModeDryRun, `{"name":"x","count":1,"kind":"a"}`)
if rep == nil {
return nil, "", err
}
t.Cleanup(rep.cleanup)
raw, _ := json.Marshal(rep)
return rep, string(raw), err
}
// opFakeSystemdRun puts a stand-in for systemd-run first on PATH. With a
// cgroup it does what `systemd-run --scope -p TasksMax=N` does for the
// process it runs: a cgroup of its own whose pids.max is N, then the command.
// The cgroup is a fresh one at the top of the hierarchy, removed after the
// test; no unit is started. Without one it fails the way systemd-run does
// with no bus to reach. It records the arguments it was given.
func opFakeSystemdRun(t *testing.T, cgroup string) (argsFile string) {
t.Helper()
dir := t.TempDir()
argsFile = filepath.Join(dir, "args")
body := "echo 'Failed to connect to bus: No such file or directory' >&2; exit 1\n"
if cgroup != "" {
body = `max=
while [ $# -gt 0 ]; do
case "$1" in
--) shift; break ;;
-p) shift; case "$1" in TasksMax=*) max=${1#TasksMax=} ;; esac ;;
esac
shift
done
mkdir -p ` + cgroup + `
[ -z "$max" ] || echo "$max" > ` + cgroup + `/pids.max
echo $$ > ` + cgroup + `/cgroup.procs
exec "$@"
`
t.Cleanup(func() {
// The cgroup empties once the sandbox's pid 1 is gone, which takes
// every task the op left behind with it.
for i := 0; i < 100; i++ {
if err := os.Remove(cgroup); err == nil || os.IsNotExist(err) {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Errorf("the test cgroup %s did not empty", cgroup)
})
}
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + argsFile + "\n" + body
if err := os.WriteFile(filepath.Join(dir, "systemd-run"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
return argsFile
}
// A root op's tasks are capped as a user op's are. RLIMIT_NPROC does not
// hold root (the kernel exempts it), so a root op runs in a scope of its own
// whose pids.max counts every task in it, root's included: a loop starting
// 100 background tasks is stopped at the cap, and the report says a cap held.
func TestARootOpIsStoppedAtItsTaskCap(t *testing.T) {
requireSandbox(t)
if ctl, err := os.ReadFile("/sys/fs/cgroup/cgroup.subtree_control"); err != nil || !strings.Contains(" "+string(ctl), " pids") {
t.Skip("no pids controller at the top of the unified cgroup hierarchy here")
}
if _, err := os.Stat("/run/systemd/system"); err != nil {
t.Skip("the bastion asks for a scope only where systemd is init")
}
cgroup := fmt.Sprintf("/sys/fs/cgroup/privatae-optest-%d", os.Getpid())
argsFile := opFakeSystemdRun(t, cgroup)
rep, js, err := opTaskCapRun(t, "root", `i=0; while [ $i -lt 100 ]; do echo "STARTING=$i"; sleep 5 & i=$((i+1)); done; echo DONE; true`, 0)
if rep == nil {
t.Fatalf("the op did not run: %v", err)
}
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "fork") || strings.Contains(rep.Output, "DONE") {
t.Fatalf("a root op started 100 background tasks with nothing stopping it: %v, %q", err, rep.Output)
}
last := ""
for _, line := range strings.Split(rep.Output, "\n") {
if strings.HasPrefix(line, "STARTING=") {
last = strings.TrimPrefix(line, "STARTING=")
}
}
if n, _ := strconv.Atoi(last); n < 50 {
t.Errorf("the cap stopped the op after %s of its own tasks: the sandbox's own processes take too much of it", last)
}
t.Logf("the root op's fork loop was stopped at task %s", last)
if !strings.Contains(js, `"tasks_capped":true`) || strings.Contains(js, `"tasks_note"`) {
t.Errorf("the report does not say the cap held: %s", js)
}
raw, _ := os.ReadFile(argsFile)
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
want := []string{"--scope", "--slice=" + opActionSlice, "-p\nTasksMax="}
for _, w := range want {
if !strings.Contains(string(raw), w) {
t.Errorf("systemd-run was not given %q: %q", w, args)
}
}
unit := ""
for _, a := range args {
if strings.HasPrefix(a, "--unit=") {
unit = strings.TrimPrefix(a, "--unit=")
}
}
if !strings.HasPrefix(unit, opScopeUnitPrefix) || strings.HasPrefix(unit, opActionUnitPrefix) {
t.Errorf("the scope's unit is %q: it must be a files op's, never one the action ops' sweep takes for its own", unit)
}
// A root op that starts a few tasks finishes as before.
rep, js, err = opTaskCapRun(t, "root", `for i in 1 2 3 4 5; do sleep 0.1 & done; wait; echo RAN-AS=$(id -u); true`, 0)
if err != nil {
t.Fatalf("a root op starting five tasks: %v", err)
}
if !strings.Contains(rep.Output, "RAN-AS=0") || !strings.Contains(js, `"tasks_capped":true`) {
t.Errorf("a capped root op: %q %s", rep.Output, js)
}
// The time limit still ends a run in its scope, with everything it
// started (the cgroup must empty for the cleanup to remove it).
rep, _, err = opTaskCapRun(t, "root", `sleep 30 & sleep 30; true`, 1)
if err == nil || rep == nil || !rep.TimedOut {
t.Fatalf("a root op past its time limit in its scope: %v", err)
}
}
// opTaskCapRoom runs a root op's fork loop in its scope and returns how many
// tasks the op held when its next fork was refused.
func opTaskCapRoom(t *testing.T) int {
t.Helper()
rep, _, err := opTaskCapRun(t, "root", `i=0; while [ $i -lt 150 ]; do echo "STARTING=$i"; sleep 5 & i=$((i+1)); done; echo DONE; true`, 0)
if rep == nil || err == nil || strings.Contains(rep.Output, "DONE") {
t.Fatalf("the fork loop was not stopped by the scope's cap: %v", err)
}
last := -1
for _, line := range strings.Split(rep.Output, "\n") {
if v, ok := strings.CutPrefix(line, "STARTING="); ok {
last, _ = strconv.Atoi(v)
}
}
// STARTING=n is printed before the fork of task n: the shell and n tasks
// were running when that fork was refused.
return last + 1
}
// A root op has the same room under its scope's cap on a machine of any
// size. The two bastion processes in the scope are Go programs, and the Go
// runtime starts threads by the CPUs it may use: left to that, with the
// runtime set for 64 CPUs they held 44 of the scope's 96 tasks, leaving the
// op fewer than a user op's 64, and set for 256 the sandbox's pid 1 could not
// start at all. Here the bastion's CPUs are narrowed to one, then given back:
// the op must have as much room either way.
func TestARootOpsTaskRoomDoesNotShrinkWithTheMachinesCPUs(t *testing.T) {
requireSandbox(t)
if ctl, err := os.ReadFile("/sys/fs/cgroup/cgroup.subtree_control"); err != nil || !strings.Contains(" "+string(ctl), " pids") {
t.Skip("no pids controller at the top of the unified cgroup hierarchy here")
}
if _, err := os.Stat("/run/systemd/system"); err != nil {
t.Skip("the bastion asks for a scope only where systemd is init")
}
var all unix.CPUSet
if err := unix.SchedGetaffinity(0, &all); err != nil || all.Count() < 4 {
t.Skip("needs at least 4 CPUs to show a difference")
}
opFakeSystemdRun(t, fmt.Sprintf("/sys/fs/cgroup/privatae-optest-cpus-%d", os.Getpid()))
// The sandbox is started from this goroutine's thread (opRunSandboxCmd
// holds it), and every process under it takes that thread's CPUs.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
defer func() { _ = unix.SchedSetaffinity(0, &all) }()
var one unix.CPUSet
for c := 0; ; c++ {
if all.IsSet(c) {
one.Set(c)
break
}
}
if err := unix.SchedSetaffinity(0, &one); err != nil {
t.Fatal(err)
}
narrow := opTaskCapRoom(t)
if err := unix.SchedSetaffinity(0, &all); err != nil {
t.Fatal(err)
}
wide := opTaskCapRoom(t)
t.Logf("a root op held %d tasks with 1 CPU and %d with %d CPUs", narrow, wide, all.Count())
if narrow < opSandboxTasksMax || wide < opSandboxTasksMax {
t.Errorf("a root op held fewer tasks than a user op's %d: %d with 1 CPU, %d with %d", opSandboxTasksMax, narrow, wide, all.Count())
}
if wide < narrow-3 {
t.Errorf("a root op's room shrank from %d tasks to %d with %d CPUs: the bastion's own processes in its scope took it", narrow, wide, all.Count())
}
}
// Where no scope can be made, a root op runs as before and its report says
// its tasks were not capped, and why; it never claims a cap. A user op's
// tasks are held by RLIMIT_NPROC with no scope at all.
func TestARootOpWithNoScopeSaysItsTasksWereNotCapped(t *testing.T) {
requireSandbox(t)
body := `echo RAN-AS=$(id -u); true`
// systemd-run is there and fails: the op still runs, once.
opFakeSystemdRun(t, "")
rep, js, err := opTaskCapRun(t, "root", `echo RUN >> @ROOT@/runs; echo "RUNS=$(wc -l < @ROOT@/runs)"; `+body, 0)
if err != nil {
t.Fatalf("a root op where systemd-run fails: %v", err)
}
if !strings.Contains(rep.Output, "RAN-AS=0") || !strings.Contains(rep.Output, "RUNS=1") {
t.Errorf("the op did not run as root, once: %q", rep.Output)
}
if strings.Contains(js, `"tasks_capped":true`) || !strings.Contains(js, `"tasks_note":"`) || !strings.Contains(js, "not capped") || !strings.Contains(js, "Failed to connect to bus") {
t.Errorf("the report must say the root op's tasks were not capped and why: %s", js)
}
// No systemd-run at all.
empty := t.TempDir()
t.Setenv("PATH", empty)
_, js, err = opTaskCapRun(t, "root", body, 0)
if err != nil {
t.Fatalf("a root op with no systemd-run: %v", err)
}
if strings.Contains(js, `"tasks_capped":true`) || !strings.Contains(js, "not capped") || !strings.Contains(js, "systemd-run is not installed") {
t.Errorf("the report must say the root op's tasks were not capped and why: %s", js)
}
// A user op needs no scope: its cap is the rlimit, and the report says so.
if _, _, err := opUnprivilegedIdentity(); err == nil {
_, js, err = opTaskCapRun(t, "user", body, 0)
if err != nil {
t.Fatalf("a user op: %v", err)
}
if !strings.Contains(js, `"tasks_capped":true`) || strings.Contains(js, `"tasks_note"`) {
t.Errorf("a user op's report: %s", js)
}
}
}
// An action op's program needs no scope of its own: it runs as a uid that no
// other run, account or subordinate range has (opNextUID), so RLIMIT_NPROC
// counts its own tasks only, and its unit's TasksMax holds every task in the
// unit, root's included. systemd-run is a stand-in that records what it was
// given and the spec it was handed; no unit starts.
func TestAnActionOpsTasksAreItsOwnAndItsUnitIsCapped(t *testing.T) {
// The run mounts its own tmpfs before it reaches systemd-run, which takes
// what the sandbox takes: root that may mount and make namespaces.
requireSandbox(t)
dir := t.TempDir()
rec := filepath.Join(dir, "rec")
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" >> " + rec + "\nfor a; do last=$a; done\ncat \"$last\" >> " + rec + "\necho >> " + rec + "\n"
if err := os.WriteFile(filepath.Join(dir, "systemd-run"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
m, err := parseOpManifest([]byte(opActionManifest))
if err != nil {
t.Fatal(err)
}
opDir := t.TempDir()
uids := map[uint32]bool{}
for i := 0; i < 2; i++ {
_ = os.Remove(rec)
if _, _, err := opRunActionProgram(opDir, m, opModeDryRun, `{}`, nil, fmt.Sprintf("tc%d", i)); err != nil {
t.Fatalf("the stand-in unit: %v", err)
}
raw, err := os.ReadFile(rec)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "-p\nTasksMax=512\n") || !strings.Contains(string(raw), "--slice="+opActionSlice+"\n") {
t.Errorf("the action op's unit is not held to a task cap: %q", raw)
}
var spec opActionSpec
js := string(raw)[strings.Index(string(raw), "{"):]
if err := json.Unmarshal([]byte(strings.TrimSpace(js)), &spec); err != nil {
t.Fatalf("the spec the unit was handed: %v %q", err, js)
}
if spec.UID < opUIDBase || spec.GID != spec.UID || uids[spec.UID] {
t.Errorf("run %d's program runs as uid %d: not a fresh one of the ops' range", i, spec.UID)
}
uids[spec.UID] = true
}
}
+1 -1
View File
@@ -519,7 +519,7 @@ func requireSandbox(t *testing.T) {
}
}
const opTestRoot = "/srv/work/optest"
const opTestRoot = "/srv/privatae-optest"
// opProbeRun is a python3 op that tries what the sandbox must deny: a dial
// to the host's sshd and a write under /proc. It prints a marker for each
+3
View File
@@ -303,6 +303,9 @@ func TestTreeCheckoutMovesOnlyForward(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
if os.Geteuid() != 0 {
t.Skip("needs root: the op moves only a tree root owns, and the fixtures are made by this user")
}
setupBox(t)
useFileRepos(t)
base := t.TempDir()
+1 -1
View File
@@ -110,7 +110,7 @@ func TestOpManifestReadsAreJudgedLikeEffects(t *testing.T) {
`"/proc/1/environ"`: false,
`"/"`: false,
} {
_, err := parseOpManifest([]byte(strings.Replace(strings.Replace(base, "%s/", "/srv/work/optest/x/", 1), "%s", read, 1)))
_, err := parseOpManifest([]byte(strings.Replace(strings.Replace(base, "%s/", "/srv/privatae-optest/x/", 1), "%s", read, 1)))
if (err == nil) != ok {
t.Errorf("reads %s: ok=%v err=%v", read, ok, err)
}
+11 -2
View File
@@ -2,8 +2,8 @@ package main
// ownaddr.go — is a destination THIS machine?
//
// The outbound lanes that dial for someone else (the egress lane on an
// anchored box, egress.go; a coder run's proxy, coder_egress.go; the backup
// The outbound lanes that dial for someone else (the egress lane,
// egress.go; a coder run's proxy, coder_egress.go; the backup
// lanes' fetch and push, backupfetch.go) must never be turned back on the
// machine itself: a service listening on 0.0.0.0 here
// answers on every address the kernel delivers locally, not only on loopback
@@ -19,6 +19,15 @@ package main
// `local` route (`ip route add local 192.0.2.0/24 dev lo`, AnyIP) makes a
// whole range this machine. Only a route of type unicast leaves the
// machine; local, broadcast, anycast and multicast routes are refused.
// A destination on a link that stays on this machine is refused too: a
// container's veth, or a bridge whose every port is a veth, a tap or a
// dummy (the bridge networks Docker, Podman, LXD and libvirt make here).
// A container or VM on one of them is a service on this machine. A route
// through a gateway on such a link leaves: the gateway sends the packet
// on, as the machine a container runs on does for the container. A link
// a main-table default route goes out of is never inside: it is this
// machine's way out, so a bastion in a container keeps the network on
// its own veth, its LAN included.
//
// The IPv4 address inside an IPv4-mapped, NAT64 (64:ff9b::/96) or 6to4
// (2002::/16) address is checked as well, since that is what such an address
+325
View File
@@ -0,0 +1,325 @@
//go:build linux
package main
import (
"context"
"encoding/binary"
"errors"
"net"
"os"
"os/exec"
"strings"
"syscall"
"testing"
"golang.org/x/sys/unix"
)
// The rule alone, over link tables written out here: which links keep a
// packet on this machine.
func TestInsideMachineReadsTheLinkAndABridgesPorts(t *testing.T) {
links := map[uint32]linkInfo{
1: {kind: ""}, // lo
2: {kind: ""}, // a network card
3: {kind: "bridge"}, // docker0
4: {kind: "veth", master: 3}, // a container's link on docker0
5: {kind: "veth"}, // a pod's link, routed to directly (Calico)
6: {kind: "bridge"}, // virbr0
7: {kind: "dummy", master: 6}, // libvirt's placeholder port
8: {kind: "tun", master: 6}, // a VM's tap
9: {kind: "bridge"}, // br0 on a server: the LAN
10: {kind: "", master: 9}, // its network card
11: {kind: "veth", master: 9}, // and a container bridged onto the LAN
12: {kind: "bridge"}, // a Docker network with no container yet
13: {kind: "wireguard"}, // a VPN
14: {kind: "vxlan"}, // a tunnel to other machines (flannel.1)
15: {kind: "bridge"}, // a bridge over a VLAN of the card
16: {kind: "vlan", master: 15}, //
17: {kind: "bridge"}, // a bridge with a bridge as its port
18: {kind: "bridge", master: 17}, //
19: {kind: "tun"}, // a VPN's tun
20: {kind: "macvlan"}, // a macvlan over the card
}
for oif, want := range map[uint32]bool{
1: false, 2: false, 3: true, 4: true, 5: true, 6: true, 9: false, 12: true,
13: false, 14: false, 15: false, 17: false, 19: false, 20: false,
99: false, // not in the table: linkInsideMachine refuses it before asking
} {
if got := insideMachine(links, oif); got != want {
t.Errorf("link %d (%q): inside this machine = %v, want %v", oif, links[oif].kind, got, want)
}
}
}
// A link dump's answers, written out here: the kind and master of each link,
// NLMSG_DONE ends it, and a dump that ended on an error (the status NLMSG_DONE
// carries) or was interrupted is not taken as the whole table.
//
// MUTATION: return done at NLMSG_DONE without reading its status.
// RESULT: red — a dump that stopped on EMSGSIZE, missing its later links, is
// taken as the table.
func TestReadLinkDumpReadsKindsMastersAndTheDumpsStatus(t *testing.T) {
ne := binary.NativeEndian
attr := func(typ uint16, val []byte) []byte {
b := make([]byte, (unix.SizeofRtAttr+len(val)+3)&^3)
ne.PutUint16(b[0:], uint16(unix.SizeofRtAttr+len(val)))
ne.PutUint16(b[2:], typ)
copy(b[unix.SizeofRtAttr:], val)
return b
}
u32 := func(v uint32) []byte { b := make([]byte, 4); ne.PutUint32(b, v); return b }
link := func(seq, idx, master uint32, kind string, flags uint16) syscall.NetlinkMessage {
d := make([]byte, unix.SizeofIfInfomsg)
ne.PutUint32(d[4:], idx)
if kind != "" {
d = append(d, attr(unix.IFLA_LINKINFO, attr(unix.IFLA_INFO_KIND, append([]byte(kind), 0)))...)
}
if master != 0 {
d = append(d, attr(unix.IFLA_MASTER, u32(master))...)
}
return syscall.NetlinkMessage{Header: syscall.NlMsghdr{Type: unix.RTM_NEWLINK, Flags: flags, Seq: seq}, Data: d}
}
doneWith := func(seq uint32, errno syscall.Errno) syscall.NetlinkMessage {
return syscall.NetlinkMessage{Header: syscall.NlMsghdr{Type: unix.NLMSG_DONE, Seq: seq}, Data: u32(uint32(-int32(errno)))}
}
links := map[uint32]linkInfo{}
done, intr, err := readLinkDump([]syscall.NetlinkMessage{
link(7, 3, 0, "bridge", 0), link(7, 4, 3, "veth", 0), link(7, 2, 0, "", 0),
link(8, 9, 0, "bridge", 0), // another request's answer
doneWith(7, 0),
}, 7, links)
if err != nil || !done || intr {
t.Fatalf("a whole dump: done %v interrupted %v err %v", done, intr, err)
}
want := map[uint32]linkInfo{3: {kind: "bridge"}, 4: {kind: "veth", master: 3}, 2: {}}
if len(links) != len(want) {
t.Fatalf("links %v, want %v", links, want)
}
for i, l := range want {
if links[i] != l {
t.Errorf("link %d: %+v, want %+v", i, links[i], l)
}
}
if done, _, err := readLinkDump([]syscall.NetlinkMessage{link(7, 3, 0, "bridge", 0), doneWith(7, unix.EMSGSIZE)},
7, map[uint32]linkInfo{}); err == nil || done {
t.Errorf("a dump that ended on EMSGSIZE is an error, got done %v err %v", done, err)
}
if _, intr, err := readLinkDump([]syscall.NetlinkMessage{link(7, 3, 0, "bridge", unix.NLM_F_MULTI|unix.NLM_F_DUMP_INTR)},
7, map[uint32]linkInfo{}); err != nil || !intr {
t.Errorf("an answer marked interrupted marks the dump, got %v %v", intr, err)
}
if done, intr, err := readLinkDump([]syscall.NetlinkMessage{link(7, 3, 0, "bridge", 0)}, 7, map[uint32]linkInfo{}); done || intr || err != nil {
t.Errorf("a batch without NLMSG_DONE is not the end: %v %v %v", done, intr, err)
}
errMsg := doneWith(7, unix.EPERM)
errMsg.Header.Type = unix.NLMSG_ERROR
if _, _, err := readLinkDump([]syscall.NetlinkMessage{errMsg}, 7, map[uint32]linkInfo{}); !errors.Is(err, unix.EPERM) {
t.Errorf("an NLMSG_ERROR in the dump is its error, got %v", err)
}
}
const insideNetnsEnv = "PRIVATAE_TEST_INSIDE_NETNS"
// THE KERNEL'S OWN ANSWER FOR NETWORKS INSIDE THIS MACHINE. The local egress
// lane keeps the person's LAN, but a container or VM on a network made on
// this machine is a service on this machine, reached around its switches the
// same way loopback is. In a private user and network namespace this builds
// the networks Docker, libvirt and Calico make (a bridge whose only port is a
// veth, a bridge whose ports are a dummy and nothing else, a bridge with no
// port yet, a veth routed to directly, and the same with an IPv6 range and a
// public range), and links that leave the machine: a bridge over a VLAN,
// which stands for a bridge over the network card; a route straight over a
// dummy, which stands for the card itself; veths that carry this machine's
// main-table default routes, as a container's own veth does; and routes
// through a gateway. It asks the real rtnetlink lookup, the local egress lane
// on an unanchored box, a coder run's proxy and the backup lanes' wall.
//
// MUTATION: return routeLeaves for every unicast route (drop the link check).
// RESULT: red — every inside address passes the local lane, the public one
// passes the coder lane.
// MUTATION: count "dummy" as an outward port.
// RESULT: red — the libvirt-style bridge is the LAN.
// MUTATION: drop the main-table default-route links from the judgement.
// RESULT: red — from a machine that is itself a container, the network on its
// own veths (IPv4, a multipath default's second veth, IPv6) is this machine.
// MUTATION: skip a default route's RTA_MULTIPATH nexthops.
// RESULT: red — both veths of the multipath default are inside.
// MUTATION: count a default route in any table, not only main.
// RESULT: red — the veth whose default is in table 100 leaves.
// MUTATION: judge the out link of a route through a gateway too.
// RESULT: red — from a machine that is itself a container, the internet
// (IPv4 and IPv6) and a LAN behind a router VM are this machine, and every
// lane refuses them.
func TestNetworksInsideThisMachineAreThisMachine(t *testing.T) {
if os.Getenv(insideNetnsEnv) != "1" {
if _, err := exec.LookPath("ip"); err != nil {
if _, err := os.Stat("/usr/sbin/ip"); err != nil {
t.Skip("needs ip(8)")
}
}
cmd := exec.Command(os.Args[0], "-test.run=^TestNetworksInsideThisMachineAreThisMachine$", "-test.v", "-test.count=1")
cmd.Env = append(os.Environ(), insideNetnsEnv+"=1")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
UidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getuid(), Size: 1}},
GidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getgid(), Size: 1}},
}
out, err := cmd.CombinedOutput()
if cmd.ProcessState == nil {
t.Skipf("no private user and network namespace here: %v", err)
}
t.Logf("in a private network namespace:\n%s", out)
if strings.Contains(string(out), "--- SKIP") {
t.Skip("the namespaced run could not build its networks")
}
if err != nil {
t.Fatalf("the namespaced run failed: %v", err)
}
if !strings.Contains(string(out), "--- PASS: TestNetworksInsideThisMachineAreThisMachine") {
t.Fatal("the namespaced run did not run")
}
return
}
ipBin, err := exec.LookPath("ip")
if err != nil {
ipBin = "/usr/sbin/ip"
}
for _, args := range [][]string{
{"link", "set", "lo", "up"},
// Docker: a bridge whose only port is a container's veth, with an IPv6
// range too, and a second network on a public range.
{"link", "add", "br-ctr", "type", "bridge"},
{"link", "add", "vh0", "type", "veth", "peer", "name", "vc0"},
{"link", "set", "vh0", "master", "br-ctr"},
{"addr", "add", "172.31.5.1/24", "dev", "br-ctr"},
{"-6", "addr", "add", "2001:db8:5::1/64", "dev", "br-ctr", "nodad"},
{"link", "add", "br-pub", "type", "bridge"},
{"link", "add", "vh1", "type", "veth", "peer", "name", "vc1"},
{"link", "set", "vh1", "master", "br-pub"},
{"addr", "add", "203.0.113.1/24", "dev", "br-pub"},
// libvirt: a bridge whose port is a dummy.
{"link", "add", "br-vm", "type", "bridge"},
{"link", "add", "dvm0", "type", "dummy"},
{"link", "set", "dvm0", "master", "br-vm"},
{"addr", "add", "192.168.122.1/24", "dev", "br-vm"},
// A Docker network no container has joined yet.
{"link", "add", "br-empty", "type", "bridge"},
{"addr", "add", "172.29.0.1/24", "dev", "br-empty"},
// Calico: a pod's veth, routed to directly (the route is added once
// the link is up, below).
{"link", "add", "cali0", "type", "veth", "peer", "name", "cali0p"},
// The LAN: a bridge over a VLAN of a card, and the card itself.
{"link", "add", "dlan0", "type", "dummy"},
{"link", "add", "link", "dlan0", "name", "dlan0.7", "type", "vlan", "id", "7"},
{"link", "add", "br-lan", "type", "bridge"},
{"link", "set", "dlan0.7", "master", "br-lan"},
{"addr", "add", "10.77.0.1/24", "dev", "br-lan"},
// The way out through a veth: this machine is itself a container, and
// its default routes go through its veths to a gateway, the machine it
// runs on: IPv4 over two (a multipath default), IPv6 over a third. A
// veth that carries a main-table default leads out of this machine, so
// the network on it is the LAN, not inside.
{"link", "add", "ctr0", "type", "veth", "peer", "name", "ctr0p"},
{"addr", "add", "10.88.0.2/24", "dev", "ctr0"},
{"link", "add", "ctr1", "type", "veth", "peer", "name", "ctr1p"},
{"addr", "add", "10.89.0.2/24", "dev", "ctr1"},
{"link", "add", "ctr6", "type", "veth", "peer", "name", "ctr6p"},
{"-6", "addr", "add", "2001:db8:88::2/64", "dev", "ctr6", "nodad"},
// A veth with a default route in another table only: still inside.
{"link", "add", "pbr0", "type", "veth", "peer", "name", "pbr0p"},
{"addr", "add", "10.91.0.2/24", "dev", "pbr0"},
// A range routed through a veth with this machine's own address on it
// as the gateway: the kernel answers it as on the link, so it is
// inside, whatever the gateway.
{"link", "add", "cilh", "type", "veth", "peer", "name", "ciln"},
{"addr", "add", "10.66.0.191/32", "dev", "cilh"},
} {
if out, err := exec.Command(ipBin, args...).CombinedOutput(); err != nil {
t.Skipf("ip %s: %v %s", strings.Join(args, " "), err, out)
}
}
for _, l := range []string{"br-ctr", "vh0", "vc0", "br-pub", "vh1", "vc1", "br-vm", "dvm0", "br-empty",
"cali0", "cali0p", "dlan0", "dlan0.7", "br-lan", "ctr0", "ctr0p", "ctr1", "ctr1p", "ctr6", "ctr6p",
"pbr0", "pbr0p", "cilh", "ciln"} {
if out, err := exec.Command(ipBin, "link", "set", l, "up").CombinedOutput(); err != nil {
t.Skipf("ip link set %s up: %v %s", l, err, out)
}
}
// The card: a link with no kind cannot be made here, so a route over the
// VLAN's lower link stands for it. A dummy as the route's own link leaves:
// only a bridge's ports are read for dummies.
for _, args := range [][]string{
{"route", "add", "172.30.9.0/24", "dev", "cali0"},
{"route", "add", "192.168.50.0/24", "dev", "dlan0"},
// Through a gateway on an inside link, the packet goes where that
// gateway sends it: the internet from a container, a LAN behind a
// router VM on a bridge of taps.
{"route", "add", "default", "nexthop", "via", "10.88.0.1", "dev", "ctr0", "nexthop", "via", "10.89.0.1", "dev", "ctr1"},
{"-6", "route", "add", "default", "via", "2001:db8:88::1", "dev", "ctr6"},
{"route", "add", "default", "via", "10.91.0.1", "dev", "pbr0", "table", "100"},
{"route", "add", "192.168.60.0/24", "via", "192.168.122.254", "dev", "br-vm"},
{"route", "add", "10.66.0.0/24", "via", "10.66.0.191", "dev", "cilh"},
} {
if out, err := exec.Command(ipBin, args...).CombinedOutput(); err != nil {
t.Skipf("ip %s: %v %s", strings.Join(args, " "), err, out)
}
}
ownAddrReset()
inside := []string{"172.31.5.9", "2001:db8:5::9", "203.0.113.9", "192.168.122.9", "172.29.0.9", "172.30.9.9",
"10.66.0.9", "10.91.0.9"}
leaves := []string{"10.77.0.9", "192.168.50.9", "198.51.100.9", "2001:db8:ff::9", "192.168.60.9",
"10.88.0.9", "10.89.0.9", "2001:db8:88::9"}
for _, a := range inside {
if got, err := ownRouteLookup(net.ParseIP(a)); err != nil || got != routeThisMachine {
t.Errorf("route lookup %s: got kind %d err %v, want this machine", a, got, err)
}
}
for _, a := range leaves {
if got, err := ownRouteLookup(net.ParseIP(a)); err != nil || got != routeLeaves {
t.Errorf("route lookup %s: got kind %d err %v, want leaves", a, got, err)
}
}
// The local egress lane on an unanchored box: an inside address, by address
// and by name, is refused as this machine; the LAN passes.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if currentAnchor() != nil || boxMode() != modeLocal {
t.Fatal("the namespaced run must be a local, unanchored box")
}
names := map[string][]string{"db.example.test": {"172.31.5.9"}, "vm.example.test": {"192.168.122.9"},
"nas.example.test": {"10.77.0.9"}}
stubEgressResolve(t, names)
ctx := context.Background()
for _, hp := range []string{"172.31.5.9:5432", "[2001:db8:5::9]:5432", "203.0.113.9:443", "192.168.122.9:22",
"172.29.0.9:80", "172.30.9.9:8080", "10.66.0.9:80", "10.91.0.9:80", "db.example.test:5432", "vm.example.test:22"} {
if got, why := egressVettedAddrs(ctx, hp); !strings.Contains(why, "that is this machine") {
t.Errorf("local egress: %s is on a network inside this machine and must be refused as this machine, got %v %q", hp, got, why)
}
}
for hp, want := range map[string]string{"10.77.0.9:445": "10.77.0.9:445", "192.168.50.9:631": "192.168.50.9:631",
"nas.example.test:445": "10.77.0.9:445", "198.51.100.9:443": "198.51.100.9:443",
"[2001:db8:ff::9]:443": "[2001:db8:ff::9]:443", "192.168.60.9:445": "192.168.60.9:445",
"10.88.0.9:80": "10.88.0.9:80", "10.89.0.9:80": "10.89.0.9:80", "[2001:db8:88::9]:80": "[2001:db8:88::9]:80"} {
if got, why := egressVettedAddrs(ctx, hp); why != "" || len(got) != 1 || got[0] != want {
t.Errorf("local egress: %s is the LAN and passes as %s: %v %q", hp, want, got, why)
}
}
// A coder run's proxy refuses the public range on an inside network too
// (its private ranges it refuses by class already).
if got, why := coderVettedAddr(ctx, "203.0.113.9:443", nil); !strings.Contains(why, "never this machine") {
t.Errorf("coder run: 203.0.113.9 is on a network inside this machine, got dial %q, %q", got, why)
}
// From a machine that is itself a container, the internet is through its
// veth: a coder run's proxy and the backup lanes still reach it.
if got, why := coderVettedAddr(ctx, "198.51.100.9:443", nil); why != "" || got != "198.51.100.9:443" {
t.Errorf("coder run: 198.51.100.9 is through the gateway and passes, got dial %q, %q", got, why)
}
if !backupAddrAllowed(net.ParseIP("2001:db8:ff::9")) || !backupAddrAllowed(net.ParseIP("198.51.100.9")) {
t.Error("backup: an address through the gateway leaves this machine and is allowed")
}
}
+404 -9
View File
@@ -7,8 +7,11 @@ import (
"errors"
"fmt"
"net"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"golang.org/x/sys/unix"
)
@@ -23,6 +26,12 @@ var netlinkRouteSeq atomic.Uint32
// connection from this process (same uid, no mark, no bound device, which is
// how the egress dialers dial).
//
// A unicast route straight onto a link that stays inside this machine, with
// no gateway, is this machine too (insideMachine): a container's or VM's
// address on a bridge network Docker, Podman, LXD or libvirt made here
// reaches a service on this machine. A route through a gateway leaves, and
// so does one out of a link a main-table default route goes out of.
//
// No route at all (ENETUNREACH, EHOSTUNREACH, or EACCES from a prohibit
// route) is routeNowhere: the kernel delivers such a packet nowhere, locally
// included, and the dial fails on its own. So does an IPv6 lookup on a kernel
@@ -36,18 +45,11 @@ func netlinkRouteKind(ip net.IP) (routeKind, error) {
if addr == nil {
return 0, errors.New("not an IP address")
}
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_ROUTE)
fd, err := netlinkSocket()
if err != nil {
return 0, fmt.Errorf("route lookup: %w", err)
}
defer unix.Close(fd)
tv := unix.Timeval{Sec: 2}
if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil {
return 0, fmt.Errorf("route lookup: %w", err)
}
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
return 0, fmt.Errorf("route lookup: %w", err)
}
// nlmsghdr | rtmsg | rtattr(RTA_DST) + address, each 4-byte aligned.
attrLen := unix.SizeofRtAttr + len(addr)
@@ -110,12 +112,405 @@ func netlinkRouteKind(ip net.IP) (routeKind, error) {
if len(m.Data) < unix.SizeofRtMsg {
return 0, errors.New("route lookup: short route message")
}
return routeKindOfType(m.Data[7]), nil // rtm_type
kind := routeKindOfType(m.Data[7]) // rtm_type
if kind != routeLeaves {
return kind, nil
}
oif, gw, err := routeOutLink(&m)
if err != nil {
return 0, fmt.Errorf("route lookup: %w", err)
}
if oif == 0 {
return routeLeaves, nil // no single link named (a multipath route)
}
if gw {
// Handed to a gateway, the packet goes where that machine
// sends it on: the internet, from the machine a container
// runs on; the LAN behind a router VM. The destination is
// not on the out link, so the link says nothing about it.
return routeLeaves, nil
}
inside, err := linkInsideMachine(oif)
if err != nil {
return 0, fmt.Errorf("route lookup: %w", err)
}
if inside {
return routeThisMachine, nil
}
return routeLeaves, nil
}
}
}
}
// netlinkSocket opens an rtnetlink socket that gives up on a reply after 2 s.
func netlinkSocket() (int, error) {
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_ROUTE)
if err != nil {
return -1, err
}
tv := unix.Timeval{Sec: 2}
if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil {
unix.Close(fd)
return -1, err
}
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
unix.Close(fd)
return -1, err
}
return fd, nil
}
// routeOutLink reads the link a route answer sends the packet out of
// (RTA_OIF), 0 when the answer names none, and whether the answer hands the
// packet to a gateway (RTA_GATEWAY, or RTA_VIA for a gateway of the other
// family) rather than to the destination on the link itself. The kernel
// answers a route whose gateway is one of this machine's own addresses as on
// the link, with no gateway, so the link decides there.
func routeOutLink(m *syscall.NetlinkMessage) (oif uint32, gw bool, err error) {
attrs, err := syscall.ParseNetlinkRouteAttr(m)
if err != nil {
return 0, false, err
}
for _, a := range attrs {
switch a.Attr.Type {
case unix.RTA_OIF:
if len(a.Value) < 4 {
return 0, false, errors.New("short output link attribute")
}
oif = binary.NativeEndian.Uint32(a.Value)
case unix.RTA_GATEWAY, unix.RTA_VIA:
gw = true
}
}
return oif, gw, nil
}
// linkInfo is what insideMachine reads of a link.
type linkInfo struct {
kind string // IFLA_INFO_KIND ("veth", "bridge", "tun", "vlan", ...); "" for a network card or loopback
master uint32 // IFLA_MASTER: the bridge or bond it is a port of; 0 for none
}
// innerPortKinds are the bridge ports that keep a bridge inside this machine:
// a veth (a container's link), a tun (the kernel kind of a tap, a VM's network
// card) and a dummy (libvirt's placeholder port).
var innerPortKinds = map[string]bool{"veth": true, "tun": true, "dummy": true}
// insideMachine reports whether a packet sent out of link oif stays on this
// machine: oif is a veth, whose other end is a container's network namespace
// here, or a bridge whose every port is an inner port, which is the network
// Docker, Podman, LXD and libvirt each make for their containers and VMs (a
// bridge with no port yet included). A bridge with any other port (a network
// card, a bond, a VLAN) is the person's LAN, and every other link leaves this
// machine: a network card, a VPN (wireguard, tun), a tunnel to other machines
// (vxlan, ipip).
func insideMachine(links map[uint32]linkInfo, oif uint32) bool {
switch links[oif].kind {
case "veth":
return true
case "bridge":
for idx, l := range links {
if idx != oif && l.master == oif && !innerPortKinds[l.kind] {
return false
}
}
return true
}
return false
}
// The link table and the links a default route goes out of, re-read
// together at most every ownAddrTTL, as the interface addresses are. A link
// missing from a cached table (a network made since) reads both again; one
// missing from a fresh table cannot be judged. A failed read of either is an
// error, and the caller refuses.
var linkCache struct {
mu sync.Mutex
at time.Time
links map[uint32]linkInfo
wayOut map[uint32]bool
}
// linkInsideMachine: insideMachine for oif, unless a main-table default route
// goes out of oif. Such a link is this machine's way out to the world, not a
// network inside it: a bastion that is itself in a container reaches its LAN
// (or the machine it runs on and its other containers) over its own veth,
// and a host whose default route goes through a router VM reaches that VM
// over the bridge of taps it shares with it.
func linkInsideMachine(oif uint32) (bool, error) {
linkCache.mu.Lock()
defer linkCache.mu.Unlock()
fresh := linkCache.at.IsZero() || time.Since(linkCache.at) >= ownAddrTTL
if !fresh {
if _, ok := linkCache.links[oif]; !ok {
fresh = true
}
}
if fresh {
links, err := dumpLinks()
if err != nil {
return false, err
}
wayOut, err := dumpDefaultRouteLinks()
if err != nil {
return false, err
}
linkCache.links, linkCache.wayOut, linkCache.at = links, wayOut, time.Now()
}
if _, ok := linkCache.links[oif]; !ok {
return false, fmt.Errorf("link %d is not in the link table", oif)
}
if linkCache.wayOut[oif] {
return false, nil
}
return insideMachine(linkCache.links, oif), nil
}
// dumpLinks lists every link with its kind and master (RTM_GETLINK, the dump
// `ip -d link` reads).
func dumpLinks() (map[uint32]linkInfo, error) {
var links map[uint32]linkInfo
err := dumpRetried("link dump", unix.RTM_GETLINK, unix.SizeofIfInfomsg, func() func(*syscall.NetlinkMessage) error {
links = map[uint32]linkInfo{}
return func(m *syscall.NetlinkMessage) error { return addLink(links, m) }
})
if err != nil {
return nil, err
}
return links, nil
}
// dumpDefaultRouteLinks lists the links an IPv4 (0.0.0.0/0) or IPv6 (::/0)
// unicast default route in the main table goes out of, every nexthop's link
// of a multipath default included. One RTM_GETROUTE dump of family AF_UNSPEC
// answers both families (rtnetlink dumps each family's routes for it), one
// round trip where two per-family dumps would be two. No single-address
// lookup can stand in: RTM_GETROUTE without a dump answers for a destination,
// never for a route, and the destination 0.0.0.0 is answered as this machine.
func dumpDefaultRouteLinks() (map[uint32]bool, error) {
var out map[uint32]bool
err := dumpRetried("route dump", unix.RTM_GETROUTE, unix.SizeofRtMsg, func() func(*syscall.NetlinkMessage) error {
out = map[uint32]bool{}
return func(m *syscall.NetlinkMessage) error { return addDefaultRouteLinks(out, m) }
})
if err != nil {
return nil, err
}
return out, nil
}
// dumpRetried takes a dump (netlinkDump), and takes it again from the start
// when the kernel marks it interrupted (the table changed while it ran), up
// to three times; start gives each attempt an empty table to fill.
func dumpRetried(what string, msgType uint16, hdrLen int, start func() func(*syscall.NetlinkMessage) error) error {
for try := 0; ; try++ {
interrupted, err := netlinkDump(msgType, hdrLen, start())
if err != nil {
return fmt.Errorf("%s: %w", what, err)
}
if !interrupted || try == 2 {
return nil
}
}
}
// netlinkDump sends one dump request of msgType, its family header hdrLen
// bytes of zero (every family, everything), and hands each answer to add.
func netlinkDump(msgType uint16, hdrLen int, add func(*syscall.NetlinkMessage) error) (bool, error) {
fd, err := netlinkSocket()
if err != nil {
return false, err
}
defer unix.Close(fd)
total := unix.SizeofNlMsghdr + hdrLen
req := make([]byte, total)
ne := binary.NativeEndian
seq := netlinkRouteSeq.Add(1)
ne.PutUint32(req[0:], uint32(total))
ne.PutUint16(req[4:], msgType)
ne.PutUint16(req[6:], unix.NLM_F_REQUEST|unix.NLM_F_DUMP)
ne.PutUint32(req[8:], seq)
if err := unix.Sendto(fd, req, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
return false, err
}
interrupted := false
buf := make([]byte, 65536)
for {
n, from, err := unix.Recvfrom(fd, buf, 0)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
return false, err
}
if sa, ok := from.(*unix.SockaddrNetlink); !ok || sa.Pid != 0 {
continue
}
msgs, err := syscall.ParseNetlinkMessage(buf[:n])
if err != nil {
return false, err
}
done, intr, err := readDump(msgs, seq, add)
if err != nil {
return false, err
}
interrupted = interrupted || intr
if done {
return interrupted, nil
}
}
}
// readDump hands one batch of a dump's answers to add: done at NLMSG_DONE,
// interrupted when the kernel marks an answer NLM_F_DUMP_INTR. NLMSG_DONE
// carries the dump's own status, and a dump that stopped on an error (one the
// kernel could not fit, say) ends with that error in it and the answers after
// it missing, so it is an error here, as an NLMSG_ERROR is.
func readDump(msgs []syscall.NetlinkMessage, seq uint32, add func(*syscall.NetlinkMessage) error) (done, interrupted bool, err error) {
ne := binary.NativeEndian
for i := range msgs {
m := &msgs[i]
if m.Header.Seq != seq {
continue
}
if m.Header.Flags&unix.NLM_F_DUMP_INTR != 0 {
interrupted = true
}
switch m.Header.Type {
case unix.NLMSG_DONE:
if len(m.Data) >= 4 {
if errno := unix.Errno(-int32(ne.Uint32(m.Data[0:4]))); errno != 0 {
return false, false, errno
}
}
return true, interrupted, nil
case unix.NLMSG_ERROR:
if len(m.Data) >= 4 {
if errno := unix.Errno(-int32(ne.Uint32(m.Data[0:4]))); errno != 0 {
return false, false, errno
}
}
return false, false, errors.New("ended with no answer")
default:
if err := add(m); err != nil {
return false, false, err
}
}
}
return false, interrupted, nil
}
// readLinkDump is readDump for a link dump, into links.
func readLinkDump(msgs []syscall.NetlinkMessage, seq uint32, links map[uint32]linkInfo) (done, interrupted bool, err error) {
return readDump(msgs, seq, func(m *syscall.NetlinkMessage) error { return addLink(links, m) })
}
// addLink reads a link's index, kind and master into links.
func addLink(links map[uint32]linkInfo, m *syscall.NetlinkMessage) error {
if m.Header.Type != unix.RTM_NEWLINK {
return nil
}
if len(m.Data) < unix.SizeofIfInfomsg {
return errors.New("short link message")
}
ne := binary.NativeEndian
idx := ne.Uint32(m.Data[4:8]) // ifi_index
attrs, err := syscall.ParseNetlinkRouteAttr(m)
if err != nil {
return err
}
var li linkInfo
for _, a := range attrs {
switch a.Attr.Type {
case unix.IFLA_MASTER:
if len(a.Value) >= 4 {
li.master = ne.Uint32(a.Value)
}
case unix.IFLA_LINKINFO:
li.kind = linkInfoKind(a.Value)
}
}
links[idx] = li
return nil
}
// addDefaultRouteLinks adds to out the links an IPv4 or IPv6 unicast default
// route of the main table goes out of: its RTA_OIF, and each nexthop's link
// in its RTA_MULTIPATH. Every other route is passed over.
func addDefaultRouteLinks(out map[uint32]bool, m *syscall.NetlinkMessage) error {
if m.Header.Type != unix.RTM_NEWROUTE {
return nil
}
if len(m.Data) < unix.SizeofRtMsg {
return errors.New("short route message")
}
family, dstLen, table, rtype := m.Data[0], m.Data[1], uint32(m.Data[4]), m.Data[7]
if (family != unix.AF_INET && family != unix.AF_INET6) || dstLen != 0 || rtype != unix.RTN_UNICAST {
return nil
}
attrs, err := syscall.ParseNetlinkRouteAttr(m)
if err != nil {
return err
}
ne := binary.NativeEndian
var links []uint32
for _, a := range attrs {
switch a.Attr.Type {
case unix.RTA_TABLE: // the table's id when it does not fit rtm_table
if len(a.Value) >= 4 {
table = ne.Uint32(a.Value)
}
case unix.RTA_OIF:
if len(a.Value) >= 4 {
links = append(links, ne.Uint32(a.Value))
}
case unix.RTA_MULTIPATH: // struct rtnexthop, each followed by its own attributes
for b := a.Value; len(b) >= unix.SizeofRtNexthop; {
l := int(ne.Uint16(b[0:2]))
if l < unix.SizeofRtNexthop || l > len(b) {
return errors.New("bad multipath nexthop")
}
links = append(links, ne.Uint32(b[4:8])) // rtnh_ifindex
next := (l + 3) &^ 3
if next >= len(b) {
break
}
b = b[next:]
}
}
}
if table != unix.RT_TABLE_MAIN {
return nil
}
for _, l := range links {
if l != 0 {
out[l] = true
}
}
return nil
}
// linkInfoKind reads IFLA_INFO_KIND out of a nested IFLA_LINKINFO.
func linkInfoKind(b []byte) string {
ne := binary.NativeEndian
for len(b) >= unix.SizeofRtAttr {
l := int(ne.Uint16(b[0:2]))
if l < unix.SizeofRtAttr || l > len(b) {
return ""
}
if ne.Uint16(b[2:4]) == unix.IFLA_INFO_KIND {
return strings.TrimRight(string(b[unix.SizeofRtAttr:l]), "\x00")
}
next := (l + 3) &^ 3
if next > len(b) {
return ""
}
b = b[next:]
}
return ""
}
// routeKindOfType reads a route's rtm_type: only a unicast route leaves this
// machine. Local, broadcast, anycast, multicast and every other type are
// delivered here or cannot be told apart from it, so they are this machine.
+2 -1
View File
@@ -4,4 +4,5 @@ package main
// No route lookup is built for this platform: ownRouteLookup stays nil and
// ownDestination (ownaddr.go) compares against the interface addresses only.
// An address delivered locally through a route alone (AnyIP) is not seen here.
// An address delivered locally through a route alone (AnyIP) is not seen here,
// nor a container's or VM's address on a network made on this machine.
+6 -3
View File
@@ -136,7 +136,7 @@ func TestCoderEgressRefusesAnAddressTheKernelDeliversHere(t *testing.T) {
// The anchored egress lane refuses this machine too: an address on its NIC
// and an address a `local` route delivers here, in every spelling, and a
// failed lookup. A neighbour passes, and a local box keeps the lane it had.
// failed lookup. A neighbour passes, and a local box refuses this machine too.
//
// MUTATION: drop the ownDestination check from egressVettedAddr.
// RESULT: red — the machine's own addresses are vetted and dialled.
@@ -161,8 +161,11 @@ func TestAnchoredEgressRefusesThisMachinesOwnAddresses(t *testing.T) {
t.Fatal("a failed route lookup refuses on an anchored box")
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if addr, why := egressVettedAddr(ctx, "198.51.100.7:8080"); why != "" || addr != "198.51.100.7:8080" {
t.Fatalf("a local box keeps its lane: %q %q", addr, why)
stubOwnAddrs(t, []string{"127.0.0.1/8", "198.51.100.7/24"}, localRange("203.0.113.0/24"))
for _, hp := range []string{"198.51.100.7:8080", "203.0.113.5:80"} {
if addr, why := egressVettedAddr(ctx, hp); why == "" || addr != "" {
t.Errorf("%s is this machine and must be refused on a local box too, got %q", hp, addr)
}
}
}
+62 -22
View File
@@ -193,7 +193,7 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi
if boxMode() == modeLocal && len(cfgs) > 0 {
return nil, "", fmt.Errorf("this bastion is in local mode and already serves %s — a local bastion serves one host. "+
"To rotate that host's credentials: `privatae-bastion pair --replace <token>`. "+
"To serve several hosts, reinstall as a networked bastion (install.sh --networked, root) and anchor it.",
"To serve several hosts, reinstall as a networked bastion (install.sh --networked, root) and anchor it",
cfgs[0].HostName)
}
if forceActive {
@@ -231,40 +231,76 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error {
cfg, all = c, fresh
}
}
fmt.Printf("\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName)
fmt.Println("waits for your approval, and any later host waits for this one to approve it.")
fmt.Printf("Enrol your second factor now, so you can approve here:\n %s\n\n", stepupEnrollHint())
}
pairReport(os.Stdout, cfg, all, anchored)
return nil
}
// pairReport is what `pair` prints once the pairing stands. Whatever the form
// (anchored just now, over TCP, over a unix socket, pending, or over the
// tunnel), it ends by saying to enrol a second factor when this pairing has
// none and no approval device is pinned: without one, a local machine installs
// a folder grant on the host's signed grant alone, and an anchored one has no
// way to approve anything the host asks for.
func pairReport(w io.Writer, cfg *Config, all []*Config, anchored bool) {
enrol := ""
if !secondFactorAvailable(cfg.dcnDir()) {
enrol = stepupEnrollHint(cfg.HostName, len(all))
}
defer func() { pairEnrolNote(w, cfg.HostName, enrol) }()
if anchored {
fmt.Fprintf(w, "\nThis machine is networked and anchored to %s: every terminal and every write\n", cfg.HostName)
fmt.Fprintln(w, "waits for your approval, and any later host waits for this one to approve it.")
if enrol != "" {
fmt.Fprintf(w, "Enrol your second factor now, so you can approve here:\n %s\n\n", enrol)
enrol = ""
}
}
if cfg.tcpTransport() && cfg.active() {
fmt.Printf("Paired with %s over TCP.\n\n", cfg.HostName)
fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
fmt.Printf(" local: http://%s\n\nStart (or restart) the bastion:\n privatae-bastion run\n", cfg.ListenAddr)
return nil
fmt.Fprintf(w, "Paired with %s over TCP.\n\n", cfg.HostName)
fmt.Fprintf(w, " lanes: %s (mutual TLS, the Host's CA pinned, name %s)\n coder runs: none over TCP\n", cfg.LaneAddr, tcpServerName)
fmt.Fprintf(w, " local: http://%s\n\nStart (or restart) the bastion:\n privatae-bastion run\n", cfg.ListenAddr)
return
}
if cfg.unixTransport() && cfg.active() {
fmt.Printf("Paired with %s, a Host on this machine.\n\n", cfg.HostName)
fmt.Printf(" lanes: %s (mutual TLS, the Host's CA pinned)\n coder runs: a socket per run in %s\n", cfg.LaneSocket, cfg.RunsDir)
fmt.Printf(" local: http://%s\n\nStart (or restart) the bastion:\n privatae-bastion run\n", cfg.ListenAddr)
return nil
fmt.Fprintf(w, "Paired with %s, a Host on this machine.\n\n", cfg.HostName)
fmt.Fprintf(w, " lanes: %s (mutual TLS, the Host's CA pinned)\n coder runs: a socket per run in %s\n", cfg.LaneSocket, cfg.RunsDir)
fmt.Fprintf(w, " local: http://%s\n\nStart (or restart) the bastion:\n privatae-bastion run\n", cfg.ListenAddr)
return
}
if !cfg.active() {
fmt.Printf("Paired with %s — PENDING.\n\n", cfg.HostName)
fmt.Println("This machine is anchored, so a new host waits until the anchor host approves it.")
fmt.Println("Nothing is started for it yet: no tunnel, no lanes. It shows on the anchor host's")
fmt.Println("Network app as a pending pairing; once approved the daemon opens its lanes by itself.")
return nil
fmt.Fprintf(w, "Paired with %s — PENDING.\n\n", cfg.HostName)
fmt.Fprintln(w, "This machine is anchored, so a new host waits until the anchor host approves it.")
fmt.Fprintln(w, "Nothing is started for it yet: no tunnel, no lanes. It shows on the anchor host's")
fmt.Fprintln(w, "Network app as a pending pairing; once approved the daemon opens its lanes by itself.")
return
}
fmt.Printf("Paired with %s.\n\nOne `privatae-bastion run` serves every pairing:\n", cfg.HostName)
fmt.Fprintf(w, "Paired with %s.\n\nOne `privatae-bastion run` serves every pairing:\n", cfg.HostName)
for _, c := range all {
// The listen address is the CLI's loopback control port for that host
// (add-folder, git-credential). The local secret that guards it is read
// from the pairing file by those verbs and is never shown: there is no
// operator client to hand it to since the MCP relay was removed.
fmt.Printf("\n %s\n local: http://%s\n", c.HostName, c.ListenAddr)
fmt.Fprintf(w, "\n %s\n local: http://%s\n", c.HostName, c.ListenAddr)
}
fmt.Printf("\nStart (or restart) the tunnels:\n privatae-bastion run\n")
fmt.Printf("\ngit to your forge goes through this bastion — one-time setup:\n privatae-bastion git-setup\n")
return nil
fmt.Fprintf(w, "\nStart (or restart) the tunnels:\n privatae-bastion run\n")
fmt.Fprintf(w, "\ngit to your forge goes through this bastion — one-time setup:\n privatae-bastion git-setup\n")
}
// pairEnrolNote says what a pairing with no second factor means on this kind
// of machine, and how to enrol one. hint "" says nothing.
func pairEnrolNote(w io.Writer, host, hint string) {
if hint == "" {
return
}
if currentAnchor() != nil {
fmt.Fprintf(w, "\nNo second factor is enrolled here for %s, so this machine cannot approve what it\n"+
"asks for until you enrol one. Enrol it now:\n %s\n", host, hint)
return
}
fmt.Fprintf(w, "\nNo second factor is enrolled here for %s. Until you enrol one, a folder grant or a\n"+
"move plan from it installs on its signed grant alone, and model pulls and root\n"+
"sessions are refused. Enrol it now, so each needs a code from this machine:\n %s\n", host, hint)
}
// anchorFirstNetworkedPairing anchors a networked machine that has no anchor
@@ -486,6 +522,10 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive
if _, err := hostTunnelAllowedIP(rr.HostTunnelIP); err != nil {
return nil, nil, fmt.Errorf("redeem response: %w", err)
}
// endpoint and endpoints are written into the tunnel's configuration text.
if err := checkEndpoints(append([]string{rr.Endpoint}, rr.Endpoints...)...); err != nil {
return nil, nil, fmt.Errorf("redeem response: %w", err)
}
// Belt: the redeemed host_name is authoritative — if it lands on a pairing the
// pre-redeem handle check missed, still refuse to overwrite it.
if prev := pairingFor(cfgs, rr.HostName); prev != nil {
+137
View File
@@ -0,0 +1,137 @@
//go:build !windows
// Tagged !windows: it borrows setupBox (anchor_test.go) and
// withMachineAuthority (claims_unix_test.go).
package main
// `pair` says to enrol a second factor after every pairing that leaves this
// machine without one, not only after it anchors a networked machine. On a
// local machine a pairing with no factor installs a folder grant on the host's
// signed grant alone, so the person has to be told at the moment they pair.
import (
"crypto/ed25519"
"encoding/base64"
"strings"
"testing"
)
func pairReportOf(cfg *Config, all []*Config, anchored bool) string {
var b strings.Builder
pairReport(&b, cfg, all, anchored)
return b.String()
}
func pairedHere(t *testing.T, host string, set func(*Config)) (*Config, []*Config) {
t.Helper()
all, err := loadPairings()
if err != nil {
t.Fatal(err)
}
cfg := pairingFor(all, host)
if cfg == nil {
t.Fatalf("%s is not paired", host)
}
if set != nil {
set(cfg)
}
return cfg, all
}
func enrolFactor(t *testing.T, cfg *Config) {
t.Helper()
if err := stepupSave(cfg.dcnDir(), &stepupFile{SecretB32: "INVENTEDTESTAAAA", EnrolledAt: "2026-01-02T03:04:05Z"}); err != nil {
t.Fatal(err)
}
if !stepupEnrolled(cfg.dcnDir()) {
t.Fatal("the fixture factor does not read as enrolled")
}
}
// MUTATION: print the hint only when the pairing anchored the machine.
// RESULT: red — the local, TCP, unix and pending forms say nothing.
func TestEveryPairingWithoutAFactorIsToldToEnrolOne(t *testing.T) {
for _, f := range []struct {
name string
set func(*Config)
}{
{"over the tunnel", nil},
{"over TCP", func(c *Config) { c.Transport, c.LaneAddr = transportTCP, "127.0.0.1:8811" }},
{"over a unix socket", func(c *Config) { c.Transport, c.LaneSocket = transportUnix, "/run/privatae-host/lane.sock" }},
} {
t.Run("local machine, "+f.name, func(t *testing.T) {
withMachineAuthority(t, false)
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
writePairing(t, tMember)
cfg, all := pairedHere(t, tMember, f.set)
out := pairReportOf(cfg, all, false)
if !strings.Contains(out, "\n privatae-bastion stepup-enroll\n") {
t.Fatalf("a local pairing with no factor must be told to enrol one, as its own user:\n%s", out)
}
if !strings.Contains(out, "signed grant alone") {
t.Fatalf("the hint says what no factor means here:\n%s", out)
}
enrolFactor(t, cfg)
if out := pairReportOf(cfg, all, false); strings.Contains(out, "stepup-enroll") {
t.Fatalf("a pairing that keeps its factor (a --replace) is not told to enrol again:\n%s", out)
}
})
}
t.Run("anchored by this pairing: said once", func(t *testing.T) {
withMachineAuthority(t, true)
setupBox(t)
cfg, all := pairedHere(t, tAnchor, nil)
out := pairReportOf(cfg, all, true)
if strings.Count(out, "stepup-enroll") != 1 || !strings.Contains(out, "\n sudo -H privatae-bastion stepup-enroll\n") {
t.Fatalf("the anchoring pairing names the factor once, with machine authority:\n%s", out)
}
})
t.Run("a pending host on an anchored machine", func(t *testing.T) {
withMachineAuthority(t, true)
setupBox(t)
writePairingState(t, tMember, pairingPending)
cfg, all := pairedHere(t, tMember, nil)
out := pairReportOf(cfg, all, false)
if !strings.Contains(out, "sudo -H privatae-bastion stepup-enroll --host "+tMember) ||
!strings.Contains(out, "cannot approve") {
t.Fatalf("a second host on an anchored machine is told to enrol a factor for it, by name:\n%s", out)
}
})
t.Run("an approval device pinned is a way to approve", func(t *testing.T) {
withMachineAuthority(t, true)
setupBox(t)
writePairingState(t, tMember, pairingPending)
pub, _, _ := ed25519.GenerateKey(nil)
if err := pinDeviceKey(base64.StdEncoding.EncodeToString(pub)); err != nil {
t.Fatal(err)
}
cfg, all := pairedHere(t, tMember, nil)
if out := pairReportOf(cfg, all, false); strings.Contains(out, "stepup-enroll") {
t.Fatalf("with a device pinned there is a way to approve; no hint:\n%s", out)
}
})
}
// The line is run as whoever ran `pair`, on each platform.
func TestStepupEnrollHintIsRunAsWhoeverPaired(t *testing.T) {
for _, c := range []struct {
goos string
authority bool
paired int
want string
}{
{"linux", false, 1, "privatae-bastion stepup-enroll"},
{"linux", true, 1, "sudo -H privatae-bastion stepup-enroll"},
{"darwin", true, 2, "sudo -H privatae-bastion stepup-enroll --host h1.example.test"},
{"windows", true, 1, "privatae-bastion stepup-enroll (from this Administrator terminal)"},
{"windows", false, 2, "privatae-bastion stepup-enroll --host h1.example.test"},
} {
if got := stepupEnrollHintFor(c.goos, c.authority, "h1.example.test", c.paired); got != c.want {
t.Errorf("%s authority=%v paired=%d: %q, want %q", c.goos, c.authority, c.paired, got, c.want)
}
}
}
+2 -2
View File
@@ -61,8 +61,8 @@ func TestTheOperatorCSRNamesTheFingerprintForItsSigner(t *testing.T) {
// platformSignerRefusal is the platform's rule for the SANs of an operator
// CSR it signs (the bridge's _checked_operator_csr): one name, a URI,
// urn:skema:hwfp: and a fingerprint of 16 to 128 base64url characters.
// Anything else is refused before anything is signed.
// the device-fingerprint prefix (fpSANPrefix) and a fingerprint of 16 to 128
// base64url characters. Anything else is refused before anything is signed.
var platformFPRe = regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`)
func platformSignerRefusal(csr *x509.CertificateRequest) string {
+15
View File
@@ -114,6 +114,14 @@ func handlePairingGrantSync(ws *websocket.Conn, _ string, args []string) {
pairingRefuse(ws, "grant_sync", t.Subject, "subject pairing was revoked — pair it again to re-approve")
return
}
// Approving would mark it active and start it with the endpoint its file
// holds, which this build refuses. The reason names a local file, so the
// host is told only what to do.
if c.invalid != "" {
anchorMu.Unlock()
pairingRefuse(ws, "grant_sync", t.Subject, "subject pairing is not served on this box: its file holds an endpoint this bastion refuses — pair it again to approve it")
return
}
if a := currentAnchor(); a != nil && a.AnchorHost == c.HostName {
anchorMu.Unlock()
pairingRefuse(ws, "grant_sync", t.Subject, "subject is the anchor itself; the anchor holds no grant")
@@ -143,6 +151,10 @@ func handlePairingGrantSync(ws *websocket.Conn, _ string, args []string) {
started := false
if wasPending {
started = supervisorStart(c)
// Every Host on this box reads this pairing's state from a beat, so
// each beats now rather than up to a minute from now (beatnow.go). A
// re-sync of an active pairing changes no state, so it beats nothing.
kickBeats()
}
pairingResult(ws, "grant_sync", c.HostName, map[string]any{
"state": "ok", "subject": c.HostName, "caps": sortedCaps(t.Caps),
@@ -209,6 +221,9 @@ func handlePairingGrantDrop(ws *websocket.Conn, _ string, args []string) {
}
anchorMu.Unlock()
stopped := stopPairing(subject)
// Every other Host on this box learns of the revoke on its next beat, which
// is now rather than up to a minute from now (beatnow.go).
kickBeats()
// A revoked pairing keeps no folder the person shared with it.
sharesDropped := dropSharesOf(subject) == nil
pairingResult(ws, "grant_drop", subject, map[string]any{
+4 -1
View File
@@ -57,7 +57,10 @@ func peersFor(selfHostName string) []peerAnnounce {
}
out := make([]peerAnnounce, 0, len(cfgs))
for _, c := range cfgs {
if c.HostName == "" || c.HostName == selfHostName {
// A pairing whose file this build refuses is never served, so it is
// no peer; pairingsReport (anchor.go) says why it is left out rather
// than sent as "invalid".
if c.HostName == "" || c.HostName == selfHostName || c.invalid != "" {
continue
}
// "Since" is first_seen when the pairing carries it (0.16.0+), else the
+24 -9
View File
@@ -139,13 +139,28 @@ func restartHintFor(goos string) string {
return "Restart the service so the change takes: systemctl restart privatae-bastion"
}
// stepupEnrollHint is the stepup-enroll line to print after a networked
// machine is anchored: approvals on it need a second factor or a device.
func stepupEnrollHint() string { return stepupEnrollHintFor(runtime.GOOS) }
func stepupEnrollHintFor(goos string) string {
if goos == "windows" {
return "privatae-bastion stepup-enroll (from this Administrator terminal)"
}
return "sudo -H privatae-bastion stepup-enroll"
// stepupEnrollHint is the stepup-enroll line `pair` prints for a pairing with
// no second factor. It is run as whoever ran `pair`, since that is whose state
// the pairing is in: with machine authority (an anchored or networked machine,
// a root install) through sudo -H or this Administrator terminal, and as the
// person's own account on a local install. --host is named when more than one
// host is paired, as stepup-enroll then requires.
func stepupEnrollHint(host string, paired int) string {
return stepupEnrollHintFor(runtime.GOOS, machineAuthorityHeld(), host, paired)
}
func stepupEnrollHintFor(goos string, authority bool, host string, paired int) string {
cmd := "privatae-bastion stepup-enroll"
if paired > 1 {
cmd += " --host " + host
}
switch {
case goos == "windows" && authority:
return cmd + " (from this Administrator terminal)"
case goos == "windows":
return cmd
case authority:
return "sudo -H " + cmd
}
return cmd
}
+153 -13
View File
@@ -1,7 +1,6 @@
package main
import (
"bufio"
"bytes"
"os"
"os/exec"
@@ -18,7 +17,10 @@ import (
// harness images, or the host still read or write under that name. Each is
// listed in keptOldNames with what holds it in place; removing one means
// changing the other side first. Prose, comments and the changelog are never
// exceptions.
// exceptions: a kept identifier is set aside only in Go code, outside its
// comments (goCodeAndComments). In a comment, a Markdown file, a script or
// any other file it is a hit like the bare word, and is described instead
// (the device-fingerprint prefix, fpSANPrefix in pair.go).
//
// The forbidden word is assembled from bytes so that this file does not
// contain it outside the exception list.
@@ -75,6 +77,118 @@ func isNameByte(c byte) bool {
return c == '_' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
// hasOldName reports whether text carries the old name at all, kept
// identifiers included.
func hasOldName(text string) bool {
return strings.Contains(strings.ToLower(text), oldName)
}
// goCodeAndComments splits Go source into its lines twice over: code[i] is
// line i with its comment text blanked, comments[i] the same line with
// everything but comment text blanked. String, raw string and rune literals
// are code, so a "//" inside one does not start a comment, and a raw string
// spanning lines stays code on every line it covers.
func goCodeAndComments(src string) (code, comments []string) {
const (
inCode = iota
inLineComment
inBlockComment
inString
inRawString
inRune
)
codeB := []byte(src)
commB := []byte(src)
state := inCode
for i := 0; i < len(src); i++ {
c := src[i]
isComment := false
switch state {
case inCode:
switch {
case c == '/' && i+1 < len(src) && src[i+1] == '/':
state, isComment = inLineComment, true
case c == '/' && i+1 < len(src) && src[i+1] == '*':
// Both opener bytes are taken here: the opener's '*' cannot
// also start the closer, so "/*/" leaves the comment open.
codeB[i], codeB[i+1] = ' ', ' '
i++
state = inBlockComment
continue
case c == '"':
state = inString
case c == '`':
state = inRawString
case c == '\'':
state = inRune
}
case inLineComment:
if c == '\n' {
state = inCode
} else {
isComment = true
}
case inBlockComment:
isComment = true
if c == '*' && i+1 < len(src) && src[i+1] == '/' {
codeB[i], codeB[i+1] = ' ', ' '
i++
state = inCode
continue
}
case inString, inRune:
end := byte('"')
if state == inRune {
end = '\''
}
switch {
case c == '\\' && i+1 < len(src) && src[i+1] != '\n':
commB[i], commB[i+1] = ' ', ' '
i++
continue
case c == end, c == '\n':
state = inCode
}
case inRawString:
if c == '`' {
state = inCode
}
}
if c == '\n' {
continue
}
if isComment {
codeB[i] = ' '
} else {
commB[i] = ' '
}
}
return strings.Split(string(codeB), "\n"), strings.Split(string(commB), "\n")
}
// oldNameHits lists, as "line: text", every line of the published file rel
// that carries the old name. Kept identifiers are set aside only in the code
// of a Go file; in its comments and in every other file they count.
func oldNameHits(rel, src string) []string {
lines := strings.Split(src, "\n")
var hits []string
if !strings.HasSuffix(rel, ".go") {
for i, l := range lines {
if hasOldName(l) {
hits = append(hits, strconv.Itoa(i+1)+": "+strings.TrimSpace(l))
}
}
return hits
}
code, comments := goCodeAndComments(src)
for i, l := range lines {
if lineHasOldName(code[i]) || hasOldName(comments[i]) {
hits = append(hits, strconv.Itoa(i+1)+": "+strings.TrimSpace(l))
}
}
return hits
}
// publishedFiles lists the files the member repository receives: what git
// tracks at the repository root (the publisher stages `git archive` of the
// release commit). Outside a git checkout it walks the tree instead.
@@ -127,19 +241,14 @@ func TestPublishedSourceCarriesNoOldInternalName(t *testing.T) {
if err != nil {
continue // a tracked file deleted in the working tree
}
sc := bufio.NewScanner(bytes.NewReader(data))
sc.Buffer(make([]byte, 0, 1<<20), 1<<24)
n := 0
for sc.Scan() {
n++
if lineHasOldName(sc.Text()) {
hits = append(hits, rel+":"+strconv.Itoa(n)+": "+strings.TrimSpace(sc.Text()))
}
for _, h := range oldNameHits(rel, string(data)) {
hits = append(hits, rel+":"+h)
}
}
if len(hits) > 0 {
t.Errorf("the old internal name appears in %d published lines. Reword prose; an identifier "+
"may be added to keptOldNames only when something outside this repository still reads it:\n %s",
t.Errorf("the old internal name appears in %d published lines. Reword prose and comments; an "+
"identifier may be added to keptOldNames only when something outside this repository still "+
"reads it, and is set aside only in Go code:\n %s",
len(hits), strings.Join(hits, "\n "))
}
}
@@ -150,7 +259,7 @@ func TestOldNameCheckCatchesProseAndPassesKeptIdentifiers(t *testing.T) {
up := strings.ToUpper(oldName)
title := strings.ToUpper(oldName[:1]) + oldName[1:]
for _, line := range []string{
"// see " + oldName + " #649",
"// see " + oldName + " for the rest",
"// the " + title + " server",
up + "_NEW_VAR",
keptOldNames[0] + "X", // a kept name with a suffix is a different name
@@ -173,3 +282,34 @@ func TestOldNameCheckCatchesProseAndPassesKeptIdentifiers(t *testing.T) {
}
}
}
// Where a kept identifier is set aside: in Go code only. In a Go comment, the
// changelog, any other Markdown file or a script it is a hit like the bare
// word. A "//" inside a string, a raw string spanning lines and an escaped
// quote do not move the line between code and comment.
func TestKeptIdentifiersAreSetAsideOnlyInGoCode(t *testing.T) {
prefix := keptOldNames[len(keptOldNames)-1] // the fingerprint prefix
env := keptOldNames[0]
for _, c := range []struct {
rel, src string
hit bool
}{
{"CHANGELOG.md", "- the fingerprint goes under `" + prefix + "<fp>`, as before\n", true},
{"docs/claims.md", "the agent token is read from " + env + "\n", true},
{"deploy/run.sh", "export " + env + "=x\n", true},
{"a.go", "package a\n\n// names it " + prefix + "<fp>\n", true},
{"a.go", "x := 1 /* " + env + " */\n", true},
{"a.go", "x := 1 /*/ " + env + " */\n", true},
{"a.go", "s := \"a\\\"b\" // " + env + "\n", true},
{"a.go", "const p = \"" + prefix + "\"\n", false},
{"a.go", "v := []string{\"" + env + "\"} // the host mints this tag\n", false},
{"a.go", "u := \"https://example.com/" + env + "\"\n", false},
{"a.go", "y := `\nname: x // " + env + "\n`\n", false},
{"a.go", "r := '\"' + 1 // " + env + "\n", true},
} {
got := oldNameHits(c.rel, c.src)
if (len(got) > 0) != c.hit {
t.Errorf("%s %q: hits %q, want a hit: %v", c.rel, c.src, got, c.hit)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
// A "#NNN" in this repository names a pull request or an issue of the
// development repository, which a reader of the published source cannot open.
// On a forge that links such numbers it points at an unrelated item of the
// published repository instead. A comment or a changelog entry says why
// something is so in the sentence itself.
// Six or more digits is a colour (#000000), not a number anyone cites.
var prNumberRe = regexp.MustCompile(`(^|[^&A-Za-z0-9_])#[0-9]{2,5}\b`)
func TestPublishedSourceCitesNoPullRequestNumber(t *testing.T) {
root, err := filepath.Abs("..")
if err != nil {
t.Fatal(err)
}
files := publishedFiles(t, root)
if len(files) < 50 {
t.Fatalf("found only %d files under %s; the scan is not looking at the repository", len(files), root)
}
var hits []string
for _, rel := range files {
data, err := os.ReadFile(filepath.Join(root, rel))
if err != nil {
continue // a tracked file deleted in the working tree
}
for i, line := range strings.Split(string(data), "\n") {
if prNumberRe.MatchString(line) {
hits = append(hits, rel+":"+strconv.Itoa(i+1)+": "+strings.TrimSpace(line))
}
}
}
if len(hits) > 0 {
t.Errorf("%d published lines cite a pull request or issue by number; state the reason instead:\n %s",
len(hits), strings.Join(hits, "\n "))
}
}
func TestPullRequestNumberCheck(t *testing.T) {
for line, want := range map[string]bool{
"// refused here (review of #" + "100).": true,
"see #" + "1234 for the rest": true,
"#" + "42 at the start": true,
"## 0.55.0": false,
"&#" + "123; an HTML entity": false,
"color: #fff": false,
`stop-color="#000000"`: false,
"step #1 of 3": false,
"x#" + "12 glued to a name": false,
} {
if got := prNumberRe.MatchString(line); got != want {
t.Errorf("%q: matched %v, want %v", line, got, want)
}
}
}
+4
View File
@@ -125,6 +125,10 @@ func setupBoxKeep(t *testing.T) ed25519.PrivateKey {
// before any dial; the anchor's request is dialled.
func TestEgressLaneGatedByCaps(t *testing.T) {
grantMember(t, []string{"observe"})
// The name and this machine's addresses are answered here, so the anchor's
// request is vetted the same way on a box with no network or DNS.
answerEgressNames(t, map[string][]string{"example.com": {"203.0.113.10"}})
stubOwnAddrs(t, []string{"127.0.0.1/8", "::1/128"}, nil)
dialed := false
dialOut := func(ctx context.Context, network, addr string) (net.Conn, error) {
dialed = true
+29 -5
View File
@@ -85,7 +85,9 @@ var (
rootExecMaxOutput = 4 << 20
rootExecMinOutput = 1 << 10
rootExecTranscriptMax = int64(64 << 20) // the local record keeps far more than the host sees
rootExecMaxGrantTTL = 365 * 24 * time.Hour
// The longest a root-command grant lasts before it has to be given again
// at this machine with the code.
rootExecMaxGrantTTL = 30 * 24 * time.Hour
)
// rootExecCommandMax bounds the command text itself: a script, not a payload.
@@ -134,6 +136,24 @@ type rootExecGrant struct {
RevokedAt string `json:"revoked_at,omitempty"`
}
// ends is when the grant stops admitting: its stated expiry, and never later
// than the longest term after it was given, so a grant written when the
// longest term was longer ends on today's rule.
func (g *rootExecGrant) ends() (time.Time, error) {
exp, err := time.Parse(time.RFC3339, g.Expires)
if err != nil {
return time.Time{}, err
}
at, err := time.Parse(time.RFC3339, g.GrantedAt)
if err != nil {
return time.Time{}, err
}
if limit := at.Add(rootExecMaxGrantTTL); exp.After(limit) {
exp = limit
}
return exp, nil
}
var rootExecMu sync.Mutex
func rootExecGrantPath(dir string) string { return filepath.Join(dir, "root_exec_grant.json") }
@@ -189,12 +209,12 @@ func rootExecGrantCheck(dir, self, server string, now time.Time) string {
if g.Server == "" || g.Server != server {
return fmt.Sprintf("the root-command grant is for machine '%s', this machine is '%s'", g.Server, server)
}
exp, err := time.Parse(time.RFC3339, g.Expires)
exp, err := g.ends()
if err != nil {
return "the root-command grant states no expiry (open-ended terms are refused)"
return "the root-command grant states no expiry or no grant time (open-ended terms are refused)"
}
if !exp.After(now) {
return "the root-command grant expired at " + g.Expires + " — " + rootExecGrantHowTo(self)
return "the root-command grant expired at " + exp.UTC().Format(time.RFC3339) + " — " + rootExecGrantHowTo(self)
}
return ""
}
@@ -310,7 +330,11 @@ func cmdRootExec(args []string) error {
} else if why := rootExecGrantCheck(dir, cfg.HostName, server, time.Now()); why != "" {
state = why
}
fmt.Printf("root commands for %s on %s: %s\n granted %s, until %s\n", g.Host, g.Server, state, g.GrantedAt, g.Expires)
until := g.Expires
if exp, err := g.ends(); err == nil {
until = exp.UTC().Format(time.RFC3339)
}
fmt.Printf("root commands for %s on %s: %s\n granted %s, until %s\n", g.Host, g.Server, state, g.GrantedAt, until)
fmt.Println(" every command: " + filepath.Join(cfg.contextRoot(), "sessions") + "/<id>.log and audit.jsonl")
fmt.Println(" " + rootExecOwnHostsLine())
fmt.Println(" take it back: privatae-bastion root-exec revoke --host " + cfg.HostName)

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