Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a5c310482 | |||
| 01889e2a48 |
+153
-5
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
All notable changes to the bastion.
|
All notable changes to the bastion.
|
||||||
|
|
||||||
The version in `deploy/VERSION` on `main` is the next release, not necessarily
|
The published version is whatever <https://privatae.ai/bastion/version.json>
|
||||||
the one machines run. A merged change waits there until it is published. The
|
names at the moment, and it is the only version a bastion will update to. This
|
||||||
published version is whatever <https://privatae.ai/bastion/version.json> names
|
public repository receives each release after it is published, so its
|
||||||
at the moment, and it is the only version a bastion will update to. Between a
|
`deploy/VERSION` can trail that manifest.
|
||||||
merge and a publish, `main` is ahead of the manifest.
|
|
||||||
|
|
||||||
Two things can keep a version listed here from reaching most machines:
|
Two things can keep a version listed here from reaching most machines:
|
||||||
|
|
||||||
@@ -23,6 +22,155 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
|
|||||||
that is settled. The newest entries may not be published yet; the manifest
|
that is settled. The newest entries may not be published yet; the manifest
|
||||||
says which version is. Older entries are not marked.
|
says which version is. Older entries are not marked.
|
||||||
|
|
||||||
|
## 0.58.1
|
||||||
|
|
||||||
|
- **An excluded path that names more than one folder now matches.** A
|
||||||
|
shared folder's grant lists paths no Host may read, and each was compared
|
||||||
|
with one folder name at a time, so a path written with a slash never
|
||||||
|
matched. The Host's own default list includes `.config/gcloud`, so a
|
||||||
|
shared home folder served the Google Cloud command line's sign-in files
|
||||||
|
through list, stat and read_range. A path such as `.config/gcloud` now
|
||||||
|
matches that run of folders anywhere under the shared folder, with names
|
||||||
|
compared the way this machine's file system compares them, as one-folder
|
||||||
|
paths already were. Every check that reads the excluded paths uses it:
|
||||||
|
each folder op, the listing walk and both checks on a move plan.
|
||||||
|
- **The local search agent's own folder is never served.** The agent keeps
|
||||||
|
its extracted text, its sanitized text, its vectors, its catalog and its
|
||||||
|
sealed bundles in `.agent` inside the shared folder, and a Host allowed to
|
||||||
|
read the folder could list it and read the extracted text as it is. List,
|
||||||
|
stat, read_range and move plans now refuse `.agent` at any depth and in
|
||||||
|
any case, however the path is written or reached through a link, and say
|
||||||
|
why. The bastion's own plan reports still go to `.agent/plans`.
|
||||||
|
- **What flag mode replaces is said exactly.** The note a Host passes on
|
||||||
|
for a folder searched in flag mode said card, bank and ID numbers were
|
||||||
|
replaced. It now names what is replaced (e-mail addresses, phone numbers,
|
||||||
|
card and US social security numbers, IBANs, US routing numbers after a
|
||||||
|
word such as "routing", Bitcoin addresses that start with 1 or 3, IP and
|
||||||
|
MAC addresses, private keys and known token formats) and what is never
|
||||||
|
looked for (names, street addresses, dates of birth, passport, licence
|
||||||
|
and other ID numbers, other bank account numbers and other crypto
|
||||||
|
addresses).
|
||||||
|
- **The documentation says what listens and what each claim's test checks.**
|
||||||
|
The tunnel holds one UDP port, chosen by the system, which answers nothing
|
||||||
|
that is not from the paired Host's key; every other port is on loopback.
|
||||||
|
A coder run reaches nothing on this machine or your network that you did
|
||||||
|
not name with `--allow-lan`. Nine rows of `docs/claims.md` now say only
|
||||||
|
what their tests assert. The commands page lists every command, and the
|
||||||
|
install page the one-line pairing for a Cloud Host. `git-setup` no longer
|
||||||
|
says the forge can be reached only through the bastion.
|
||||||
|
|
||||||
|
## 0.58.0
|
||||||
|
|
||||||
|
- **A Host answers four common questions about a machine itself, instead of
|
||||||
|
handing the person a command to run.** Each is a read-only observe op,
|
||||||
|
granted under `observe` like the rest of the family and listed in the ops
|
||||||
|
the machine advertises, so a grant can name it. Each value is checked
|
||||||
|
against a written rule before anything is read or run, and a refusal says
|
||||||
|
which rule it broke without repeating the value. Off Linux each answers
|
||||||
|
`unsupported`.
|
||||||
|
- `observe:folders <path> [depth]`: the 25 largest folders under an
|
||||||
|
absolute path, 1 to 3 levels down (2 when not given), and the total, on
|
||||||
|
that path's own filesystem only, counted the way `du -x` counts (disk
|
||||||
|
blocks, a file with several links once). Only folders are named, never a
|
||||||
|
file. What could not be read, and filesystems mounted under the path, are
|
||||||
|
listed against the folder that holds them, with a reason and a count.
|
||||||
|
`/proc`, `/sys`, `/dev`, a relative path, a path containing `..` and a
|
||||||
|
link that leads into those are refused. A walk that runs out of the
|
||||||
|
20-second budget answers with what it measured and says every size is at
|
||||||
|
least that. The walk follows no link: a folder swapped for one while it
|
||||||
|
reads, or reached through one put above it, is listed as changed and not
|
||||||
|
counted. A read that never returns (a network mount that stopped
|
||||||
|
answering) is answered without at the budget, and while four such reads
|
||||||
|
are still waiting no new walk starts.
|
||||||
|
- `observe:journal <unit> [minutes]`: one systemd unit's journal for the
|
||||||
|
last 1 to 60 minutes (15 when not given), up to 5000 lines and 200 KB,
|
||||||
|
newest kept. A name with no type is a service, as with `systemctl`. The
|
||||||
|
name must be in systemd's unit-name alphabet and one this machine has,
|
||||||
|
loaded now or installed; otherwise it is refused, with the units whose
|
||||||
|
names come close. Until now a Host could read logs only from a Loki,
|
||||||
|
which most machines do not run, so it asked the person to run
|
||||||
|
`journalctl` instead.
|
||||||
|
- `observe:logins [hours]`: sshd's failed and accepted logins over the last
|
||||||
|
1 to 168 hours (24 when not given). Failed attempts are counted and ranked
|
||||||
|
by source address and by user name, 20 of each; the last 20 accepted
|
||||||
|
logins come with user, source, method and time. A user name the other
|
||||||
|
side sent is cut to 32 characters, and anything outside the characters
|
||||||
|
account names use reads `?`. No raw line, key fingerprint or password is
|
||||||
|
returned. Only lines written by sshd running as root are read, so another
|
||||||
|
account on the machine cannot add login lines or bury the real ones under
|
||||||
|
its own.
|
||||||
|
- `observe:cert <port> [server name]`: the certificate this machine's own
|
||||||
|
listener on a TCP port serves: subject, names, issuer, dates, days left,
|
||||||
|
and whether the chain verifies against this machine's roots for that
|
||||||
|
name. The port must be one `ss` shows listening here. The handshake goes
|
||||||
|
to loopback first and otherwise only to an address of this machine the
|
||||||
|
listener is bound to, never to one another machine could hold.
|
||||||
|
- **A journal the bastion cannot read is said, not shown as empty.**
|
||||||
|
`journalctl` run by an account outside the journal's groups shows only that
|
||||||
|
account's own lines, which would look like a quiet machine. `observe:journal`
|
||||||
|
and `observe:logins` first check that the bastion can read the system
|
||||||
|
journal (as root, which is how the networked install runs it, or as a member
|
||||||
|
of `systemd-journal`) and otherwise answer with the reason;
|
||||||
|
`observe:logins` then reports its counts as -1, never 0.
|
||||||
|
- **A grant from now on can be held to a window and a rate, by the
|
||||||
|
machine.** An op grant's bounds may now carry `_window` (days, from, to and
|
||||||
|
a zone, read on the machine's own clock; a window may wrap midnight) and
|
||||||
|
`_rate` (at most so many applies in any 24 hours, and so many seconds
|
||||||
|
between two). An apply outside them does not run, spends no use and is no
|
||||||
|
strike toward the quarantine: the answer is `not_now`, exit 75, with the
|
||||||
|
bound that held it and when it next could run. A dry run is never held, and
|
||||||
|
an apply with arguments outside the op is still refused as before. The
|
||||||
|
zone database is in the binary, so a zone reads the same on every machine.
|
||||||
|
An older bastion refuses both keys as naming no param, so a grant carrying
|
||||||
|
them fails closed there; the enforcing report says `grant_run_bounds` on a
|
||||||
|
bastion that holds them.
|
||||||
|
- **The machine keeps the runs, and who approved.** Each grant's record in
|
||||||
|
`ops.json` now holds the times of the applies that spent a use, the key
|
||||||
|
that approved it (the approval device's key, or the factor enrolled at this
|
||||||
|
console) and the machine's own seal over the record. The rate is counted
|
||||||
|
from that log, so it holds across a restart. A record edited outside the
|
||||||
|
bastion holds no grant until it is dropped and approved again, and so does
|
||||||
|
a grant installed by an older bastion (none was held when this shipped). A
|
||||||
|
grant whose approving key is no longer pinned is refused; `unpin-device`
|
||||||
|
drops every grant that key approved, so pinning it again brings none back.
|
||||||
|
- **`op_grant_drop <grant | all>` takes a grant back from the Host.** It is
|
||||||
|
a tighten: no quarantine holds it and no approval is asked. A grant not
|
||||||
|
held here is already gone and is answered as dropped. At the machine,
|
||||||
|
`ops drop --all` takes back every op grant a Host holds, beside `ops drop
|
||||||
|
<grant>`.
|
||||||
|
- **An action op may have no program.** With `interpreter: none` and no
|
||||||
|
`run` file, an action op's verbs are its whole work. Nothing the Host wrote
|
||||||
|
executes, so it needs no sandbox and runs on a machine with no overlay. Its
|
||||||
|
dry run is the verbs' own read of what each would move, and its apply the
|
||||||
|
verbs and the log checks. Such an op that ships a `run` file is refused,
|
||||||
|
as is one that names destinations, a builder, reads, or a credential no
|
||||||
|
verb uses. An op with a program is still refused where the sandbox is not.
|
||||||
|
A verb whose tool the machine lacks refuses the run as the machine's own
|
||||||
|
state, never a strike, and the enforcing report now says, for each verb,
|
||||||
|
whether this machine can run it (`verbs`).
|
||||||
|
- **On your own machine, an action op reaches the internet, never your
|
||||||
|
network.** On a machine no fleet has anchored, an action op's destination
|
||||||
|
is never a private, ULA, CGNAT or reserved address (also inside NAT64 or
|
||||||
|
6to4): one written as an address is refused when the op arrives, and a
|
||||||
|
name is refused at apply if any address it resolves to is one. Another
|
||||||
|
computer of yours is reached through a bastion on that computer. An
|
||||||
|
anchored machine keeps private destinations, since a fleet's ship steps
|
||||||
|
reach its own private control planes. A verb's fetch is held the same way
|
||||||
|
(`tree.checkout` fetching its tree's repository, `k8s.set_image` fetching
|
||||||
|
its source to check ancestry): the repository's host is checked once and
|
||||||
|
git is held to that address, so an op with no program reaches no more than
|
||||||
|
one with a program. On an anchored machine too a verb's fetch is held to
|
||||||
|
its checked address and is never this machine, its loopback, a link-local
|
||||||
|
address or a cloud's metadata service. An image `k8s.set_image` would put
|
||||||
|
live, which the machine's container runtime pulls by name, is refused on
|
||||||
|
your own machine when its registry is on your network or this machine,
|
||||||
|
both when a program reports it and when the verb would set it.
|
||||||
|
`k8s.set_image` now says it needs git as well as kubectl, so a machine
|
||||||
|
without git is told before a run. What stops working: a ship step on your
|
||||||
|
own unanchored bastion that deploys to another computer on your LAN,
|
||||||
|
fetches from a forge on it or rolls an image from a registry on it; and a
|
||||||
|
verb's fetch needs git 2.37 or newer, which holds a fetch to one address.
|
||||||
|
|
||||||
## 0.57.0
|
## 0.57.0
|
||||||
|
|
||||||
- **Pairing with a Cloud Host is one pasted line.** A Host's "Set up a
|
- **Pairing with a Cloud Host is one pasted line.** A Host's "Set up a
|
||||||
|
|||||||
+4
-4
@@ -38,10 +38,10 @@ The backup tests also check this code against the age command itself when
|
|||||||
one is on PATH or named by `AGE_BIN`; CI installs a pinned age release and
|
one is on PATH or named by `AGE_BIN`; CI installs a pinned age release and
|
||||||
sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip.
|
sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip.
|
||||||
|
|
||||||
The test suite covers all three platforms' parsing and judgement logic from
|
Most parsing and judgement logic is in files with no build tag and runs under
|
||||||
one `go test` on any OS. Platform-specific decisions live in files with no
|
one `go test` on any OS. Some platform parsers (on Linux and macOS) and the
|
||||||
build tag; only the enumerators are tagged. Keep it that way: a new judgement
|
Linux sandbox are in tagged files, and their tests run only on that OS. A new
|
||||||
goes in an untagged file with a table test. Check it with
|
judgement goes in an untagged file with a table test. Check it with
|
||||||
`GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` as well: `go build`
|
`GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` as well: `go build`
|
||||||
does not compile test files, so only vet sees a test that uses a Linux-only
|
does not compile test files, so only vet sees a test that uses a Linux-only
|
||||||
name.
|
name.
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ gates. Your files stay where they are. Nothing on this machine drives the host:
|
|||||||
there is no MCP endpoint here.
|
there is no MCP endpoint here.
|
||||||
|
|
||||||
The published release is whatever `privatae.ai/bastion/version.json` names.
|
The published release is whatever `privatae.ai/bastion/version.json` names.
|
||||||
`deploy/VERSION` on `main` is the next release, which may not be published
|
This repository receives each release after it is published, so
|
||||||
yet. From 0.42.0 on, every release manifest is signed and every published
|
`deploy/VERSION` here can trail it. From 0.42.0 on, every release manifest is
|
||||||
binary can be rebuilt from this source and compared
|
signed and every published binary can be rebuilt from this source and compared
|
||||||
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
|
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -71,10 +71,12 @@ A Privatae host is an AI that works for you. To be useful it needs to see your
|
|||||||
files, use your machine's models, and sometimes act on the machine. The bastion
|
files, use your machine's models, and sometimes act on the machine. The bastion
|
||||||
is the piece that makes that possible **without handing the host your machine**.
|
is the piece that makes that possible **without handing the host your machine**.
|
||||||
|
|
||||||
The bastion dials your host; your host never dials your machine. No port is
|
The bastion dials your host; your host never dials your machine. The only
|
||||||
opened on any externally reachable interface (the control ports and the local
|
ports it holds open on an outside interface are its tunnels' WireGuard UDP
|
||||||
console bind loopback only; the optional memory-mirror forwarder also binds
|
ports, one per host it reaches through a tunnel, each at a port the system
|
||||||
the replica container's private gateway address). The only thing your host can reach is the
|
picks, and WireGuard drops every packet there that does not come from that
|
||||||
|
host's key. Every other port it opens binds loopback (the optional memory-mirror
|
||||||
|
forwarder also binds the replica container's private gateway address). The only thing your host can reach is the
|
||||||
inside of the tunnel this binary brought up, and what it may do there arrives
|
inside of the tunnel this binary brought up, and what it may do there arrives
|
||||||
as a signed grant that this binary verifies locally. Once you enrol a second
|
as a signed grant that this binary verifies locally. Once you enrol a second
|
||||||
factor on this machine, anything that would move your files, download a model,
|
factor on this machine, anything that would move your files, download a model,
|
||||||
@@ -248,7 +250,8 @@ its own page, listed with one line each in [docs/README.md](docs/README.md):
|
|||||||
Each point in full: [docs/security-model.md](docs/security-model.md); the link after each goes to its detail.
|
Each point in full: [docs/security-model.md](docs/security-model.md); the link after each goes to its detail.
|
||||||
|
|
||||||
- **The bastion dials out.** Every lane is a reverse connection to the host;
|
- **The bastion dials out.** Every lane is a reverse connection to the host;
|
||||||
no port is opened on an externally reachable interface. ([lanes](docs/lanes.md))
|
the only ports open on an outside interface are the tunnels' WireGuard
|
||||||
|
ports, which drop everything not from the host's key. ([lanes](docs/lanes.md))
|
||||||
- **Inside the tunnel, only gated listeners**: up to three inference forwards,
|
- **Inside the tunnel, only gated listeners**: up to three inference forwards,
|
||||||
one port per service-slot server and one per coder run, each checked when a
|
one port per service-slot server and one per coder run, each checked when a
|
||||||
connection is accepted. ([tunnel](docs/lanes.md#tunnel))
|
connection is accepted. ([tunnel](docs/lanes.md#tunnel))
|
||||||
@@ -276,10 +279,11 @@ Each point in full: [docs/security-model.md](docs/security-model.md); the link a
|
|||||||
and never change anything outside their declared file effects. ([ops](docs/ops.md))
|
and never change anything outside their declared file effects. ([ops](docs/ops.md))
|
||||||
- **A server in the service slot has no network.** The host's tunnel is its
|
- **A server in the service slot has no network.** The host's tunnel is its
|
||||||
only way in. ([service slot](docs/service-slot.md))
|
only way in. ([service slot](docs/service-slot.md))
|
||||||
- **Egress for someone else reaches the public internet, never this
|
- **Egress for someone else reaches the public internet, and nothing on this
|
||||||
machine**: a coder run's proxy and an anchored machine's egress lane refuse
|
machine or your network that you did not name**: a coder run's proxy and an
|
||||||
internal addresses and this machine's own (a coder run reaches a LAN host
|
anchored machine's egress lane refuse internal addresses and this machine's
|
||||||
you named). A local machine's egress lane refuses this machine and your
|
own (a coder run reaches a host you named with `coders set --allow-lan`,
|
||||||
|
even when it is this machine or on your LAN). A local machine's egress lane refuses this machine and your
|
||||||
LAN too; your own machines are reached through their approved lanes. An address that reaches this machine only through a 1:1 NAT is
|
LAN too; your own machines are reached through their approved lanes. An address that reaches this machine only through a 1:1 NAT is
|
||||||
not covered. ([full text](docs/security-model.md))
|
not covered. ([full text](docs/security-model.md))
|
||||||
- **A terminal is checked here and logged.** On a local machine it opens when
|
- **A terminal is checked here and logged.** On a local machine it opens when
|
||||||
@@ -298,6 +302,10 @@ Each point in full: [docs/security-model.md](docs/security-model.md); the link a
|
|||||||
that one backup. ([backups](docs/backups.md), [restoring a lost host](docs/backups.md#restoring-a-lost-host))
|
that one backup. ([backups](docs/backups.md), [restoring a lost host](docs/backups.md#restoring-a-lost-host))
|
||||||
- **No delete op on shared stores.** The one write lane is a move plan you
|
- **No delete op on shared stores.** The one write lane is a move plan you
|
||||||
approved, and it archives. ([DCN stores](docs/context.md#distributed-context-network-dcn-stores))
|
approved, and it archives. ([DCN stores](docs/context.md#distributed-context-network-dcn-stores))
|
||||||
|
- **A shared folder serves only what its grant leaves in.** Every read and
|
||||||
|
move is held to the grant's excludes, `.config/gcloud` and other patterns of
|
||||||
|
several folders included, and the search agent's own folder (`.agent`) is
|
||||||
|
never read or moved. ([DCN stores](docs/context.md#distributed-context-network-dcn-stores))
|
||||||
- **Verified releases.** The self-updater checks the manifest's signature and
|
- **Verified releases.** The self-updater checks the manifest's signature and
|
||||||
the binary's SHA-256 before swapping; the release key never reaches the
|
the binary's SHA-256 before swapping; the release key never reaches the
|
||||||
build machine. The installer checks the SHA-256 ([SECURITY.md](SECURITY.md)
|
build machine. The installer checks the SHA-256 ([SECURITY.md](SECURITY.md)
|
||||||
@@ -324,9 +332,9 @@ Development happens in a private repository, and its history is not
|
|||||||
published. When a release is published, each change merged since the last one
|
published. When a release is published, each change merged since the last one
|
||||||
arrives on GitHub as a commit with the same files, titled as the change was
|
arrives on GitHub as a commit with the same files, titled as the change was
|
||||||
and dated when it was merged; the copy at git.privatae.ai receives the release
|
and dated when it was merged; the copy at git.privatae.ai receives the release
|
||||||
as one commit. In both, the release is tagged `v<version>` on a commit whose
|
as one commit. In both, the release is tagged `v<version>` (0.55.0 to 0.55.2
|
||||||
tree is identical to the commit the signed release manifest names as
|
were not) on a commit whose tree is identical to the commit the signed release
|
||||||
`built_from`. Every binary can be rebuilt from the tag and compared with the
|
manifest names as `built_from`. Every binary can be rebuilt from the tag and compared with the
|
||||||
published one: see [Verify a release](#verify-a-release).
|
published one: see [Verify a release](#verify-a-release).
|
||||||
|
|
||||||
Issues and pull requests are welcome on GitHub, at
|
Issues and pull requests are welcome on GitHub, at
|
||||||
@@ -359,7 +367,11 @@ CGO_ENABLED=0 go build -o privatae-bastion .
|
|||||||
./privatae-bastion version
|
./privatae-bastion version
|
||||||
```
|
```
|
||||||
|
|
||||||
Every push runs the same checks in CI (`.gitea/workflows/ci.yml`).
|
Every push to `main` and every pull request into it runs the checks
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) asks for in CI: `.github/workflows/ci.yml`
|
||||||
|
on GitHub, `.gitea/workflows/ci.yml` on git.privatae.ai. On GitHub it also
|
||||||
|
runs every test [docs/claims.md](docs/claims.md) cites, as root on a machine
|
||||||
|
of its own, and fails if any of them skips.
|
||||||
|
|
||||||
To build a specific release rather than the latest source, check out its tag
|
To build a specific release rather than the latest source, check out its tag
|
||||||
first (`git checkout v<version>`).
|
first (`git checkout v<version>`).
|
||||||
@@ -382,8 +394,9 @@ It checks the release manifest's signature against the keys pinned in
|
|||||||
`client-go/release_sign.go`, checks out the release's source, rebuilds it, and
|
`client-go/release_sign.go`, checks out the release's source, rebuilds it, and
|
||||||
compares every binary's sha256 with the published one.
|
compares every binary's sha256 with the published one.
|
||||||
|
|
||||||
Each published release in this repository carries the tag `v<version>` (for
|
From 0.53.2 on, each release in this repository carries the tag `v<version>`
|
||||||
example `v0.54.1`). The manifest's `built_from` names the
|
(for example `v0.54.1`), apart from 0.55.0, 0.55.1 and 0.55.2, which have no
|
||||||
|
tag. The manifest's `built_from` names the
|
||||||
commit in the repository the release was built in, which is not a commit here,
|
commit in the repository the release was built in, which is not a commit here,
|
||||||
so the script checks out the tag `v<version>` for the version the manifest
|
so the script checks out the tag `v<version>` for the version the manifest
|
||||||
names, and prints which source it used. The tag is annotated and its message
|
names, and prints which source it used. The tag is annotated and its message
|
||||||
@@ -396,8 +409,9 @@ bytes.
|
|||||||
|
|
||||||
If the published version has no tag in your clone, the script says so and
|
If the published version has no tag in your clone, the script says so and
|
||||||
stops without building. If it cannot fetch tags, it says that instead.
|
stops without building. If it cannot fetch tags, it says that instead.
|
||||||
Releases from 0.53.2 on can be checked this way from a clone of this
|
Only a release tagged here can be checked this way from a clone of this
|
||||||
repository; the tags of earlier releases are not in it.
|
repository. A release arrives here after it is published, so the newest one
|
||||||
|
has no tag here until then.
|
||||||
|
|
||||||
Each release on GitHub carries the six binaries, the signed manifest
|
Each release on GitHub carries the six binaries, the signed manifest
|
||||||
`version.json`, its signature `version.json.<first 16 hex of its sha256>.sig`
|
`version.json`, its signature `version.json.<first 16 hex of its sha256>.sig`
|
||||||
@@ -415,7 +429,7 @@ go run . verify bastion <dir>/version.json <dir>/version.json.<…>.sig $(go run
|
|||||||
```
|
```
|
||||||
|
|
||||||
`deploy/release-assets.sh v<version> <dir>` does all of it from a clone,
|
`deploy/release-assets.sh v<version> <dir>` does all of it from a clone,
|
||||||
rebuild included, for any release from 0.53.2 on.
|
rebuild included, for any release tagged here.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+8
-6
@@ -102,13 +102,15 @@ one installed.
|
|||||||
**Checking our work.** Release builds are reproducible.
|
**Checking our work.** Release builds are reproducible.
|
||||||
`deploy/verify-release.sh` fetches the published manifest, checks its
|
`deploy/verify-release.sh` fetches the published manifest, checks its
|
||||||
signature, checks out the release's source, rebuilds it, and compares every
|
signature, checks out the release's source, rebuilds it, and compares every
|
||||||
binary's digest. From 0.53.2 on, each published release in this repository
|
binary's digest. From 0.53.2 on, apart from 0.55.0 to 0.55.2, each release in
|
||||||
carries the annotated tag `v<version>`, whose message records the release's
|
this repository carries the annotated tag `v<version>`, whose message records
|
||||||
`built_from`. That field names the commit in the repository the release was
|
the release's `built_from`. A release arrives here after it is published.
|
||||||
built in, which is not a commit here, so the script uses the tag and prints
|
That field names the commit in the repository the release was built in,
|
||||||
|
which is not a commit here, so the script uses the tag and prints
|
||||||
which source it used. If the digests match, this source builds the binaries we
|
which source it used. If the digests match, this source builds the binaries we
|
||||||
serve. Releases published before build reproducibility was added (0.40.0 and
|
serve. Only a release tagged here can be checked this way, and releases
|
||||||
earlier) cannot be checked this way.
|
published before build reproducibility was added (0.40.0 and earlier) cannot
|
||||||
|
be checked at all.
|
||||||
|
|
||||||
**The agent.** The bastion downloads the bastion agent, runs it, and gives it
|
**The agent.** The bastion downloads the bastion agent, runs it, and gives it
|
||||||
the folder you granted. The agent is a separate program in its own repository
|
the folder you granted. The agent is a separate program in its own repository
|
||||||
|
|||||||
@@ -1179,8 +1179,8 @@ func TestObserveOpsWriteNothing(t *testing.T) {
|
|||||||
roots = append(roots, fn)
|
roots = append(roots, fn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(roots) != 14 {
|
if len(roots) != 18 {
|
||||||
t.Fatalf("expected the 11 observe and 3 computer handlers, found %d", len(roots))
|
t.Fatalf("expected the 15 observe and 3 computer handlers, found %d", len(roots))
|
||||||
}
|
}
|
||||||
stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true,
|
stop := map[string]bool{"sendMgmt": true, "sendMgmtExit": true, "refuseMgmt": true,
|
||||||
"storeRecordObserve": true}
|
"storeRecordObserve": true}
|
||||||
@@ -1256,6 +1256,10 @@ func TestObserveOpsWriteNothing(t *testing.T) {
|
|||||||
"dmesg -T",
|
"dmesg -T",
|
||||||
"iptables -S",
|
"iptables -S",
|
||||||
"journalctl -n1 -o short-unix --no-pager",
|
"journalctl -n1 -o short-unix --no-pager",
|
||||||
|
// observe:logins: sshd's lines over a window of whole hours.
|
||||||
|
"journalctl <sinceArg> -o short-iso --no-pager -q --lines=50000 _COMM=sshd _COMM=sshd-session _COMM=sshd-auth _UID=0 + _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service _UID=0",
|
||||||
|
// observe:journal: one unit this machine's own lists name, over minutes.
|
||||||
|
"journalctl <unitArg> <sinceArg> -o short-iso --no-pager -q --lines=5000",
|
||||||
"kubectl --kubeconfig <observeKubeconfig> get nodes -o json",
|
"kubectl --kubeconfig <observeKubeconfig> get nodes -o json",
|
||||||
"kubectl --kubeconfig <observeKubeconfig> get pods -A -o json",
|
"kubectl --kubeconfig <observeKubeconfig> get pods -A -o json",
|
||||||
"nft list chain inet pubwall input",
|
"nft list chain inet pubwall input",
|
||||||
@@ -1270,6 +1274,8 @@ func TestObserveOpsWriteNothing(t *testing.T) {
|
|||||||
"system_profiler SPApplicationsDataType -json",
|
"system_profiler SPApplicationsDataType -json",
|
||||||
"systemctl is-active fail2ban",
|
"systemctl is-active fail2ban",
|
||||||
"systemctl list-timers --all --no-legend --plain",
|
"systemctl list-timers --all --no-legend --plain",
|
||||||
|
"systemctl list-unit-files --no-legend --plain", // observe:journal's unit check
|
||||||
|
"systemctl list-units --all --no-legend --plain",
|
||||||
"systemctl list-units --failed --no-legend --plain",
|
"systemctl list-units --failed --no-legend --plain",
|
||||||
"ufw status",
|
"ufw status",
|
||||||
"ufw status verbose",
|
"ufw status verbose",
|
||||||
|
|||||||
+2
-2
@@ -624,8 +624,8 @@ func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// nextGitProxyAddr picks the first loopback port from 7978 up that no existing
|
// nextGitProxyAddr picks the first loopback port from 7978 up that no existing
|
||||||
// pairing claims: the per-host git proxy (git.privatae.ai is reachable only through
|
// pairing claims: the per-host git proxy, through which local git reaches
|
||||||
// the bastion → host → platform path). Allocated lazily for pairings that predate it.
|
// git.privatae.ai via that host (gitlane.go). Allocated lazily for pairings that predate it.
|
||||||
func nextGitProxyAddr(cfgs []*Config) string {
|
func nextGitProxyAddr(cfgs []*Config) string {
|
||||||
used := map[string]bool{}
|
used := map[string]bool{}
|
||||||
for _, c := range cfgs {
|
for _, c := range cfgs {
|
||||||
|
|||||||
@@ -142,6 +142,14 @@ func cmdOpsDrop(host, prefix string) error {
|
|||||||
}
|
}
|
||||||
dir := cfg.dcnDir()
|
dir := cfg.dcnDir()
|
||||||
prefix = strings.TrimSpace(prefix)
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
if prefix == "--all" {
|
||||||
|
dropped, err := opsDropWhere(dir, "dropped at this machine's console", func(string, *opGrantRec) bool { return true })
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%d op grant(s) dropped — the host cannot run under any of them again\n", len(dropped))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var hits []string
|
var hits []string
|
||||||
for nonce, rec := range opsLoad(dir).Grants {
|
for nonce, rec := range opsLoad(dir).Grants {
|
||||||
if rec.Dropped == "" && strings.HasPrefix(nonce, prefix) {
|
if rec.Dropped == "" && strings.HasPrefix(nonce, prefix) {
|
||||||
|
|||||||
+83
-10
@@ -276,27 +276,82 @@ func dcnResolvePath(root, rpath string) (string, bool) {
|
|||||||
return joined, true
|
return joined, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// dcnExcluded reports whether rpath trips any exclude pattern. Patterns match the
|
// dcnExcluded reports whether rpath trips any exclude pattern, or is inside a
|
||||||
// basename and every path segment (glob per segment: ".ssh", "*.key", ".env*"),
|
// local search agent's folder (dcnInAgentDirOn), which no grant reaches. A
|
||||||
// compared the way this platform's file system compares names (dcnNameKey).
|
// pattern of one folder name matches any segment of the path (".ssh", "*.key",
|
||||||
|
// ".env*"); a pattern with a "/" (".config/gcloud") matches that run of
|
||||||
|
// consecutive segments wherever it sits in the path, each one a glob. Names
|
||||||
|
// are compared the way this platform's file system compares them (dcnNameKey).
|
||||||
// rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent.
|
// rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent.
|
||||||
func dcnExcluded(excludes []string, rpath string) bool {
|
func dcnExcluded(excludes []string, rpath string) bool {
|
||||||
return dcnExcludedOn(runtime.GOOS, excludes, rpath)
|
return dcnExcludedOn(runtime.GOOS, excludes, rpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
|
func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
|
||||||
|
if dcnInAgentDirOn(goos, rpath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
segs := strings.Split(filepath.ToSlash(rpath), "/")
|
segs := strings.Split(filepath.ToSlash(rpath), "/")
|
||||||
|
for i, seg := range segs {
|
||||||
|
segs[i] = dcnNameKey(goos, seg)
|
||||||
|
}
|
||||||
for _, pat := range excludes {
|
for _, pat := range excludes {
|
||||||
pat = strings.TrimSpace(pat)
|
pat = strings.TrimSpace(pat)
|
||||||
if pat == "" {
|
if pat == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pat = dcnNameKey(goos, pat)
|
var run []string
|
||||||
for _, seg := range segs {
|
for _, p := range strings.Split(pat, "/") {
|
||||||
if ok, _ := filepath.Match(pat, dcnNameKey(goos, seg)); ok {
|
if p != "" {
|
||||||
return true
|
run = append(run, dcnNameKey(goos, p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if dcnRunMatches(run, segs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// dcnRunMatches says whether the globs in run match len(run) consecutive
|
||||||
|
// segments of segs, starting anywhere. An empty run matches nothing.
|
||||||
|
func dcnRunMatches(run, segs []string) bool {
|
||||||
|
if len(run) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i+len(run) <= len(segs); i++ {
|
||||||
|
all := true
|
||||||
|
for j, p := range run {
|
||||||
|
if ok, _ := filepath.Match(p, segs[i+j]); !ok {
|
||||||
|
all = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// dcnAgentDir is the folder the local search agent keeps everything it makes
|
||||||
|
// in, beside the files it reads: the plaintext of every file in scope, the
|
||||||
|
// sanitized text, vectors, the catalog and sealed bundles. The bastion puts
|
||||||
|
// it at <root>/.agent wherever it may write into the folder (freeagent_managed.go;
|
||||||
|
// the agent's own default too), and a folder inside a store can hold another
|
||||||
|
// store's. The agent's walker never reads one, at any depth and in any case;
|
||||||
|
// the file ops never serve one either.
|
||||||
|
const dcnAgentDir = ".agent"
|
||||||
|
|
||||||
|
// dcnInAgentDirOn reports whether any segment of rpath names an agent's
|
||||||
|
// folder, as the file system on goos names it, and in any case on every OS
|
||||||
|
// (as the agent's own walker judges it: a ".Agent" on a volume that ignores
|
||||||
|
// case is the agent's folder).
|
||||||
|
func dcnInAgentDirOn(goos, rpath string) bool {
|
||||||
|
for _, seg := range strings.Split(filepath.ToSlash(rpath), "/") {
|
||||||
|
if strings.EqualFold(dcnNameKey(goos, seg), dcnAgentDir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -328,11 +383,25 @@ func dcnNameKey(goos, name string) string {
|
|||||||
//
|
//
|
||||||
// A path cleanRel refuses is judged as sent (the sandbox refuses it anyway).
|
// A path cleanRel refuses is judged as sent (the sandbox refuses it anyway).
|
||||||
func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
|
func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
|
||||||
|
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnExcluded(excludes, rel) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// dcnAgentAsSent is whether a path a host sent opens an agent's folder (by its
|
||||||
|
// spelling or through a link), judged as dcnExcludedAsSent judges excludes.
|
||||||
|
// dcnExcluded refuses the same paths; this one exists so the refusal can say
|
||||||
|
// why.
|
||||||
|
func dcnAgentAsSent(root, rpath string) bool {
|
||||||
|
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnInAgentDirOn(runtime.GOOS, rel) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// dcnJudgedAsSent applies judge to what a host's path opens: the cleaned path
|
||||||
|
// relative to the root, then where it leads relative to the resolved root.
|
||||||
|
func dcnJudgedAsSent(root, rpath string, judge func(rel string) bool) bool {
|
||||||
joined, ok := cleanRel(root, rpath)
|
joined, ok := cleanRel(root, rpath)
|
||||||
if !ok {
|
if !ok {
|
||||||
return dcnExcluded(excludes, rpath)
|
return judge(rpath)
|
||||||
}
|
}
|
||||||
if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || dcnExcluded(excludes, rel) {
|
if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || judge(rel) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||||
@@ -343,7 +412,7 @@ func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return dcnExcluded(excludes, rel)
|
return judge(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dcnWhereItLeads is p with its deepest existing part's links resolved and the
|
// dcnWhereItLeads is p with its deepest existing part's links resolved and the
|
||||||
@@ -428,6 +497,10 @@ func dcnLoadGrantMask(ws *websocket.Conn, dir, op string, storeID int64, rpath,
|
|||||||
dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask")
|
dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if rpath != "" && dcnAgentAsSent(terms.Root, rpath) {
|
||||||
|
dcnRefuse(ws, dir, op, storeID, rpath, "path is in the local search agent's own folder (.agent), which no grant reaches")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) {
|
if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) {
|
||||||
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
|
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ func dcnCheckPlan(root string, excludes []string, moves []dcnMove) []dcnPlanProb
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i
|
seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i
|
||||||
|
if dcnAgentAsSent(root, m.From) || dcnAgentAsSent(root, m.To) {
|
||||||
|
bad(i, m, "the local search agent's own folder (.agent) is never moved from or into")
|
||||||
|
continue
|
||||||
|
}
|
||||||
if dcnExcludedAsSent(excludes, root, m.From) {
|
if dcnExcludedAsSent(excludes, root, m.From) {
|
||||||
bad(i, m, "source is excluded by the grant")
|
bad(i, m, "source is excluded by the grant")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Two things a host holding `read` on a folder must never reach through the
|
||||||
|
// file ops (list, stat, read_range), nor move with a plan:
|
||||||
|
// - what an exclude of several folders names. The Host's default excludes
|
||||||
|
// carry ".config/gcloud", and a pattern with a "/" was compared with one
|
||||||
|
// folder name at a time, so it matched nothing: a shared home folder
|
||||||
|
// served its gcloud credentials;
|
||||||
|
// - the local search agent's own folder, .agent, which holds the plaintext
|
||||||
|
// of every file the agent read, whatever the grant excludes.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dcnDefaultExcludes is the exclude list the Host proposes for a new folder.
|
||||||
|
var dcnDefaultExcludes = []string{
|
||||||
|
".ssh", ".gnupg", ".aws", ".azure", ".config/gcloud", ".kube",
|
||||||
|
".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "*.keystore",
|
||||||
|
"id_rsa*", "id_ed25519*", "*.token", "credentials*", ".netrc",
|
||||||
|
".git-credentials", "*history",
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncExcluding installs a read grant for the store with these excludes,
|
||||||
|
// through the real grant_sync op.
|
||||||
|
func (fx *dcnStoreFixture) syncExcluding(t *testing.T, excludes []string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_store_grant", "class": "machine",
|
||||||
|
"root": fx.store, "mask": []string{"read"}, "excludes": excludes,
|
||||||
|
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)})
|
||||||
|
payload, _ := json.Marshal(map[string]any{"store_id": 1, "terms_canonical": rec.TermsCanonical,
|
||||||
|
"signature": rec.Signature, "verify_pubkey": base64.StdEncoding.EncodeToString(fx.pub)})
|
||||||
|
_, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
|
||||||
|
handleDcnGrantSync(ws, fx.dir, []string{base64.StdEncoding.EncodeToString(payload)})
|
||||||
|
})
|
||||||
|
if exit != 0 {
|
||||||
|
t.Fatalf("grant_sync: exit %d: %s", exit, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fx *dcnStoreFixture) put(t *testing.T, rel, body string) {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(fx.store, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listed is what dcn_list returns under prefix, and its exit code.
|
||||||
|
func (fx *dcnStoreFixture) listed(t *testing.T, prefix string) ([]string, int) {
|
||||||
|
t.Helper()
|
||||||
|
obj, _, exit := fx.op(t, handleDcnList, map[string]any{"prefix": prefix})
|
||||||
|
var paths []string
|
||||||
|
files, _ := obj["files"].([]any)
|
||||||
|
for _, f := range files {
|
||||||
|
if m, ok := f.(map[string]any); ok {
|
||||||
|
p, _ := m["path"].(string)
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, exit
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pattern with a "/" names that run of folders, wherever it sits in the
|
||||||
|
// path, each folder compared as the file system compares names. Judged per
|
||||||
|
// platform from any OS.
|
||||||
|
func TestDcnAMultiFolderExcludeMatchesThatRunAnywhere(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
goos, path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"linux", ".config/gcloud/access_tokens.db", true},
|
||||||
|
{"linux", ".config/gcloud", true},
|
||||||
|
{"linux", "home/.config/gcloud/x", true},
|
||||||
|
{"linux", "a/b/.config/gcloud/configurations/config_default", true},
|
||||||
|
{"linux", ".config/gcloudx", false},
|
||||||
|
{"linux", ".config/gcloudx/access_tokens.db", false},
|
||||||
|
{"linux", ".configx/gcloud/x", false},
|
||||||
|
{"linux", ".config/other/gcloud/x", false},
|
||||||
|
{"linux", "gcloud/.config/x", false},
|
||||||
|
{"linux", ".config", false},
|
||||||
|
{"linux", ".config/settings.ini", false},
|
||||||
|
{"linux", ".Config/gcloud/x", false}, // another folder on Linux
|
||||||
|
{"darwin", ".Config/GCloud/access_tokens.db", true},
|
||||||
|
{"darwin", "home/.CONFIG/gcloud/x", true},
|
||||||
|
{"darwin", ".config/gcloudx/x", false},
|
||||||
|
{"windows", ".CONFIG/GCLOUD/access_tokens.db", true},
|
||||||
|
{"windows", "home/.config./gcloud /x", true},
|
||||||
|
{"windows", ".config/gcloudx/x", false},
|
||||||
|
// one-folder patterns as before
|
||||||
|
{"linux", "deep/.ssh/config", true},
|
||||||
|
{"linux", "certs/server.key", true},
|
||||||
|
{"linux", "notes/ssh-notes.txt", false},
|
||||||
|
{"windows", ".SSH/id_rsa", true},
|
||||||
|
} {
|
||||||
|
if got := dcnExcludedOn(c.goos, dcnDefaultExcludes, c.path); got != c.want {
|
||||||
|
t.Errorf("%s: %q excluded=%v, want %v", c.goos, c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A pattern's own slashes at either end, or doubled, name the same run.
|
||||||
|
for _, pat := range []string{".config/gcloud/", "/.config/gcloud", ".config//gcloud"} {
|
||||||
|
if !dcnExcludedOn("linux", []string{pat}, "home/.config/gcloud/x") {
|
||||||
|
t.Errorf("%q must exclude home/.config/gcloud/x", pat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The default exclude of gcloud's folder holds on every op a host sends, at the
|
||||||
|
// top of a home folder and inside one, and however the path is spelled.
|
||||||
|
func TestDcnTheDefaultGcloudExcludeHoldsOnEveryOp(t *testing.T) {
|
||||||
|
fx := newDcnStoreFixture(t)
|
||||||
|
fx.put(t, ".config/gcloud/access_tokens.db", "GCLOUD-TOKEN")
|
||||||
|
fx.put(t, "home/.config/gcloud/credentials.db", "NESTED-TOKEN")
|
||||||
|
fx.put(t, ".config/gcloudx/ok.txt", "NEAR-MISS")
|
||||||
|
fx.put(t, ".config/settings.ini", "SETTINGS")
|
||||||
|
fx.syncExcluding(t, dcnDefaultExcludes)
|
||||||
|
|
||||||
|
paths, exit := fx.listed(t, "")
|
||||||
|
if exit != 0 {
|
||||||
|
t.Fatalf("list: exit %d", exit)
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, p := range paths {
|
||||||
|
have[p] = true
|
||||||
|
if strings.Contains(p, ".config/gcloud/") {
|
||||||
|
t.Errorf("list returned %q, under an excluded folder", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{".config/gcloudx/ok.txt", ".config/settings.ini", "a.txt"} {
|
||||||
|
if !have[p] {
|
||||||
|
t.Errorf("list left out %q, which no exclude names (got %v)", p, paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if paths, exit := fx.listed(t, "home/.config"); exit != 0 || len(paths) != 0 {
|
||||||
|
t.Errorf("list under home/.config: exit %d, %v; want nothing", exit, paths)
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{".config/gcloud", "home/.config/gcloud"} {
|
||||||
|
if _, exit := fx.listed(t, prefix); exit != 126 {
|
||||||
|
t.Errorf("list of %q: exit %d, want 126", prefix, exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{".config/gcloud", ".config/gcloud/access_tokens.db",
|
||||||
|
"home/.config/gcloud/credentials.db", " .config/gcloud/access_tokens.db",
|
||||||
|
"docs/../.config/gcloud/access_tokens.db"} {
|
||||||
|
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": p}); exit != 126 {
|
||||||
|
t.Errorf("stat %q: exit %d %v, want 126", p, exit, obj)
|
||||||
|
}
|
||||||
|
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "TOKEN") {
|
||||||
|
t.Errorf("read_range %q: exit %d %q, want 126 and nothing", p, exit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, why, exit := fx.read(t, ".config/gcloudx/ok.txt"); exit != 0 || got != "NEAR-MISS" {
|
||||||
|
t.Errorf("a folder only named like gcloud's must read: exit %d %q %s", exit, got, why)
|
||||||
|
}
|
||||||
|
if problems := dcnCheckPlan(fx.store, dcnDefaultExcludes, []dcnMove{
|
||||||
|
{From: ".config/gcloud/access_tokens.db", To: "out.db"}}); len(problems) == 0 {
|
||||||
|
t.Error("a move out of gcloud's folder must be refused")
|
||||||
|
}
|
||||||
|
if problems := dcnCheckPlan(fx.store, dcnDefaultExcludes, []dcnMove{
|
||||||
|
{From: "a.txt", To: "home/.config/gcloud/a.txt"}}); len(problems) == 0 {
|
||||||
|
t.Error("a move into gcloud's folder must be refused")
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(".config", "gcloud"), filepath.Join(fx.store, "gc")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if got, _, exit := fx.read(t, "gc/access_tokens.db"); exit != 126 || got == "GCLOUD-TOKEN" {
|
||||||
|
t.Errorf("read through a link to gcloud's folder: exit %d %q, want 126", exit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent's folder is refused on every op a host sends: at the store's top,
|
||||||
|
// inside a folder another grant shares (where that store's agent keeps its
|
||||||
|
// own), through a link, and however it is spelled. The grant here excludes
|
||||||
|
// nothing, so the refusal is not the grant's to give or take.
|
||||||
|
func TestDcnTheAgentsFolderIsNeverServed(t *testing.T) {
|
||||||
|
fx := newDcnStoreFixture(t)
|
||||||
|
fx.put(t, ".agent/extract/ab/report.txt", "PLAINTEXT-OF-A-FILE")
|
||||||
|
fx.put(t, ".agent/manifest.yaml", "manifest")
|
||||||
|
fx.put(t, "proj/.agent/extract/x.txt", "NESTED-PLAINTEXT")
|
||||||
|
fx.put(t, "proj/readme.md", "README")
|
||||||
|
fx.syncExcluding(t, nil)
|
||||||
|
|
||||||
|
paths, exit := fx.listed(t, "")
|
||||||
|
if exit != 0 {
|
||||||
|
t.Fatalf("list: exit %d", exit)
|
||||||
|
}
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, p := range paths {
|
||||||
|
have[p] = true
|
||||||
|
if strings.Contains("/"+p, "/.agent/") {
|
||||||
|
t.Errorf("list returned %q, inside an agent's folder", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !have["a.txt"] || !have["proj/readme.md"] {
|
||||||
|
t.Errorf("list left out the folder's own files: %v", paths)
|
||||||
|
}
|
||||||
|
for _, prefix := range []string{".agent", ".agent/extract", "proj/.agent"} {
|
||||||
|
if _, exit := fx.listed(t, prefix); exit != 126 {
|
||||||
|
t.Errorf("list of %q: exit %d, want 126", prefix, exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{".agent", ".agent/extract/ab/report.txt", ".agent/manifest.yaml",
|
||||||
|
"proj/.agent/extract/x.txt", " .agent/manifest.yaml", "docs/../.agent/extract/ab/report.txt"} {
|
||||||
|
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": p}); exit != 126 {
|
||||||
|
t.Errorf("stat %q: exit %d %v, want 126", p, exit, obj)
|
||||||
|
}
|
||||||
|
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "PLAINTEXT") {
|
||||||
|
t.Errorf("read_range %q: exit %d %q, want 126 and nothing", p, exit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range []dcnMove{{From: ".agent/extract/ab/report.txt", To: "out.txt"},
|
||||||
|
{From: "a.txt", To: ".agent/a.txt"}, {From: "a.txt", To: "proj/.agent/a.txt"}} {
|
||||||
|
if problems := dcnCheckPlan(fx.store, nil, []dcnMove{m}); len(problems) == 0 {
|
||||||
|
t.Errorf("a move %q -> %q must be refused", m.From, m.To)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bastion's own report of a plan it applied still goes into
|
||||||
|
// .agent/plans, through the real plan op.
|
||||||
|
hn, _ := os.Hostname()
|
||||||
|
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_move_plan", "store_id": 1, "plan_id": 41,
|
||||||
|
"server": hn, "root": fx.store, "archive": "Archive",
|
||||||
|
"moves": []map[string]any{{"from": "a.txt", "to": "Archive/a.txt"}},
|
||||||
|
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
|
||||||
|
payload, _ := json.Marshal(map[string]any{"terms_canonical": rec.TermsCanonical,
|
||||||
|
"signature": rec.Signature, "mode": "apply"})
|
||||||
|
out, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
|
||||||
|
handleDcnPlanApply(ws, fx.dir, []string{"1", base64.StdEncoding.EncodeToString(payload)})
|
||||||
|
})
|
||||||
|
if exit != 0 || !strings.Contains(out, `"ok"`) {
|
||||||
|
t.Fatalf("plan apply: exit %d %s %s", exit, out, why)
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(fx.store, ".agent", "plans", "41.json")); err != nil ||
|
||||||
|
!strings.Contains(string(b), `"state": "ok"`) {
|
||||||
|
t.Fatalf("the plan's report was not written into .agent/plans: %q %v", b, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(filepath.Join(".agent", "extract", "ab", "report.txt"), filepath.Join(fx.store, "notes.txt")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(".agent", filepath.Join(fx.store, "ag")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []string{"notes.txt", "ag/extract/ab/report.txt", "ag/manifest.yaml"} {
|
||||||
|
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "PLAINTEXT") {
|
||||||
|
t.Errorf("read_range %q, a link into .agent: exit %d %q, want 126", p, exit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exit := fx.listed(t, "ag"); exit != 126 {
|
||||||
|
t.Errorf("list of a link to .agent: exit %d, want 126", exit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent's folder is named as the file system names it, and on every OS
|
||||||
|
// regardless of case, as the agent's own walker treats it: a ".Agent" left on
|
||||||
|
// a volume that ignores case is the agent's folder.
|
||||||
|
func TestDcnTheAgentsFolderIsMatchedHoweverTheNameIsCased(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
goos, path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"linux", ".agent/extract/x", true},
|
||||||
|
{"linux", ".AGENT/extract/x", true},
|
||||||
|
{"darwin", ".Agent/vectors.db", true},
|
||||||
|
{"windows", ".AGENT/extract/x", true},
|
||||||
|
{"windows", ".agent./extract/x", true},
|
||||||
|
{"windows", ".agent /extract/x", true},
|
||||||
|
{"windows", ".agent::$INDEX_ALLOCATION/x", true},
|
||||||
|
{"linux", "deep/.agent", true},
|
||||||
|
{"linux", ".agentx/x", false},
|
||||||
|
{"linux", "agent/x", false},
|
||||||
|
{"linux", "notes/.agent-old/x", false},
|
||||||
|
{"darwin", "docs/readme.md", false},
|
||||||
|
} {
|
||||||
|
if got := dcnExcludedOn(c.goos, nil, c.path); got != c.want {
|
||||||
|
t.Errorf("%s: %q refused=%v, want %v", c.goos, c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -995,6 +995,11 @@ func cmdPinDevice(key string, unpin bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("device key unpinned; approvals signed by it are refused from now on")
|
fmt.Println("device key unpinned; approvals signed by it are refused from now on")
|
||||||
|
// Every op grant it approved dies with it (ops_bounds.go), dropped
|
||||||
|
// now so pinning the key again does not bring one back.
|
||||||
|
if n := opsDropApprovedBy("device:" + strings.TrimSpace(key)); n > 0 {
|
||||||
|
fmt.Printf("%d op grant(s) it approved dropped\n", n)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := pinDeviceKey(key); err != nil {
|
if err := pinDeviceKey(key); err != nil {
|
||||||
|
|||||||
@@ -193,6 +193,14 @@ func buildEnforcingReport(dir string, allowed []string) map[string]any {
|
|||||||
if !aok {
|
if !aok {
|
||||||
rep["action_ops_reason"] = awhy
|
rep["action_ops_reason"] = awhy
|
||||||
}
|
}
|
||||||
|
// Each verb, and whether this machine has the tool it runs: an op
|
||||||
|
// with no program (interpreter: none) runs here when its verbs do,
|
||||||
|
// with or without the sandbox above.
|
||||||
|
rep["verbs"] = opVerbsReport()
|
||||||
|
// An op grant's bounds may hold its runs to a window and a rate
|
||||||
|
// (ops_bounds.go). Said by the box, so the host sends neither to one
|
||||||
|
// that would refuse them as naming no param.
|
||||||
|
rep["grant_run_bounds"] = true
|
||||||
}
|
}
|
||||||
// This machine's encryption key, signed by its own key: what the host
|
// This machine's encryption key, signed by its own key: what the host
|
||||||
// seals an action op's credentials to (ops_seal.go).
|
// seals an action op's credentials to (ops_seal.go).
|
||||||
|
|||||||
+10
-5
@@ -68,17 +68,22 @@ type freeagentConfig struct {
|
|||||||
|
|
||||||
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
|
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
|
||||||
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
|
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
|
||||||
// only: it replaces e-mail addresses, phone numbers, card, bank and ID
|
// only (the agent's pii/detect.py): it replaces what a pattern can find
|
||||||
// numbers, IP and MAC addresses and keys, and does not find names, street
|
// (faSanitizeNotes["flag"] names each kind), and does not look for names,
|
||||||
// addresses or dates of birth.
|
// street addresses, dates of birth, ID numbers such as passport numbers, or
|
||||||
|
// bank account numbers other than IBANs.
|
||||||
const faManagedSanitizeMode = "flag"
|
const faManagedSanitizeMode = "flag"
|
||||||
|
|
||||||
// faSanitizeNotes says, for each mode a reply can carry, what personal data
|
// faSanitizeNotes says, for each mode a reply can carry, what personal data
|
||||||
// may still be in the text, in the words the host relays. A mode with no entry
|
// may still be in the text, in the words the host relays. A mode with no entry
|
||||||
// here is reported as not known to remove personal data.
|
// here is reported as not known to remove personal data.
|
||||||
var faSanitizeNotes = map[string]string{
|
var faSanitizeNotes = map[string]string{
|
||||||
"flag": "rules only: e-mail addresses, phone numbers, card, bank and ID numbers, IP and MAC addresses " +
|
"flag": "rules only. Replaced: e-mail addresses, phone numbers, card numbers, US social security numbers, " +
|
||||||
"and keys were replaced; names, street addresses and dates of birth were not",
|
"international bank account numbers (IBANs), US bank routing numbers written after a word like " +
|
||||||
|
"\"routing\", Bitcoin addresses that start with 1 or 3, IP and MAC addresses, private keys, and " +
|
||||||
|
"access keys and tokens of the kinds it knows. Other numbers that look like one of these can be " +
|
||||||
|
"replaced too. Not looked for, so they can still be in the text: names, street addresses, dates of " +
|
||||||
|
"birth, passport, licence and other ID numbers, other bank account numbers and other crypto addresses",
|
||||||
"sterilize": "",
|
"sterilize": "",
|
||||||
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
|
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,8 +330,16 @@ func TestASanitizeStampNamesTheModeThatRan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(faSanitizeNotes["flag"], "names") {
|
// The agent's rules pass (pii/detect.py) does not look for these, so the
|
||||||
t.Fatal("the flag note must say names are left in")
|
// note the host relays must say they can still be there.
|
||||||
|
for _, left := range []string{"names", "street addresses", "dates of birth", "passport",
|
||||||
|
"other bank account numbers", "other crypto addresses"} {
|
||||||
|
if !strings.Contains(faSanitizeNotes["flag"], left) {
|
||||||
|
t.Errorf("the flag note must say %s can still be in the text: %q", left, faSanitizeNotes["flag"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(faSanitizeNotes["flag"], "ID numbers, IP") || strings.Contains(faSanitizeNotes["flag"], "bank and ID") {
|
||||||
|
t.Errorf("the flag note says ID or bank numbers were replaced, which the rules pass does not do: %q", faSanitizeNotes["flag"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -345,3 +345,51 @@ func FuzzADeviceApprovalIsReadOneWayOnly(f *testing.F) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FuzzAGrantsRunBoundsHoldWhateverTheHostSends: whatever bounds text a host
|
||||||
|
// sends, a run bound the machine accepts is one it can hold — a window has a
|
||||||
|
// length, a zone and a day, a rate names a limit inside the ceilings — and
|
||||||
|
// judging a run against it at any moment answers, never panics, and never
|
||||||
|
// lets a run inside an interval or past a day's count.
|
||||||
|
func FuzzAGrantsRunBoundsHoldWhateverTheHostSends(f *testing.F) {
|
||||||
|
m := &opManifest{Params: map[string]opParam{"count": {Type: "integer", Min: ptr64(1), Max: ptr64(5)}}}
|
||||||
|
for _, s := range []string{
|
||||||
|
`{"_window":{"days":["mon"],"from":"22:00","to":"06:00","tz":"Europe/Berlin"}}`,
|
||||||
|
`{"_rate":{"per_day":2,"min_interval_s":600},"count":{"max":2}}`,
|
||||||
|
`{"_window":null,"_rate":null}`, `{"_x":1}`, `{"_window":{"from":"02:00","to":"02:00"}}`,
|
||||||
|
} {
|
||||||
|
f.Add(s, int64(1761739200), int64(300))
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, raw string, at, gap int64) {
|
||||||
|
b, err := parseGrantBounds(m, raw)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w := b.Window; w != nil {
|
||||||
|
if w.from == w.to || w.loc == nil || w.day == [7]bool{} {
|
||||||
|
t.Fatalf("accepted a window that is no window: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := b.Rate; r != nil {
|
||||||
|
if (r.PerDay == nil && r.MinInterval == nil) || (r.PerDay != nil && (*r.PerDay < 1 || *r.PerDay > opGrantUsesMax)) ||
|
||||||
|
(r.MinInterval != nil && (*r.MinInterval < 1 || *r.MinInterval > opRateMinInterval)) {
|
||||||
|
t.Fatalf("accepted a rate outside the ceilings: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Unix(at%(1<<40), 0)
|
||||||
|
last := now.Add(-time.Duration(gap%(1<<30)) * time.Second)
|
||||||
|
if last.After(now) {
|
||||||
|
last = now
|
||||||
|
}
|
||||||
|
why, _, _ := opNotNowWhy(b, []time.Time{last}, now)
|
||||||
|
if b.Window == nil && b.Rate != nil && b.Rate.MinInterval != nil &&
|
||||||
|
now.Sub(last) < time.Duration(*b.Rate.MinInterval)*time.Second && why == "" {
|
||||||
|
t.Fatalf("a run inside the interval was let through: %s", raw)
|
||||||
|
}
|
||||||
|
if b.Window == nil && b.Rate != nil && b.Rate.PerDay != nil && *b.Rate.PerDay == 1 && now.Sub(last) < 24*time.Hour && why == "" {
|
||||||
|
t.Fatalf("a second run in a day was let through: %s", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr64(v int64) *int64 { return &v }
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Fuzz the walls a host's value meets in observe:folders, observe:journal,
|
||||||
|
// observe:cert and observe:logins. Whatever the input, a path that passes is
|
||||||
|
// absolute, clean and outside the kernel's trees; a unit that passes is in
|
||||||
|
// systemd's alphabet and shaped like no flag; a server name that passes is a
|
||||||
|
// DNS name; and the sshd parser names only addresses as sources and bounded
|
||||||
|
// names as users, whatever a remote side put in a user name.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FuzzFolderPathIsAbsoluteCleanAndOffTheKernelTrees(f *testing.F) {
|
||||||
|
for _, s := range []string{"/", "/var/lib", "/var//lib/", "/proc", "//proc/1", "/a/../proc", "/a..b",
|
||||||
|
"rel", "/dev/shm", "/x\x00y", "/\xff", "/srv/./data", "/sys/", "/devices"} {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, in string) {
|
||||||
|
p, err := checkFolderPath(in)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "/") || path.Clean(p) != p || strings.Contains(p, "..") {
|
||||||
|
t.Fatalf("%q passed as %q", in, p)
|
||||||
|
}
|
||||||
|
for _, r := range p {
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
t.Fatalf("%q passed with a control character", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, root := range folderRefusedRoots {
|
||||||
|
if p == root || strings.HasPrefix(p, root+"/") {
|
||||||
|
t.Fatalf("%q passed into %s", in, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzUnitNameIsSystemdsAlphabet(f *testing.F) {
|
||||||
|
for _, s := range []string{"example", "getty@tty1", `dev-disk-by\x2dlabel-data.device`, "-.mount", "--unit=x",
|
||||||
|
"worker@.service", "a@b@c", "x;id", "data.mount", ".service", "@x"} {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, in string) {
|
||||||
|
u, err := normalizeUnitName(in)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !unitNameRe.MatchString(u) || len(u) > unitNameMax || !hasUnitSuffix(u) {
|
||||||
|
t.Fatalf("%q passed as %q", in, u)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(u, "-") || strings.HasPrefix(u, ".") || strings.HasPrefix(u, "@") {
|
||||||
|
t.Fatalf("%q passed as a flag-shaped %q", in, u)
|
||||||
|
}
|
||||||
|
if u != in && u != in+".service" {
|
||||||
|
t.Fatalf("%q became %q: the name is kept or given .service, nothing else", in, u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzCertArgsAreAPortAndADNSName(f *testing.F) {
|
||||||
|
for _, s := range [][2]string{{"443", "box-a.example"}, {"0", ""}, {"65536", "x"}, {"443", "-x"},
|
||||||
|
{"443", "a..b"}, {"8443", "192.0.2.10"}, {" 443 ", "A.EXAMPLE"}, {"443", "a_b"}} {
|
||||||
|
f.Add(s[0], s[1])
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, port, name string) {
|
||||||
|
p, n, err := parseCertArgs([]string{port, name})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p < 1 || p > 65535 || !serverNameRe.MatchString(n) || n != strings.ToLower(n) ||
|
||||||
|
strings.HasPrefix(n, "-") || strings.HasPrefix(n, ".") || strings.Contains(n, "..") {
|
||||||
|
t.Fatalf("(%q, %q) passed as (%d, %q)", port, name, p, n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzSSHLoginsNameOnlyAddressesAndBoundedNames(f *testing.F) {
|
||||||
|
f.Add(fxSSHD)
|
||||||
|
f.Add("2026-10-29T10:00:01+00:00 h sshd[1]: Invalid user a from 1.2.3.4 port 1 from 203.0.113.5 port 2\n")
|
||||||
|
f.Add("2026-10-29T10:00:01+00:00 h sshd[1]: Failed password for x from evil/host port 1 ssh2\n")
|
||||||
|
f.Add("x y z: Accepted publickey for \x00 from ::1 port 9 ssh2: K\n")
|
||||||
|
f.Fuzz(func(t *testing.T, text string) {
|
||||||
|
tally := parseSSHLogins(text)
|
||||||
|
var rep loginsReport
|
||||||
|
tally.fill(&rep)
|
||||||
|
sum := 0
|
||||||
|
for src, n := range tally.bySource {
|
||||||
|
sum += n
|
||||||
|
if _, err := netip.ParseAddr(src); err != nil && !loginHostRe.MatchString(src) {
|
||||||
|
t.Fatalf("source %q is not an address or a host name", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum != rep.FailedTotal {
|
||||||
|
t.Fatalf("sources count %d, total %d", sum, rep.FailedTotal)
|
||||||
|
}
|
||||||
|
users := 0
|
||||||
|
for name, u := range tally.byUser {
|
||||||
|
users += u.Count
|
||||||
|
if len(name) > loginNameMax || loginName(name) != name {
|
||||||
|
t.Fatalf("user %q is not a bounded name", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if users != rep.FailedTotal {
|
||||||
|
t.Fatalf("users count %d, total %d", users, rep.FailedTotal)
|
||||||
|
}
|
||||||
|
if len(rep.Accepted) > loginsTopN || rep.AcceptedTotal < len(rep.Accepted) {
|
||||||
|
t.Fatalf("accepted %d of %d", len(rep.Accepted), rep.AcceptedTotal)
|
||||||
|
}
|
||||||
|
for _, a := range rep.Accepted {
|
||||||
|
if _, err := netip.ParseAddr(a.Source); err != nil && !loginHostRe.MatchString(a.Source) {
|
||||||
|
t.Fatalf("accepted source %q", a.Source)
|
||||||
|
}
|
||||||
|
if loginName(a.User) != a.User || loginMethod(a.Method) != a.Method {
|
||||||
|
t.Fatalf("accepted %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -137,13 +137,14 @@ func landsInside(root, p string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// excludedOnDisk is the fixture's excludes (".ssh", "*.key") judged
|
// excludedOnDisk is the fixture's excludes (".ssh", "*.key"), and the local
|
||||||
|
// search agent's folder (".agent", which no grant reaches), judged
|
||||||
// independently of dcnExcluded: on the path relative to the root after every
|
// independently of dcnExcluded: on the path relative to the root after every
|
||||||
// link is followed, and on the text of the path the sandbox opens.
|
// link is followed, and on the text of the path the sandbox opens.
|
||||||
func excludedOnDisk(root, p string) bool {
|
func excludedOnDisk(root, p string) bool {
|
||||||
check := func(rel string) bool {
|
check := func(rel string) bool {
|
||||||
for _, seg := range strings.Split(rel, "/") {
|
for _, seg := range strings.Split(rel, "/") {
|
||||||
if seg == ".ssh" || strings.HasSuffix(seg, ".key") {
|
if seg == ".ssh" || strings.HasSuffix(seg, ".key") || strings.EqualFold(seg, ".agent") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -2,12 +2,13 @@ package main
|
|||||||
|
|
||||||
// Git lane: Gitea through the Host.
|
// Git lane: Gitea through the Host.
|
||||||
//
|
//
|
||||||
// git.privatae.ai answers only platform/DMZ addresses now, so the user's LOCAL git
|
// This bastion offers a loopback HTTP proxy for the user's LOCAL git to reach
|
||||||
// cannot reach it. This bastion offers a loopback HTTP proxy for git; every local
|
// git.privatae.ai through their host; every local connection is spliced over the
|
||||||
// connection is spliced over the tunnel into ONE WebSocket on the host (/bastion-git,
|
// tunnel into ONE WebSocket on the host (/bastion-git, same bearer + mTLS binding
|
||||||
// same bearer + mTLS binding as the other lanes) and the host relays it out the
|
// as the other lanes) and the host relays it on to the forge. Which addresses the
|
||||||
// platform path to git.privatae.ai ONLY. The wire framing is the egress lane's
|
// forge itself answers is the forge's rule; nothing here sets or checks it. The
|
||||||
// ('D'+bytes / 'E' end) with the roles swapped, so wsConn is reused unchanged.
|
// wire framing is the egress lane's ('D'+bytes / 'E' end) with the roles swapped,
|
||||||
|
// so wsConn is reused unchanged.
|
||||||
//
|
//
|
||||||
// Setup for the user (printed by `privatae-bastion git-setup`):
|
// Setup for the user (printed by `privatae-bastion git-setup`):
|
||||||
// git config --global http.https://git.privatae.ai.proxy http://127.0.0.1:<port>
|
// git config --global http.https://git.privatae.ai.proxy http://127.0.0.1:<port>
|
||||||
@@ -199,7 +200,8 @@ func gitCredentialReply(body []byte, authtype bool) []byte {
|
|||||||
return []byte("username=" + user + "\npassword=" + pass + "\nquit=1\n")
|
return []byte("username=" + user + "\npassword=" + pass + "\nquit=1\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdGitSetup prints the two git config lines for every pairing.
|
// cmdGitSetup prints the three git config lines for every pairing: the proxy,
|
||||||
|
// the reset of the forge's helper chain, and the helper.
|
||||||
func cmdGitSetup() error {
|
func cmdGitSetup() error {
|
||||||
all, err := loadPairings()
|
all, err := loadPairings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,7 +210,7 @@ func cmdGitSetup() error {
|
|||||||
if len(all) == 0 {
|
if len(all) == 0 {
|
||||||
return fmt.Errorf("no pairings — run `privatae-bastion pair <token>` first")
|
return fmt.Errorf("no pairings — run `privatae-bastion pair <token>` first")
|
||||||
}
|
}
|
||||||
fmt.Println("git through your bastion (git.privatae.ai is reachable only this way):")
|
fmt.Println("git to git.privatae.ai through this bastion and your host:")
|
||||||
if len(all) > 1 {
|
if len(all) > 1 {
|
||||||
fmt.Println("(several hosts are paired: pick ONE for git, and name it with --host in the helper line)")
|
fmt.Println("(several hosts are paired: pick ONE for git, and name it with --host in the helper line)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1018,19 +1018,27 @@ var observeArgsRecorded = map[string]int{
|
|||||||
"observe:net": 1, // host[:port], charset-checked before use
|
"observe:net": 1, // host[:port], charset-checked before use
|
||||||
"observe:metrics": 1, // a name from a fixed query table
|
"observe:metrics": 1, // a name from a fixed query table
|
||||||
"observe:logs": 2, // a name, and an optional minutes window (a whole number)
|
"observe:logs": 2, // a name, and an optional minutes window (a whole number)
|
||||||
|
"observe:folders": 2, // an absolute path, and an optional depth
|
||||||
|
"observe:journal": 2, // a unit name, and an optional minutes window
|
||||||
|
"observe:logins": 1, // an optional window in hours
|
||||||
|
"observe:cert": 2, // a local port, and an optional server name
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeObserveLine is one line of observe/<op>/<date>.jsonl.
|
// storeObserveLine is one line of observe/<op>/<date>.jsonl.
|
||||||
//
|
//
|
||||||
// WHAT COMES TO REST HERE. Eight of the eleven ops return fixed schemas the
|
// WHAT COMES TO REST HERE. Most of the ops return fixed schemas the beat
|
||||||
// beat already summarises (posture ints, unit names, sockets, a probe's TLS
|
// already summarises (posture ints, unit names, sockets, a probe's TLS
|
||||||
// subject). observe:alerts and observe:metrics carry label maps that can name
|
// subject, a served certificate's names). observe:alerts and observe:metrics
|
||||||
// namespaces and tenants. observe:logs is the outlier: its result is raw
|
// carry label maps that can name namespaces and tenants. observe:folders
|
||||||
// production log text, up to the line bound, persisted for 30 days — the one
|
// carries folder paths, which can carry a person's account or project name.
|
||||||
// payload whose content this repo does not control, and so the one place a
|
// observe:logins carries account names and the addresses they came from, the
|
||||||
|
// failed ones as the other side sent them (bounded by loginName).
|
||||||
|
// observe:logs and observe:journal are the outliers: their result is raw
|
||||||
|
// production log text, up to the line bound, persisted for 30 days — the
|
||||||
|
// payloads whose content this repo does not control, and so the places a
|
||||||
// token or a user identifier can come to rest. Files are 0600 under a 0700
|
// token or a user identifier can come to rest. Files are 0600 under a 0700
|
||||||
// tree; the reader is the machine's own OS account and the anchor's approved
|
// tree; the reader is the machine's own OS account and the anchor's approved
|
||||||
// store. Named here so the next person choosing what to log through this op
|
// store. Named here so the next person choosing what to log through these ops
|
||||||
// knows it stays.
|
// knows it stays.
|
||||||
type storeObserveLine struct {
|
type storeObserveLine struct {
|
||||||
At string `json:"at"`
|
At string `json:"at"`
|
||||||
@@ -1097,8 +1105,8 @@ func storeObserveDroppedReset() {
|
|||||||
// (`observe:k8s`, `observe-k8s` and `observe_k8s` all reduce to
|
// (`observe:k8s`, `observe-k8s` and `observe_k8s` all reduce to
|
||||||
// `observe-k8s`) and, since the lowercasing below, case folding
|
// `observe-k8s`) and, since the lowercasing below, case folding
|
||||||
// (`observe:K8s` and `observe:k8s`). Neither can bite today — the op reaches
|
// (`observe:K8s` and `observe:k8s`). Neither can bite today — the op reaches
|
||||||
// this writer only from the eleven names registered in handlerOps, all
|
// this writer only from the observe names registered in handlerOps, all
|
||||||
// lowercase, which reduce to eleven distinct directories
|
// lowercase, which reduce to as many distinct directories
|
||||||
// (TestEveryRegisteredOpSanitisesToTheHostsShelfRule walks them) — but a
|
// (TestEveryRegisteredOpSanitisesToTheHostsShelfRule walks them) — but a
|
||||||
// future pair like `observe:k8s-nodes` / `observe:k8s_nodes` would interleave
|
// future pair like `observe:k8s-nodes` / `observe:k8s_nodes` would interleave
|
||||||
// into one day-file. Whoever adds such a pair renames one of them.
|
// into one day-file. Whoever adds such a pair renames one of them.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -1696,9 +1697,12 @@ func TestObserveArgsRecordedMatchesTheHandlers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Arities, from the handlers' own checks (observe_ops.go handleObserveNet
|
// Arities, from the handlers' own checks (observe_ops.go handleObserveNet
|
||||||
// len(args) != 1; observe_pull.go handleObserveMetrics len(args) != 1;
|
// len(args) != 1; observe_pull.go handleObserveMetrics len(args) != 1;
|
||||||
// handleObserveLogs len(args) > 2).
|
// handleObserveLogs len(args) > 2; observe_reads.go folders, journal and
|
||||||
|
// cert len(args) > 2, logins len(args) > 1).
|
||||||
if observeArgsRecorded["observe:net"] != 1 || observeArgsRecorded["observe:metrics"] != 1 ||
|
if observeArgsRecorded["observe:net"] != 1 || observeArgsRecorded["observe:metrics"] != 1 ||
|
||||||
observeArgsRecorded["observe:logs"] != 2 {
|
observeArgsRecorded["observe:logs"] != 2 || observeArgsRecorded["observe:folders"] != 2 ||
|
||||||
|
observeArgsRecorded["observe:journal"] != 2 || observeArgsRecorded["observe:logins"] != 1 ||
|
||||||
|
observeArgsRecorded["observe:cert"] != 2 {
|
||||||
t.Fatalf("arities drifted from the handlers: %v", observeArgsRecorded)
|
t.Fatalf("arities drifted from the handlers: %v", observeArgsRecorded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1785,10 +1789,38 @@ func TestRecordedArgCountsMatchWhatTheHandlersAccept(t *testing.T) {
|
|||||||
// And every ACCEPTED position must hold a value the handler accepts —
|
// And every ACCEPTED position must hold a value the handler accepts —
|
||||||
// observe:logs's second argument is a minutes window, and "x" there is
|
// observe:logs's second argument is a minutes window, and "x" there is
|
||||||
// refused for its shape, which is not an arity refusal either.
|
// refused for its shape, which is not an arity refusal either.
|
||||||
|
//
|
||||||
|
// The four in observe_reads.go answer through their drivers on Linux, so
|
||||||
|
// what they read is put in front of them here and nothing on the box that
|
||||||
|
// runs the suite is: one unit this machine "has", one TLS listener ss
|
||||||
|
// "reports", an empty journal, and a folder of the test's own.
|
||||||
|
cert := observeTestCert(t, "box-a.example", []string{"box-a.example"}, nil, time.Now().Add(24*time.Hour))
|
||||||
|
port := strconv.Itoa(observeTLSListener(t, cert))
|
||||||
|
origExec, origHave, origAccess := observeExec, observeHave, observeJournalAccess
|
||||||
|
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
switch name + " " + args[0] {
|
||||||
|
case "systemctl list-units":
|
||||||
|
return []byte("example.service loaded active running Example\n"), nil
|
||||||
|
case "ss -tlnpH":
|
||||||
|
return []byte("LISTEN 0 4096 127.0.0.1:" + port + " 0.0.0.0:*\n"), nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
observeHave = func(string) bool { return true }
|
||||||
|
observeJournalAccess = func() string { return "" }
|
||||||
|
defer func() { observeExec, observeHave, observeJournalAccess = origExec, origHave, origAccess }()
|
||||||
|
folder := "/"
|
||||||
|
if observeHostSupported {
|
||||||
|
folder = t.TempDir()
|
||||||
|
}
|
||||||
accepted := map[string][]string{
|
accepted := map[string][]string{
|
||||||
"observe:net": {"203.0.113.9:443"},
|
"observe:net": {"203.0.113.9:443"},
|
||||||
"observe:metrics": {"up"},
|
"observe:metrics": {"up"},
|
||||||
"observe:logs": {"kernel", "5"},
|
"observe:logs": {"kernel", "5"},
|
||||||
|
"observe:folders": {folder, "1"},
|
||||||
|
"observe:journal": {"example", "5"},
|
||||||
|
"observe:logins": {"24"},
|
||||||
|
"observe:cert": {port, "box-a.example"},
|
||||||
}
|
}
|
||||||
for op, n := range observeArgsRecorded {
|
for op, n := range observeArgsRecorded {
|
||||||
if len(accepted[op]) != n {
|
if len(accepted[op]) != n {
|
||||||
@@ -1828,7 +1860,7 @@ func TestEveryRegisteredOpSanitisesToTheHostsShelfRule(t *testing.T) {
|
|||||||
t.Fatalf("%s sanitises to %q, which the host rejects as unreadable", op, got)
|
t.Fatalf("%s sanitises to %q, which the host rejects as unreadable", op, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n < 11 {
|
if n < 15 {
|
||||||
t.Fatalf("only %d observe ops walked — the registry moved and this test is not covering it", n)
|
t.Fatalf("only %d observe ops walked — the registry moved and this test is not covering it", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -142,8 +142,8 @@ Usage:
|
|||||||
let your hosts run sealed coding runs on this machine (Linux, as
|
let your hosts run sealed coding runs on this machine (Linux, as
|
||||||
root): how many at once, how big each is, which engines. Off by
|
root): how many at once, how big each is, which engines. Off by
|
||||||
default; each run has no network but a proxy to the internet
|
default; each run has no network but a proxy to the internet
|
||||||
privatae-bastion git-setup print the two git config lines that route git.privatae.ai
|
privatae-bastion git-setup print the git config lines that route git.privatae.ai
|
||||||
through this bastion (the forge is reachable only this way)
|
through this bastion and your host
|
||||||
privatae-bastion git-credential git's credential helper — fetches your forge token from your
|
privatae-bastion git-credential git's credential helper — fetches your forge token from your
|
||||||
host on every git auth (git-setup resets the helper chain so no
|
host on every git auth (git-setup resets the helper chain so no
|
||||||
other helper caches it)
|
other helper caches it)
|
||||||
@@ -607,6 +607,7 @@ func main() {
|
|||||||
case "ops":
|
case "ops":
|
||||||
// ops [--host <host>] what ops and op grants this machine holds
|
// ops [--host <host>] what ops and op grants this machine holds
|
||||||
// ops drop <grant> [--host <host>] take back a held op grant (the host is told)
|
// ops drop <grant> [--host <host>] take back a held op grant (the host is told)
|
||||||
|
// ops drop --all [--host <host>] take back every op grant the host holds here
|
||||||
host, args := takeHostFlag(os.Args[2:])
|
host, args := takeHostFlag(os.Args[2:])
|
||||||
if len(args) >= 2 && args[0] == "drop" {
|
if len(args) >= 2 && args[0] == "drop" {
|
||||||
err = cmdOpsDrop(host, args[1])
|
err = cmdOpsDrop(host, args[1])
|
||||||
|
|||||||
+22
-4
@@ -105,6 +105,15 @@ type opHandler struct {
|
|||||||
// is exec'd, so the discipline here is a fixed QUERY TABLE instead of a
|
// is exec'd, so the discipline here is a fixed QUERY TABLE instead of a
|
||||||
// fixed argv: the host sends a NAME, the box owns the PromQL and the LogQL,
|
// fixed argv: the host sends a NAME, the box owns the PromQL and the LogQL,
|
||||||
// and an unknown name is refused with the known ones listed.
|
// and an unknown name is refused with the known ones listed.
|
||||||
|
// - observe:folders / observe:journal / observe:logins / observe:cert
|
||||||
|
// (observe_reads.go): the questions a host otherwise handed the person as
|
||||||
|
// a command — the largest folders under a path, one unit's recent journal,
|
||||||
|
// sshd's failed and accepted logins, the certificate a local port serves.
|
||||||
|
// Their caller values are a path, a unit name, a number and a port with a
|
||||||
|
// server name, each checked against a written grammar before use. The
|
||||||
|
// one caller string that reaches a program is the unit name, and only
|
||||||
|
// once this machine's own unit list names it; a window reaches journalctl
|
||||||
|
// only as a number this box parsed and wrote back out.
|
||||||
var handlerOps = map[string]opHandler{
|
var handlerOps = map[string]opHandler{
|
||||||
"computer:inventory": {rootNone, "", handleComputerInventory},
|
"computer:inventory": {rootNone, "", handleComputerInventory},
|
||||||
"computer:windows": {rootNone, "", handleComputerWindows},
|
"computer:windows": {rootNone, "", handleComputerWindows},
|
||||||
@@ -120,6 +129,10 @@ var handlerOps = map[string]opHandler{
|
|||||||
"observe:alerts": {rootNone, "", handleObserveAlerts},
|
"observe:alerts": {rootNone, "", handleObserveAlerts},
|
||||||
"observe:metrics": {rootNone, "", handleObserveMetrics},
|
"observe:metrics": {rootNone, "", handleObserveMetrics},
|
||||||
"observe:logs": {rootNone, "", handleObserveLogs},
|
"observe:logs": {rootNone, "", handleObserveLogs},
|
||||||
|
"observe:folders": {rootNone, "", handleObserveFolders},
|
||||||
|
"observe:journal": {rootNone, "", handleObserveJournal},
|
||||||
|
"observe:logins": {rootNone, "", handleObserveLogins},
|
||||||
|
"observe:cert": {rootNone, "", handleObserveCert},
|
||||||
"ctx_put": {rootCtx, "context store not configured", handleCtxPut},
|
"ctx_put": {rootCtx, "context store not configured", handleCtxPut},
|
||||||
"ctx_get": {rootCtx, "context store not configured", handleCtxGet},
|
"ctx_get": {rootCtx, "context store not configured", handleCtxGet},
|
||||||
"ctx_del": {rootCtx, "context store not configured", handleCtxDel},
|
"ctx_del": {rootCtx, "context store not configured", handleCtxDel},
|
||||||
@@ -158,10 +171,13 @@ var handlerOps = map[string]opHandler{
|
|||||||
// op_* (ops.go / ops_exec_linux.go): an op the host wrote, held under
|
// op_* (ops.go / ops_exec_linux.go): an op the host wrote, held under
|
||||||
// signed terms and run in a sandbox. rootDcn: the op store and ops.json
|
// signed terms and run in a sandbox. rootDcn: the op store and ops.json
|
||||||
// sit beside the pairing's grants, and the terms verify under its key.
|
// sit beside the pairing's grants, and the terms verify under its key.
|
||||||
|
// op_grant_drop <grant|all> (ops_bounds.go) takes a held grant back: a
|
||||||
|
// tighten (writes.go opTightens), so no quarantine or approval holds it.
|
||||||
"op_sync": {rootDcn, "dcn store not configured", handleOpSync},
|
"op_sync": {rootDcn, "dcn store not configured", handleOpSync},
|
||||||
"op_dry_run": {rootDcn, "dcn store not configured", handleOpDryRun},
|
"op_dry_run": {rootDcn, "dcn store not configured", handleOpDryRun},
|
||||||
"op_apply": {rootDcn, "dcn store not configured", handleOpApply},
|
"op_apply": {rootDcn, "dcn store not configured", handleOpApply},
|
||||||
"op_grant_run": {rootDcn, "dcn store not configured", handleOpGrantRun},
|
"op_grant_run": {rootDcn, "dcn store not configured", handleOpGrantRun},
|
||||||
|
"op_grant_drop": {rootDcn, "dcn store not configured", handleOpGrantDrop},
|
||||||
"op_list": {rootDcn, "dcn store not configured", handleOpList},
|
"op_list": {rootDcn, "dcn store not configured", handleOpList},
|
||||||
"op_run_status": {rootDcn, "dcn store not configured", handleOpRunStatus},
|
"op_run_status": {rootDcn, "dcn store not configured", handleOpRunStatus},
|
||||||
// op_plan_* (ops_plan.go): a line of a ship plan the person's device
|
// op_plan_* (ops_plan.go): a line of a ship plan the person's device
|
||||||
@@ -231,10 +247,12 @@ type mgmtAccount struct {
|
|||||||
self string
|
self string
|
||||||
op string // "" when the frame did not parse: the box does not know
|
op string // "" when the frame did not parse: the box does not know
|
||||||
// args is what the host passed, so the observe shelf can record WHAT was
|
// args is what the host passed, so the observe shelf can record WHAT was
|
||||||
// asked beside the result. Three ops read a caller value — observe:net
|
// asked beside the result. Seven ops read a caller value — observe:net
|
||||||
// (host[:port]), observe:metrics (a name), observe:logs (a name and an
|
// (host[:port]), observe:metrics (a name), observe:logs (a name and an
|
||||||
// optional selector); the rest ignore theirs. The shelf records args only
|
// optional window), observe:folders (a path and a depth), observe:journal
|
||||||
// for those three, bounded (machinestore.go observeArgsRecorded): the first
|
// (a unit and a window), observe:logins (hours) and observe:cert (a port
|
||||||
|
// and a server name); the rest ignore theirs. The shelf records args only
|
||||||
|
// for those seven, bounded (machinestore.go observeArgsRecorded): the first
|
||||||
// version recorded whatever the host sent for every op, unbounded, and
|
// version recorded whatever the host sent for every op, unbounded, and
|
||||||
// this comment — which said only one op took a value — made that look safe.
|
// this comment — which said only one op took a value — made that look safe.
|
||||||
args []string
|
args []string
|
||||||
@@ -258,7 +276,7 @@ func beginMgmtAccount(ws *websocket.Conn, self, op string, args []string) {
|
|||||||
|
|
||||||
// mgmtAccountOf is the record for this connection, or nil. Read by the observe
|
// mgmtAccountOf is the record for this connection, or nil. Read by the observe
|
||||||
// shelf to learn which op produced a result and which host asked, without the
|
// shelf to learn which op produced a result and which host asked, without the
|
||||||
// eleven handlers having to be told either.
|
// observe handlers having to be told either.
|
||||||
func mgmtAccountOf(ws *websocket.Conn) *mgmtAccount {
|
func mgmtAccountOf(ws *websocket.Conn) *mgmtAccount {
|
||||||
if v, ok := mgmtAccounts.Load(ws); ok {
|
if v, ok := mgmtAccounts.Load(ws); ok {
|
||||||
return v.(*mgmtAccount)
|
return v.(*mgmtAccount)
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ package main
|
|||||||
// execs runs as root.
|
// execs runs as root.
|
||||||
// The discipline is the one a read-only sudoers policy lives by: every op is
|
// The discipline is the one a read-only sudoers policy lives by: every op is
|
||||||
// a FIXED argv, read-only forms only, and no byte of a job's args ever reaches a shell or an
|
// a FIXED argv, read-only forms only, and no byte of a job's args ever reaches a shell or an
|
||||||
// argv position. The single exception is observe:net, whose one argument is
|
// argv position. The single exception in this file is observe:net, whose one argument is
|
||||||
// a host[:port] that passes a strict charset check and is then handed to
|
// a host[:port] that passes a strict charset check and is then handed to
|
||||||
// Go's net package — never to a program.
|
// Go's net package — never to a program. observe_reads.go has the one caller
|
||||||
|
// string in the family that does reach an argv: observe:journal's unit name,
|
||||||
|
// after systemd's unit-name alphabet and this machine's own unit list. Its
|
||||||
|
// windows reach journalctl only as numbers this box parsed and wrote back out.
|
||||||
//
|
//
|
||||||
// Every op answers typed JSON on stdout and exit 0. A box that cannot answer
|
// Every op answers typed JSON on stdout and exit 0. A box that cannot answer
|
||||||
// (a tool missing, a platform without the probes) says so in the JSON —
|
// (a tool missing, a platform without the probes) says so in the JSON —
|
||||||
@@ -849,7 +852,7 @@ func writeObserve(ws *websocket.Conn, v any) {
|
|||||||
// The machine's own record of what it answered (machinestore.go, the
|
// The machine's own record of what it answered (machinestore.go, the
|
||||||
// observe shelf): the same bytes that go down the lane, filed under the op
|
// observe shelf): the same bytes that go down the lane, filed under the op
|
||||||
// and the host that asked. Read off this connection's account rather than
|
// and the host that asked. Read off this connection's account rather than
|
||||||
// threaded through eleven handlers. BEFORE the reply, so a slow disk
|
// threaded through every handler. BEFORE the reply, so a slow disk
|
||||||
// cannot leave a result sent-but-unrecorded; a failed write is counted,
|
// cannot leave a result sent-but-unrecorded; a failed write is counted,
|
||||||
// never allowed to fail the reply.
|
// never allowed to fail the reply.
|
||||||
if a := mgmtAccountOf(ws); a != nil {
|
if a := mgmtAccountOf(ws); a != nil {
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ func TestNetTargetPublicOnly(t *testing.T) {
|
|||||||
|
|
||||||
var observeOps = []string{"observe:host", "observe:sockets", "observe:firewall", "observe:units",
|
var observeOps = []string{"observe:host", "observe:sockets", "observe:firewall", "observe:units",
|
||||||
"observe:disk", "observe:kernel", "observe:k8s", "observe:net",
|
"observe:disk", "observe:kernel", "observe:k8s", "observe:net",
|
||||||
"observe:alerts", "observe:metrics", "observe:logs"}
|
"observe:alerts", "observe:metrics", "observe:logs",
|
||||||
|
"observe:folders", "observe:journal", "observe:logins", "observe:cert"}
|
||||||
|
|
||||||
// observeArgOps are the ops in the family that read a caller value. Every one
|
// observeArgOps are the ops in the family that read a caller value. Every one
|
||||||
// of them must REFUSE the poison set rather than answer it; the rest ignore
|
// of them must REFUSE the poison set rather than answer it; the rest ignore
|
||||||
@@ -408,6 +409,10 @@ var observeArgRefusal = map[string]string{
|
|||||||
"observe:net": "one argument",
|
"observe:net": "one argument",
|
||||||
"observe:metrics": "exactly one argument",
|
"observe:metrics": "exactly one argument",
|
||||||
"observe:logs": "takes a name",
|
"observe:logs": "takes a name",
|
||||||
|
"observe:folders": "takes a folder path",
|
||||||
|
"observe:journal": "takes a unit name",
|
||||||
|
"observe:logins": "at most one argument",
|
||||||
|
"observe:cert": "takes a local tcp port",
|
||||||
}
|
}
|
||||||
|
|
||||||
var observeArgOps = func() map[string]bool {
|
var observeArgOps = func() map[string]bool {
|
||||||
@@ -560,7 +565,8 @@ func TestObserveArgsNeverReachArgv(t *testing.T) {
|
|||||||
t.Fatalf("%s: the arity refusal must say %q, got %q", op, want, errOut)
|
t.Fatalf("%s: the arity refusal must say %q, got %q", op, want, errOut)
|
||||||
}
|
}
|
||||||
// exactly one poisoned value: still refused — by the charset check
|
// exactly one poisoned value: still refused — by the charset check
|
||||||
// for observe:net, by the fixed query table for the other two.
|
// for observe:net, by the fixed query table for metrics and logs,
|
||||||
|
// and by the written grammar of each of the four in observe_reads.go.
|
||||||
_, errOut, code = driveObserve(t, op, poison[:1])
|
_, errOut, code = driveObserve(t, op, poison[:1])
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatalf("%s with a shell value must refuse: code=%d err=%q", op, code, errOut)
|
t.Fatalf("%s with a shell value must refuse: code=%d err=%q", op, code, errOut)
|
||||||
|
|||||||
@@ -325,8 +325,8 @@ func parseAlerts(raw []byte) (alertsReport, error) {
|
|||||||
|
|
||||||
// handleObserveAlerts takes no argument: the op is the whole active set, the
|
// handleObserveAlerts takes no argument: the op is the whole active set, the
|
||||||
// way observe:host is the whole posture. Args are ignored exactly as they are
|
// way observe:host is the whole posture. Args are ignored exactly as they are
|
||||||
// there, so the only arg-taking ops in the family stay observe:net,
|
// there, so it stays out of the arg-taking ops (observeArgsRecorded,
|
||||||
// observe:metrics and observe:logs.
|
// machinestore.go, lists them).
|
||||||
func handleObserveAlerts(ws *websocket.Conn, _ string, _ []string) {
|
func handleObserveAlerts(ws *websocket.Conn, _ string, _ []string) {
|
||||||
ctx, cancel := opBudget()
|
ctx, cancel := opBudget()
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,508 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// Linux drivers for observe:folders, observe:journal, observe:logins and
|
||||||
|
// observe:cert (observe_reads.go holds their grammar and parsers). The
|
||||||
|
// programs are the ones observe_ops_linux.go keeps to — journalctl, systemctl
|
||||||
|
// and ss, read-only forms only — and every exec shares one op budget.
|
||||||
|
//
|
||||||
|
// No elevation of its own. The networked install runs the bastion as root
|
||||||
|
// (deploy/install.sh writes no User=), which is how observe:host already reads
|
||||||
|
// nft and iptables, and so how these read the journal and every folder. A
|
||||||
|
// bastion run as a user reads what that user can: a folder it cannot open is
|
||||||
|
// named in skipped, and a journal it cannot read wholly is said so outright
|
||||||
|
// (journalAccess), because journalctl does not fail for such a user — it shows
|
||||||
|
// only the user's own lines, an empty answer that would read as a quiet machine.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalAccess is the default observeJournalAccess. root reads everything;
|
||||||
|
// any other account can read the system journal only when it may open the
|
||||||
|
// system journal file itself (the systemd-journal or adm group), which is
|
||||||
|
// exactly what journalctl will be able to do.
|
||||||
|
func journalAccess() string {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile("/etc/machine-id")
|
||||||
|
mid := strings.TrimSpace(string(raw))
|
||||||
|
if err == nil && machineIDRe.MatchString(mid) {
|
||||||
|
for _, dir := range []string{"/var/log/journal/", "/run/log/journal/"} {
|
||||||
|
if f, err := os.Open(dir + mid + "/system.journal"); err == nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "the bastion runs as an account that cannot read the system journal; " +
|
||||||
|
"run it as root (the networked install does) or add that account to the systemd-journal group"
|
||||||
|
}
|
||||||
|
|
||||||
|
var machineIDRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
|
||||||
|
|
||||||
|
// ── observe:folders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const (
|
||||||
|
// folderReadBatch is how many names one directory read takes: a folder of
|
||||||
|
// a million files is read in pieces, not into memory at once.
|
||||||
|
folderReadBatch = 512
|
||||||
|
// folderLinkCap bounds the set of hard-linked files already counted.
|
||||||
|
// Past it each further link is counted (sizes may read high) and the
|
||||||
|
// answer says so.
|
||||||
|
folderLinkCap = 1 << 20
|
||||||
|
// folderStuckMax bounds the walks left inside a read that has not
|
||||||
|
// returned. Each holds an OS thread for as long as that read hangs (a hard
|
||||||
|
// network mount: until it comes back), and a Host asking again and again
|
||||||
|
// about a path on one would otherwise pile them up until the bastion
|
||||||
|
// could start no more. Past it the op answers at once, sizing nothing.
|
||||||
|
folderStuckMax = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// folderStuckGrace is how long past the op budget the answer waits for a walk
|
||||||
|
// that is inside one read, before answering without it. folderSizer is the
|
||||||
|
// walk. Both are variables so a test can stand in a walk that never returns.
|
||||||
|
var (
|
||||||
|
folderStuckGrace = 2 * time.Second
|
||||||
|
folderSizer = sizeFolders
|
||||||
|
// folderWalksStuck counts the walks readFolders answered without that have not
|
||||||
|
// returned since.
|
||||||
|
folderWalksStuck atomic.Int32
|
||||||
|
)
|
||||||
|
|
||||||
|
// folderBeforeOpen is called with each folder's path between its stat and its
|
||||||
|
// open: a seam so a test can swap the folder in that gap. nil outside tests.
|
||||||
|
var folderBeforeOpen func(string)
|
||||||
|
|
||||||
|
// reasonChanged is the skip reason for a folder that is no longer the one the
|
||||||
|
// walk stat'd when it comes to open it: swapped for a link, or reached
|
||||||
|
// through a folder above it that was.
|
||||||
|
const reasonChanged = "changed while it was read, not counted"
|
||||||
|
|
||||||
|
type folderWalk struct {
|
||||||
|
ctx context.Context
|
||||||
|
root string
|
||||||
|
depth int
|
||||||
|
dev uint64
|
||||||
|
mounts map[string]bool
|
||||||
|
linked map[uint64]struct{}
|
||||||
|
linkFull bool
|
||||||
|
top folderTop
|
||||||
|
sized int
|
||||||
|
skips folderSkips
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *folderWalk) expired() bool {
|
||||||
|
if !w.cut && w.ctx.Err() != nil {
|
||||||
|
w.cut = true
|
||||||
|
}
|
||||||
|
return w.cut
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip records what could not be sized against the folder that holds it,
|
||||||
|
// folded to the asked depth. A file is never named: its folder is.
|
||||||
|
func (w *folderWalk) skip(dir, reason string) {
|
||||||
|
w.skips.add(folderAt(w.root, dir, w.depth), reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderSkipReason(err error) string {
|
||||||
|
var errno syscall.Errno
|
||||||
|
if errors.As(err, &errno) {
|
||||||
|
if errno == syscall.EACCES || errno == syscall.EPERM {
|
||||||
|
return "permission denied"
|
||||||
|
}
|
||||||
|
return errno.Error()
|
||||||
|
}
|
||||||
|
return "could not be read"
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstLink says whether a file with more than one link is being seen for
|
||||||
|
// the first time, so its blocks are counted once, as du counts them.
|
||||||
|
func (w *folderWalk) firstLink(ino uint64) bool {
|
||||||
|
if _, seen := w.linked[ino]; seen {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(w.linked) >= folderLinkCap {
|
||||||
|
w.linkFull = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w.linked[ino] = struct{}{}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFolder opens p only when it is still the folder st describes. Between
|
||||||
|
// the stat and the open, p, or a folder above it, can be swapped for a link
|
||||||
|
// by whoever owns the folder above; O_NOFOLLOW refuses a link at p itself,
|
||||||
|
// and the device and inode of what opened refuse one above it. Without this
|
||||||
|
// a link put there in that gap would lead the walk elsewhere, onto another
|
||||||
|
// filesystem or into /proc, under p's name.
|
||||||
|
func openFolder(p string, st *syscall.Stat_t) (*os.File, error) {
|
||||||
|
f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.ENOTDIR) {
|
||||||
|
return nil, errFolderChanged
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fi, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok || got.Dev != st.Dev || got.Ino != st.Ino {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, errFolderChanged
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errFolderChanged = errors.New(reasonChanged)
|
||||||
|
|
||||||
|
// dir sizes one folder and everything under it on the same filesystem, and
|
||||||
|
// returns the bytes. st is the folder's own stat, taken when its parent was
|
||||||
|
// read: its blocks are counted, and the folder opened must still be the one
|
||||||
|
// it describes (openFolder). Names are read in batches and stat'd as read;
|
||||||
|
// subfolders are walked after the folder is closed, so the walk holds one
|
||||||
|
// open folder at a time however deep it goes.
|
||||||
|
func (w *folderWalk) dir(p string, level int, st *syscall.Stat_t) int64 {
|
||||||
|
total := int64(st.Blocks) * 512
|
||||||
|
if w.expired() {
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
if folderBeforeOpen != nil {
|
||||||
|
folderBeforeOpen(p)
|
||||||
|
}
|
||||||
|
f, err := openFolder(p, st)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errFolderChanged) {
|
||||||
|
w.skip(p, reasonChanged)
|
||||||
|
} else {
|
||||||
|
w.skip(p, folderSkipReason(err))
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
var subdirs []string
|
||||||
|
var subSt []syscall.Stat_t
|
||||||
|
for !w.expired() {
|
||||||
|
ents, rerr := f.ReadDir(folderReadBatch)
|
||||||
|
for _, e := range ents {
|
||||||
|
child := childPath(p, e.Name())
|
||||||
|
if w.mounts[child] {
|
||||||
|
w.skip(child, reasonOtherFS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var st syscall.Stat_t
|
||||||
|
if err := syscall.Lstat(child, &st); err != nil {
|
||||||
|
// Gone between the read and the stat is churn, not a gap.
|
||||||
|
if !errors.Is(err, syscall.ENOENT) {
|
||||||
|
w.skip(p, folderSkipReason(err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
size := int64(st.Blocks) * 512
|
||||||
|
if st.Mode&syscall.S_IFMT == syscall.S_IFDIR {
|
||||||
|
// The mount table is read once at the start; a mount made
|
||||||
|
// since is caught here by its device instead.
|
||||||
|
if uint64(st.Dev) != w.dev {
|
||||||
|
w.skip(child, reasonOtherFS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subdirs = append(subdirs, child)
|
||||||
|
subSt = append(subSt, st)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uint64(st.Nlink) > 1 && !w.firstLink(uint64(st.Ino)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += size
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
if rerr != io.EOF {
|
||||||
|
w.skip(p, folderSkipReason(rerr))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
for i, c := range subdirs {
|
||||||
|
if w.expired() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
total += w.dir(c, level+1, &subSt[i])
|
||||||
|
}
|
||||||
|
if level >= 1 && level <= w.depth {
|
||||||
|
w.sized++
|
||||||
|
w.top.offer(folderSize{Path: p, Bytes: total, Depth: level})
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFolders sizes the folders under asked. The error is a refusal: a path
|
||||||
|
// that is not a folder here, or whose links lead into a kernel tree.
|
||||||
|
//
|
||||||
|
// All of it, the path's resolution included, runs on its own goroutine, so
|
||||||
|
// the answer goes out at the op budget even when one call never returns: a
|
||||||
|
// stat of a dead network mount named as the path, or a disk erroring slowly
|
||||||
|
// under it. That goroutine owns everything it touches; on the stuck path this
|
||||||
|
// function reads nothing of it and answers without it. Such a walk is counted
|
||||||
|
// until it returns, and past folderStuckMax of them no walk starts.
|
||||||
|
func readFolders(asked string, depth int) (foldersReport, error) {
|
||||||
|
stuck := func(partial, note string) foldersReport {
|
||||||
|
return foldersReport{Supported: true, Path: asked, Depth: depth, Folders: []folderSize{},
|
||||||
|
Skipped: []folderSkip{}, Partial: partial, Note: note}
|
||||||
|
}
|
||||||
|
if folderWalksStuck.Load() >= folderStuckMax {
|
||||||
|
return stuck("earlier reads on this machine have not returned",
|
||||||
|
"nothing was sized: "+strconv.Itoa(folderStuckMax)+" earlier walks are still inside a read that "+
|
||||||
|
"has not returned (a network mount that stopped answering, most often); try again once it answers"), nil
|
||||||
|
}
|
||||||
|
ctx, cancel := opBudget()
|
||||||
|
defer cancel()
|
||||||
|
type result struct {
|
||||||
|
rep foldersReport
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
// 0 running, 1 returned, 2 answered without: whichever side moves it
|
||||||
|
// from 0 first decides whether the walk holds a place in folderWalksStuck.
|
||||||
|
var state atomic.Int32
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
rep, err := folderSizer(ctx, asked, depth)
|
||||||
|
done <- result{rep, err}
|
||||||
|
if !state.CompareAndSwap(0, 1) {
|
||||||
|
folderWalksStuck.Add(-1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
return r.rep, r.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
select {
|
||||||
|
case r := <-done:
|
||||||
|
return r.rep, r.err
|
||||||
|
case <-time.After(folderStuckGrace):
|
||||||
|
if !state.CompareAndSwap(0, 2) {
|
||||||
|
r := <-done
|
||||||
|
return r.rep, r.err
|
||||||
|
}
|
||||||
|
folderWalksStuck.Add(1)
|
||||||
|
return stuck("a read under this path did not return inside the op budget",
|
||||||
|
"nothing was sized; ask about a folder further down, or try again"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeFolders(ctx context.Context, asked string, depth int) (foldersReport, error) {
|
||||||
|
resolved, err := filepath.EvalSymlinks(asked)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return foldersReport{}, errors.New("there is no folder at that path on this machine")
|
||||||
|
}
|
||||||
|
return foldersReport{}, errors.New("that path cannot be read: " + folderSkipReason(err))
|
||||||
|
}
|
||||||
|
if err := folderPathAllowed(resolved); err != nil {
|
||||||
|
return foldersReport{}, err
|
||||||
|
}
|
||||||
|
var st syscall.Stat_t
|
||||||
|
if err := syscall.Lstat(resolved, &st); err != nil {
|
||||||
|
return foldersReport{}, errors.New("that path cannot be read: " + folderSkipReason(err))
|
||||||
|
}
|
||||||
|
if st.Mode&syscall.S_IFMT != syscall.S_IFDIR {
|
||||||
|
return foldersReport{}, errors.New("that path is not a folder")
|
||||||
|
}
|
||||||
|
rep := foldersReport{Supported: true, Path: asked, Depth: depth}
|
||||||
|
if resolved != asked {
|
||||||
|
rep.Resolved = resolved
|
||||||
|
}
|
||||||
|
mounts := map[string]bool{}
|
||||||
|
if b, err := os.ReadFile("/proc/self/mountinfo"); err == nil {
|
||||||
|
mounts = parseMountPoints(string(b))
|
||||||
|
}
|
||||||
|
w := &folderWalk{ctx: ctx, root: resolved, depth: depth, dev: uint64(st.Dev), mounts: mounts,
|
||||||
|
linked: map[uint64]struct{}{}}
|
||||||
|
rep.TotalBytes = w.dir(resolved, 0, &st)
|
||||||
|
rep.Folders = w.top.sorted()
|
||||||
|
rep.Sized = w.sized
|
||||||
|
rep.Skipped = w.skips.list()
|
||||||
|
rep.SkippedTotal = w.skips.total
|
||||||
|
rep.Complete = !w.cut
|
||||||
|
if w.cut {
|
||||||
|
rep.Partial = "timeout"
|
||||||
|
rep.Note = "the walk ran out of time: every size is at least what is shown, and a folder it never reached is missing"
|
||||||
|
}
|
||||||
|
if w.linkFull {
|
||||||
|
rep.Note = strings.TrimSpace(rep.Note + " More than " + strconv.Itoa(folderLinkCap) +
|
||||||
|
" hard-linked files: past that each link was counted, so sizes may read high.")
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:journal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// readJournal reads one unit's recent journal. The unit has passed
|
||||||
|
// normalizeUnitName; the error is a refusal when this machine has no such
|
||||||
|
// unit, and every other gap is a reported reason.
|
||||||
|
func readJournal(unit string, minutes int) (journalReport, error) {
|
||||||
|
rep := journalReport{Source: "journal", Unit: unit, Minutes: minutes}
|
||||||
|
if !observeHave("journalctl") {
|
||||||
|
rep.Reason = "journalctl not installed"
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
if !observeHave("systemctl") {
|
||||||
|
rep.Reason = "systemctl not installed, so this machine cannot say which units it has"
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
if why := observeJournalAccess(); why != "" {
|
||||||
|
rep.Reason = why
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := opBudget()
|
||||||
|
defer cancel()
|
||||||
|
// list-units is observe:units' read with --all for --failed: every unit
|
||||||
|
// systemd has loaded. A failed run is not an empty list here either —
|
||||||
|
// it would refuse every unit as unknown — so its error is checked.
|
||||||
|
listed, lerr := observeExec(ctx, "systemctl", "list-units", "--all", "--no-legend", "--plain")
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
rep.Reason = "systemctl list-units: timeout"
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
if lerr != nil && len(listed) == 0 {
|
||||||
|
rep.Reason = "systemctl list-units: " + lerr.Error()
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
// The same for list-unit-files: without it an installed unit that is not
|
||||||
|
// loaded right now would be refused as one this machine does not have.
|
||||||
|
files, ferr := observeExec(ctx, "systemctl", "list-unit-files", "--no-legend", "--plain")
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
rep.Reason = "systemctl list-unit-files: timeout"
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
if ferr != nil && len(files) == 0 {
|
||||||
|
rep.Reason = "systemctl list-unit-files: " + ferr.Error()
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
loaded, installed := parseUnitList(string(listed)), parseUnitFiles(string(files))
|
||||||
|
if !unitKnown(unit, loaded, installed) {
|
||||||
|
return journalReport{}, errUnknownUnit(unit, loaded, installed)
|
||||||
|
}
|
||||||
|
// Bound to their flags with '=', so neither can be read as a flag of its
|
||||||
|
// own whatever it held. --lines is observeLogLineLimit, the line limit
|
||||||
|
// observe:logs asks Loki for: the newest 5000 lines in the window.
|
||||||
|
unitArg := "--unit=" + unit
|
||||||
|
sinceArg := "--since=-" + strconv.Itoa(minutes) + "min"
|
||||||
|
out, err := observeExec(ctx, "journalctl", unitArg, sinceArg, "-o", "short-iso", "--no-pager", "-q", "--lines=5000")
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
rep.Partial = "journalctl: timeout"
|
||||||
|
} else if err != nil && len(out) == 0 {
|
||||||
|
rep.Reason = "journalctl: " + err.Error()
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
rep.Supported = true
|
||||||
|
rep.Log, rep.Truncated = capLines(string(out), observeLogLineLimit, observeTextCap)
|
||||||
|
if strings.TrimSpace(rep.Log) != "" {
|
||||||
|
rep.Lines = strings.Count(strings.TrimRight(rep.Log, "\n"), "\n") + 1
|
||||||
|
} else {
|
||||||
|
rep.Log = ""
|
||||||
|
rep.Note = "no lines from " + unit + " in the last " + strconv.Itoa(minutes) + " minutes"
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:logins ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// readLogins counts sshd's logins over the window. sshd is matched two ways,
|
||||||
|
// either one enough ("+" is journalctl's OR): by program — sshd, and the
|
||||||
|
// sshd-session and sshd-auth processes newer OpenSSH splits it into — and by
|
||||||
|
// unit, ssh.service on Debian and Ubuntu, sshd.service elsewhere. --lines is
|
||||||
|
// loginsLineCap.
|
||||||
|
//
|
||||||
|
// Each match also needs _UID=0. Any account can run a program named sshd and
|
||||||
|
// log under _COMM=sshd, and one whose shell stays in ssh.service's cgroup (no
|
||||||
|
// pam_systemd) logs under that unit; either could add failures or accepted
|
||||||
|
// logins, or bury the real ones under 50,000 lines. _UID is journald's own,
|
||||||
|
// from the sender's credentials, and every message parsed here is written by
|
||||||
|
// sshd's root monitor (OpenSSH sends its unprivileged child's lines through
|
||||||
|
// it). A sshd run by an ordinary account is not read.
|
||||||
|
func readLogins(hours int) loginsReport {
|
||||||
|
rep := loginsReport{Source: "journal", Hours: hours, FailedTotal: -1, AcceptedTotal: -1,
|
||||||
|
FailedBySource: []loginSource{}, FailedByUser: []loginUser{}, Accepted: []acceptedLogin{}}
|
||||||
|
if !observeHave("journalctl") {
|
||||||
|
rep.Reason = "journalctl not installed"
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
if why := observeJournalAccess(); why != "" {
|
||||||
|
rep.Reason = why
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
ctx, cancel := opBudget()
|
||||||
|
defer cancel()
|
||||||
|
sinceArg := "--since=-" + strconv.Itoa(hours) + "h"
|
||||||
|
out, err := observeExec(ctx, "journalctl", sinceArg, "-o", "short-iso", "--no-pager", "-q", "--lines=50000",
|
||||||
|
"_COMM=sshd", "_COMM=sshd-session", "_COMM=sshd-auth", "_UID=0",
|
||||||
|
"+", "_SYSTEMD_UNIT=ssh.service", "_SYSTEMD_UNIT=sshd.service", "_UID=0")
|
||||||
|
late := ctx.Err() != nil
|
||||||
|
if !late && err != nil && len(out) == 0 {
|
||||||
|
rep.Reason = "journalctl: " + err.Error()
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
t := parseSSHLogins(string(out))
|
||||||
|
rep.Supported = true
|
||||||
|
t.fill(&rep)
|
||||||
|
rep.Truncated = t.entries >= loginsLineCap
|
||||||
|
if late {
|
||||||
|
rep.Partial = "journalctl: timeout"
|
||||||
|
}
|
||||||
|
if t.entries == 0 {
|
||||||
|
rep.Note = "no sshd entries in the journal in the last " + strconv.Itoa(hours) + " hours"
|
||||||
|
}
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:cert ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// readCert reads the certificate this machine's listener on port serves. The
|
||||||
|
// listener table is observe:sockets' own read (ss -tlnpH); the error is a
|
||||||
|
// refusal when nothing listens on the port.
|
||||||
|
func readCert(port int, name string) (certReport, error) {
|
||||||
|
rep := certReport{Port: port, ServerName: name, Listening: []string{}, Tried: []string{},
|
||||||
|
CheckedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if !observeHave("ss") {
|
||||||
|
rep.Reason = "ss not installed, so this machine cannot say which ports it listens on"
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := opBudget()
|
||||||
|
defer cancel()
|
||||||
|
out, err := observeExec(ctx, "ss", "-tlnpH")
|
||||||
|
// A failed ss is not an empty table (readSockets says why): here an empty
|
||||||
|
// table would refuse every port as not listening.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
rep.Reason = "ss: timeout"
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
rep.Reason = "ss: " + err.Error()
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
targets, listening := certTargets(parseSS("tcp", string(out)), port, observeOwnAddrs())
|
||||||
|
if len(listening) == 0 {
|
||||||
|
return certReport{}, errors.New("nothing on this machine listens on tcp port " + strconv.Itoa(port))
|
||||||
|
}
|
||||||
|
rep.Supported = true
|
||||||
|
rep.Listening = listening
|
||||||
|
dialCert(ctx, &rep, targets, name)
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubObserveReads puts every exec of the four ops behind answer and records
|
||||||
|
// each argv, joined by spaces, in the order run. Every tool is present and
|
||||||
|
// the journal readable unless a test says otherwise.
|
||||||
|
func stubObserveReads(t *testing.T, answer func(argv string) ([]byte, error)) func() []string {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var recorded []string
|
||||||
|
origExec, origHave, origAccess, origOwn := observeExec, observeHave, observeJournalAccess, observeOwnAddrs
|
||||||
|
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
argv := strings.Join(append([]string{name}, args...), " ")
|
||||||
|
mu.Lock()
|
||||||
|
recorded = append(recorded, argv)
|
||||||
|
mu.Unlock()
|
||||||
|
return answer(argv)
|
||||||
|
}
|
||||||
|
observeHave = func(string) bool { return true }
|
||||||
|
observeJournalAccess = func() string { return "" }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
observeExec, observeHave, observeJournalAccess, observeOwnAddrs = origExec, origHave, origAccess, origOwn
|
||||||
|
})
|
||||||
|
return func() []string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
out := append([]string(nil), recorded...)
|
||||||
|
recorded = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unitAnswers(journal string) func(string) ([]byte, error) {
|
||||||
|
return func(argv string) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(argv, "systemctl list-units "):
|
||||||
|
return []byte(fxListUnits), nil
|
||||||
|
case strings.HasPrefix(argv, "systemctl list-unit-files "):
|
||||||
|
return []byte(fxUnitFiles), nil
|
||||||
|
case strings.HasPrefix(argv, "journalctl "):
|
||||||
|
return []byte(journal), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected " + argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:journal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// observe:journal runs exactly three argvs, and the unit reaches the third
|
||||||
|
// only as a name this machine's own lists hold, bound to --unit=.
|
||||||
|
func TestObserveJournalRunsOnlyItsPinnedArgv(t *testing.T) {
|
||||||
|
log := "2026-10-29T10:00:00+00:00 box-a example[42]: started\n" +
|
||||||
|
"2026-10-29T10:00:05+00:00 box-a example[42]: listening on 8080\n"
|
||||||
|
recorded := stubObserveReads(t, unitAnswers(log))
|
||||||
|
out, errOut, code := driveObserve(t, "observe:journal", []string{"example", "5"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit %d: %s", code, errOut)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"systemctl list-units --all --no-legend --plain",
|
||||||
|
"systemctl list-unit-files --no-legend --plain",
|
||||||
|
"journalctl --unit=example.service --since=-5min -o short-iso --no-pager -q --lines=" +
|
||||||
|
strconv.Itoa(observeLogLineLimit),
|
||||||
|
}
|
||||||
|
if got := recorded(); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Fatalf("argvs\n got: %q\nwant: %q", got, want)
|
||||||
|
}
|
||||||
|
var rep journalReport
|
||||||
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||||
|
t.Fatalf("%v: %s", err, out)
|
||||||
|
}
|
||||||
|
if !rep.Supported || rep.Unit != "example.service" || rep.Minutes != 5 || rep.Lines != 2 || rep.Truncated ||
|
||||||
|
!strings.HasSuffix(rep.Log, "listening on 8080") || rep.Note != "" || rep.Source != "journal" {
|
||||||
|
t.Fatalf("%+v", rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installed and unloaded since, and an instance of an installed template:
|
||||||
|
// both are this machine's units.
|
||||||
|
for unit, want := range map[string]string{"report": "--unit=report.service", "worker@3": "--unit=worker@3.service"} {
|
||||||
|
if _, errOut, code := driveObserve(t, "observe:journal", []string{unit}); code != 0 {
|
||||||
|
t.Fatalf("%s: exit %d %s", unit, code, errOut)
|
||||||
|
}
|
||||||
|
got := recorded()
|
||||||
|
if len(got) != 3 || !strings.HasPrefix(got[2], "journalctl "+want+" --since=-15min ") {
|
||||||
|
t.Fatalf("%s: %q", unit, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A unit this machine does not have, or knows only as not-found, is
|
||||||
|
// refused before journalctl runs, and the refusal names the near ones.
|
||||||
|
for unit, hint := range map[string]string{"ssh": "ssh.socket", "gone": "no unit gone.service"} {
|
||||||
|
_, errOut, code := driveObserve(t, "observe:journal", []string{unit})
|
||||||
|
if code != 2 || !strings.Contains(errOut, "refused: this machine has no unit") || !strings.Contains(errOut, hint) {
|
||||||
|
t.Fatalf("%s: code=%d err=%q", unit, code, errOut)
|
||||||
|
}
|
||||||
|
for _, argv := range recorded() {
|
||||||
|
if strings.HasPrefix(argv, "journalctl") {
|
||||||
|
t.Fatalf("%s: journalctl ran for a unit this machine does not have: %q", unit, argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveJournalSaysWhyItCannotAnswer(t *testing.T) {
|
||||||
|
recorded := stubObserveReads(t, unitAnswers(""))
|
||||||
|
// Empty: a note that names the unit and the window.
|
||||||
|
out, _, code := driveObserve(t, "observe:journal", []string{"example.service", "1"})
|
||||||
|
var rep journalReport
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || !rep.Supported || rep.Lines != 0 || rep.Log != "" ||
|
||||||
|
rep.Note != "no lines from example.service in the last 1 minutes" {
|
||||||
|
t.Fatalf("empty: %d %+v", code, rep)
|
||||||
|
}
|
||||||
|
recorded()
|
||||||
|
|
||||||
|
// An account that cannot read the system journal: said, at exit 0, and
|
||||||
|
// nothing is run — journalctl would show that account's own lines only.
|
||||||
|
observeJournalAccess = func() string { return "cannot read the system journal" }
|
||||||
|
out, _, code = driveObserve(t, "observe:journal", []string{"example"})
|
||||||
|
rep = journalReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || rep.Supported || rep.Reason != "cannot read the system journal" || len(recorded()) != 0 {
|
||||||
|
t.Fatalf("no access: %d %+v", code, rep)
|
||||||
|
}
|
||||||
|
observeJournalAccess = func() string { return "" }
|
||||||
|
|
||||||
|
// A list-units that failed is not an empty list: every unit would be
|
||||||
|
// refused as unknown.
|
||||||
|
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
return nil, errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
out, _, code = driveObserve(t, "observe:journal", []string{"example"})
|
||||||
|
rep = journalReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || rep.Supported || !strings.Contains(rep.Reason, "systemctl list-units: exit status 1") {
|
||||||
|
t.Fatalf("failed list: %d %+v", code, rep)
|
||||||
|
}
|
||||||
|
// And a failed list-unit-files: an installed unit that is not loaded now
|
||||||
|
// would otherwise be refused as one this machine does not have.
|
||||||
|
observeExec = func(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
if args[0] == "list-units" {
|
||||||
|
return []byte(fxListUnits), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("exit status 1")
|
||||||
|
}
|
||||||
|
out, _, code = driveObserve(t, "observe:journal", []string{"report"})
|
||||||
|
rep = journalReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || rep.Supported || !strings.Contains(rep.Reason, "systemctl list-unit-files: exit status 1") {
|
||||||
|
t.Fatalf("failed unit files: %d %+v", code, rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:logins ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestObserveLoginsRunsItsPinnedArgv(t *testing.T) {
|
||||||
|
recorded := stubObserveReads(t, func(argv string) ([]byte, error) { return []byte(fxSSHD), nil })
|
||||||
|
out, errOut, code := driveObserve(t, "observe:logins", []string{"48"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit %d: %s", code, errOut)
|
||||||
|
}
|
||||||
|
want := "journalctl --since=-48h -o short-iso --no-pager -q --lines=" + strconv.Itoa(loginsLineCap) +
|
||||||
|
" _COMM=sshd _COMM=sshd-session _COMM=sshd-auth _UID=0 + _SYSTEMD_UNIT=ssh.service _SYSTEMD_UNIT=sshd.service _UID=0"
|
||||||
|
if got := recorded(); len(got) != 1 || got[0] != want {
|
||||||
|
t.Fatalf("argv\n got: %q\nwant: %q", got, want)
|
||||||
|
}
|
||||||
|
var rep loginsReport
|
||||||
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||||
|
t.Fatalf("%v: %s", err, out)
|
||||||
|
}
|
||||||
|
if !rep.Supported || rep.Hours != 48 || rep.FailedTotal != 8 || rep.FailedSources != 5 || rep.AcceptedTotal != 2 ||
|
||||||
|
rep.EntriesRead != 16 || rep.Truncated || rep.Note != "" || rep.OldestEntry != "2026-10-29T10:00:01Z" {
|
||||||
|
t.Fatalf("%+v", rep)
|
||||||
|
}
|
||||||
|
if _, _, code := driveObserve(t, "observe:logins", nil); code != 0 || !strings.HasPrefix(recorded()[0], "journalctl --since=-24h ") {
|
||||||
|
t.Fatalf("the default window is 24 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No journal access: unknown, not zero.
|
||||||
|
observeJournalAccess = func() string { return "cannot read the system journal" }
|
||||||
|
out, _, code = driveObserve(t, "observe:logins", nil)
|
||||||
|
rep = loginsReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || rep.Supported || rep.FailedTotal != -1 || rep.AcceptedTotal != -1 || len(recorded()) != 0 {
|
||||||
|
t.Fatalf("no access must read unknown (-1), never 0: %d %+v", code, rep)
|
||||||
|
}
|
||||||
|
observeJournalAccess = func() string { return "" }
|
||||||
|
|
||||||
|
// Nothing logged: zeros, and a note that says which window was empty.
|
||||||
|
observeExec = func(context.Context, string, ...string) ([]byte, error) { return nil, nil }
|
||||||
|
out, _, _ = driveObserve(t, "observe:logins", []string{"1"})
|
||||||
|
rep = loginsReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if !rep.Supported || rep.FailedTotal != 0 || rep.Note != "no sshd entries in the journal in the last 1 hours" ||
|
||||||
|
rep.FailedBySource == nil || rep.Accepted == nil {
|
||||||
|
t.Fatalf("empty: %+v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any account can name a process sshd (copy a logger, call it sshd) and
|
||||||
|
// write lines journald files under _COMM=sshd, and an account whose shell
|
||||||
|
// stays in ssh.service's cgroup (no pam_systemd) writes under that unit. Only
|
||||||
|
// root's lines count: every message this op reads is the root monitor's
|
||||||
|
// (OpenSSH logs its unprivileged child through it), and _UID is journald's
|
||||||
|
// own, from the sender's credentials. Each disjunct carries _UID=0, so no
|
||||||
|
// match reads a non-root line.
|
||||||
|
func TestObserveLoginsCountsOnlyRootsSSHDLines(t *testing.T) {
|
||||||
|
recorded := stubObserveReads(t, func(string) ([]byte, error) { return nil, nil })
|
||||||
|
if _, errOut, code := driveObserve(t, "observe:logins", nil); code != 0 {
|
||||||
|
t.Fatalf("exit %d: %s", code, errOut)
|
||||||
|
}
|
||||||
|
got := recorded()
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
f := strings.Fields(got[0])
|
||||||
|
start := 0
|
||||||
|
for i, a := range f {
|
||||||
|
if strings.HasPrefix(a, "--lines=") {
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start == 0 || start >= len(f) {
|
||||||
|
t.Fatalf("no matches after --lines: %q", got[0])
|
||||||
|
}
|
||||||
|
var groups [][]string
|
||||||
|
cur := []string{}
|
||||||
|
for _, a := range f[start:] {
|
||||||
|
if a == "+" {
|
||||||
|
groups, cur = append(groups, cur), []string{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur = append(cur, a)
|
||||||
|
}
|
||||||
|
groups = append(groups, cur)
|
||||||
|
for _, g := range groups {
|
||||||
|
uid := false
|
||||||
|
for _, a := range g {
|
||||||
|
uid = uid || a == "_UID=0"
|
||||||
|
}
|
||||||
|
if !uid {
|
||||||
|
t.Fatalf("a match reads lines any account can write: %q in %q", g, got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:cert ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func ssLine(local string, port int) string {
|
||||||
|
return "LISTEN 0 4096 " + local + ":" + strconv.Itoa(port) + " 0.0.0.0:* users:((\"example\",pid=10,fd=3))\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// The port must be one this machine listens on, the listener table is ss's,
|
||||||
|
// and the handshake goes to loopback or this machine's own addresses only.
|
||||||
|
func TestObserveCertReadsOnlyThisMachinesListener(t *testing.T) {
|
||||||
|
cert := observeTestCert(t, "box-a.example", []string{"box-a.example"}, nil, time.Now().Add(10*24*time.Hour+time.Hour))
|
||||||
|
port := observeTLSListener(t, cert)
|
||||||
|
foreign := closedPort(t)
|
||||||
|
ss := ssLine("127.0.0.1", port) + ssLine("192.0.2.50", foreign)
|
||||||
|
recorded := stubObserveReads(t, func(argv string) ([]byte, error) {
|
||||||
|
if argv == "ss -tlnpH" {
|
||||||
|
return []byte(ss), nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected " + argv)
|
||||||
|
})
|
||||||
|
observeOwnAddrs = func() map[netip.Addr]bool { return map[netip.Addr]bool{netip.MustParseAddr("127.0.0.1"): true} }
|
||||||
|
|
||||||
|
out, errOut, code := driveObserve(t, "observe:cert", []string{strconv.Itoa(port), "box-a.example"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit %d: %s", code, errOut)
|
||||||
|
}
|
||||||
|
if got := recorded(); len(got) != 1 || got[0] != "ss -tlnpH" {
|
||||||
|
t.Fatalf("the one argv is observe:sockets' tcp read: %q", got)
|
||||||
|
}
|
||||||
|
var rep certReport
|
||||||
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||||
|
t.Fatalf("%v: %s", err, out)
|
||||||
|
}
|
||||||
|
if !rep.Supported || rep.ConnectedTo != "127.0.0.1:"+strconv.Itoa(port) || !rep.TLS || rep.ServerName != "box-a.example" ||
|
||||||
|
len(rep.Listening) != 1 || rep.Listening[0] != "127.0.0.1" || rep.DaysLeft == nil || *rep.DaysLeft != 10 ||
|
||||||
|
rep.ChainVerified == nil || *rep.ChainVerified || rep.CheckedAt == "" {
|
||||||
|
t.Fatalf("%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing listens: refused, and nothing dialled.
|
||||||
|
_, errOut, code = driveObserve(t, "observe:cert", []string{"1"})
|
||||||
|
if code != 2 || !strings.Contains(errOut, "refused: nothing on this machine listens on tcp port 1") {
|
||||||
|
t.Fatalf("code=%d err=%q", code, errOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A listener bound to an address this machine does not hold: loopback is
|
||||||
|
// tried and the other address never is — it would reach another machine.
|
||||||
|
out, _, code = driveObserve(t, "observe:cert", []string{strconv.Itoa(foreign)})
|
||||||
|
rep = certReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || !rep.Supported || rep.ConnectedTo != "" || len(rep.Tried) != 2 || rep.TLS {
|
||||||
|
t.Fatalf("%d %s", code, out)
|
||||||
|
}
|
||||||
|
for _, tried := range rep.Tried {
|
||||||
|
if strings.Contains(tried, "192.0.2.50") {
|
||||||
|
t.Fatalf("dialled an address this machine does not hold: %v", rep.Tried)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed ss is not an empty table: it says so instead of refusing.
|
||||||
|
observeExec = func(context.Context, string, ...string) ([]byte, error) { return nil, errors.New("exit status 1") }
|
||||||
|
out, _, code = driveObserve(t, "observe:cert", []string{strconv.Itoa(port)})
|
||||||
|
rep = certReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if code != 0 || rep.Supported || rep.Reason != "ss: exit status 1" {
|
||||||
|
t.Fatalf("%d %s", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:folders ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, p string, n int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, bytes.Repeat([]byte("x"), n), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// folderTree is a tree with a nested chain, a folder with one file linked
|
||||||
|
// twice, a file linked from a second folder, and an empty folder.
|
||||||
|
func folderTree(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, root+"/a/a1", 64<<10)
|
||||||
|
writeFile(t, root+"/a/b/b1", 32<<10)
|
||||||
|
writeFile(t, root+"/a/b/c/c1", 16<<10)
|
||||||
|
writeFile(t, root+"/a/b/c/d/d1", 8<<10)
|
||||||
|
writeFile(t, root+"/big/big1", 256<<10)
|
||||||
|
if err := os.Link(root+"/big/big1", root+"/big/big1-again"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(root+"/empty", 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func blocksOf(t *testing.T, p string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var st syscall.Stat_t
|
||||||
|
if err := syscall.Lstat(p, &st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return int64(st.Blocks) * 512
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveFoldersSizesTheTree(t *testing.T) {
|
||||||
|
root := folderTree(t)
|
||||||
|
rep, err := readFolders(root, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !rep.Supported || !rep.Complete || rep.Partial != "" || rep.Path != root || rep.Resolved != "" || rep.Depth != 2 {
|
||||||
|
t.Fatalf("%+v", rep)
|
||||||
|
}
|
||||||
|
// What is expected, from the blocks of each thing: big1 once, though two
|
||||||
|
// names link it.
|
||||||
|
dirs := func(ps ...string) (n int64) {
|
||||||
|
for _, p := range ps {
|
||||||
|
n += blocksOf(t, p)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
c := dirs(root+"/a/b/c", root+"/a/b/c/c1", root+"/a/b/c/d", root+"/a/b/c/d/d1")
|
||||||
|
b := dirs(root+"/a/b", root+"/a/b/b1") + c
|
||||||
|
a := dirs(root+"/a", root+"/a/a1") + b
|
||||||
|
bigd := dirs(root+"/big", root+"/big/big1")
|
||||||
|
empty := dirs(root + "/empty")
|
||||||
|
want := map[string]int64{root + "/a": a, root + "/a/b": b, root + "/big": bigd, root + "/empty": empty}
|
||||||
|
if rep.Sized != len(want) || len(rep.Folders) != len(want) {
|
||||||
|
t.Fatalf("depth 2 sizes the folders at 1 and 2 below the path: %+v", rep.Folders)
|
||||||
|
}
|
||||||
|
for _, f := range rep.Folders {
|
||||||
|
if want[f.Path] != f.Bytes {
|
||||||
|
t.Fatalf("%s: %d, want %d", f.Path, f.Bytes, want[f.Path])
|
||||||
|
}
|
||||||
|
if f.Depth != strings.Count(strings.TrimPrefix(f.Path, root), "/") {
|
||||||
|
t.Fatalf("depth of %s: %d", f.Path, f.Depth)
|
||||||
|
}
|
||||||
|
if strings.Contains(f.Path, "/a/b/c") {
|
||||||
|
t.Fatalf("a folder below the depth was named: %s", f.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rep.TotalBytes != blocksOf(t, root)+a+bigd+empty {
|
||||||
|
t.Fatalf("total %d", rep.TotalBytes)
|
||||||
|
}
|
||||||
|
if rep.Folders[0].Path != root+"/big" && rep.Folders[0].Path != root+"/a" {
|
||||||
|
t.Fatalf("largest first: %+v", rep.Folders)
|
||||||
|
}
|
||||||
|
// Only folders: no file name anywhere in the answer.
|
||||||
|
raw, _ := json.Marshal(rep)
|
||||||
|
for _, file := range []string{"a1", "b1", "c1", "d1", "big1"} {
|
||||||
|
if strings.Contains(string(raw), "/"+file) {
|
||||||
|
t.Fatalf("a file name reached the answer: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Through a symlink: sized where it leads, and said so.
|
||||||
|
link := filepath.Join(t.TempDir(), "link")
|
||||||
|
if err := os.Symlink(root, link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rep, err = readFolders(link, 1)
|
||||||
|
if err != nil || rep.Resolved != root || rep.Path != link || rep.Sized != 3 {
|
||||||
|
t.Fatalf("%v %+v", err, rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same tree measured by du: the op's numbers are du -x's, folder for
|
||||||
|
// folder. Skipped where there is no GNU du to ask.
|
||||||
|
func TestObserveFoldersAgreesWithDu(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("du"); err != nil {
|
||||||
|
t.Skip("no du here")
|
||||||
|
}
|
||||||
|
root := folderTree(t)
|
||||||
|
out, err := exec.Command("du", "-x", "-B1", "--max-depth=2", root).Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("not a du that takes -B1 --max-depth: %v", err)
|
||||||
|
}
|
||||||
|
du := map[string]int64{}
|
||||||
|
for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
n, p, _ := strings.Cut(l, "\t")
|
||||||
|
du[p], _ = strconv.ParseInt(n, 10, 64)
|
||||||
|
}
|
||||||
|
rep, err := readFolders(root, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rep.TotalBytes != du[root] {
|
||||||
|
t.Fatalf("total %d, du %d", rep.TotalBytes, du[root])
|
||||||
|
}
|
||||||
|
for _, f := range rep.Folders {
|
||||||
|
if du[f.Path] != f.Bytes {
|
||||||
|
t.Fatalf("%s: %d, du %d", f.Path, f.Bytes, du[f.Path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mount point under the path is not crossed and not stat'd, and is
|
||||||
|
// reported against the folder at the asked depth that holds it.
|
||||||
|
func TestObserveFoldersDoesNotCrossAMount(t *testing.T) {
|
||||||
|
root := folderTree(t)
|
||||||
|
var st syscall.Stat_t
|
||||||
|
if err := syscall.Lstat(root, &st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
walk := func(depth int, mounts ...string) *folderWalk {
|
||||||
|
w := &folderWalk{ctx: context.Background(), root: root, depth: depth, dev: uint64(st.Dev),
|
||||||
|
mounts: map[string]bool{}, linked: map[uint64]struct{}{}}
|
||||||
|
for _, m := range mounts {
|
||||||
|
w.mounts[m] = true
|
||||||
|
}
|
||||||
|
w.dir(root, 0, &st)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
w := walk(2, root+"/a/b")
|
||||||
|
for _, f := range w.top.sorted() {
|
||||||
|
if f.Path == root+"/a/b" {
|
||||||
|
t.Fatalf("a mount point was sized: %+v", f)
|
||||||
|
}
|
||||||
|
if f.Path == root+"/a" && f.Bytes != blocksOf(t, root+"/a")+blocksOf(t, root+"/a/a1") {
|
||||||
|
t.Fatalf("what is under the mount was counted: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l := w.skips.list(); len(l) != 1 || l[0].Path != root+"/a/b" || l[0].Reason != reasonOtherFS || l[0].Count != 1 {
|
||||||
|
t.Fatalf("%+v", l)
|
||||||
|
}
|
||||||
|
w = walk(1, root+"/a/b/c/d")
|
||||||
|
if l := w.skips.list(); len(l) != 1 || l[0].Path != root+"/a" {
|
||||||
|
t.Fatalf("a deep skip is named by its folder at the asked depth: %+v", l)
|
||||||
|
}
|
||||||
|
// A different device on a folder, as a mount made after the table was
|
||||||
|
// read looks: not crossed either.
|
||||||
|
w = &folderWalk{ctx: context.Background(), root: root, depth: 2, dev: uint64(st.Dev) + 1,
|
||||||
|
mounts: map[string]bool{}, linked: map[uint64]struct{}{}}
|
||||||
|
w.dir(root, 0, &st)
|
||||||
|
if w.sized != 0 || w.skips.total != 3 {
|
||||||
|
t.Fatalf("every subfolder is on another device here: sized %d skipped %d", w.sized, w.skips.total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder swapped for a link between the walk's stat and its open, or one
|
||||||
|
// reached through a folder above it that was, is not read: the walk reads
|
||||||
|
// only the folder it stat'd, so a link can never lead it elsewhere, onto
|
||||||
|
// another filesystem or into /proc. Here elsewhere is on the same filesystem,
|
||||||
|
// so the device alone would not tell; the inode does.
|
||||||
|
func TestObserveFoldersReadsOnlyTheFolderItStatd(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ name, swap string }{
|
||||||
|
{"the folder itself", "/a/b"},
|
||||||
|
{"a folder above it", "/a"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
root := folderTree(t)
|
||||||
|
elsewhere := t.TempDir()
|
||||||
|
writeFile(t, elsewhere+"/b/elsewhere1", 4<<20)
|
||||||
|
target := elsewhere
|
||||||
|
if tc.swap == "/a/b" {
|
||||||
|
target = elsewhere + "/b"
|
||||||
|
}
|
||||||
|
orig := folderBeforeOpen
|
||||||
|
t.Cleanup(func() { folderBeforeOpen = orig })
|
||||||
|
folderBeforeOpen = func(p string) {
|
||||||
|
if p != root+"/a/b" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(root+tc.swap, root+tc.swap+".moved"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, root+tc.swap); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rep, err := readFolders(root, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rep.TotalBytes >= 4<<20 {
|
||||||
|
t.Fatalf("the walk read a folder it never stat'd: total %d, %+v", rep.TotalBytes, rep.Folders)
|
||||||
|
}
|
||||||
|
for _, f := range rep.Folders {
|
||||||
|
if f.Bytes >= 4<<20 {
|
||||||
|
t.Fatalf("the walk read a folder it never stat'd: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range rep.Skipped {
|
||||||
|
found = found || (s.Path == root+"/a/b" && s.Reason == reasonChanged)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the swapped folder is not said: %+v", rep.Skipped)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A walk inside a read that never returns (a hard network mount that went
|
||||||
|
// away) keeps its thread however long that read hangs. The op answers at the
|
||||||
|
// budget either way, and past folderStuckMax such walks it starts no more, so
|
||||||
|
// a Host asking again and again cannot pile threads up until the bastion can
|
||||||
|
// start none. A walk that comes back frees its place.
|
||||||
|
func TestObserveFoldersStartsNoWalkPastTheStuckOnes(t *testing.T) {
|
||||||
|
origSizer, origGrace, origBudget := folderSizer, folderStuckGrace, observeOpBudget
|
||||||
|
release := make(chan struct{})
|
||||||
|
var started atomic.Int32
|
||||||
|
folderSizer = func(context.Context, string, int) (foldersReport, error) {
|
||||||
|
started.Add(1)
|
||||||
|
<-release
|
||||||
|
return foldersReport{}, nil
|
||||||
|
}
|
||||||
|
folderStuckGrace, observeOpBudget = time.Millisecond, time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
folderSizer, folderStuckGrace, observeOpBudget = origSizer, origGrace, origBudget
|
||||||
|
})
|
||||||
|
for i := 0; i < folderStuckMax+3; i++ {
|
||||||
|
rep, err := readFolders("/srv", 1)
|
||||||
|
if err != nil || rep.Partial == "" || len(rep.Folders) != 0 {
|
||||||
|
t.Fatalf("call %d: %v %+v", i, err, rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := started.Load(); n != folderStuckMax {
|
||||||
|
t.Fatalf("%d walks started behind reads that never returned; at most %d may", n, folderStuckMax)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for folderWalksStuck.Load() != 0 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if n := folderWalksStuck.Load(); n != 0 {
|
||||||
|
t.Fatalf("walks that came back still hold %d places", n)
|
||||||
|
}
|
||||||
|
folderSizer = func(context.Context, string, int) (foldersReport, error) {
|
||||||
|
started.Add(1)
|
||||||
|
return foldersReport{Supported: true, Complete: true}, nil
|
||||||
|
}
|
||||||
|
observeOpBudget = time.Second
|
||||||
|
if rep, err := readFolders("/srv", 1); err != nil || !rep.Complete {
|
||||||
|
t.Fatalf("a walk once the stuck ones came back: %v %+v", err, rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObserveFoldersRefusalsAndBudget(t *testing.T) {
|
||||||
|
root := folderTree(t)
|
||||||
|
for p, want := range map[string]string{
|
||||||
|
root + "/a/a1": "not a folder",
|
||||||
|
root + "/missing": "no folder at that path",
|
||||||
|
root + "/a/missing": "no folder at that path",
|
||||||
|
} {
|
||||||
|
if _, err := readFolders(p, 2); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A link into /proc is refused like /proc.
|
||||||
|
link := filepath.Join(t.TempDir(), "into-proc")
|
||||||
|
if err := os.Symlink("/proc/self", link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := readFolders(link, 1); err == nil || err.Error() != errFolderKernelTree {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
// Through the dispatcher: refused at exit 2 without the path.
|
||||||
|
_, errOut, code := driveObserve(t, "observe:folders", []string{root + "/a/a1"})
|
||||||
|
if code != 2 || !strings.Contains(errOut, "not a folder") || strings.Contains(errOut, root) {
|
||||||
|
t.Fatalf("code=%d err=%q", code, errOut)
|
||||||
|
}
|
||||||
|
// An unreadable folder is named in skipped. Root reads every folder, so
|
||||||
|
// this half runs as a user only.
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
if err := os.Chmod(root+"/a/b", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Chmod(root+"/a/b", 0o755)
|
||||||
|
rep, err := readFolders(root, 1)
|
||||||
|
if err != nil || len(rep.Skipped) != 1 || rep.Skipped[0].Path != root+"/a" || rep.Skipped[0].Reason != "permission denied" {
|
||||||
|
t.Fatalf("%v %+v", err, rep.Skipped)
|
||||||
|
}
|
||||||
|
_ = os.Chmod(root+"/a/b", 0o755)
|
||||||
|
}
|
||||||
|
// Out of budget: an answer, marked incomplete, never a hang.
|
||||||
|
orig := observeOpBudget
|
||||||
|
observeOpBudget = time.Nanosecond
|
||||||
|
defer func() { observeOpBudget = orig }()
|
||||||
|
rep, err := readFolders(root, 2)
|
||||||
|
if err != nil || rep.Complete || rep.Partial != "timeout" || rep.Note == "" {
|
||||||
|
t.Fatalf("%v %+v", err, rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Through the dispatcher, the answer is the report.
|
||||||
|
func TestObserveFoldersThroughTheLane(t *testing.T) {
|
||||||
|
root := folderTree(t)
|
||||||
|
out, errOut, code := driveObserve(t, "observe:folders", []string{root, "1"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit %d: %s", code, errOut)
|
||||||
|
}
|
||||||
|
var rep foldersReport
|
||||||
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
||||||
|
t.Fatalf("%v: %s", err, out)
|
||||||
|
}
|
||||||
|
if !rep.Supported || rep.Depth != 1 || rep.Sized != 3 || len(rep.Folders) != 3 || !rep.Complete || rep.Skipped == nil {
|
||||||
|
t.Fatalf("%s", out)
|
||||||
|
}
|
||||||
|
for _, f := range rep.Folders {
|
||||||
|
if f.Depth != 1 {
|
||||||
|
t.Fatalf("depth 1 names only the folders directly under the path: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four against this machine, through the real dispatcher with the real
|
||||||
|
// programs — the same switch as TestObserveLive. A machine without systemd
|
||||||
|
// answers journal and logins with a reason, which is an answer.
|
||||||
|
func TestObserveReadsLive(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("runs the real programs")
|
||||||
|
}
|
||||||
|
cert := observeTestCert(t, "box-a.example", []string{"box-a.example"}, nil, time.Now().Add(48*time.Hour))
|
||||||
|
port := strconv.Itoa(observeTLSListener(t, cert))
|
||||||
|
for _, c := range []struct {
|
||||||
|
op string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"observe:folders", []string{"/etc", "1"}, `"supported":true`},
|
||||||
|
{"observe:journal", []string{"systemd-journald", "5"}, `"source":"journal"`},
|
||||||
|
{"observe:logins", []string{"1"}, `"hours":1`},
|
||||||
|
{"observe:cert", []string{port, "box-a.example"}, `"connected_to":"127.0.0.1:` + port + `"`},
|
||||||
|
} {
|
||||||
|
// A machine without ss cannot say which ports it listens on, and the
|
||||||
|
// cert read answers that as its own state (CI's container has no ss).
|
||||||
|
if c.op == "observe:cert" {
|
||||||
|
if _, err := exec.LookPath("ss"); err != nil {
|
||||||
|
c.want = `"reason":"ss not installed`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, errOut, code := driveObserve(t, c.op, c.args)
|
||||||
|
if code != 0 || !json.Valid([]byte(out)) || !strings.Contains(out, c.want) {
|
||||||
|
t.Fatalf("%s: exit %d %s %s", c.op, code, errOut, out)
|
||||||
|
}
|
||||||
|
if len(out) > 300 {
|
||||||
|
out = out[:300] + "…"
|
||||||
|
}
|
||||||
|
t.Logf("%s: %s", c.op, strings.TrimSpace(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// observe:folders, observe:journal, observe:logins and observe:cert off Linux:
|
||||||
|
// the handlers check the arguments the same way and answer
|
||||||
|
// {"unsupported": "<goos>"}, as observe_ops_other.go's ops do. These are
|
||||||
|
// never reached (the handlers ask observeHostSupported first) and exist so
|
||||||
|
// the package builds.
|
||||||
|
|
||||||
|
func journalAccess() string { return "" }
|
||||||
|
func readFolders(string, int) (any, error) { return observeUnsupported(), nil }
|
||||||
|
func readJournal(string, int) (any, error) { return observeUnsupported(), nil }
|
||||||
|
func readLogins(int) any { return observeUnsupported() }
|
||||||
|
func readCert(int, string) (any, error) { return observeUnsupported(), nil }
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
// Invented, in the shapes the programs print: systemctl's --plain lists,
|
||||||
|
// journalctl -o short-iso, /proc/self/mountinfo.
|
||||||
|
|
||||||
|
const fxListUnits = `example.service loaded active running Example service
|
||||||
|
backup-nightly.service loaded inactive dead Nightly backup
|
||||||
|
getty@tty1.service loaded active running Getty on tty1
|
||||||
|
gone.service not-found inactive dead gone.service
|
||||||
|
dev-disk-by\x2dlabel-data.device loaded active plugged /dev/disk/by-label/data
|
||||||
|
`
|
||||||
|
|
||||||
|
const fxUnitFiles = `example.service enabled enabled
|
||||||
|
report.service static -
|
||||||
|
worker@.service static -
|
||||||
|
ssh.socket enabled enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
// One connection per pid. The spoof line names an address inside the user
|
||||||
|
// name; the last line's user name is a sentence; one line uses the older
|
||||||
|
// offset format; the indented one is a continuation, not an entry.
|
||||||
|
const fxSSHD = `2026-10-29T10:00:01+00:00 box-a sshd-session[1001]: Invalid user admin from 203.0.113.5 port 40001
|
||||||
|
2026-10-29T10:00:02+00:00 box-a sshd-session[1001]: Failed password for invalid user admin from 203.0.113.5 port 40001 ssh2
|
||||||
|
2026-10-29T10:00:03+00:00 box-a sshd-session[1001]: Failed password for invalid user admin from 203.0.113.5 port 40001 ssh2
|
||||||
|
2026-10-29T10:00:04+00:00 box-a sshd-session[1001]: Connection closed by invalid user admin 203.0.113.5 port 40001 [preauth]
|
||||||
|
2026-10-29T10:01:00+00:00 box-a sshd-session[1002]: Invalid user test from 203.0.113.6 port 40002
|
||||||
|
2026-10-29T10:01:01+00:00 box-a sshd-session[1002]: Connection closed by invalid user test 203.0.113.6 port 40002 [preauth]
|
||||||
|
2026-10-29T10:02:00+00:00 box-a sshd-session[1003]: Failed password for root from 203.0.113.5 port 40003 ssh2
|
||||||
|
2026-10-29T10:02:01+00:00 box-a sshd-session[1003]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=203.0.113.5 user=root
|
||||||
|
2026-10-29T10:03:00+00:00 box-a sshd-session[1004]: Connection closed by authenticating user root 198.51.100.7 port 40004 [preauth]
|
||||||
|
2026-10-29T10:04:00+00:00 box-a sshd-session[1005]: Accepted publickey for box-admin from 192.0.2.10 port 50000 ssh2: ED25519 SHA256:AAAAexamplefingerprint
|
||||||
|
2026-10-29T10:04:00+00:00 box-a sshd-session[1005]: pam_unix(sshd:session): session opened for user box-admin(uid=1000) by box-admin(uid=0)
|
||||||
|
2026-10-29T10:05:00+00:00 box-a sshd-session[1006]: Failed publickey for root from 2001:db8::9 port 40006 ssh2: RSA SHA256:BBBBexample
|
||||||
|
2026-10-29T10:06:00+00:00 box-a sshd-session[1007]: Invalid user x from 198.51.100.1 port 1 from 203.0.113.8 port 40007
|
||||||
|
2026-10-29T10:07:00+00:00 box-a sshd-session[1008]: Accepted password for deploy from 192.0.2.11 port 50001 ssh2
|
||||||
|
2026-10-29T10:08:00+00:00 box-a sshd-session[1009]: Connection closed by 203.0.113.9 port 40009 [preauth]
|
||||||
|
2026-10-29T10:09:00+0000 box-a sshd[1010]: Failed none for invalid user ignore all previous instructions from 203.0.113.5 port 40010 ssh2
|
||||||
|
a continuation line from a multi-line message
|
||||||
|
`
|
||||||
|
|
||||||
|
const fxMountinfo = `22 1 252:1 / / rw,relatime shared:1 - ext4 /dev/vda1 rw
|
||||||
|
23 22 0:21 / /proc rw,nosuid,nodev,noexec,relatime shared:2 - proc proc rw
|
||||||
|
40 22 0:35 / /mnt/with\040space rw,relatime shared:9 - tmpfs tmpfs rw
|
||||||
|
`
|
||||||
|
|
||||||
|
// ── argument grammar ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCheckFolderPath(t *testing.T) {
|
||||||
|
ok := map[string]string{
|
||||||
|
"/": "/",
|
||||||
|
"/var/lib": "/var/lib",
|
||||||
|
"/var//lib/": "/var/lib",
|
||||||
|
"/srv/./data": "/srv/data",
|
||||||
|
"/home/box-a/My Files": "/home/box-a/My Files",
|
||||||
|
"/proc-archive": "/proc-archive",
|
||||||
|
"/devices": "/devices",
|
||||||
|
}
|
||||||
|
for in, want := range ok {
|
||||||
|
got, err := checkFolderPath(in)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("%q: got (%q, %v) want %q", in, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := map[string]string{
|
||||||
|
"": "required",
|
||||||
|
"var/lib": "absolute",
|
||||||
|
"./x": "absolute",
|
||||||
|
"~/x": "absolute",
|
||||||
|
"/var/../etc": "'..'",
|
||||||
|
"/a..b": "'..'",
|
||||||
|
"/run/../proc": "'..'",
|
||||||
|
"/proc": errFolderKernelTree,
|
||||||
|
"/proc/1": errFolderKernelTree,
|
||||||
|
"/sys/kernel": errFolderKernelTree,
|
||||||
|
"/dev": errFolderKernelTree,
|
||||||
|
"/dev/shm/": errFolderKernelTree,
|
||||||
|
"//proc": errFolderKernelTree,
|
||||||
|
"/x\n": "control",
|
||||||
|
"/x\x00y": "control",
|
||||||
|
"/\xff": "UTF-8",
|
||||||
|
"/" + strings.Repeat("a", 4096): "at most",
|
||||||
|
"/var/lib\x7f": "control",
|
||||||
|
"C:\\Windows": "absolute",
|
||||||
|
"/srv/data; rm -rf /": "", // a strange name, not a refusal: it never reaches a program
|
||||||
|
"/srv/$(id)": "",
|
||||||
|
"/srv/" + strings.Repeat("b", 4000): "",
|
||||||
|
}
|
||||||
|
for in, want := range bad {
|
||||||
|
_, err := checkFolderPath(in)
|
||||||
|
if want == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q must be accepted (the path goes to a Go walk, never a program): %v", in, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("%q: want a refusal saying %q, got %v", in, want, err)
|
||||||
|
}
|
||||||
|
if want != errFolderKernelTree && len(in) > 1 && strings.Contains(err.Error(), in) {
|
||||||
|
t.Fatalf("%q: a refusal must not repeat the value: %v", in, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, d, err := parseFolderArgs([]string{"/srv"}); err != nil || d != folderDepthDefault {
|
||||||
|
t.Fatalf("default depth: %d %v", d, err)
|
||||||
|
}
|
||||||
|
for _, d := range []string{"0", "4", "-1", "x", "2.5", ""} {
|
||||||
|
if _, _, err := parseFolderArgs([]string{"/srv", d}); err == nil {
|
||||||
|
t.Fatalf("depth %q must be refused", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, d, err := parseFolderArgs([]string{"/srv", "3"}); err != nil || d != 3 {
|
||||||
|
t.Fatalf("depth 3: %d %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUnitName(t *testing.T) {
|
||||||
|
ok := map[string]string{
|
||||||
|
"example": "example.service",
|
||||||
|
"example.service": "example.service",
|
||||||
|
"getty@tty1": "getty@tty1.service",
|
||||||
|
"foo.bar": "foo.bar.service",
|
||||||
|
"data.mount": "data.mount",
|
||||||
|
"backup.timer": "backup.timer",
|
||||||
|
`dev-disk-by\x2dlabel-data.device`: `dev-disk-by\x2dlabel-data.device`,
|
||||||
|
"user-1000.slice": "user-1000.slice",
|
||||||
|
"session-4.scope": "session-4.scope",
|
||||||
|
"systemd-fsck@dev-vda1.service": "systemd-fsck@dev-vda1.service",
|
||||||
|
"a:b_c": "a:b_c.service",
|
||||||
|
strings.Repeat("a", 255-len(".mount")) + ".mount": strings.Repeat("a", 255-len(".mount")) + ".mount",
|
||||||
|
}
|
||||||
|
for in, want := range ok {
|
||||||
|
got, err := normalizeUnitName(in)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("%q: got (%q, %v) want %q", in, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range []string{"", "-.mount", ".service", "@x.service", "foo bar", "foo;id", "../x", "a/b",
|
||||||
|
"worker@.service", "worker@", "a@b@c.service", "$(id)", "--output=/tmp/x", "-u", "ünit",
|
||||||
|
strings.Repeat("a", 250), "x\n", "*.service", "ssh?", "[a].service", "a'b", `a"b`, "a|b"} {
|
||||||
|
if got, err := normalizeUnitName(in); err == nil {
|
||||||
|
t.Fatalf("%q must be refused, got %q", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, m, err := parseJournalArgs([]string{"example"}); err != nil || m != observeLogMinutesDefault {
|
||||||
|
t.Fatalf("default window: %d %v", m, err)
|
||||||
|
}
|
||||||
|
for _, m := range []string{"0", "61", "x", "-5"} {
|
||||||
|
if _, _, err := parseJournalArgs([]string{"example", m}); err == nil {
|
||||||
|
t.Fatalf("minutes %q must be refused", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLoginsArgsAndCertArgs(t *testing.T) {
|
||||||
|
if h, err := parseLoginsArgs(nil); err != nil || h != loginsHoursDefault {
|
||||||
|
t.Fatalf("default hours: %d %v", h, err)
|
||||||
|
}
|
||||||
|
if h, err := parseLoginsArgs([]string{"168"}); err != nil || h != 168 {
|
||||||
|
t.Fatalf("168: %d %v", h, err)
|
||||||
|
}
|
||||||
|
for _, h := range []string{"0", "169", "x", "24h", "-1", ""} {
|
||||||
|
if _, err := parseLoginsArgs([]string{h}); err == nil {
|
||||||
|
t.Fatalf("hours %q must be refused", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p, n, err := parseCertArgs([]string{"443"}); err != nil || p != 443 || n != "" {
|
||||||
|
t.Fatalf("port alone: %d %q %v", p, n, err)
|
||||||
|
}
|
||||||
|
if p, n, err := parseCertArgs([]string{"8443", "Box-A.Example"}); err != nil || p != 8443 || n != "box-a.example" {
|
||||||
|
t.Fatalf("port and name: %d %q %v", p, n, err)
|
||||||
|
}
|
||||||
|
if _, n, err := parseCertArgs([]string{"443", "192.0.2.10"}); err != nil || n != "192.0.2.10" {
|
||||||
|
t.Fatalf("an IPv4 literal is a name a certificate can carry: %q %v", n, err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"0"}, {"65536"}, {"https"}, {"443", ""}, {"443", "-x.example"},
|
||||||
|
{"443", ".example"}, {"443", "example."}, {"443", "a..b"}, {"443", "a b"}, {"443", "a_b.example"},
|
||||||
|
{"443", "*.example"}, {"443", "[::1]"}, {"443", strings.Repeat("a", 64) + ".example"},
|
||||||
|
{"443", "a-.example"}, {"443", "a;id"}} {
|
||||||
|
if _, _, err := parseCertArgs(args); err == nil {
|
||||||
|
t.Fatalf("%q must be refused", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:folders helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestFolderAtFoldsToTheAskedDepth(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
root, p string
|
||||||
|
depth int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/", "/", 2, "/"},
|
||||||
|
{"/", "/a", 2, "/a"},
|
||||||
|
{"/", "/a/b", 2, "/a/b"},
|
||||||
|
{"/", "/a/b/c/d", 2, "/a/b"},
|
||||||
|
{"/srv", "/srv", 1, "/srv"},
|
||||||
|
{"/srv", "/srv/x", 1, "/srv/x"},
|
||||||
|
{"/srv", "/srv/x/y/z", 1, "/srv/x"},
|
||||||
|
{"/srv", "/srv/x/y/z", 3, "/srv/x/y/z"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := folderAt(c.root, c.p, c.depth); got != c.want {
|
||||||
|
t.Fatalf("folderAt(%q, %q, %d) = %q, want %q", c.root, c.p, c.depth, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderTopKeepsTheLargestInOrder(t *testing.T) {
|
||||||
|
var top folderTop
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
top.offer(folderSize{Path: "/f" + strconv.Itoa(i), Bytes: int64(i % 30), Depth: 1})
|
||||||
|
}
|
||||||
|
got := top.sorted()
|
||||||
|
if len(got) != folderTopN {
|
||||||
|
t.Fatalf("want %d rows, got %d", folderTopN, len(got))
|
||||||
|
}
|
||||||
|
for i := 1; i < len(got); i++ {
|
||||||
|
if got[i].Bytes > got[i-1].Bytes || (got[i].Bytes == got[i-1].Bytes && got[i].Path < got[i-1].Path) {
|
||||||
|
t.Fatalf("not largest first, ties in path order: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sizes 0-29 and 0-9 again: the 25 largest are 29 down to 10, both 9s,
|
||||||
|
// both 8s and one 7, the 7 whose path sorts first.
|
||||||
|
if got[0].Bytes != 29 || got[len(got)-1] != (folderSize{Path: "/f37", Bytes: 7, Depth: 1}) {
|
||||||
|
t.Fatalf("the largest were not the ones kept: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFolderSkipsFoldAndBound(t *testing.T) {
|
||||||
|
var s folderSkips
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
s.add("/var/lib", "permission denied")
|
||||||
|
}
|
||||||
|
s.add("/var/lib", reasonOtherFS)
|
||||||
|
for i := 0; i < folderSkipKeys+50; i++ {
|
||||||
|
s.add("/x"+strconv.Itoa(i), "permission denied")
|
||||||
|
}
|
||||||
|
l := s.list()
|
||||||
|
if len(l) != folderSkipCap || s.total != 4+folderSkipKeys+50 {
|
||||||
|
t.Fatalf("list %d total %d", len(l), s.total)
|
||||||
|
}
|
||||||
|
if l[0].Path != "/var/lib" || l[0].Count != 3 || l[0].Reason != "permission denied" {
|
||||||
|
t.Fatalf("most first: %+v", l[0])
|
||||||
|
}
|
||||||
|
if len(s.by) > folderSkipKeys {
|
||||||
|
t.Fatalf("the fold map grew past its bound: %d", len(s.by))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMountPoints(t *testing.T) {
|
||||||
|
m := parseMountPoints(fxMountinfo)
|
||||||
|
for _, p := range []string{"/", "/proc", "/mnt/with space"} {
|
||||||
|
if !m[p] {
|
||||||
|
t.Fatalf("%q missing: %v", p, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m) != 3 {
|
||||||
|
t.Fatalf("%v", m)
|
||||||
|
}
|
||||||
|
if got := unescapeMountField(`/a\134b\011c\012`); got != "/a\\b\tc\n" {
|
||||||
|
t.Fatalf("%q", got)
|
||||||
|
}
|
||||||
|
if got := unescapeMountField(`/a\9`); got != `/a\9` {
|
||||||
|
t.Fatalf("a short escape is left as written: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:journal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestUnitKnownReadsBothLists(t *testing.T) {
|
||||||
|
loaded, files := parseUnitList(fxListUnits), parseUnitFiles(fxUnitFiles)
|
||||||
|
for _, u := range []string{"example.service", "backup-nightly.service", "report.service", "getty@tty1.service",
|
||||||
|
"worker@3.service", `dev-disk-by\x2dlabel-data.device`, "ssh.socket"} {
|
||||||
|
if !unitKnown(u, loaded, files) {
|
||||||
|
t.Fatalf("%s is on this machine", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range []string{"gone.service", "nope.service", "getty@tty2.service", "worker.service", "example.socket"} {
|
||||||
|
if unitKnown(u, loaded, files) {
|
||||||
|
t.Fatalf("%s is not on this machine", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The bullet a systemd might print in front of a failed unit is skipped.
|
||||||
|
if l := parseUnitList("● bad.service loaded failed failed Bad\n"); l["bad.service"] != "loaded" {
|
||||||
|
t.Fatalf("%v", l)
|
||||||
|
}
|
||||||
|
err := errUnknownUnit("ssh.service", loaded, files)
|
||||||
|
if !strings.Contains(err.Error(), "no unit ssh.service") || !strings.Contains(err.Error(), "ssh.socket") {
|
||||||
|
t.Fatalf("the refusal names the unit and the near ones: %v", err)
|
||||||
|
}
|
||||||
|
if s := unitSuggestions("gone.service", loaded, files); len(s) != 0 {
|
||||||
|
t.Fatalf("a not-found unit is never suggested: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:logins parser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestParseSSHLogins(t *testing.T) {
|
||||||
|
tally := parseSSHLogins(fxSSHD)
|
||||||
|
var rep loginsReport
|
||||||
|
tally.fill(&rep)
|
||||||
|
if rep.EntriesRead != 16 {
|
||||||
|
t.Fatalf("entries: %d", rep.EntriesRead)
|
||||||
|
}
|
||||||
|
if rep.OldestEntry != "2026-10-29T10:00:01Z" {
|
||||||
|
t.Fatalf("oldest: %q", rep.OldestEntry)
|
||||||
|
}
|
||||||
|
// 2 Failed lines on 1001, 1 knock on 1002, 1 Failed on 1003, 1 knock on
|
||||||
|
// 1004, 1 Failed on 1006, 1 knock on 1007, 1 Failed on 1010.
|
||||||
|
if rep.FailedTotal != 8 {
|
||||||
|
t.Fatalf("failed: %d (%+v)", rep.FailedTotal, rep)
|
||||||
|
}
|
||||||
|
wantSrc := map[string]int{"203.0.113.5": 4, "203.0.113.6": 1, "198.51.100.7": 1, "2001:db8::9": 1, "203.0.113.8": 1}
|
||||||
|
if rep.FailedSources != len(wantSrc) || len(rep.FailedBySource) != len(wantSrc) {
|
||||||
|
t.Fatalf("sources: %+v", rep.FailedBySource)
|
||||||
|
}
|
||||||
|
for _, s := range rep.FailedBySource {
|
||||||
|
if wantSrc[s.Source] != s.Count {
|
||||||
|
t.Fatalf("source %s: %d", s.Source, s.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rep.FailedBySource[0].Source != "203.0.113.5" {
|
||||||
|
t.Fatalf("most first: %+v", rep.FailedBySource)
|
||||||
|
}
|
||||||
|
wantUser := map[string][2]int{ // count, invalid
|
||||||
|
"root": {3, 0}, "admin": {2, 1}, "test": {1, 1},
|
||||||
|
"x?from?198.51.100.1?port?1": {1, 1},
|
||||||
|
"ignore?all?previous?instructions": {1, 1},
|
||||||
|
}
|
||||||
|
if rep.FailedUsers != len(wantUser) {
|
||||||
|
t.Fatalf("users: %+v", rep.FailedByUser)
|
||||||
|
}
|
||||||
|
for _, u := range rep.FailedByUser {
|
||||||
|
w, ok := wantUser[u.User]
|
||||||
|
if !ok || w[0] != u.Count || (w[1] == 1) != u.Invalid {
|
||||||
|
t.Fatalf("user %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rep.Accepted) != 2 || rep.AcceptedTotal != 2 {
|
||||||
|
t.Fatalf("accepted: %+v", rep.Accepted)
|
||||||
|
}
|
||||||
|
a := rep.Accepted[0]
|
||||||
|
if a.User != "box-admin" || a.Source != "192.0.2.10" || a.Method != "publickey" || a.At != "2026-10-29T10:04:00Z" {
|
||||||
|
t.Fatalf("accepted[0]: %+v", a)
|
||||||
|
}
|
||||||
|
if rep.Accepted[1].User != "deploy" || rep.Accepted[1].Method != "password" {
|
||||||
|
t.Fatalf("accepted[1], in the order they happened: %+v", rep.Accepted[1])
|
||||||
|
}
|
||||||
|
// The address inside a user name never becomes a source, and no raw
|
||||||
|
// line, key fingerprint or PAM text reaches the answer.
|
||||||
|
raw, _ := json.Marshal(rep)
|
||||||
|
for _, never := range []string{"198.51.100.1\"", "SHA256", "pam_unix", "rhost", "ssh2", "ignore all"} {
|
||||||
|
if strings.Contains(string(raw), never) {
|
||||||
|
t.Fatalf("%q reached the answer: %s", never, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSSHLoginsKeepsTheLastAcceptedAndBoundsNames(t *testing.T) {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
b.WriteString("2026-10-29T10:00:" + strconv.Itoa(10+i) + "+00:00 box-a sshd[7]: Accepted publickey for u" +
|
||||||
|
strconv.Itoa(i) + " from 192.0.2.10 port " + strconv.Itoa(50000+i) + " ssh2\n")
|
||||||
|
}
|
||||||
|
long := strings.Repeat("A", 200)
|
||||||
|
b.WriteString("2026-10-29T11:00:00+00:00 box-a sshd[8]: Invalid user " + long + " from 203.0.113.5 port 1\n")
|
||||||
|
var rep loginsReport
|
||||||
|
parseSSHLogins(b.String()).fill(&rep)
|
||||||
|
if rep.AcceptedTotal != 30 || len(rep.Accepted) != loginsTopN || rep.Accepted[0].User != "u10" ||
|
||||||
|
rep.Accepted[loginsTopN-1].User != "u29" {
|
||||||
|
t.Fatalf("the last %d, oldest first: %+v", loginsTopN, rep.Accepted)
|
||||||
|
}
|
||||||
|
if len(rep.FailedByUser) != 1 || len(rep.FailedByUser[0].User) != loginNameMax {
|
||||||
|
t.Fatalf("a name is cut at %d: %+v", loginNameMax, rep.FailedByUser)
|
||||||
|
}
|
||||||
|
for _, src := range []string{"not an address!", "a/b", "$(id)"} {
|
||||||
|
if _, ok := loginSourceOf(src); ok {
|
||||||
|
t.Fatalf("%q is not a source", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loginMethod("keyboard-interactive/pam") != "keyboard-interactive/pam" || loginMethod("x y") != "other" {
|
||||||
|
t.Fatalf("a method keeps sshd's own shape and nothing else")
|
||||||
|
}
|
||||||
|
if s, ok := loginSourceOf("::ffff:203.0.113.5"); !ok || s != "203.0.113.5" {
|
||||||
|
t.Fatalf("a mapped address reads as v4: %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── observe:cert helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSSLocalAddr(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
addr string
|
||||||
|
wild, ok bool
|
||||||
|
}{
|
||||||
|
"*": {"", true, true},
|
||||||
|
"0.0.0.0": {"0.0.0.0", true, true},
|
||||||
|
"[::]": {"::", true, true},
|
||||||
|
"127.0.0.1": {"127.0.0.1", false, true},
|
||||||
|
"[::1]": {"::1", false, true},
|
||||||
|
"127.0.0.53%lo": {"127.0.0.53", false, true},
|
||||||
|
"[fe80::1]%eth0": {"fe80::1%eth0", false, true},
|
||||||
|
"10.99.0.1": {"10.99.0.1", false, true},
|
||||||
|
"not-an-address": {"", false, false},
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
a, wild, ok := ssLocalAddr(in)
|
||||||
|
if ok != want.ok || wild != want.wild || (want.addr != "" && a.String() != want.addr) {
|
||||||
|
t.Fatalf("%q: got (%v, %v, %v) want %+v", in, a, wild, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertTargetsAreThisMachinesOwn(t *testing.T) {
|
||||||
|
socks := []listenSocket{
|
||||||
|
{Proto: "tcp", Local: "0.0.0.0", Port: 443},
|
||||||
|
{Proto: "tcp", Local: "10.99.0.1", Port: 8443},
|
||||||
|
{Proto: "tcp", Local: "192.0.2.50", Port: 8443}, // bound, but not an address this machine holds
|
||||||
|
{Proto: "tcp", Local: "[::1]", Port: 8443},
|
||||||
|
{Proto: "tcp", Local: "127.0.0.53%lo", Port: 8443},
|
||||||
|
{Proto: "udp", Local: "10.99.0.2", Port: 8443},
|
||||||
|
}
|
||||||
|
own := map[netip.Addr]bool{netip.MustParseAddr("10.99.0.1"): true, netip.MustParseAddr("10.99.0.2"): true}
|
||||||
|
targets, listening := certTargets(socks, 8443, own)
|
||||||
|
want := []string{"127.0.0.1:8443", "[::1]:8443", "10.99.0.1:8443", "127.0.0.53:8443"}
|
||||||
|
if strings.Join(targets, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("targets %v want %v", targets, want)
|
||||||
|
}
|
||||||
|
if strings.Join(listening, " ") != "10.99.0.1 192.0.2.50 [::1] 127.0.0.53%lo" {
|
||||||
|
t.Fatalf("listening %v", listening)
|
||||||
|
}
|
||||||
|
targets, listening = certTargets(socks, 443, own)
|
||||||
|
if len(targets) != 2 || len(listening) != 1 {
|
||||||
|
t.Fatalf("a wildcard listener is reached on loopback: %v %v", targets, listening)
|
||||||
|
}
|
||||||
|
if _, listening = certTargets(socks, 22, own); len(listening) != 0 {
|
||||||
|
t.Fatalf("nothing on 22: %v", listening)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// observeTestCert makes a self-signed certificate for the names given.
|
||||||
|
func observeTestCert(t *testing.T, cn string, dns []string, ips []net.IP, notAfter time.Time) tls.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{SerialNumber: big.NewInt(7), Subject: pkix.Name{CommonName: cn, Organization: []string{"Example"}},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour), NotAfter: notAfter, DNSNames: dns, IPAddresses: ips,
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// observeTLSListener serves cert on 127.0.0.1 until the test ends.
|
||||||
|
func observeTLSListener(t *testing.T, cert tls.Certificate) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{cert}})
|
||||||
|
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.(*tls.Conn).Handshake()
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ln.Addr().(*net.TCPAddr).Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// closedPort is a loopback port nothing listens on.
|
||||||
|
func closedPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
_ = ln.Close()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDialCertReadsTheFirstListenerThatAnswers(t *testing.T) {
|
||||||
|
cert := observeTestCert(t, "box-a.example", []string{"box-a.example", "www.box-a.example"},
|
||||||
|
[]net.IP{net.ParseIP("127.0.0.1")}, time.Now().Add(30*24*time.Hour+time.Hour))
|
||||||
|
port := observeTLSListener(t, cert)
|
||||||
|
served := "127.0.0.1:" + strconv.Itoa(port)
|
||||||
|
refused := "127.0.0.1:" + strconv.Itoa(closedPort(t))
|
||||||
|
rep := certReport{Tried: []string{}}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
dialCert(ctx, &rep, []string{refused, served}, "box-a.example")
|
||||||
|
if len(rep.Tried) != 1 || !strings.Contains(rep.Tried[0], "refused") || rep.ConnectedTo != served {
|
||||||
|
t.Fatalf("tried %v, connected %q", rep.Tried, rep.ConnectedTo)
|
||||||
|
}
|
||||||
|
if !rep.TLS || rep.TLSVersion == "" || !strings.Contains(rep.Subject, "CN=box-a.example") ||
|
||||||
|
!strings.Contains(rep.Issuer, "O=Example") {
|
||||||
|
t.Fatalf("%+v", rep)
|
||||||
|
}
|
||||||
|
if strings.Join(rep.SANs, " ") != "box-a.example www.box-a.example 127.0.0.1" {
|
||||||
|
t.Fatalf("sans %v", rep.SANs)
|
||||||
|
}
|
||||||
|
if rep.DaysLeft == nil || *rep.DaysLeft != 30 || rep.ChainLength != 1 {
|
||||||
|
t.Fatalf("days %v chain %d", rep.DaysLeft, rep.ChainLength)
|
||||||
|
}
|
||||||
|
// Self-signed: read, and reported as not verifying, for the name asked.
|
||||||
|
if rep.ChainVerified == nil || *rep.ChainVerified || rep.VerifyError == "" || rep.VerifiedFor != "box-a.example" {
|
||||||
|
t.Fatalf("verification: %+v", rep)
|
||||||
|
}
|
||||||
|
if _, err := time.Parse(time.RFC3339, rep.NotAfter); err != nil {
|
||||||
|
t.Fatalf("not_after %q", rep.NotAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A listener that is not TLS: connected, and the handshake's failure is
|
||||||
|
// the answer.
|
||||||
|
plain, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer plain.Close()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
c, err := plain.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
rep = certReport{Tried: []string{}}
|
||||||
|
dialCert(ctx, &rep, []string{plain.Addr().String(), served}, "")
|
||||||
|
if rep.ConnectedTo != plain.Addr().String() || rep.TLS || rep.TLSError == "" || rep.Subject != "" {
|
||||||
|
t.Fatalf("the first connection decides: %+v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── through the dispatcher, on every platform ────────────────────────────────
|
||||||
|
|
||||||
|
// A value that breaks the grammar is refused at exit 2 with the rule it
|
||||||
|
// broke, and never repeated back; on every platform, before anything is read.
|
||||||
|
func TestObserveReadsRefuseBadValuesWithTheRule(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
op string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"observe:folders", []string{"etc/passwd-dir"}, "absolute"},
|
||||||
|
{"observe:folders", []string{"/srv/../etc"}, "'..'"},
|
||||||
|
{"observe:folders", []string{"/proc/1/root"}, "/proc, /sys and /dev"},
|
||||||
|
{"observe:folders", []string{"/srv", "9"}, "depth must be between 1 and 3"},
|
||||||
|
{"observe:journal", []string{"nginx;reboot"}, "unit name uses only"},
|
||||||
|
{"observe:journal", []string{"-x.service"}, "cannot start with"},
|
||||||
|
{"observe:journal", []string{"worker@.service"}, "template"},
|
||||||
|
{"observe:journal", []string{"example", "600"}, "minutes must be between 1 and 60"},
|
||||||
|
{"observe:logins", []string{"1000"}, "hours must be between 1 and 168"},
|
||||||
|
{"observe:logins", []string{"a week"}, "hours must be a whole number"},
|
||||||
|
{"observe:cert", []string{"99999"}, "port must be between 1 and 65535"},
|
||||||
|
{"observe:cert", []string{"443", "box a"}, "server name uses"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
out, errOut, code := driveObserve(t, c.op, c.args)
|
||||||
|
if code != 2 || out != "" || !strings.Contains(errOut, "refused: ") || !strings.Contains(errOut, c.want) {
|
||||||
|
t.Fatalf("%s %q: code=%d out=%q err=%q (want %q)", c.op, c.args, code, out, errOut, c.want)
|
||||||
|
}
|
||||||
|
for _, a := range c.args {
|
||||||
|
if len(a) > 3 && strings.Contains(errOut, a) {
|
||||||
|
t.Fatalf("%s repeated the value %q: %q", c.op, a, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for op, args := range map[string][]string{
|
||||||
|
"observe:folders": nil, "observe:journal": {"a", "1", "x"}, "observe:logins": {"1", "2"}, "observe:cert": {},
|
||||||
|
} {
|
||||||
|
if _, errOut, code := driveObserve(t, op, args); code != 2 || !strings.Contains(errOut, observeArgRefusal[op]) {
|
||||||
|
t.Fatalf("%s %q: code=%d err=%q", op, args, code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off Linux the four check their arguments and then say they cannot answer
|
||||||
|
// here, at exit 0, in the shape observe:host uses.
|
||||||
|
func TestObserveReadsUnsupportedOffLinux(t *testing.T) {
|
||||||
|
if observeHostSupported {
|
||||||
|
t.Skip("the Linux drivers answer here (observe_reads_linux_test.go)")
|
||||||
|
}
|
||||||
|
for op, args := range map[string][]string{
|
||||||
|
"observe:folders": {"/"}, "observe:journal": {"example"}, "observe:logins": nil, "observe:cert": {"443"},
|
||||||
|
} {
|
||||||
|
out, errOut, code := driveObserve(t, op, args)
|
||||||
|
if code != 0 || strings.TrimSpace(out) != `{"unsupported":"`+observeUnsupported()["unsupported"]+`"}` {
|
||||||
|
t.Fatalf("%s: code=%d out=%q err=%q", op, code, out, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+168
-42
@@ -9,7 +9,9 @@ package main
|
|||||||
// params (a typed schema), effects (the FILES it may touch),
|
// params (a typed schema), effects (the FILES it may touch),
|
||||||
// time_limit, undo
|
// time_limit, undo
|
||||||
// run the program, run by one of a fixed set of interpreters with a
|
// run the program, run by one of a fixed set of interpreters with a
|
||||||
// fixed argv and the params as JSON on stdin
|
// fixed argv and the params as JSON on stdin; absent, and refused
|
||||||
|
// if present, for an action op with interpreter: none, whose verbs
|
||||||
|
// are its whole work (ops_action.go verbsOnly)
|
||||||
// test/ what the host's build ran it against (not read here)
|
// test/ what the host's build ran it against (not read here)
|
||||||
//
|
//
|
||||||
// Its identity is the sha256 of the directory's canonical form (opDirDigest):
|
// Its identity is the sha256 of the directory's canonical form (opDirDigest):
|
||||||
@@ -29,8 +31,11 @@ package main
|
|||||||
// one use. Purpose op_run|server|op|sha256|sha256(params)|expires
|
// one use. Purpose op_run|server|op|sha256|sha256(params)|expires
|
||||||
// op_grant "From now on": this op, this machine, param BOUNDS, expires ≤
|
// op_grant "From now on": this op, this machine, param BOUNDS, expires ≤
|
||||||
// 30 d, a use count; privilege user only — root is never reusable.
|
// 30 d, a use count; privilege user only — root is never reusable.
|
||||||
// Held in ops.json beside grants.json. Purpose
|
// The bounds may also hold the run to a window and a rate, which
|
||||||
// op_grant|server|op|sha256|sha256(bounds)|expires|uses
|
// the machine reads on its own clock and from its own run log
|
||||||
|
// (ops_bounds.go). Held in ops.json beside grants.json, with the
|
||||||
|
// key that approved it and this machine's seal over the record.
|
||||||
|
// Purpose op_grant|server|op|sha256|sha256(bounds)|expires|uses
|
||||||
//
|
//
|
||||||
// The executor (ops_exec_linux.go) runs the op inside an overlay sandbox and
|
// The executor (ops_exec_linux.go) runs the op inside an overlay sandbox and
|
||||||
// diffs what it changed; a files op has FILE effects only, and an op whose
|
// diffs what it changed; a files op has FILE effects only, and an op whose
|
||||||
@@ -98,6 +103,10 @@ var (
|
|||||||
opsMu sync.Mutex
|
opsMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// opInterpreterNone is an action op with no program: no run file, nothing
|
||||||
|
// executed but the bastion's own verbs (ops_action.go verbsOnly).
|
||||||
|
const opInterpreterNone = "none"
|
||||||
|
|
||||||
// opInterpreters is the fixed set. The bastion execs `<interpreter> <run>
|
// opInterpreters is the fixed set. The bastion execs `<interpreter> <run>
|
||||||
// --mode <mode>`; nothing the host wrote chooses the program that runs.
|
// --mode <mode>`; nothing the host wrote chooses the program that runs.
|
||||||
var opInterpreters = map[string]string{
|
var opInterpreters = map[string]string{
|
||||||
@@ -184,8 +193,14 @@ func parseOpManifest(raw []byte) (*opManifest, error) {
|
|||||||
if m.Privilege != "user" && m.Privilege != "root" {
|
if m.Privilege != "user" && m.Privilege != "root" {
|
||||||
return nil, errors.New("op.yaml: privilege must be 'user' or 'root'")
|
return nil, errors.New("op.yaml: privilege must be 'user' or 'root'")
|
||||||
}
|
}
|
||||||
if _, ok := opInterpreters[m.Interpreter]; !ok {
|
if m.Interpreter == opInterpreterNone {
|
||||||
return nil, fmt.Errorf("op.yaml: interpreter must be one of %s", strings.Join(opInterpreterNames(), ", "))
|
// No program at all: an action op whose verbs are its whole work
|
||||||
|
// (ops_action.go verbsOnly), judged with the action fields below.
|
||||||
|
if m.Kind != opKindAction {
|
||||||
|
return nil, errors.New("op.yaml: interpreter: none is an action op whose verbs are its whole work (kind: action)")
|
||||||
|
}
|
||||||
|
} else if _, ok := opInterpreters[m.Interpreter]; !ok {
|
||||||
|
return nil, fmt.Errorf("op.yaml: interpreter must be one of %s, or none for an action op that is verbs only", strings.Join(opInterpreterNames(), ", "))
|
||||||
}
|
}
|
||||||
if m.TimeLimit <= 0 || time.Duration(m.TimeLimit)*time.Second > opTimeLimitMax {
|
if m.TimeLimit <= 0 || time.Duration(m.TimeLimit)*time.Second > opTimeLimitMax {
|
||||||
return nil, fmt.Errorf("op.yaml: time_limit must be 1..%d seconds", int(opTimeLimitMax.Seconds()))
|
return nil, fmt.Errorf("op.yaml: time_limit must be 1..%d seconds", int(opTimeLimitMax.Seconds()))
|
||||||
@@ -622,11 +637,21 @@ type opBound struct {
|
|||||||
Fixed *bool `json:"fixed,omitempty"` // boolean
|
Fixed *bool `json:"fixed,omitempty"` // boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseBounds is the params half of an op_grant's bounds (parseGrantBounds,
|
||||||
|
// ops_bounds.go, reads the whole: the params and the run's window and rate).
|
||||||
|
func parseBounds(m *opManifest, raw string) (map[string]opBound, error) {
|
||||||
|
b, err := parseGrantBounds(m, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return b.Params, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Strict: a field the param's type does not read is refused, not ignored —
|
// Strict: a field the param's type does not read is refused, not ignored —
|
||||||
// the card shows the bounds text as sent, so a bound the box would not
|
// the card shows the bounds text as sent, so a bound the box would not
|
||||||
// enforce would read as a limit the person never got. Each bound must sit
|
// enforce would read as a limit the person never got. Each bound must sit
|
||||||
// inside the schema's own.
|
// inside the schema's own.
|
||||||
func parseBounds(m *opManifest, raw string) (map[string]opBound, error) {
|
func parseParamBounds(m *opManifest, raw string) (map[string]opBound, error) {
|
||||||
if len(raw) > opParamsMaxBytes {
|
if len(raw) > opParamsMaxBytes {
|
||||||
return nil, errors.New("bounds too large")
|
return nil, errors.New("bounds too large")
|
||||||
}
|
}
|
||||||
@@ -785,7 +810,8 @@ func opDirDigest(files []opFile) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// opBundleOK checks the bundle's shape before anything is written: relative
|
// opBundleOK checks the bundle's shape before anything is written: relative
|
||||||
// clean paths, no links, op.yaml and run present, bounded size.
|
// clean paths, no links, op.yaml present, bounded size. Whether it must carry
|
||||||
|
// run is the manifest's to say (opBundleFitsManifest).
|
||||||
func opBundleOK(files []opFile) error {
|
func opBundleOK(files []opFile) error {
|
||||||
total := 0
|
total := 0
|
||||||
have := map[string]bool{}
|
have := map[string]bool{}
|
||||||
@@ -810,7 +836,24 @@ func opBundleOK(files []opFile) error {
|
|||||||
}
|
}
|
||||||
have[p] = true
|
have[p] = true
|
||||||
}
|
}
|
||||||
if !have["op.yaml"] || !have["run"] {
|
if !have["op.yaml"] {
|
||||||
|
return errors.New("an op bundle needs op.yaml")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opBundleFitsManifest: a program op ships its run; an op with no program
|
||||||
|
// (interpreter: none) ships none — a run file there would be a program the
|
||||||
|
// manifest says is not there.
|
||||||
|
func opBundleFitsManifest(files []opFile, m *opManifest) error {
|
||||||
|
hasRun := false
|
||||||
|
for _, f := range files {
|
||||||
|
hasRun = hasRun || f.Path == "run"
|
||||||
|
}
|
||||||
|
if m.verbsOnly() && hasRun {
|
||||||
|
return errors.New("an op with interpreter: none ships no run: its verbs are its whole work")
|
||||||
|
}
|
||||||
|
if !m.verbsOnly() && !hasRun {
|
||||||
return errors.New("an op bundle needs op.yaml and run")
|
return errors.New("an op bundle needs op.yaml and run")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -893,7 +936,14 @@ func opLoad(dcnDir, sha string) (*opManifest, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return parseOpManifest(raw)
|
m, err := parseOpManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := opBundleFitsManifest(files, m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the terms ───────────────────────────────────────────────────────────────
|
// ── the terms ───────────────────────────────────────────────────────────────
|
||||||
@@ -1035,6 +1085,15 @@ type opGrantRec struct {
|
|||||||
InstalledAt string `json:"installed_at"`
|
InstalledAt string `json:"installed_at"`
|
||||||
UsesLeft int `json:"uses_left"`
|
UsesLeft int `json:"uses_left"`
|
||||||
Dropped string `json:"dropped,omitempty"` // when and why, kept for the record
|
Dropped string `json:"dropped,omitempty"` // when and why, kept for the record
|
||||||
|
// Runs: when each apply that spent a use began (RFC3339Nano, oldest
|
||||||
|
// first): the rate is counted from it, so it holds across a restart.
|
||||||
|
Runs []string `json:"runs,omitempty"`
|
||||||
|
// Approver: the key that approved the grant — "device:<public key>" or
|
||||||
|
// "console:<factor fingerprint>" (ops_bounds.go). The grant dies with it.
|
||||||
|
Approver string `json:"approver,omitempty"`
|
||||||
|
// MAC: this machine's seal over the record (opGrantRecMAC). A record that
|
||||||
|
// does not check out holds no grant.
|
||||||
|
MAC string `json:"mac,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type opsFile struct {
|
type opsFile struct {
|
||||||
@@ -1062,36 +1121,42 @@ func opsSave(dcnDir string, f *opsFile) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// opsHeldGrant re-verifies a held grant record (a tampered ops.json holds
|
// opsHeldGrant re-verifies a held grant record (a tampered ops.json holds
|
||||||
// nothing) and returns its terms and uses left.
|
// nothing): the terms under the pinned key, the record under this machine's
|
||||||
|
// own seal with its run log in order, the approving key still pinned, a use
|
||||||
|
// left. Returns the terms, the record and the run times. The caller holds
|
||||||
|
// opsMu.
|
||||||
func opsHeldGrant(dcnDir, nonce string) (*opTerms, *opGrantRec, error) {
|
func opsHeldGrant(dcnDir, nonce string) (*opTerms, *opGrantRec, error) {
|
||||||
|
t, rec, _, err := opsHeldGrantRuns(dcnDir, nonce)
|
||||||
|
return t, rec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func opsHeldGrantRuns(dcnDir, nonce string) (*opTerms, *opGrantRec, []time.Time, error) {
|
||||||
f := opsLoad(dcnDir)
|
f := opsLoad(dcnDir)
|
||||||
rec, ok := f.Grants[nonce]
|
rec, ok := f.Grants[nonce]
|
||||||
if !ok || rec.Dropped != "" {
|
if !ok || rec.Dropped != "" {
|
||||||
return nil, nil, errors.New("no such op grant is held here")
|
return nil, nil, nil, errors.New("no such op grant is held here")
|
||||||
}
|
}
|
||||||
t, err := verifyOpTerms(dcnDir, rec.TermsCanonical, rec.Signature, opKindGrant)
|
t, err := verifyOpTerms(dcnDir, rec.TermsCanonical, rec.Signature, opKindGrant)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("held op grant does not verify: %v", err)
|
return nil, nil, nil, fmt.Errorf("held op grant does not verify: %v", err)
|
||||||
|
}
|
||||||
|
runs, err := opGrantRecCheck(nonce, &rec, t, opNow())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
if !opApproverLive(dcnDir, rec.Approver) {
|
||||||
|
return nil, nil, nil, fmt.Errorf("the key that approved this grant (%s) is no longer pinned on this machine; the grant is dead", opApproverWords(rec.Approver))
|
||||||
}
|
}
|
||||||
if rec.UsesLeft <= 0 {
|
if rec.UsesLeft <= 0 {
|
||||||
return nil, nil, errors.New("op grant has no uses left")
|
return nil, nil, nil, errors.New("op grant has no uses left")
|
||||||
}
|
}
|
||||||
return t, &rec, nil
|
return t, &rec, runs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// opsDropGrant — the console (or a host revoke) removes a held op grant.
|
// opsDropGrant — the console (or a host revoke) removes a held op grant.
|
||||||
func opsDropGrant(dcnDir, nonce, why string) (bool, error) {
|
func opsDropGrant(dcnDir, nonce, why string) (bool, error) {
|
||||||
opsMu.Lock()
|
d, err := opsDropWhere(dcnDir, why, func(n string, _ *opGrantRec) bool { return n == nonce })
|
||||||
defer opsMu.Unlock()
|
return len(d) > 0, err
|
||||||
f := opsLoad(dcnDir)
|
|
||||||
rec, ok := f.Grants[nonce]
|
|
||||||
if !ok || rec.Dropped != "" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rec.Dropped = time.Now().UTC().Format(time.RFC3339) + " " + why
|
|
||||||
rec.UsesLeft = 0
|
|
||||||
f.Grants[nonce] = rec
|
|
||||||
return true, opsSave(dcnDir, f)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── the run record ──────────────────────────────────────────────────────────
|
// ── the run record ──────────────────────────────────────────────────────────
|
||||||
@@ -1166,6 +1231,17 @@ func opFail(ws *websocket.Conn, why string) {
|
|||||||
|
|
||||||
const noSandboxHere = "this machine cannot sandbox an op"
|
const noSandboxHere = "this machine cannot sandbox an op"
|
||||||
|
|
||||||
|
// noVerbToolHere is the refusal of a verb whose tool this machine lacks: the
|
||||||
|
// machine's own state, never a strike (quarantine.go refusalCounts).
|
||||||
|
const noVerbToolHere = "this machine cannot run the verb"
|
||||||
|
|
||||||
|
// Seams, so the tests can stand in for a machine with no overlay, or no
|
||||||
|
// action sandbox.
|
||||||
|
var (
|
||||||
|
opSandboxCheck = opSandboxAvailable
|
||||||
|
opActionCheck = opActionAvailable
|
||||||
|
)
|
||||||
|
|
||||||
// op_sync <terms_b64> <sig_b64> <bundle_b64>: hold the op the terms name,
|
// op_sync <terms_b64> <sig_b64> <bundle_b64>: hold the op the terms name,
|
||||||
// once the bundle hashes to it and its manifest parses. For op_grant terms
|
// once the bundle hashes to it and its manifest parses. For op_grant terms
|
||||||
// the grant is installed too (its approval consumed, like a store grant).
|
// the grant is installed too (its approval consumed, like a store grant).
|
||||||
@@ -1224,6 +1300,10 @@ func handleOpSync(ws *websocket.Conn, dir string, args []string) {
|
|||||||
opFail(ws, "op_sync refused: the manifest and the terms disagree on name, version or privilege")
|
opFail(ws, "op_sync refused: the manifest and the terms disagree on name, version or privilege")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := opBundleFitsManifest(files, m); err != nil {
|
||||||
|
opFail(ws, "op_sync refused: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
if entries, err := os.ReadDir(opsDir(dir)); err == nil && len(entries) > opsMaxHeldOps {
|
if entries, err := os.ReadDir(opsDir(dir)); err == nil && len(entries) > opsMaxHeldOps {
|
||||||
opFail(ws, fmt.Sprintf("op_sync refused: this box holds %d ops already; drop some", opsMaxHeldOps))
|
opFail(ws, fmt.Sprintf("op_sync refused: this box holds %d ops already; drop some", opsMaxHeldOps))
|
||||||
return
|
return
|
||||||
@@ -1237,7 +1317,7 @@ func handleOpSync(ws *websocket.Conn, dir string, args []string) {
|
|||||||
}
|
}
|
||||||
out := map[string]any{"state": "held", "op": t.Op, "sha256": sha, "kind": t.Kind}
|
out := map[string]any{"state": "held", "op": t.Op, "sha256": sha, "kind": t.Kind}
|
||||||
if t.Kind == opKindGrant {
|
if t.Kind == opKindGrant {
|
||||||
if _, err := parseBounds(m, t.Bounds); err != nil {
|
if _, err := parseGrantBounds(m, t.Bounds); err != nil {
|
||||||
opFail(ws, "op_sync refused: "+err.Error())
|
opFail(ws, "op_sync refused: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1251,14 +1331,22 @@ func handleOpSync(ws *websocket.Conn, dir string, args []string) {
|
|||||||
if prev, held := f.Grants[t.Nonce]; !held || prev.Dropped != "" {
|
if prev, held := f.Grants[t.Nonce]; !held || prev.Dropped != "" {
|
||||||
// THE PERSON'S APPROVAL, spent here: one approval installs the
|
// THE PERSON'S APPROVAL, spent here: one approval installs the
|
||||||
// grant once; a dropped grant cannot be re-installed under it.
|
// grant once; a dropped grant cannot be re-installed under it.
|
||||||
id, err := consumeApproval(dir, t.Approval, purpose)
|
// The key that gave it is kept with the grant, which dies with
|
||||||
|
// that key (ops_bounds.go).
|
||||||
|
id, approver, err := consumeApprovalKeyed(dir, t.Approval, purpose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
opsMu.Unlock()
|
opsMu.Unlock()
|
||||||
opFail(ws, "op_grant refused: "+err.Error())
|
opFail(ws, "op_grant refused: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f.Grants[t.Nonce] = opGrantRec{TermsCanonical: args[0], Signature: args[1],
|
rec := opGrantRec{TermsCanonical: args[0], Signature: args[1],
|
||||||
InstalledAt: time.Now().UTC().Format(time.RFC3339), UsesLeft: t.Uses}
|
InstalledAt: opNow().UTC().Format(time.RFC3339), UsesLeft: t.Uses, Approver: approver}
|
||||||
|
if err := opGrantRecSeal(t.Nonce, &rec); err != nil {
|
||||||
|
opsMu.Unlock()
|
||||||
|
opFail(ws, "op_grant: could not hold the grant: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Grants[t.Nonce] = rec
|
||||||
if err := opsSave(dir, f); err != nil {
|
if err := opsSave(dir, f); err != nil {
|
||||||
opsMu.Unlock()
|
opsMu.Unlock()
|
||||||
opFail(ws, "op_grant: could not hold the grant: "+err.Error())
|
opFail(ws, "op_grant: could not hold the grant: "+err.Error())
|
||||||
@@ -1359,9 +1447,15 @@ func handleOpApply(ws *websocket.Conn, dir string, args []string) {
|
|||||||
sendMgmtExit(ws, 0)
|
sendMgmtExit(ws, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opGrantExec runs the terms of one run under a grant (a seam, so the tests
|
||||||
|
// can stand in for the sandbox and judge the gate alone).
|
||||||
|
var opGrantExec = opRunTerms
|
||||||
|
|
||||||
// op_grant_run <grant nonce> <mode> <params_json>: a run under a held grant —
|
// op_grant_run <grant nonce> <mode> <params_json>: a run under a held grant —
|
||||||
// dry_run or apply — with the params checked against the schema AND the
|
// dry_run or apply — with the params checked against the schema AND the
|
||||||
// approved bounds; an apply spends one use.
|
// approved bounds; an apply spends one use. An apply outside the grant's
|
||||||
|
// window or rate is NOT NOW (ops_bounds.go): nothing runs, nothing is spent,
|
||||||
|
// no strike. Identity and arguments are judged first and stay refusals.
|
||||||
func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
||||||
if dir == "" || len(args) < 3 {
|
if dir == "" || len(args) < 3 {
|
||||||
opFail(ws, "op_grant_run: need <grant> <dry_run|apply> <params_json>")
|
opFail(ws, "op_grant_run: need <grant> <dry_run|apply> <params_json>")
|
||||||
@@ -1393,12 +1487,12 @@ func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
|||||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
bounds, err := parseBounds(m, t.Bounds)
|
bounds, err := parseGrantBounds(m, t.Bounds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := checkBounds(m, bounds, vals); err != nil {
|
if err := checkBounds(m, bounds.Params, vals); err != nil {
|
||||||
opFail(ws, "op_grant_run refused: "+err.Error())
|
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1413,16 +1507,31 @@ func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mode == opModeApply {
|
if mode == opModeApply {
|
||||||
// Spend the use BEFORE the run (the same order the approval takes).
|
// Spend the use BEFORE the run (the same order the approval takes),
|
||||||
|
// and log the run with it, in one write — after the window and the
|
||||||
|
// rate, read from that same record, say it may run now.
|
||||||
opsMu.Lock()
|
opsMu.Lock()
|
||||||
f := opsLoad(dir)
|
_, _, runs, err := opsHeldGrantRuns(dir, t.Nonce)
|
||||||
r := f.Grants[t.Nonce]
|
if err != nil {
|
||||||
if r.UsesLeft <= 0 || r.Dropped != "" {
|
|
||||||
opsMu.Unlock()
|
opsMu.Unlock()
|
||||||
opFail(ws, "op_grant_run refused: op grant has no uses left")
|
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := opNow()
|
||||||
|
if why, bound, next := opNotNowWhy(bounds, runs, now); why != "" {
|
||||||
|
opsMu.Unlock()
|
||||||
|
sendNotNow(ws, dir, t, why, bound, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := opsLoad(dir)
|
||||||
|
r := f.Grants[t.Nonce]
|
||||||
r.UsesLeft--
|
r.UsesLeft--
|
||||||
|
r.Runs = append(r.Runs, now.UTC().Format(time.RFC3339Nano))
|
||||||
|
if err := opGrantRecSeal(t.Nonce, &r); err != nil {
|
||||||
|
opsMu.Unlock()
|
||||||
|
opFail(ws, "op_grant_run refused: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
f.Grants[t.Nonce] = r
|
f.Grants[t.Nonce] = r
|
||||||
if err := opsSave(dir, f); err != nil {
|
if err := opsSave(dir, f); err != nil {
|
||||||
opsMu.Unlock()
|
opsMu.Unlock()
|
||||||
@@ -1432,7 +1541,7 @@ func handleOpGrantRun(ws *websocket.Conn, dir string, args []string) {
|
|||||||
opsMu.Unlock()
|
opsMu.Unlock()
|
||||||
_ = rec
|
_ = rec
|
||||||
}
|
}
|
||||||
rep, record, err := opRunTerms(dir, &run, mode, "grant:"+t.Nonce, "", creds)
|
rep, record, err := opGrantExec(dir, &run, mode, "grant:"+t.Nonce, "", creds)
|
||||||
record.Kind = opKindGrant
|
record.Kind = opKindGrant
|
||||||
opRecordRun(dir, record)
|
opRecordRun(dir, record)
|
||||||
opAuditRun(dir, "op_grant_run", record)
|
opAuditRun(dir, "op_grant_run", record)
|
||||||
@@ -1464,6 +1573,7 @@ func handleOpList(ws *websocket.Conn, dir string, _ []string) {
|
|||||||
if m.isAction() {
|
if m.isAction() {
|
||||||
item["kind"] = opKindAction
|
item["kind"] = opKindAction
|
||||||
}
|
}
|
||||||
|
item["verbs_only"] = m.verbsOnly()
|
||||||
} else {
|
} else {
|
||||||
item["error"] = err.Error()
|
item["error"] = err.Error()
|
||||||
}
|
}
|
||||||
@@ -1475,9 +1585,20 @@ func handleOpList(ws *websocket.Conn, dir string, _ []string) {
|
|||||||
f := opsLoad(dir)
|
f := opsLoad(dir)
|
||||||
opsMu.Unlock()
|
opsMu.Unlock()
|
||||||
for nonce, rec := range f.Grants {
|
for nonce, rec := range f.Grants {
|
||||||
g := map[string]any{"grant": nonce, "uses_left": rec.UsesLeft, "installed_at": rec.InstalledAt, "dropped": rec.Dropped}
|
g := map[string]any{"grant": nonce, "uses_left": rec.UsesLeft, "installed_at": rec.InstalledAt, "dropped": rec.Dropped,
|
||||||
|
"runs": len(rec.Runs), "approver": rec.Approver}
|
||||||
|
if len(rec.Runs) > 0 {
|
||||||
|
g["last_run"] = rec.Runs[len(rec.Runs)-1]
|
||||||
|
}
|
||||||
if t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant); err == nil {
|
if t, err := verifyOpTerms(dir, rec.TermsCanonical, rec.Signature, opKindGrant); err == nil {
|
||||||
g["op"], g["sha256"], g["expires"], g["bounds"] = t.Op, t.SHA256, t.Expires, t.Bounds
|
g["op"], g["sha256"], g["expires"], g["bounds"] = t.Op, t.SHA256, t.Expires, t.Bounds
|
||||||
|
if rec.Dropped == "" {
|
||||||
|
if _, err := opGrantRecCheck(nonce, &rec, t, opNow()); err != nil {
|
||||||
|
g["error"] = err.Error()
|
||||||
|
} else if !opApproverLive(dir, rec.Approver) {
|
||||||
|
g["error"] = "the key that approved it is no longer pinned on this machine"
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
g["error"] = err.Error()
|
g["error"] = err.Error()
|
||||||
}
|
}
|
||||||
@@ -1606,9 +1727,14 @@ func opRunTerms(dir string, t *opTerms, mode, approvalID, termsB64 string, creds
|
|||||||
rec.Error = err.Error()
|
rec.Error = err.Error()
|
||||||
return nil, rec, err
|
return nil, rec, err
|
||||||
}
|
}
|
||||||
if ok, why := opSandboxAvailable(); !ok {
|
// The sandbox is for a program. An op with none (interpreter: none) runs
|
||||||
rec.Error = noSandboxHere + ": " + why
|
// only the bastion's verbs, which need their own tools instead
|
||||||
return nil, rec, errors.New(rec.Error)
|
// (opRunAction): it runs where no overlay is.
|
||||||
|
if !m.verbsOnly() {
|
||||||
|
if ok, why := opSandboxCheck(); !ok {
|
||||||
|
rec.Error = noSandboxHere + ": " + why
|
||||||
|
return nil, rec, errors.New(rec.Error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if m.isAction() {
|
if m.isAction() {
|
||||||
// An action op (ops_action.go): its program in a transient unit, its
|
// An action op (ops_action.go): its program in a transient unit, its
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ package main
|
|||||||
// else is written into the manifest, so what a verb can reach is fixed when
|
// else is written into the manifest, so what a verb can reach is fixed when
|
||||||
// the op is approved.
|
// the op is approved.
|
||||||
//
|
//
|
||||||
|
// An action op may have no program at all (interpreter: none, no run file):
|
||||||
|
// its verbs are its whole work. Nothing the Host wrote executes, so it needs
|
||||||
|
// no sandbox and runs on a machine with no overlay; each verb needs only its
|
||||||
|
// own tool (systemctl, journalctl, git, kubectl), and the beat reports which
|
||||||
|
// this machine has (enforcing.verbs).
|
||||||
|
//
|
||||||
// Every verb line, with the state it moves from, is in the dry run's change
|
// Every verb line, with the state it moves from, is in the dry run's change
|
||||||
// list, so an approval binds that list and an apply refuses when the machine
|
// list, so an approval binds that list and an apply refuses when the machine
|
||||||
// moved under it. An op with any verb is privilege root: its verbs run with
|
// moved under it. An op with any verb is privilege root: its verbs run with
|
||||||
@@ -394,10 +400,29 @@ func opParseDest(s string) (opDest, error) {
|
|||||||
}
|
}
|
||||||
} else if why := opForbiddenDest(ip); why != "" {
|
} else if why := opForbiddenDest(ip); why != "" {
|
||||||
return opDest{}, errors.New(why)
|
return opDest{}, errors.New(why)
|
||||||
|
} else if why := opLocalNetworkDest(ip); why != "" {
|
||||||
|
return opDest{}, errors.New(why)
|
||||||
}
|
}
|
||||||
return opDest{Host: h, Port: uint16(port)}, nil
|
return opDest{Host: h, Port: uint16(port)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opLocalNetworkDest: "" when an address may be a destination on this box as
|
||||||
|
// it stands, else why not. An anchored machine is a fleet's, and its ship
|
||||||
|
// steps reach the fleet's private control planes, so it keeps private
|
||||||
|
// destinations. On any other machine (a person's own, which no fleet has
|
||||||
|
// anchored) an op reaches the public internet and nothing around the
|
||||||
|
// machine: a private, ULA, CGNAT or reserved address (internalIP, the rule
|
||||||
|
// the egress lane holds every box to) is refused, by address here and, for a
|
||||||
|
// name, by every address it resolves to at apply (opResolveDests). Another
|
||||||
|
// computer of theirs is reached through a bastion on that computer.
|
||||||
|
func opLocalNetworkDest(ip net.IP) string {
|
||||||
|
if currentAnchor() != nil || !internalIP(ip) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "a destination is never an address on a private network (a home network, a VPN or a carrier NAT) " +
|
||||||
|
"on a machine no fleet has anchored; to reach another computer on it, install a bastion on that computer"
|
||||||
|
}
|
||||||
|
|
||||||
// opMetadataAddrs are the cloud metadata services that do not sit in
|
// opMetadataAddrs are the cloud metadata services that do not sit in
|
||||||
// link-local space (Alibaba's, AWS's IPv6 one). They hand out the machine's
|
// link-local space (Alibaba's, AWS's IPv6 one). They hand out the machine's
|
||||||
// own credentials to anything that asks from it.
|
// own credentials to anything that asks from it.
|
||||||
@@ -445,6 +470,12 @@ func opCredEnv(name string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verbsOnly: an action op with no program (interpreter: none). Its whole work
|
||||||
|
// is its verbs, done by the bastion with their arguments fixed in the
|
||||||
|
// manifest; nothing the Host wrote executes, so it needs no sandbox (a machine
|
||||||
|
// with no overlay runs it), only the tools its verbs run.
|
||||||
|
func (m *opManifest) verbsOnly() bool { return m.isAction() && m.Interpreter == opInterpreterNone }
|
||||||
|
|
||||||
// opActionManifestOK is parseOpManifest's judgement of an action op.
|
// opActionManifestOK is parseOpManifest's judgement of an action op.
|
||||||
func opActionManifestOK(m *opManifest) error {
|
func opActionManifestOK(m *opManifest) error {
|
||||||
if len(m.Effects.Files)+len(m.Effects.Units)+len(m.Effects.Packages)+len(m.Effects.Network) > 0 {
|
if len(m.Effects.Files)+len(m.Effects.Units)+len(m.Effects.Packages)+len(m.Effects.Network) > 0 {
|
||||||
@@ -489,6 +520,25 @@ func opActionManifestOK(m *opManifest) error {
|
|||||||
if (len(verbs) > 0) != (m.Privilege == "root") {
|
if (len(verbs) > 0) != (m.Privilege == "root") {
|
||||||
return errors.New("op.yaml: an action op is privilege root exactly when it has verbs: they run with the bastion's own root")
|
return errors.New("op.yaml: an action op is privilege root exactly when it has verbs: they run with the bastion's own root")
|
||||||
}
|
}
|
||||||
|
if m.verbsOnly() {
|
||||||
|
if len(verbs) == 0 {
|
||||||
|
return errors.New("op.yaml: interpreter: none is an op whose verbs are its whole work, and it names none")
|
||||||
|
}
|
||||||
|
if len(m.Destinations) > 0 || m.Builder || len(m.Reads) > 0 {
|
||||||
|
return errors.New("op.yaml: destinations, builder and reads are a program's, and interpreter: none has no program")
|
||||||
|
}
|
||||||
|
named := map[string]bool{}
|
||||||
|
for _, v := range verbs {
|
||||||
|
if c := v.Args["credential"]; c != "" {
|
||||||
|
named[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range m.Credentials {
|
||||||
|
if !named[c] {
|
||||||
|
return fmt.Errorf("op.yaml: credential %q is named by no verb, and interpreter: none has no program to hand it to", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -350,7 +350,9 @@ type opResolvedDests struct {
|
|||||||
// what the unit is allowed to reach, and the names go in the run's own
|
// what the unit is allowed to reach, and the names go in the run's own
|
||||||
// /etc/hosts (the machine's resolver is not reachable from the sandbox). A
|
// /etc/hosts (the machine's resolver is not reachable from the sandbox). A
|
||||||
// destination that resolves to this machine, its loopback or nothing is
|
// destination that resolves to this machine, its loopback or nothing is
|
||||||
// refused: a local service is never an op's destination.
|
// refused: a local service is never an op's destination. On a machine no
|
||||||
|
// fleet has anchored, so is one that resolves to a private network
|
||||||
|
// (opLocalNetworkDest).
|
||||||
func opResolveDests(ctx context.Context, dests []string) (*opResolvedDests, error) {
|
func opResolveDests(ctx context.Context, dests []string) (*opResolvedDests, error) {
|
||||||
own := map[string]bool{}
|
own := map[string]bool{}
|
||||||
for _, ip := range opActionOwnAddrs() {
|
for _, ip := range opActionOwnAddrs() {
|
||||||
@@ -379,6 +381,9 @@ func opResolveDests(ctx context.Context, dests []string) (*opResolvedDests, erro
|
|||||||
if why := opForbiddenDest(ip); why != "" {
|
if why := opForbiddenDest(ip); why != "" {
|
||||||
return nil, fmt.Errorf("destination %s resolves to %s: %s", d, ip, why)
|
return nil, fmt.Errorf("destination %s resolves to %s: %s", d, ip, why)
|
||||||
}
|
}
|
||||||
|
if why := opLocalNetworkDest(ip); why != "" {
|
||||||
|
return nil, fmt.Errorf("destination %s resolves to %s: %s", d, ip, why)
|
||||||
|
}
|
||||||
if own[ip.String()] {
|
if own[ip.String()] {
|
||||||
return nil, fmt.Errorf("destination %s resolves to %s, which is this machine", d, ip)
|
return nil, fmt.Errorf("destination %s resolves to %s, which is this machine", d, ip)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RESULT: red before opLocalNetworkDest — on a local box a name that resolves
|
||||||
|
// to a home network's address was put in the apply's allowlist.
|
||||||
|
func TestALocalBoxRefusesANameThatResolvesToAPrivateNetworkAtApply(t *testing.T) {
|
||||||
|
localOpBox(t)
|
||||||
|
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||||
|
opActionOwnAddrs = func() []net.IP { return nil }
|
||||||
|
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||||
|
switch h {
|
||||||
|
case "nas.home.example":
|
||||||
|
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||||
|
case "split.example.com":
|
||||||
|
return []net.IP{net.ParseIP("203.0.113.10"), net.ParseIP("192.168.1.2")}, nil
|
||||||
|
case "forge.example.com":
|
||||||
|
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||||
|
|
||||||
|
for _, d := range []string{"nas.home.example:80", "split.example.com:443"} {
|
||||||
|
if _, err := opResolveDests(context.Background(), []string{d}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "private network") {
|
||||||
|
t.Errorf("%s: a local box resolves it into the allowlist: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := opResolveDests(context.Background(), []string{"forge.example.com:443"})
|
||||||
|
if err != nil || len(got.Addrs) != 1 || got.Addrs[0] != "203.0.113.10" {
|
||||||
|
t.Fatalf("a public name resolves to its address: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An anchored fleet keeps the same name.
|
||||||
|
setupBox(t)
|
||||||
|
if got, err := opResolveDests(context.Background(), []string{"nas.home.example:80"}); err != nil || got.Addrs[0] != "10.0.0.2" {
|
||||||
|
t.Fatalf("an anchored box resolves a private control plane: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A destination written as an address is judged again at apply by the anchor
|
||||||
|
// the machine has then: an op held while the machine was anchored does not
|
||||||
|
// reach a private address after the anchor is gone.
|
||||||
|
func TestAPrivateDestinationHeldWhileAnchoredIsRefusedAtApplyOnceTheAnchorIsGone(t *testing.T) {
|
||||||
|
setupBox(t)
|
||||||
|
prevOwn := opActionOwnAddrs
|
||||||
|
opActionOwnAddrs = func() []net.IP { return nil }
|
||||||
|
t.Cleanup(func() { opActionOwnAddrs = prevOwn })
|
||||||
|
for _, d := range []string{"10.0.0.2:80", "[::ffff:10.0.0.2]:80"} {
|
||||||
|
if _, err := opResolveDests(context.Background(), []string{d}); err != nil {
|
||||||
|
t.Fatalf("%s: an anchored box reaches its private control plane: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localOpBox(t)
|
||||||
|
for _, d := range []string{"10.0.0.2:80", "[::ffff:10.0.0.2]:80"} {
|
||||||
|
if _, err := opResolveDests(context.Background(), []string{d}); err == nil || !strings.Contains(err.Error(), "private network") {
|
||||||
|
t.Errorf("%s: held while anchored, reached after the anchor is gone: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A verb fetches too: tree.checkout fetches its repo, and k8s.set_image
|
||||||
|
// fetches its repo to check ancestry, each with the bastion's own root and
|
||||||
|
// outside the program's sandbox. On a local machine that fetch is held to
|
||||||
|
// the rule a destination is: a repository whose host resolves to a private
|
||||||
|
// network is never fetched, and no git runs for it. An interpreter: none op
|
||||||
|
// is nothing but verbs, so this is the whole of its reach.
|
||||||
|
//
|
||||||
|
// RESULT: red before opFetchPin — the ancestry fetch ran git against the
|
||||||
|
// name, which git resolved itself, so a name on a home network was fetched.
|
||||||
|
func TestALocalBoxNeverFetchesAVerbsRepositoryFromAPrivateNetwork(t *testing.T) {
|
||||||
|
localOpBox(t)
|
||||||
|
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||||
|
opActionOwnAddrs = func() []net.IP { return nil }
|
||||||
|
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||||
|
switch h {
|
||||||
|
case "nas.home.example":
|
||||||
|
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||||
|
case "split.example.com":
|
||||||
|
return []net.IP{net.ParseIP("203.0.113.10"), net.ParseIP("::ffff:192.168.1.2")}, nil
|
||||||
|
case "meta.example.com":
|
||||||
|
return []net.IP{net.ParseIP("169.254.169.254")}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||||
|
|
||||||
|
older, newer := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||||
|
for repo, want := range map[string]string{
|
||||||
|
"https://nas.home.example/acme/app.git": "private network",
|
||||||
|
"https://split.example.com/acme/app.git": "private network",
|
||||||
|
"https://meta.example.com/acme/app.git": "link-local",
|
||||||
|
} {
|
||||||
|
err := opAncestryByFetch(repo, older, newer, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: a local box fetched it: %v", repo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The pin a public repository is fetched under: git is held to the
|
||||||
|
// address checked here and never looks the name up again.
|
||||||
|
prevGit := opGitPinnable
|
||||||
|
opGitPinnable = func() string { return "" }
|
||||||
|
t.Cleanup(func() { opGitPinnable = prevGit })
|
||||||
|
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||||
|
return []net.IP{net.ParseIP("2001:db8::10")}, nil
|
||||||
|
}
|
||||||
|
if pin, err := opFetchPin(context.Background(), "https://forge.example.com:8443/acme/app.git"); err != nil ||
|
||||||
|
strings.Join(pin, " ") != "-c http.curloptResolve=forge.example.com:8443:[2001:db8::10]" {
|
||||||
|
t.Fatalf("a public repository is fetched pinned to its address: %q %v", pin, err)
|
||||||
|
}
|
||||||
|
// A git that would drop the pin is refused, never run unpinned.
|
||||||
|
opGitPinnable = func() string { return "git 2.34 cannot hold a fetch to one address" }
|
||||||
|
if _, err := opFetchPin(context.Background(), "https://forge.example.com/acme/app.git"); err == nil {
|
||||||
|
t.Fatal("a git that ignores the pin fetched unpinned")
|
||||||
|
}
|
||||||
|
// An anchored fleet fetches from its private forge, pinned to it.
|
||||||
|
opGitPinnable = func() string { return "" }
|
||||||
|
setupBox(t)
|
||||||
|
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||||
|
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||||
|
}
|
||||||
|
if pin, err := opFetchPin(context.Background(), "https://nas.home.example/acme/app.git"); err != nil ||
|
||||||
|
strings.Join(pin, " ") != "-c http.curloptResolve=nas.home.example:443:10.0.0.2" {
|
||||||
|
t.Fatalf("an anchored box fetches from its private forge: %q %v", pin, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An anchored machine keeps its fleet's private forges, but a verb's fetch
|
||||||
|
// there is still never this machine, its loopback, a link-local address or a
|
||||||
|
// cloud's metadata service: a name steered to one is refused before git runs.
|
||||||
|
//
|
||||||
|
// RESULT: red before opFetchPin ran on anchored machines — git fetched each
|
||||||
|
// name itself, whatever it resolved to.
|
||||||
|
func TestAnAnchoredBoxNeverFetchesAVerbsRepositoryFromItselfOrTheMetadataService(t *testing.T) {
|
||||||
|
setupBox(t)
|
||||||
|
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||||
|
opActionOwnAddrs = func() []net.IP { return []net.IP{net.ParseIP("203.0.113.99")} }
|
||||||
|
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||||
|
switch h {
|
||||||
|
case "lo.example.com":
|
||||||
|
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||||
|
case "mapped.example.com":
|
||||||
|
return []net.IP{net.ParseIP("203.0.113.10"), net.ParseIP("::ffff:127.0.0.1")}, nil
|
||||||
|
case "meta.example.com":
|
||||||
|
return []net.IP{net.ParseIP("169.254.169.254")}, nil
|
||||||
|
case "ali.example.com":
|
||||||
|
return []net.IP{net.ParseIP("100.100.100.200")}, nil
|
||||||
|
case "self.example.com":
|
||||||
|
return []net.IP{net.ParseIP("203.0.113.99")}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||||
|
older, newer := strings.Repeat("a", 40), strings.Repeat("b", 40)
|
||||||
|
for repo, want := range map[string]string{
|
||||||
|
"https://lo.example.com/acme/app.git": "loopback",
|
||||||
|
"https://mapped.example.com/acme/app.git": "loopback",
|
||||||
|
"https://meta.example.com/acme/app.git": "link-local",
|
||||||
|
"https://ali.example.com/acme/app.git": "metadata service",
|
||||||
|
"https://self.example.com/acme/app.git": "this machine",
|
||||||
|
} {
|
||||||
|
err := opAncestryByFetch(repo, older, newer, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: an anchored box fetched it: %v", repo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8s.set_image hands an image to the cluster, and the machine's container
|
||||||
|
// runtime pulls it by name, outside the bastion's walls. On a local machine
|
||||||
|
// an image whose registry is on the private network around it is refused
|
||||||
|
// where the bastion takes the image, so the runtime is never sent there; a
|
||||||
|
// Docker Hub repository and a public registry pass; an anchored fleet keeps
|
||||||
|
// its private registry.
|
||||||
|
//
|
||||||
|
// RESULT: red before opRegistryOK — the plan took a receipt for an image on a
|
||||||
|
// home network's registry and went on to the live image's record.
|
||||||
|
func TestALocalBoxNeverPutsLiveAnImageFromARegistryOnItsNetwork(t *testing.T) {
|
||||||
|
localOpBox(t)
|
||||||
|
prevOwn, prevRes := opActionOwnAddrs, opActionResolve
|
||||||
|
opActionOwnAddrs = func() []net.IP { return nil }
|
||||||
|
opActionResolve = func(_ context.Context, h string) ([]net.IP, error) {
|
||||||
|
switch h {
|
||||||
|
case "nas.home.example":
|
||||||
|
return []net.IP{net.ParseIP("10.0.0.2")}, nil
|
||||||
|
case "registry.example.com":
|
||||||
|
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opActionOwnAddrs, opActionResolve = prevOwn, prevRes })
|
||||||
|
useAncestry(t, func(_, _, _ string, _ []string) error { return nil })
|
||||||
|
ctx := context.Background()
|
||||||
|
commit := strings.Repeat("c", 40)
|
||||||
|
for i, tc := range []struct{ image, want string }{
|
||||||
|
{"nas.home.example:5000/acme/web", "private network"},
|
||||||
|
{"10.0.0.2:5000/acme/web", "private network"},
|
||||||
|
{"192.168.1.2/acme/web", "private network"},
|
||||||
|
{"localhost:5000/acme/web", "loopback"},
|
||||||
|
{"169.254.169.254/acme/web", "link-local"},
|
||||||
|
{"registry.example.com/acme/web", ""},
|
||||||
|
{"acme/web", ""},
|
||||||
|
} {
|
||||||
|
digest := "sha256:" + strings.Repeat(fmt.Sprintf("%x", i+1), 64)
|
||||||
|
if _, err := opIssueReceipts("r", "web-build", strings.Repeat("e", 64), "{}", []opResultImage{{Image: tc.image,
|
||||||
|
Digest: digest, Source: tRepo, Commit: commit}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := &fakeKube{containers: map[string]string{"bridge": tc.image + "@sha256:" + strings.Repeat("0", 64)}}
|
||||||
|
useFakeKube(t, f)
|
||||||
|
line := setImageLine(digest)
|
||||||
|
line.Args["image"] = tc.image
|
||||||
|
_, err := opImagePlan(ctx, line, nil)
|
||||||
|
if tc.want != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "comes from the registry") {
|
||||||
|
t.Errorf("%s: a local box would put it live: %v", tc.image, err)
|
||||||
|
}
|
||||||
|
if _, err := opImageDo(ctx, line, nil, nil, ""); err == nil || len(f.sets()) != 0 {
|
||||||
|
t.Errorf("%s: and set it: %v %v", tc.image, err, f.sets())
|
||||||
|
}
|
||||||
|
} else if err != nil && strings.Contains(err.Error(), "comes from the registry") {
|
||||||
|
t.Errorf("%s: a public registry is refused: %v", tc.image, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An anchored fleet keeps its private registry.
|
||||||
|
setupBox(t)
|
||||||
|
if err := opRegistryOK(ctx, "nas.home.example:5000/acme/web@sha256:"+strings.Repeat("1", 64)); err != nil {
|
||||||
|
t.Fatalf("an anchored box pulls from its private registry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An action op on a person's own machine (local, unanchored) reaches the
|
||||||
|
// public internet and nothing around the machine; an anchored fleet keeps its
|
||||||
|
// private destinations, because its ship steps reach private control planes.
|
||||||
|
// The addresses are documentation and private examples, invented here.
|
||||||
|
|
||||||
|
// localOpBox: a local machine, anchored by nobody, with one pairing whose
|
||||||
|
// pairing dir pins a signing key we hold.
|
||||||
|
func localOpBox(t *testing.T) (dcn string, priv ed25519.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
p, _ := modePath()
|
||||||
|
if err := os.WriteFile(p, []byte(`{"mode":"local"}`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePairing(t, tAnchor)
|
||||||
|
if currentAnchor() != nil {
|
||||||
|
t.Fatal("the local box must not be anchored")
|
||||||
|
}
|
||||||
|
dcn = t.TempDir()
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||||
|
if err := dcnSaveFile(dcn, &dcnGrantFile{Stores: map[string]dcnGrantRec{},
|
||||||
|
VerifyPubkey: base64.StdEncoding.EncodeToString(pub)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dcn, priv
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionBundle: an unprivileged action op declaring one destination.
|
||||||
|
func actionBundle(dest string) []opFile {
|
||||||
|
man := `name: optest-reach
|
||||||
|
version: 1.0.0
|
||||||
|
summary: An action op that reaches one destination.
|
||||||
|
kind: action
|
||||||
|
target: machine
|
||||||
|
privilege: user
|
||||||
|
interpreter: /bin/sh
|
||||||
|
time_limit: 30
|
||||||
|
destinations: ["` + dest + `"]
|
||||||
|
modes: [dry_run, apply, verify]
|
||||||
|
undo: none
|
||||||
|
`
|
||||||
|
return []opFile{
|
||||||
|
{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
|
||||||
|
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\ntrue\n"))},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncAction drives op_sync for an action op declaring dest, under op_run
|
||||||
|
// terms signed by priv; it returns the op's output, its error text and code.
|
||||||
|
func syncAction(t *testing.T, dcn string, priv ed25519.PrivateKey, dest string) (string, string, int) {
|
||||||
|
t.Helper()
|
||||||
|
files := actionBundle(dest)
|
||||||
|
sha, err := opDirDigest(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
terms := opTerm(opKindRun, sha, `{}`, "", 1, time.Hour, "user")
|
||||||
|
terms["op"] = "optest-reach"
|
||||||
|
pair := signTermsPair(t, priv, terms)
|
||||||
|
return driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESULT: red before opLocalNetworkDest — a local box held an op declaring
|
||||||
|
// 10.0.0.2:80 (a home network's address), and its apply would have reached it.
|
||||||
|
func TestALocalBoxRefusesAnActionOpThatDeclaresAPrivateNetwork(t *testing.T) {
|
||||||
|
dcn, priv := localOpBox(t)
|
||||||
|
for _, dest := range []string{
|
||||||
|
"10.0.0.2:80", // RFC 1918
|
||||||
|
"192.168.1.2:443", // a home router's range
|
||||||
|
"172.16.0.2:8080", // RFC 1918
|
||||||
|
"100.64.0.2:22", // carrier NAT, and VPN meshes that use it
|
||||||
|
"[fd00::2]:80", // ULA
|
||||||
|
"[64:ff9b::a00:2]:80", // 10.0.0.2 through NAT64
|
||||||
|
"[2002:a00:2::1]:80", // 10.0.0.2 through 6to4
|
||||||
|
"198.18.0.2:80", // benchmarking, never a public host
|
||||||
|
} {
|
||||||
|
out, errOut, code := syncAction(t, dcn, priv, dest)
|
||||||
|
if code == 0 || strings.Contains(out, `"held"`) {
|
||||||
|
t.Errorf("%s: a local box held an op declaring it: %d %s %s", dest, code, out, errOut)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "private network") || !strings.Contains(errOut, "install a bastion on that computer") {
|
||||||
|
t.Errorf("%s: the refusal must say why and what to do instead: %q", dest, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The public internet is still a destination.
|
||||||
|
if out, errOut, code := syncAction(t, dcn, priv, "203.0.113.10:443"); code != 0 || !strings.Contains(out, `"held"`) {
|
||||||
|
t.Fatalf("a public destination is held on a local box: %d %s %s", code, out, errOut)
|
||||||
|
}
|
||||||
|
// A name is judged at apply, by every address it resolves to
|
||||||
|
// (opResolveDests); at sync it is only a name.
|
||||||
|
if out, errOut, code := syncAction(t, dcn, priv, "forge.example.com:443"); code != 0 || !strings.Contains(out, `"held"`) {
|
||||||
|
t.Fatalf("a destination by name is held on a local box: %d %s %s", code, out, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAnchoredFleetKeepsItsPrivateDestinations(t *testing.T) {
|
||||||
|
dcn, priv := opTestBox(t)
|
||||||
|
enrollFactor(t, dcn)
|
||||||
|
for _, dest := range []string{"10.0.0.2:80", "100.64.0.2:6443", "[fd00::2]:80"} {
|
||||||
|
if out, errOut, code := syncAction(t, dcn, priv, dest); code != 0 || !strings.Contains(out, `"held"`) {
|
||||||
|
t.Errorf("%s: an anchored box holds a ship step to a private control plane: %d %s %s", dest, code, out, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What every box refuses stays refused.
|
||||||
|
if _, errOut, code := syncAction(t, dcn, priv, "127.0.0.1:80"); code == 0 || !strings.Contains(errOut, "loopback") {
|
||||||
|
t.Fatalf("loopback stays refused on an anchored box: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ops_bounds.go — what an op_grant bounds besides the params, and the record
|
||||||
|
// that lets this machine hold it there (bastion 0.58.0).
|
||||||
|
//
|
||||||
|
// The bounds text an op_grant carries (ops.go parseBounds) names params by
|
||||||
|
// their names, which match ^[a-z]. Two keys beginning with "_" bound the RUN
|
||||||
|
// rather than a param, so they can never collide with one:
|
||||||
|
//
|
||||||
|
// "_window": {"days": ["mon", …, "sun"], "from": "HH:MM", "to": "HH:MM", "tz": "Europe/Berlin"}
|
||||||
|
// applies run only inside this window of the machine's clock, read
|
||||||
|
// in tz (an IANA name, "UTC" when absent). days is optional (every
|
||||||
|
// day when absent). from > to wraps midnight: the window opens on a
|
||||||
|
// listed day and closes the next morning.
|
||||||
|
// "_rate": {"per_day": n, "min_interval_s": s}
|
||||||
|
// at most n applies in any 24 hours, and s seconds between two.
|
||||||
|
// Either may be absent, not both.
|
||||||
|
//
|
||||||
|
// Either key may be null, which bounds nothing. Any other "_" key is refused:
|
||||||
|
// a bound this machine does not enforce would read on the card as a limit the
|
||||||
|
// person never got. An older box refuses both as "names no param", so a grant
|
||||||
|
// carrying them fails closed there.
|
||||||
|
//
|
||||||
|
// An apply outside the window or the rate is NOT NOW, not refused: nothing
|
||||||
|
// runs, no use is spent, and it is no strike toward the quarantine — the Host
|
||||||
|
// asked for something the person allows, at a time they do not. The answer is
|
||||||
|
// an 'O' frame {"state": "not_now", …} and exit 75 (sysexits EX_TEMPFAIL).
|
||||||
|
// Identity and argument failures stay refusals, as before.
|
||||||
|
//
|
||||||
|
// THE RECORD. ops.json keeps, for each grant, the times of the applies that
|
||||||
|
// spent a use (the run log, which the rate is counted from, so it holds across
|
||||||
|
// a restart), the key that approved it, and a MAC over the record under a key
|
||||||
|
// this machine derives from its own encryption key. A record edited outside
|
||||||
|
// the bastion — a run removed, a use given back, the approver changed — no
|
||||||
|
// longer checks out, and the grant is unusable until the person drops it and
|
||||||
|
// approves again. A grant installed by a bastion older than this one carries
|
||||||
|
// neither and is unusable too (none were held when it shipped).
|
||||||
|
//
|
||||||
|
// THE APPROVER. The device key that signed the approval, or the factor enrolled
|
||||||
|
// at this console. A grant whose approver is no longer pinned (the device key
|
||||||
|
// unpinned, the factor re-enrolled) is dead: unpinning a device drops every
|
||||||
|
// grant it approved, here and in every pairing.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
// The zone database is in the binary: a window's tz reads the same on a
|
||||||
|
// machine with no tzdata installed, and a grant is never refused at one
|
||||||
|
// box and accepted at another for want of a file.
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
opBoundWindow = "_window"
|
||||||
|
opBoundRate = "_rate"
|
||||||
|
opRateMinInterval = 30 * 24 * 60 * 60 // seconds: the longest a grant lives
|
||||||
|
// opNotNowExit is the exit an apply that was held to its window or its
|
||||||
|
// rate ends with (sysexits.h EX_TEMPFAIL: "try again later").
|
||||||
|
opNotNowExit = 75
|
||||||
|
opNotNow = "not_now"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
opHHMMRe = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||||
|
opTZRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$`)
|
||||||
|
opDays = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"} // time.Weekday order
|
||||||
|
// opNow is the machine's clock as the run gate reads it (a seam for the
|
||||||
|
// tests, which move it).
|
||||||
|
opNow = time.Now
|
||||||
|
)
|
||||||
|
|
||||||
|
type opWindow struct {
|
||||||
|
Days []string `json:"days,omitempty"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
TZ string `json:"tz,omitempty"`
|
||||||
|
|
||||||
|
loc *time.Location
|
||||||
|
from, to int // minutes past midnight
|
||||||
|
day [7]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type opRate struct {
|
||||||
|
PerDay *int `json:"per_day,omitempty"`
|
||||||
|
MinInterval *int `json:"min_interval_s,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// opGrantBounds is the whole of what an op_grant's bounds text says.
|
||||||
|
type opGrantBounds struct {
|
||||||
|
Params map[string]opBound
|
||||||
|
Window *opWindow
|
||||||
|
Rate *opRate
|
||||||
|
}
|
||||||
|
|
||||||
|
func strictJSON(raw []byte, into any) error {
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(into); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dec.More() {
|
||||||
|
return errors.New("trailing data")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOpWindow(raw json.RawMessage) (*opWindow, error) {
|
||||||
|
if string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var w opWindow
|
||||||
|
if err := strictJSON(raw, &w); err != nil {
|
||||||
|
return nil, errors.New(`bound "_window": {"days": [mon..sun], "from": "HH:MM", "to": "HH:MM", "tz": "<IANA zone>"}`)
|
||||||
|
}
|
||||||
|
if !opHHMMRe.MatchString(w.From) || !opHHMMRe.MatchString(w.To) {
|
||||||
|
return nil, errors.New(`bound "_window": from and to are 24-hour "HH:MM"`)
|
||||||
|
}
|
||||||
|
if w.From == w.To {
|
||||||
|
return nil, errors.New(`bound "_window": from and to are the same minute, which is no window`)
|
||||||
|
}
|
||||||
|
hm := func(s string) int { h, _ := strconv.Atoi(s[:2]); m, _ := strconv.Atoi(s[3:]); return h*60 + m }
|
||||||
|
w.from, w.to = hm(w.From), hm(w.To)
|
||||||
|
if w.Days != nil {
|
||||||
|
if len(w.Days) == 0 || len(w.Days) > 7 {
|
||||||
|
return nil, errors.New(`bound "_window": days names 1..7 days, or is left out for every day`)
|
||||||
|
}
|
||||||
|
for _, d := range w.Days {
|
||||||
|
i := opDayIndex(d)
|
||||||
|
if i < 0 {
|
||||||
|
return nil, fmt.Errorf(`bound "_window": %q is not a day (mon, tue, wed, thu, fri, sat, sun)`, d)
|
||||||
|
}
|
||||||
|
if w.day[i] {
|
||||||
|
return nil, fmt.Errorf(`bound "_window": %q is named twice`, d)
|
||||||
|
}
|
||||||
|
w.day[i] = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for i := range w.day {
|
||||||
|
w.day[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tz := w.TZ
|
||||||
|
if tz == "" {
|
||||||
|
tz = "UTC"
|
||||||
|
}
|
||||||
|
if !opTZRe.MatchString(tz) || strings.EqualFold(tz, "local") {
|
||||||
|
return nil, fmt.Errorf(`bound "_window": tz %q is not an IANA zone name`, w.TZ)
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(`bound "_window": tz %q is not a zone this machine knows`, w.TZ)
|
||||||
|
}
|
||||||
|
w.loc = loc
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opDayIndex(d string) int {
|
||||||
|
for i, s := range opDays {
|
||||||
|
if s == d {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOpRate(raw json.RawMessage) (*opRate, error) {
|
||||||
|
if string(raw) == "null" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var r opRate
|
||||||
|
if err := strictJSON(raw, &r); err != nil {
|
||||||
|
return nil, errors.New(`bound "_rate": {"per_day": n, "min_interval_s": s}`)
|
||||||
|
}
|
||||||
|
if r.PerDay == nil && r.MinInterval == nil {
|
||||||
|
return nil, errors.New(`bound "_rate": names per_day, min_interval_s or both`)
|
||||||
|
}
|
||||||
|
if r.PerDay != nil && (*r.PerDay < 1 || *r.PerDay > opGrantUsesMax) {
|
||||||
|
return nil, fmt.Errorf(`bound "_rate": per_day is 1..%d`, opGrantUsesMax)
|
||||||
|
}
|
||||||
|
if r.MinInterval != nil && (*r.MinInterval < 1 || *r.MinInterval > opRateMinInterval) {
|
||||||
|
return nil, fmt.Errorf(`bound "_rate": min_interval_s is 1..%d`, opRateMinInterval)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGrantBounds reads an op_grant's bounds text: the params (parseBounds'
|
||||||
|
// rules, each inside the schema) and the run bounds above.
|
||||||
|
func parseGrantBounds(m *opManifest, raw string) (*opGrantBounds, error) {
|
||||||
|
if len(raw) > opParamsMaxBytes {
|
||||||
|
return nil, errors.New("bounds too large")
|
||||||
|
}
|
||||||
|
var top map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(raw), &top); err != nil || top == nil {
|
||||||
|
return nil, errors.New("bounds are not a JSON object of {param: {min, max | values | prefix | fixed}, _window, _rate}")
|
||||||
|
}
|
||||||
|
out := &opGrantBounds{}
|
||||||
|
params := map[string]json.RawMessage{}
|
||||||
|
for k, v := range top {
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case k == opBoundWindow:
|
||||||
|
out.Window, err = parseOpWindow(v)
|
||||||
|
case k == opBoundRate:
|
||||||
|
out.Rate, err = parseOpRate(v)
|
||||||
|
case strings.HasPrefix(k, "_"):
|
||||||
|
err = fmt.Errorf("bound %q is not one this machine enforces (%s, %s)", k, opBoundWindow, opBoundRate)
|
||||||
|
default:
|
||||||
|
params[k] = v
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prest, _ := json.Marshal(params)
|
||||||
|
p, err := parseParamBounds(m, string(prest))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Params = p
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// inside: is t inside the window? A window that wraps midnight belongs to the
|
||||||
|
// day it opens on.
|
||||||
|
func (w *opWindow) inside(t time.Time) bool {
|
||||||
|
l := t.In(w.loc)
|
||||||
|
mins := l.Hour()*60 + l.Minute()
|
||||||
|
today := int(l.Weekday())
|
||||||
|
if w.from < w.to {
|
||||||
|
return w.day[today] && mins >= w.from && mins < w.to
|
||||||
|
}
|
||||||
|
yesterday := (today + 6) % 7
|
||||||
|
return (w.day[today] && mins >= w.from) || (w.day[yesterday] && mins < w.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// next is when the window next opens after t (zero when it never does, which
|
||||||
|
// a parsed window cannot be).
|
||||||
|
func (w *opWindow) next(t time.Time) time.Time {
|
||||||
|
l := t.In(w.loc)
|
||||||
|
for i := 0; i <= 8; i++ {
|
||||||
|
d := l.AddDate(0, 0, i)
|
||||||
|
open := time.Date(d.Year(), d.Month(), d.Day(), w.from/60, w.from%60, 0, 0, w.loc)
|
||||||
|
if open.After(t) && w.day[int(open.Weekday())] {
|
||||||
|
return open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *opWindow) words() string {
|
||||||
|
days := "every day"
|
||||||
|
if len(w.Days) > 0 && len(w.Days) < 7 {
|
||||||
|
days = strings.Join(w.Days, ",")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s-%s %s", days, w.From, w.To, w.loc.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// opNotNowWhy is the run gate on window and rate: "" when an apply may run at
|
||||||
|
// now, else why not, which bound held it, and when it next could.
|
||||||
|
func opNotNowWhy(b *opGrantBounds, runs []time.Time, now time.Time) (why, bound string, next time.Time) {
|
||||||
|
if b.Window != nil && !b.Window.inside(now) {
|
||||||
|
return "outside the approved window (" + b.Window.words() + ")", opBoundWindow, b.Window.next(now)
|
||||||
|
}
|
||||||
|
if b.Rate == nil || len(runs) == 0 {
|
||||||
|
return "", "", time.Time{}
|
||||||
|
}
|
||||||
|
last := runs[len(runs)-1]
|
||||||
|
if b.Rate.MinInterval != nil {
|
||||||
|
gap := time.Duration(*b.Rate.MinInterval) * time.Second
|
||||||
|
if now.Sub(last) < gap {
|
||||||
|
return fmt.Sprintf("the last run was %s ago; the approval keeps %s between runs",
|
||||||
|
now.Sub(last).Round(time.Second), gap), opBoundRate, last.Add(gap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Rate.PerDay != nil {
|
||||||
|
var day []time.Time
|
||||||
|
for _, r := range runs {
|
||||||
|
if now.Sub(r) < 24*time.Hour {
|
||||||
|
day = append(day, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(day) >= *b.Rate.PerDay {
|
||||||
|
return fmt.Sprintf("%d run(s) in the last 24 hours; the approval allows %d", len(day), *b.Rate.PerDay),
|
||||||
|
opBoundRate, day[len(day)-*b.Rate.PerDay].Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendNotNow answers an apply that was held to its window or rate: recorded,
|
||||||
|
// never a strike, nothing spent.
|
||||||
|
func sendNotNow(ws *websocket.Conn, dir string, t *opTerms, why, bound string, next time.Time) {
|
||||||
|
out := map[string]any{"state": opNotNow, "grant": t.Nonce, "op": t.Op, "why": why, "bound": bound}
|
||||||
|
if !next.IsZero() {
|
||||||
|
out["next"] = next.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
dcnAudit(dir, "op_grant_run", 0, t.Op+"@"+t.SHA256[:12], "not now "+t.Nonce[:8]+": "+truncate(why, mgmtAuditWhyMax))
|
||||||
|
b, _ := json.Marshal(out)
|
||||||
|
sendMgmt(ws, 'O', b)
|
||||||
|
sendMgmtExit(ws, opNotNowExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the record ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// opGrantMACKey is this machine's key for its grant records, derived from its
|
||||||
|
// encryption key (which lives in the machine's record, masked from every op,
|
||||||
|
// and is never silently replaced).
|
||||||
|
func opGrantMACKey() ([]byte, error) {
|
||||||
|
_, priv, err := opEncKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte("privatae-bastion op-grant record v1\x00"))
|
||||||
|
h.Write(priv[:])
|
||||||
|
return h.Sum(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opGrantRecMAC covers every field of the record, the grant's nonce with it,
|
||||||
|
// each framed by its length (argsDigest's framing).
|
||||||
|
func opGrantRecMAC(nonce string, rec *opGrantRec) (string, error) {
|
||||||
|
key, err := opGrantMACKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
parts := []string{"v1", nonce, rec.TermsCanonical, rec.Signature, rec.InstalledAt,
|
||||||
|
strconv.Itoa(rec.UsesLeft), rec.Dropped, rec.Approver, strconv.Itoa(len(rec.Runs))}
|
||||||
|
parts = append(parts, rec.Runs...)
|
||||||
|
m := hmac.New(sha256.New, key)
|
||||||
|
m.Write([]byte(argsDigest(parts)))
|
||||||
|
return hex.EncodeToString(m.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func opGrantRecSeal(nonce string, rec *opGrantRec) error {
|
||||||
|
mac, err := opGrantRecMAC(nonce, rec)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("this machine cannot seal the grant's record: %v", err)
|
||||||
|
}
|
||||||
|
rec.MAC = mac
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const opGrantTampered = "the record of this grant on this machine does not check out (changed outside the bastion); " +
|
||||||
|
"it is held unusable — drop it and approve it again"
|
||||||
|
|
||||||
|
// opGrantRecCheck: the record is this machine's own (its MAC), the run log
|
||||||
|
// reads in order and none of it is in the future, and the uses left are the
|
||||||
|
// uses granted less the runs logged. Returns the run times.
|
||||||
|
func opGrantRecCheck(nonce string, rec *opGrantRec, t *opTerms, now time.Time) ([]time.Time, error) {
|
||||||
|
if rec.MAC == "" || rec.Approver == "" {
|
||||||
|
return nil, errors.New("this grant was installed before this machine kept who approved it and what ran under it; approve it again")
|
||||||
|
}
|
||||||
|
want, err := opGrantRecMAC(nonce, rec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !hmac.Equal([]byte(want), []byte(rec.MAC)) {
|
||||||
|
return nil, errors.New(opGrantTampered)
|
||||||
|
}
|
||||||
|
runs := make([]time.Time, 0, len(rec.Runs))
|
||||||
|
for _, s := range rec.Runs {
|
||||||
|
r, err := time.Parse(time.RFC3339Nano, s)
|
||||||
|
if err != nil || r.After(now.Add(deviceClockSkew)) || (len(runs) > 0 && r.Before(runs[len(runs)-1])) {
|
||||||
|
return nil, errors.New(opGrantTampered)
|
||||||
|
}
|
||||||
|
runs = append(runs, r)
|
||||||
|
}
|
||||||
|
if t != nil && rec.UsesLeft != t.Uses-len(runs) {
|
||||||
|
return nil, errors.New(opGrantTampered)
|
||||||
|
}
|
||||||
|
return runs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the approver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// stepupFactorFingerprint names the factor enrolled at this console for a
|
||||||
|
// pairing, without its secret: a re-enrolled factor is another one. "" when
|
||||||
|
// none is enrolled.
|
||||||
|
func stepupFactorFingerprint(dir string) string {
|
||||||
|
f, err := stepupLoad(dir)
|
||||||
|
if err != nil || f.SecretB32 == "" || f.EnrolledAt == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte("privatae-bastion stepup factor v1\x00" + f.SecretB32 + "\x00" + f.EnrolledAt))
|
||||||
|
return hex.EncodeToString(sum[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumeApprovalKeyed is consumeApproval that also names the key that gave
|
||||||
|
// the approval: "device:<public key>" or "console:<factor fingerprint>".
|
||||||
|
func consumeApprovalKeyed(dir, approval, purpose string) (id, key string, err error) {
|
||||||
|
if isDeviceApproval(approval) {
|
||||||
|
var a deviceApproval
|
||||||
|
_ = json.Unmarshal([]byte(strings.TrimSpace(approval)), &a)
|
||||||
|
if id, err = consumeApproval(dir, approval, purpose); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return id, "device:" + a.Key, nil
|
||||||
|
}
|
||||||
|
fp := stepupFactorFingerprint(dir)
|
||||||
|
if id, err = consumeApproval(dir, approval, purpose); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if fp == "" {
|
||||||
|
return "", "", errors.New("the approval came from this console with no factor enrolled to keep it under")
|
||||||
|
}
|
||||||
|
return id, "console:" + fp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opApproverLive: is the key that approved a grant still pinned here?
|
||||||
|
func opApproverLive(dir, approver string) bool {
|
||||||
|
kind, key, _ := strings.Cut(approver, ":")
|
||||||
|
switch kind {
|
||||||
|
case "device":
|
||||||
|
for _, k := range deviceKeys() {
|
||||||
|
if k == key && key != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "console":
|
||||||
|
return key != "" && key == stepupFactorFingerprint(dir)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func opApproverWords(approver string) string {
|
||||||
|
kind, key, _ := strings.Cut(approver, ":")
|
||||||
|
if kind == "device" {
|
||||||
|
return "approval device " + truncate(key, 12)
|
||||||
|
}
|
||||||
|
return "this console's factor " + truncate(key, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── taking grants back ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// opsDropWhere drops every held grant in one pairing that match says to,
|
||||||
|
// sealing each record again. Returns the nonces dropped.
|
||||||
|
func opsDropWhere(dcnDir, why string, match func(nonce string, rec *opGrantRec) bool) ([]string, error) {
|
||||||
|
opsMu.Lock()
|
||||||
|
defer opsMu.Unlock()
|
||||||
|
f := opsLoad(dcnDir)
|
||||||
|
var dropped []string
|
||||||
|
for nonce, rec := range f.Grants {
|
||||||
|
if rec.Dropped != "" || !match(nonce, &rec) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rec.Dropped = opNow().UTC().Format(time.RFC3339) + " " + why
|
||||||
|
rec.UsesLeft = 0
|
||||||
|
// A record that can no longer be sealed is still dropped: the drop is
|
||||||
|
// what makes it unusable, and an unsealed record is unusable anyway.
|
||||||
|
_ = opGrantRecSeal(nonce, &rec)
|
||||||
|
f.Grants[nonce] = rec
|
||||||
|
dropped = append(dropped, nonce)
|
||||||
|
}
|
||||||
|
if len(dropped) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return dropped, opsSave(dcnDir, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// opsDropApprovedBy drops, in every pairing, the grants a device key approved
|
||||||
|
// (the key is being unpinned). Returns how many.
|
||||||
|
func opsDropApprovedBy(approver string) int {
|
||||||
|
cfgs, err := loadPairings()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, c := range cfgs {
|
||||||
|
if c == nil || c.dcnDir() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, _ := opsDropWhere(c.dcnDir(), "its approving key was unpinned", func(_ string, rec *opGrantRec) bool {
|
||||||
|
return rec.Approver == approver
|
||||||
|
})
|
||||||
|
n += len(d)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// op_grant_drop <grant nonce | all>: the Host (or the app, through it) takes
|
||||||
|
// a grant back. A tighten (writes.go opTightens): never held by the
|
||||||
|
// quarantine, never waiting on an approval. A grant not held here is already
|
||||||
|
// gone, which is what was asked for.
|
||||||
|
func handleOpGrantDrop(ws *websocket.Conn, dir string, args []string) {
|
||||||
|
if dir == "" || len(args) < 1 || strings.TrimSpace(args[0]) == "" {
|
||||||
|
opFail(ws, "op_grant_drop: need <grant> or all")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
which := strings.TrimSpace(args[0])
|
||||||
|
dropped, err := opsDropWhere(dir, "taken back by the host", func(nonce string, _ *opGrantRec) bool {
|
||||||
|
return which == "all" || nonce == which
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
opFail(ws, "op_grant_drop: could not record the drop: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dropped == nil {
|
||||||
|
dropped = []string{}
|
||||||
|
}
|
||||||
|
for _, n := range dropped {
|
||||||
|
dcnAudit(dir, "op_grant_drop", 0, truncate(n, 8), "dropped by the host")
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"state": "dropped", "dropped": dropped})
|
||||||
|
sendMgmt(ws, 'O', b)
|
||||||
|
sendMgmtExit(ws, 0)
|
||||||
|
}
|
||||||
@@ -79,6 +79,15 @@ func opRunAction(_ string, _ *opManifest, _ *opTerms, _, _ string, _ []string, _
|
|||||||
return nil, errors.New(why)
|
return nil, errors.New(why)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opVerbsReport: no verb runs on this platform.
|
||||||
|
func opVerbsReport() map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, verb := range opVerbNames() {
|
||||||
|
out[verb] = map[string]any{"ok": false, "why": "no op runs on this platform yet"}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func opActionOuterMain(_ string) int { return 125 }
|
func opActionOuterMain(_ string) int { return 125 }
|
||||||
func opActionChildMain(_ string) int { return 125 }
|
func opActionChildMain(_ string) int { return 125 }
|
||||||
func opActionProbeMain(_ string) int { return 125 }
|
func opActionProbeMain(_ string) int { return 125 }
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ func planRunLine(dir, planB64, nArg, recB64 string, credBlobs []string) (map[str
|
|||||||
if _, err := opOpenCreds(credBlobs, m, p.Nonce, false); err != nil {
|
if _, err := opOpenCreds(credBlobs, m, p.Nonce, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if ok, why := opActionAvailable(); !ok {
|
if !m.verbsOnly() {
|
||||||
return nil, errors.New("this machine cannot run an action op: " + why)
|
if ok, why := opActionCheck(); !ok {
|
||||||
|
return nil, errors.New("this machine cannot run an action op: " + why)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := planRecordVersion(p.Pipeline, p.Version); err != nil {
|
if err := planRecordVersion(p.Pipeline, p.Version); err != nil {
|
||||||
return nil, fmt.Errorf("could not record the plan's version: %v", err)
|
return nil, fmt.Errorf("could not record the plan's version: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// ops_skill_bounds_test.go — a grant held to a window and a rate on the
|
||||||
|
// machine (ops_bounds.go): "not now" is no strike and spends nothing, the run
|
||||||
|
// log holds across a restart and refuses being edited, a grant dies with the
|
||||||
|
// key that approved it, and a drop goes through a quarantine. And the op with
|
||||||
|
// no program (interpreter: none): no run file, no sandbox needed, while a
|
||||||
|
// program op still needs one.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func userFixture() []opFile {
|
||||||
|
files := opFixture("/srv/optest", "")
|
||||||
|
files[0].B64 = base64.StdEncoding.EncodeToString([]byte(strings.Replace(fmt.Sprintf(opTestManifest, "/srv/optest"),
|
||||||
|
"privilege: root", "privilege: user", 1)))
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantBox installs a user grant with these bounds under a console approval,
|
||||||
|
// and stands in for the sandbox: each run under it is counted, never run.
|
||||||
|
func grantBox(t *testing.T, bounds string, uses int, approve func(dcn, purpose string) string) (dcn, nonce string, ran *int) {
|
||||||
|
t.Helper()
|
||||||
|
dcn, priv := opTestBox(t)
|
||||||
|
files := userFixture()
|
||||||
|
sha, _ := opDirDigest(files)
|
||||||
|
gt := opTerm(opKindGrant, sha, "", bounds, uses, time.Hour, "user")
|
||||||
|
purpose, err := purposeForOpGrant(&opTerms{Server: gt["server"].(string), Op: "write-hello", SHA256: sha,
|
||||||
|
Bounds: bounds, Expires: gt["expires"].(string), Uses: uses})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if approve == nil {
|
||||||
|
approve = grantConsoleApproval(t)
|
||||||
|
}
|
||||||
|
gt["approval"] = approve(dcn, purpose)
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, gt), bundleB64(files))}, "", dcn); code != 0 {
|
||||||
|
t.Fatalf("the grant installs: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
prev := opGrantExec
|
||||||
|
opGrantExec = func(_ string, tm *opTerms, mode, _, _ string, _ []string) (*opReport, *opRunRecord, error) {
|
||||||
|
n++
|
||||||
|
id, _ := newID()
|
||||||
|
return &opReport{Mode: mode, Changes: []opChange{}}, &opRunRecord{ID: id, Op: tm.Op, SHA256: tm.SHA256, Mode: mode}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opGrantExec = prev })
|
||||||
|
return dcn, gt["nonce"].(string), &n
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantConsoleApproval(t *testing.T) func(dcn, purpose string) string {
|
||||||
|
return func(dcn, purpose string) string {
|
||||||
|
t.Helper()
|
||||||
|
enrollFactor(t, dcn)
|
||||||
|
raw, _ := b32decode(tSecret)
|
||||||
|
a, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func atClock(t *testing.T, at *time.Time) {
|
||||||
|
prev := opNow
|
||||||
|
opNow = func() time.Time { return *at }
|
||||||
|
t.Cleanup(func() { opNow = prev })
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantParams = `{"name":"x","count":1,"kind":"a"}`
|
||||||
|
|
||||||
|
func grantApply(t *testing.T, dcn, nonce string) (string, string, int) {
|
||||||
|
t.Helper()
|
||||||
|
return driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, grantParams}}, "", dcn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func strikes() int { return len(quarantineLoad().Pairing[tAnchor].refusalsNow()) }
|
||||||
|
|
||||||
|
func usesLeft(t *testing.T, dcn, nonce string) int {
|
||||||
|
t.Helper()
|
||||||
|
rec, ok := opsLoad(dcn).Grants[nonce]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no record")
|
||||||
|
}
|
||||||
|
return rec.UsesLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the grammar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGrantRunBoundsGrammar(t *testing.T) {
|
||||||
|
setupBox(t)
|
||||||
|
m, _ := parseOpManifest([]byte(fmt.Sprintf(opTestManifest, "/srv/optest")))
|
||||||
|
for _, ok := range []string{
|
||||||
|
`{"_window":{"from":"02:00","to":"04:00"}}`,
|
||||||
|
`{"_window":{"days":["mon","fri"],"from":"22:00","to":"06:00","tz":"Europe/Berlin"},"count":{"max":2}}`,
|
||||||
|
`{"_rate":{"per_day":3,"min_interval_s":600}}`,
|
||||||
|
`{"_rate":{"per_day":1}}`,
|
||||||
|
`{"_window":null,"_rate":null}`,
|
||||||
|
} {
|
||||||
|
if _, err := parseGrantBounds(m, ok); err != nil {
|
||||||
|
t.Errorf("%s parses: %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, bad := range map[string]string{
|
||||||
|
"an unknown run bound": `{"_when":{"from":"02:00","to":"04:00"}}`,
|
||||||
|
"a day that is not one": `{"_window":{"days":["mon","funday"],"from":"02:00","to":"04:00"}}`,
|
||||||
|
"a day named twice": `{"_window":{"days":["mon","mon"],"from":"02:00","to":"04:00"}}`,
|
||||||
|
"no days at all": `{"_window":{"days":[],"from":"02:00","to":"04:00"}}`,
|
||||||
|
"a window of no length": `{"_window":{"from":"02:00","to":"02:00"}}`,
|
||||||
|
"a clock past 23:59": `{"_window":{"from":"24:00","to":"02:00"}}`,
|
||||||
|
"a zone nobody has": `{"_window":{"from":"02:00","to":"04:00","tz":"Mars/Olympus"}}`,
|
||||||
|
"the machine's local zone": `{"_window":{"from":"02:00","to":"04:00","tz":"Local"}}`,
|
||||||
|
"a field the window lacks": `{"_window":{"from":"02:00","to":"04:00","every":"day"}}`,
|
||||||
|
"a rate that bounds none": `{"_rate":{}}`,
|
||||||
|
"zero a day": `{"_rate":{"per_day":0}}`,
|
||||||
|
"more a day than uses": `{"_rate":{"per_day":1001}}`,
|
||||||
|
"an interval past 30 days": `{"_rate":{"min_interval_s":2592001}}`,
|
||||||
|
"a field the rate lacks": `{"_rate":{"per_hour":3}}`,
|
||||||
|
"a rate as a string": `{"_rate":"3/day"}`,
|
||||||
|
} {
|
||||||
|
if _, err := parseGrantBounds(m, bad); err == nil {
|
||||||
|
t.Errorf("%s must be refused: %s", name, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A window that wraps midnight belongs to the day it opens on.
|
||||||
|
b, _ := parseGrantBounds(m, `{"_window":{"days":["fri"],"from":"22:00","to":"06:00","tz":"UTC"}}`)
|
||||||
|
for at, want := range map[string]bool{
|
||||||
|
"2026-10-30T23:00:00Z": true, // Friday night
|
||||||
|
"2026-10-31T03:00:00Z": true, // Saturday morning, the Friday window
|
||||||
|
"2026-10-30T03:00:00Z": false, // Friday morning: Thursday's window, not listed
|
||||||
|
"2026-10-31T07:00:00Z": false,
|
||||||
|
} {
|
||||||
|
ts, _ := time.Parse(time.RFC3339, at)
|
||||||
|
if got := b.Window.inside(ts); got != want {
|
||||||
|
t.Errorf("%s inside=%v, want %v", at, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── not now: the window ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGrantOutsideItsWindowIsNotNowAndSpendsNothing(t *testing.T) {
|
||||||
|
// Thursday noon UTC; the window is Monday 02:00-04:00 in Berlin.
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-10-29T12:00:00Z")
|
||||||
|
atClock(t, &now)
|
||||||
|
dcn, nonce, ran := grantBox(t, `{"_window":{"days":["mon"],"from":"02:00","to":"04:00","tz":"Europe/Berlin"}}`, 5, nil)
|
||||||
|
before := strikes()
|
||||||
|
out, errOut, code := grantApply(t, dcn, nonce)
|
||||||
|
if code != opNotNowExit || !strings.Contains(out, `"state":"not_now"`) || !strings.Contains(out, `"bound":"_window"`) {
|
||||||
|
t.Fatalf("outside the window: not now: %d %s %s", code, out, errOut)
|
||||||
|
}
|
||||||
|
// Berlin is UTC+1 after 25 October: Monday 02:00 there is 01:00 UTC.
|
||||||
|
if !strings.Contains(out, `"next":"2026-11-02T01:00:00Z"`) {
|
||||||
|
t.Fatalf("it says when the window opens: %s", out)
|
||||||
|
}
|
||||||
|
if *ran != 0 || usesLeft(t, dcn, nonce) != 5 || strikes() != before {
|
||||||
|
t.Fatalf("nothing ran, no use spent, no strike: ran=%d uses=%d strikes %d->%d", *ran, usesLeft(t, dcn, nonce), before, strikes())
|
||||||
|
}
|
||||||
|
// A dry run changes nothing and is never held to the window.
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeDryRun, grantParams}}, "", dcn); code != 0 {
|
||||||
|
t.Fatalf("a dry run outside the window: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
// Bad params outside the window are still refused, with the strike.
|
||||||
|
if _, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_run", Args: []string{nonce, opModeApply, `{"name":"x","count":9,"kind":"a"}`}}, "", dcn); code != 126 {
|
||||||
|
t.Fatalf("arguments outside the op are refused, window or not: %d", code)
|
||||||
|
}
|
||||||
|
now, _ = time.Parse(time.RFC3339, "2026-11-02T01:30:00Z")
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 || *ran != 2 || usesLeft(t, dcn, nonce) != 4 {
|
||||||
|
t.Fatalf("inside the window it runs and spends one use: %d %s ran=%d uses=%d", code, errOut, *ran, usesLeft(t, dcn, nonce))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── not now: the rate ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGrantInsideItsMinIntervalDoesNotRun(t *testing.T) {
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
atClock(t, &now)
|
||||||
|
dcn, nonce, ran := grantBox(t, `{"_rate":{"min_interval_s":600}}`, 5, nil)
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||||
|
t.Fatalf("the first run: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
before := strikes()
|
||||||
|
first := now
|
||||||
|
now = now.Add(5 * time.Minute)
|
||||||
|
out, _, code := grantApply(t, dcn, nonce)
|
||||||
|
if code != opNotNowExit || !strings.Contains(out, `"bound":"_rate"`) ||
|
||||||
|
!strings.Contains(out, `"next":"`+first.Add(10*time.Minute).Format(time.RFC3339)+`"`) {
|
||||||
|
t.Fatalf("five minutes after a run, with ten approved between: not now: %d %s", code, out)
|
||||||
|
}
|
||||||
|
if *ran != 1 || usesLeft(t, dcn, nonce) != 4 || strikes() != before {
|
||||||
|
t.Fatalf("the second did not run, spend or strike: ran=%d uses=%d", *ran, usesLeft(t, dcn, nonce))
|
||||||
|
}
|
||||||
|
now = first.Add(11 * time.Minute)
|
||||||
|
if _, _, code := grantApply(t, dcn, nonce); code != 0 || *ran != 2 {
|
||||||
|
t.Fatalf("past the interval it runs: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantPerDayHoldsAcrossARestart(t *testing.T) {
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
atClock(t, &now)
|
||||||
|
dcn, nonce, ran := grantBox(t, `{"_rate":{"per_day":2}}`, 10, nil)
|
||||||
|
start := now
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||||
|
t.Fatalf("run %d: %d %s", i, code, errOut)
|
||||||
|
}
|
||||||
|
now = now.Add(time.Hour)
|
||||||
|
}
|
||||||
|
// The run log is on disk, in the record, not in this process.
|
||||||
|
raw, err := os.ReadFile(opsFilePath(dcn))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var f opsFile
|
||||||
|
if json.Unmarshal(raw, &f) != nil || len(f.Grants[nonce].Runs) != 2 {
|
||||||
|
t.Fatalf("both runs are in ops.json: %s", raw)
|
||||||
|
}
|
||||||
|
// A restart: another process reads the same record. Copy the pairing's
|
||||||
|
// state to a fresh place and ask there.
|
||||||
|
moved := t.TempDir()
|
||||||
|
if err := copyTree(dcn, moved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, _, code := grantApply(t, moved, nonce)
|
||||||
|
if code != opNotNowExit || !strings.Contains(out, "2 run(s) in the last 24 hours") {
|
||||||
|
t.Fatalf("the third run in a day, after a restart: not now: %d %s", code, out)
|
||||||
|
}
|
||||||
|
if *ran != 2 {
|
||||||
|
t.Fatalf("it did not run: %d", *ran)
|
||||||
|
}
|
||||||
|
// 24 hours after the first, one slot is free again.
|
||||||
|
now = start.Add(24*time.Hour + time.Second)
|
||||||
|
if _, errOut, code := grantApply(t, moved, nonce); code != 0 {
|
||||||
|
t.Fatalf("a day after the first run: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTree(from, to string) error {
|
||||||
|
return filepath.Walk(from, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(from, p)
|
||||||
|
dst := filepath.Join(to, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(dst, 0o755)
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(dst, raw, info.Mode().Perm())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the record ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGrantWithATamperedRunLogIsNoGrant(t *testing.T) {
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
atClock(t, &now)
|
||||||
|
for name, edit := range map[string]func(r *opGrantRec){
|
||||||
|
"the run taken out and the use given back": func(r *opGrantRec) { r.Runs = nil; r.UsesLeft++ },
|
||||||
|
"the run taken out": func(r *opGrantRec) { r.Runs = nil },
|
||||||
|
"a use given back": func(r *opGrantRec) { r.UsesLeft++ },
|
||||||
|
"the run moved a day back": func(r *opGrantRec) { r.Runs[0] = now.Add(-25 * time.Hour).Format(time.RFC3339Nano) },
|
||||||
|
"another approver": func(r *opGrantRec) { r.Approver = "console:0000" },
|
||||||
|
"no seal (an older bastion's record)": func(r *opGrantRec) { r.MAC = "" },
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
dcn, nonce, ran := grantBox(t, `{"_rate":{"per_day":1}}`, 5, nil)
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||||
|
t.Fatalf("the first run: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
f := opsLoad(dcn)
|
||||||
|
r := f.Grants[nonce]
|
||||||
|
edit(&r)
|
||||||
|
f.Grants[nonce] = r
|
||||||
|
if err := opsSave(dcn, f); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, errOut, code := grantApply(t, dcn, nonce)
|
||||||
|
if code != 126 || (!strings.Contains(errOut, "does not check out") && !strings.Contains(errOut, "approve it again")) {
|
||||||
|
t.Fatalf("an edited record holds no grant: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
if *ran != 1 {
|
||||||
|
t.Fatalf("nothing ran under it: %d", *ran)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGrantDiesWithTheKeyThatApprovedIt(t *testing.T) {
|
||||||
|
var pub string
|
||||||
|
byDevice := func(_, purpose string) string {
|
||||||
|
var err error
|
||||||
|
if pub, err = deviceInit(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pinDeviceKey(pub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, err := deviceSign(purpose, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
dcn, nonce, ran := grantBox(t, `{"count":{"max":2}}`, 5, byDevice)
|
||||||
|
if rec := opsLoad(dcn).Grants[nonce]; rec.Approver != "device:"+pub {
|
||||||
|
t.Fatalf("the approving key is kept with the grant: %q", rec.Approver)
|
||||||
|
}
|
||||||
|
// The grant as the pairing holds it, in the pairing's own state folder
|
||||||
|
// (unpinning walks every pairing's).
|
||||||
|
cfgs, err := loadPairings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home := pairingFor(cfgs, tAnchor).dcnDir()
|
||||||
|
if err := copyTree(dcn, home); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dcn = home
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 0 {
|
||||||
|
t.Fatalf("a run under it: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
if err := unpinDeviceKey(pub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 126 || !strings.Contains(errOut, "no longer pinned") {
|
||||||
|
t.Fatalf("its key unpinned, the grant is dead: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
// Unpinning drops what the key approved, so pinning it again does not
|
||||||
|
// bring the grant back.
|
||||||
|
if n := opsDropApprovedBy("device:" + pub); n != 1 {
|
||||||
|
t.Fatalf("one grant dropped with its key: %d", n)
|
||||||
|
}
|
||||||
|
if err := pinDeviceKey(pub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 126 || !strings.Contains(errOut, "no such op grant") {
|
||||||
|
t.Fatalf("pinned again, the grant stays dropped: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
if *ran != 1 {
|
||||||
|
t.Fatalf("one run only: %d", *ran)
|
||||||
|
}
|
||||||
|
// A console approval dies with the factor: re-enrolled is another one.
|
||||||
|
dcn2, nonce2, _ := grantBox(t, `{"count":{"max":2}}`, 5, nil)
|
||||||
|
if err := stepupSave(dcn2, &stepupFile{SecretB32: tSecret, EnrolledAt: "2026-10-29T00:00:00Z", LastCounter: -1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, errOut, code := grantApply(t, dcn2, nonce2); code != 126 || !strings.Contains(errOut, "no longer pinned") {
|
||||||
|
t.Fatalf("its factor re-enrolled, the grant is dead: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── taking it back ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGrantDropGoesThroughAQuarantine(t *testing.T) {
|
||||||
|
if !opTightens("op_grant_drop") || !ownApproval("op_grant_drop") {
|
||||||
|
t.Fatal("op_grant_drop is a tighten: no approval, no quarantine")
|
||||||
|
}
|
||||||
|
dcn, nonce, _ := grantBox(t, `{"count":{"max":2}}`, 5, nil)
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < quarantineStrikes; i++ {
|
||||||
|
noteRefusal(tAnchor, "op_apply", "test strike", true, now)
|
||||||
|
}
|
||||||
|
if quarantinedUntil(tAnchor, time.Now()).IsZero() {
|
||||||
|
t.Fatal("the host is quarantined here")
|
||||||
|
}
|
||||||
|
if _, errOut, code := grantApply(t, dcn, nonce); code != 126 || !strings.Contains(errOut, "quarantined") {
|
||||||
|
t.Fatalf("a run while quarantined is refused: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_drop", Args: []string{nonce}}, "", dcn)
|
||||||
|
if code != 0 || !strings.Contains(out, nonce) {
|
||||||
|
t.Fatalf("a drop while quarantined goes through: %d %s %s", code, out, errOut)
|
||||||
|
}
|
||||||
|
if _, _, err := opsHeldGrant(dcn, nonce); err == nil {
|
||||||
|
t.Fatal("the grant is gone")
|
||||||
|
}
|
||||||
|
// A drop of what is not held is already done; all takes every one.
|
||||||
|
if out, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_drop", Args: []string{nonce}}, "", dcn); code != 0 || !strings.Contains(out, `"dropped":[]`) {
|
||||||
|
t.Fatalf("dropping it again: %d %s", code, out)
|
||||||
|
}
|
||||||
|
dcn2, nonce2, _ := grantBox(t, `{"count":{"max":2}}`, 5, nil)
|
||||||
|
for i := 0; i < quarantineStrikes; i++ {
|
||||||
|
noteRefusal(tAnchor, "op_apply", "test strike", true, time.Now())
|
||||||
|
}
|
||||||
|
if out, _, code := driveJob(t, tAnchor, mgmtJob{Op: "op_grant_drop", Args: []string{"all"}}, "", dcn2); code != 0 || !strings.Contains(out, nonce2) {
|
||||||
|
t.Fatalf("drop all: %d %s", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── interpreter: none ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const verbsOnlyManifest = `name: restart-web
|
||||||
|
version: 1.0.0
|
||||||
|
summary: Restart the web service.
|
||||||
|
kind: action
|
||||||
|
target: machine
|
||||||
|
privilege: root
|
||||||
|
interpreter: none
|
||||||
|
time_limit: 120
|
||||||
|
verbs:
|
||||||
|
- {verb: unit.restart, unit: web.service}
|
||||||
|
- {verb: logs.check, unit: web.service, must_not: "emerg", lines: "200"}
|
||||||
|
modes: [dry_run, apply, verify]
|
||||||
|
undo: none
|
||||||
|
`
|
||||||
|
|
||||||
|
func verbsOnlyFiles(withRun bool) []opFile {
|
||||||
|
files := []opFile{{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(verbsOnlyManifest))}}
|
||||||
|
if withRun {
|
||||||
|
files = append(files, opFile{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\nid\n"))})
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerbsOnlyManifest(t *testing.T) {
|
||||||
|
setupBox(t)
|
||||||
|
m, err := parseOpManifest([]byte(verbsOnlyManifest))
|
||||||
|
if err != nil || !m.verbsOnly() {
|
||||||
|
t.Fatalf("a verbs-only op parses: %v", err)
|
||||||
|
}
|
||||||
|
for name, bad := range map[string]string{
|
||||||
|
"interpreter none on a files op": strings.Replace(fmt.Sprintf(opTestManifest, "/srv/optest"), "interpreter: /bin/sh", "interpreter: none", 1),
|
||||||
|
"no verbs": strings.Replace(strings.Replace(verbsOnlyManifest, "privilege: root", "privilege: user", 1), "verbs:\n - {verb: unit.restart, unit: web.service}\n - {verb: logs.check, unit: web.service, must_not: \"emerg\", lines: \"200\"}\n", "", 1),
|
||||||
|
"a destination": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\ndestinations: [\"forge.example.com:443\"]\n", 1),
|
||||||
|
"a builder": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\nbuilder: true\n", 1),
|
||||||
|
"a credential no verb names": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\ncredentials: [forge-read]\n", 1),
|
||||||
|
"a read": strings.Replace(verbsOnlyManifest, "time_limit: 120\n", "time_limit: 120\nreads: [\"/srv/data/\"]\n", 1),
|
||||||
|
} {
|
||||||
|
if _, err := parseOpManifest([]byte(bad)); err == nil {
|
||||||
|
t.Errorf("%s must be refused", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerbsOnlyOpShippingRunIsRefused(t *testing.T) {
|
||||||
|
dcn, priv := opTestBox(t)
|
||||||
|
files := verbsOnlyFiles(true)
|
||||||
|
sha, _ := opDirDigest(files)
|
||||||
|
run := opTerm(opKindRun, sha, `{}`, "", 1, time.Hour, "root")
|
||||||
|
run["op"] = "restart-web"
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, run), bundleB64(files))}, "", dcn); code != 126 || !strings.Contains(errOut, "ships no run") {
|
||||||
|
t.Fatalf("a verbs-only op that ships a program is refused: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
files = verbsOnlyFiles(false)
|
||||||
|
sha, _ = opDirDigest(files)
|
||||||
|
run["sha256"] = sha
|
||||||
|
if out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, run), bundleB64(files))}, "", dcn); code != 0 || !strings.Contains(out, `"held"`) {
|
||||||
|
t.Fatalf("without it, held: %d %s %s", code, out, errOut)
|
||||||
|
}
|
||||||
|
// And a program op without its run is refused, as before.
|
||||||
|
prog := opFixture("/srv/optest", "")[:1]
|
||||||
|
psha, _ := opDirDigest(prog)
|
||||||
|
pr := opTerm(opKindRun, psha, grantParams, "", 1, time.Hour, "root")
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(signTermsPair(t, priv, pr), bundleB64(prog))}, "", dcn); code != 126 || !strings.Contains(errOut, "needs op.yaml and run") {
|
||||||
|
t.Fatalf("a program op with no run: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func noOverlay(t *testing.T) {
|
||||||
|
ps, pa := opSandboxCheck, opActionCheck
|
||||||
|
opSandboxCheck = func() (bool, string) { return false, "the kernel offers no overlay filesystem" }
|
||||||
|
opActionCheck = func() (bool, string) { return false, "the kernel offers no overlay filesystem" }
|
||||||
|
t.Cleanup(func() { opSandboxCheck, opActionCheck = ps, pa })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgramOpStillRefusedWithoutTheSandbox(t *testing.T) {
|
||||||
|
noOverlay(t)
|
||||||
|
dcn, priv := opTestBox(t)
|
||||||
|
files := opFixture("/srv/optest", "")
|
||||||
|
sha, _ := opDirDigest(files)
|
||||||
|
pair := signTermsPair(t, priv, opTerm(opKindRun, sha, grantParams, "", 1, time.Hour, "root"))
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
|
||||||
|
t.Fatalf("held: %s", errOut)
|
||||||
|
}
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn); code != 126 || !strings.Contains(errOut, noSandboxHere) {
|
||||||
|
t.Fatalf("a files op with no sandbox: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
// An action op with a program and verbs: the program needs the sandbox.
|
||||||
|
man := strings.Replace(verbsOnlyManifest, "interpreter: none", "interpreter: /bin/sh", 1)
|
||||||
|
afiles := []opFile{{Path: "op.yaml", Mode: 0o644, B64: base64.StdEncoding.EncodeToString([]byte(man))},
|
||||||
|
{Path: "run", Mode: 0o755, B64: base64.StdEncoding.EncodeToString([]byte("#!/bin/sh\n"))}}
|
||||||
|
asha, _ := opDirDigest(afiles)
|
||||||
|
at := opTerm(opKindRun, asha, `{}`, "", 1, time.Hour, "root")
|
||||||
|
at["op"] = "restart-web"
|
||||||
|
apair := signTermsPair(t, priv, at)
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(apair, bundleB64(afiles))}, "", dcn); code != 0 {
|
||||||
|
t.Fatalf("held: %s", errOut)
|
||||||
|
}
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: apair}, "", dcn); code != 126 || !strings.Contains(errOut, noSandboxHere) {
|
||||||
|
t.Fatalf("an action op with a program and no sandbox: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
-13
@@ -19,10 +19,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -81,10 +84,16 @@ func opCmd(ctx context.Context, name string, args ...string) (string, error) {
|
|||||||
return out.String(), err
|
return out.String(), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// opRunAction runs one mode of an action op (ops.go opRunTerms).
|
// opRunAction runs one mode of an action op (ops.go opRunTerms). An op with
|
||||||
|
// no program (interpreter: none) runs no unit and needs no sandbox: its dry
|
||||||
|
// run is the change list its verbs read, its apply the verbs and the log
|
||||||
|
// checks, and what it needs of the machine is each verb's own tool
|
||||||
|
// (opVerbProbe).
|
||||||
func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, blobs []string, pc *opPlanCtx) (*opReport, error) {
|
func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, blobs []string, pc *opPlanCtx) (*opReport, error) {
|
||||||
if ok, why := opActionAvailable(); !ok {
|
if !m.verbsOnly() {
|
||||||
return nil, errors.New("this machine cannot run an action op: " + why)
|
if ok, why := opActionCheck(); !ok {
|
||||||
|
return nil, errors.New("this machine cannot run an action op: " + why)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if mode != opModeDryRun && mode != opModeApply {
|
if mode != opModeDryRun && mode != opModeApply {
|
||||||
return nil, errors.New("an action op is run as dry_run or apply; its verify runs inside the apply")
|
return nil, errors.New("an action op is run as dry_run or apply; its verify runs inside the apply")
|
||||||
@@ -97,6 +106,11 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if m.verbsOnly() {
|
||||||
|
if why := opVerbProbe(verbs); why != "" {
|
||||||
|
return nil, errors.New(noVerbToolHere + ": " + why)
|
||||||
|
}
|
||||||
|
}
|
||||||
creds, err := opOpenCreds(blobs, m, t.Nonce, mode == opModeDryRun)
|
creds, err := opOpenCreds(blobs, m, t.Nonce, mode == opModeDryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -108,6 +122,9 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
|||||||
}
|
}
|
||||||
planSHA := opChangesDigest(plan)
|
planSHA := opChangesDigest(plan)
|
||||||
if mode == opModeDryRun {
|
if mode == opModeDryRun {
|
||||||
|
if m.verbsOnly() {
|
||||||
|
return &opReport{Mode: opModeDryRun, Changes: plan, ChangesSHA256: planSHA}, nil
|
||||||
|
}
|
||||||
rep, _, err := opRunActionProgram(opDir, m, opModeDryRun, t.Params, nil, runID)
|
rep, _, err := opRunActionProgram(opDir, m, opModeDryRun, t.Params, nil, runID)
|
||||||
if rep != nil {
|
if rep != nil {
|
||||||
rep.Changes, rep.ChangesSHA256 = plan, planSHA
|
rep.Changes, rep.ChangesSHA256 = plan, planSHA
|
||||||
@@ -122,9 +139,15 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
|||||||
truncate(planSHA, 12), truncate(t.Changes, 12))
|
truncate(planSHA, 12), truncate(t.Changes, 12))
|
||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
rep, result, err := opRunActionProgram(opDir, m, opModeApply, t.Params, opProgramCreds(verbs, creds), runID)
|
var rep *opReport
|
||||||
if rep == nil {
|
var result []byte
|
||||||
return nil, err
|
if m.verbsOnly() {
|
||||||
|
rep = &opReport{Mode: opModeApply}
|
||||||
|
} else {
|
||||||
|
rep, result, err = opRunActionProgram(opDir, m, opModeApply, t.Params, opProgramCreds(verbs, creds), runID)
|
||||||
|
if rep == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rep.Changes, rep.ChangesSHA256 = plan, planSHA
|
rep.Changes, rep.ChangesSHA256 = plan, planSHA
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,6 +158,11 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, err
|
return rep, err
|
||||||
}
|
}
|
||||||
|
for _, im := range images {
|
||||||
|
if err := opRegistryOK(context.Background(), im.Image); err != nil {
|
||||||
|
return rep, err
|
||||||
|
}
|
||||||
|
}
|
||||||
receipts, err := opIssueReceipts(runID, t.Op, t.SHA256, t.Params, images)
|
receipts, err := opIssueReceipts(runID, t.Op, t.SHA256, t.Params, images)
|
||||||
rep.Receipts = receipts
|
rep.Receipts = receipts
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,10 +199,12 @@ func opRunAction(opDir string, m *opManifest, t *opTerms, mode, runID string, bl
|
|||||||
// image), then each log check, reading only what came after the apply
|
// image), then each log check, reading only what came after the apply
|
||||||
// began.
|
// began.
|
||||||
var failed []string
|
var failed []string
|
||||||
if ver, _, err := opRunActionProgram(opDir, m, opModeVerify, t.Params, nil, runID); err != nil {
|
if !m.verbsOnly() {
|
||||||
failed = append(failed, "verify: "+err.Error())
|
if ver, _, err := opRunActionProgram(opDir, m, opModeVerify, t.Params, nil, runID); err != nil {
|
||||||
} else if ver.Exit != 0 {
|
failed = append(failed, "verify: "+err.Error())
|
||||||
failed = append(failed, fmt.Sprintf("verify exited %d", ver.Exit))
|
} else if ver.Exit != 0 {
|
||||||
|
failed = append(failed, fmt.Sprintf("verify exited %d", ver.Exit))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, v := range verbs {
|
for _, v := range verbs {
|
||||||
if v.Verb != opVerbLogs {
|
if v.Verb != opVerbLogs {
|
||||||
@@ -508,7 +538,7 @@ func opCredHeaderEnv(creds map[string]string, name string) ([]string, error) {
|
|||||||
return []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=http.extraHeader", "GIT_CONFIG_VALUE_0=Authorization: token " + tok}, nil
|
return []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=http.extraHeader", "GIT_CONFIG_VALUE_0=Authorization: token " + tok}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func opTreeDo(_ context.Context, v opVerbLine, creds map[string]string, want string) (string, error) {
|
func opTreeDo(ctx context.Context, v opVerbLine, creds map[string]string, want string) (string, error) {
|
||||||
defer opLockTarget(v)()
|
defer opLockTarget(v)()
|
||||||
path, to := v.Args["path"], v.Args["commit"]
|
path, to := v.Args["path"], v.Args["commit"]
|
||||||
head, branch, err := opTreeState(v)
|
head, branch, err := opTreeState(v)
|
||||||
@@ -525,7 +555,11 @@ func opTreeDo(_ context.Context, v opVerbLine, creds map[string]string, want str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if out, err := opTreeGit(path, env, "fetch", "--no-tags", "--quiet", v.Args["repo"], "+refs/heads/*:refs/remotes/origin/*"); err != nil {
|
pin, err := opFetchPin(ctx, v.Args["repo"])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out, err := opTreeGit(path, env, append(pin, "fetch", "--no-tags", "--quiet", v.Args["repo"], "+refs/heads/*:refs/remotes/origin/*")...); err != nil {
|
||||||
return "", errors.New("fetch failed: " + truncate(out, 300))
|
return "", errors.New("fetch failed: " + truncate(out, 300))
|
||||||
}
|
}
|
||||||
if _, err := opTreeGit(path, nil, "cat-file", "-e", to+"^{commit}"); err != nil {
|
if _, err := opTreeGit(path, nil, "cat-file", "-e", to+"^{commit}"); err != nil {
|
||||||
@@ -702,6 +736,9 @@ func opImageCheck(ctx context.Context, v opVerbLine, pc *opPlanCtx) (*opImageFac
|
|||||||
if rc.Image != a["image"] {
|
if rc.Image != a["image"] {
|
||||||
return nil, fmt.Errorf("the receipt for %s is for %s, not %s", truncate(a["digest"], 19), rc.Image, a["image"])
|
return nil, fmt.Errorf("the receipt for %s is for %s, not %s", truncate(a["digest"], 19), rc.Image, a["image"])
|
||||||
}
|
}
|
||||||
|
if err := opRegistryOK(ctx, a["image"]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// The repository ancestry is checked in is the manifest's, never one a
|
// The repository ancestry is checked in is the manifest's, never one a
|
||||||
// program reported: the check fetches it, with the verb's credential.
|
// program reported: the check fetches it, with the verb's credential.
|
||||||
if rc.Source != a["repo"] {
|
if rc.Source != a["repo"] {
|
||||||
@@ -806,12 +843,156 @@ func opImageDo(ctx context.Context, v opVerbLine, creds map[string]string, pc *o
|
|||||||
return truncate(f.liveDigest, 19) + " → " + truncate(a["digest"], 19) + ", rolled out", nil
|
return truncate(f.liveDigest, 19) + " → " + truncate(a["digest"], 19) + ", rolled out", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opFetchPin is the git options a verb's fetch of repo carries. A verb
|
||||||
|
// fetches with the bastion's own root, outside the program's sandbox
|
||||||
|
// (tree.checkout its tree's repository, k8s.set_image its source to check
|
||||||
|
// ancestry), so it is held to the rule a destination is: the repository's
|
||||||
|
// host is resolved here, once, every address it answers must be one an op may
|
||||||
|
// reach, and git is held to the first (http.curloptResolve, as the mirror
|
||||||
|
// pins its fetch) so a second lookup cannot answer another. On every machine
|
||||||
|
// that is never this machine, its loopback, a link-local address or a cloud's
|
||||||
|
// metadata service (opForbiddenDest); on a machine no fleet has anchored,
|
||||||
|
// never a private network either (opLocalNetworkDest). An anchored machine's
|
||||||
|
// ship steps reach its fleet's private forges, so it keeps those.
|
||||||
|
func opFetchPin(ctx context.Context, repo string) ([]string, error) {
|
||||||
|
u, err := url.Parse(repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("the repository is not a URL this bastion can read")
|
||||||
|
}
|
||||||
|
if u.Scheme == "file" && slices.Contains(opTreeProtocols, "file") {
|
||||||
|
return nil, nil // a test's own repository: no network
|
||||||
|
}
|
||||||
|
if u.Scheme != "https" || u.Hostname() == "" {
|
||||||
|
return nil, fmt.Errorf("%s: a verb fetches a repository over https from a named host", repo)
|
||||||
|
}
|
||||||
|
host, port := u.Hostname(), u.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "443"
|
||||||
|
}
|
||||||
|
ips, err := opVerbHostAddrs(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the repository's host %s does not resolve on this machine", host)
|
||||||
|
}
|
||||||
|
own := map[string]bool{}
|
||||||
|
for _, ip := range opActionOwnAddrs() {
|
||||||
|
own[ip.String()] = true
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
why := opForbiddenDest(ip)
|
||||||
|
if why == "" {
|
||||||
|
why = opLocalNetworkDest(ip)
|
||||||
|
}
|
||||||
|
if why == "" && own[ip.String()] {
|
||||||
|
why = "it is this machine"
|
||||||
|
}
|
||||||
|
if why != "" {
|
||||||
|
return nil, fmt.Errorf("the repository's host %s resolves to %s: %s", host, ip, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if why := opGitPinnable(); why != "" {
|
||||||
|
return nil, errors.New(why)
|
||||||
|
}
|
||||||
|
addr := ips[0].String()
|
||||||
|
if strings.Contains(addr, ":") {
|
||||||
|
addr = "[" + addr + "]"
|
||||||
|
}
|
||||||
|
return []string{"-c", "http.curloptResolve=" + host + ":" + port + ":" + addr}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opRegistryOK: nil when the registry an image is pulled from may be
|
||||||
|
// reached from this machine, else why not. The bastion never pulls an image
|
||||||
|
// itself: k8s.set_image hands the reference to the cluster, and the
|
||||||
|
// machine's container runtime pulls it, by the name, with its own lookup and
|
||||||
|
// none of the bastion's walls. So on a machine no fleet has anchored the
|
||||||
|
// registry is judged where the bastion takes the image, when a program's
|
||||||
|
// result names it and again when k8s.set_image would put it live: written as
|
||||||
|
// an address or resolving to one that is this machine, its loopback, a
|
||||||
|
// link-local address, a cloud's metadata service or a private network, it is
|
||||||
|
// refused. A first part with no dot, no port and not localhost is a Docker
|
||||||
|
// Hub repository, as the runtime reads it. An anchored machine's fleet pulls
|
||||||
|
// from its own private registries, so it keeps them.
|
||||||
|
func opRegistryOK(ctx context.Context, image string) error {
|
||||||
|
if currentAnchor() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ref, _ := opSplitImage(image)
|
||||||
|
first, _, ok := strings.Cut(ref, "/")
|
||||||
|
if !ok || (!strings.ContainsAny(first, ".:") && first != "localhost") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host := first
|
||||||
|
if h, _, err := net.SplitHostPort(first); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
ips, err := opVerbHostAddrs(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the image's registry %s does not resolve on this machine", host)
|
||||||
|
}
|
||||||
|
own := map[string]bool{}
|
||||||
|
for _, ip := range opActionOwnAddrs() {
|
||||||
|
own[ip.String()] = true
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
why := opForbiddenDest(ip)
|
||||||
|
if why == "" {
|
||||||
|
why = opLocalNetworkDest(ip)
|
||||||
|
}
|
||||||
|
if why == "" && own[ip.String()] {
|
||||||
|
why = "it is this machine"
|
||||||
|
}
|
||||||
|
if why != "" {
|
||||||
|
return fmt.Errorf("the image %s comes from the registry %s, which resolves to %s: %s", truncate(ref, 90), host, ip, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opVerbHostAddrs is every address host stands for on this machine: itself
|
||||||
|
// when it is one, loopback for localhost and a name under it (which never
|
||||||
|
// leave the machine, whatever a resolver says), else what it resolves to.
|
||||||
|
func opVerbHostAddrs(ctx context.Context, host string) ([]net.IP, error) {
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
return []net.IP{ip}, nil
|
||||||
|
}
|
||||||
|
if l := strings.ToLower(host); l == "localhost" || strings.HasSuffix(l, ".localhost") {
|
||||||
|
return []net.IP{net.IPv4(127, 0, 0, 1)}, nil
|
||||||
|
}
|
||||||
|
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ips, err := opActionResolve(rctx, host)
|
||||||
|
if err == nil && len(ips) == 0 {
|
||||||
|
err = errors.New("no address")
|
||||||
|
}
|
||||||
|
return ips, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// opGitPinnable: "" when this machine's git holds a fetch to the address
|
||||||
|
// opFetchPin checked, else why not. http.curloptResolve arrived in git 2.37,
|
||||||
|
// and an older git ignores it without an error, which would drop the pin; so
|
||||||
|
// an older git does not fetch for a verb. A seam for the tests.
|
||||||
|
var opGitPinnable = func() string {
|
||||||
|
out, err := runGitEnv(15*time.Second, nil, "version")
|
||||||
|
if err != nil {
|
||||||
|
return "git on this machine did not report its version"
|
||||||
|
}
|
||||||
|
if gitVersionRefusal(out) != "" {
|
||||||
|
return "a verb's fetch is held to the address the bastion checked, " +
|
||||||
|
"which needs git 2.37 or newer, and this machine has " + truncate(strings.TrimSpace(out), 40) +
|
||||||
|
": install git 2.37 or newer"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// opAncestryByFetch fetches the source repository into the bastion's own
|
// opAncestryByFetch fetches the source repository into the bastion's own
|
||||||
// cache and asks git whether newer descends from older.
|
// cache and asks git whether newer descends from older.
|
||||||
func opAncestryByFetch(source, older, newer string, env []string) error {
|
func opAncestryByFetch(source, older, newer string, env []string) error {
|
||||||
if older == newer {
|
if older == newer {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
pin, err := opFetchPin(context.Background(), source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
d, err := configDir()
|
d, err := configDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -826,7 +1007,7 @@ func opAncestryByFetch(source, older, newer string, env []string) error {
|
|||||||
return errors.New("ancestry cache: " + truncate(out, 200))
|
return errors.New("ancestry cache: " + truncate(out, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if out, err := opTreeGit(cache, env, "fetch", "--no-tags", "--quiet", source, "+refs/heads/*:refs/heads/*"); err != nil {
|
if out, err := opTreeGit(cache, env, append(pin, "fetch", "--no-tags", "--quiet", source, "+refs/heads/*:refs/heads/*")...); err != nil {
|
||||||
return errors.New("fetching the source to check ancestry failed: " + truncate(out, 300))
|
return errors.New("fetching the source to check ancestry failed: " + truncate(out, 300))
|
||||||
}
|
}
|
||||||
for _, c := range []string{older, newer} {
|
for _, c := range []string{older, newer} {
|
||||||
@@ -906,6 +1087,95 @@ func opProgramCreds(verbs []opVerbLine, creds map[string]string) map[string]stri
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── what each verb needs of the machine ─────────────────────────────────────
|
||||||
|
|
||||||
|
// opLookPath finds a tool a verb runs (a seam for the tests).
|
||||||
|
var opLookPath = exec.LookPath
|
||||||
|
|
||||||
|
// opVerbProbe: "" when this machine can run every line, else why not. The
|
||||||
|
// verbs run with the bastion's own root and each runs one tool. A seam, so a
|
||||||
|
// test can stand in for a machine that has them.
|
||||||
|
var opVerbProbe = func(lines []opVerbLine) string {
|
||||||
|
if !processIsRoot() {
|
||||||
|
return "the bastion is not root, and a verb runs with its root"
|
||||||
|
}
|
||||||
|
for _, v := range lines {
|
||||||
|
for _, tool := range opVerbTools(v.Verb, v.Args) {
|
||||||
|
if _, err := opLookPath(tool); err != nil {
|
||||||
|
return fmt.Sprintf("%s %s runs %s, which is not installed here", v.Verb, v.target(), tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// opVerbTools is the tool one verb line runs (logs.check: the journal for a
|
||||||
|
// unit, kubectl for a workload).
|
||||||
|
func opVerbTools(verb string, args map[string]string) []string {
|
||||||
|
switch verb {
|
||||||
|
case "unit.reload", "unit.restart":
|
||||||
|
return []string{"systemctl"}
|
||||||
|
case "tree.checkout":
|
||||||
|
return []string{"git"}
|
||||||
|
case "k8s.set_image":
|
||||||
|
// kubectl sets the image; git fetches the source to check the new
|
||||||
|
// commit descends from the live one (opAncestryByFetch).
|
||||||
|
return []string{"kubectl", "git"}
|
||||||
|
case opVerbLogs:
|
||||||
|
if args["unit"] != "" {
|
||||||
|
return []string{"journalctl"}
|
||||||
|
}
|
||||||
|
return []string{"kubectl"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opVerbsReport is the beat's enforcing.verbs: for each verb this bastion
|
||||||
|
// runs, whether this machine has what it needs, and why not when it does
|
||||||
|
// not. logs.check is ready when either of its tools is (the line decides
|
||||||
|
// which it reads).
|
||||||
|
func opVerbsReport() map[string]any {
|
||||||
|
root := processIsRoot()
|
||||||
|
out := map[string]any{}
|
||||||
|
for _, verb := range opVerbNames() {
|
||||||
|
var tools [][]string
|
||||||
|
if verb == opVerbLogs {
|
||||||
|
tools = [][]string{{"journalctl"}, {"kubectl"}}
|
||||||
|
} else {
|
||||||
|
tools = [][]string{opVerbTools(verb, nil)}
|
||||||
|
}
|
||||||
|
why := ""
|
||||||
|
if !root {
|
||||||
|
why = "the bastion is not root, and a verb runs with its root"
|
||||||
|
} else {
|
||||||
|
why = "needs " + strings.Join(tools[0], ", ")
|
||||||
|
for _, set := range tools {
|
||||||
|
ok := true
|
||||||
|
for _, tool := range set {
|
||||||
|
if _, err := opLookPath(tool); err != nil {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
why = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if why == "" && verb == "k8s.set_image" {
|
||||||
|
if _, err := os.Stat(opKubeconfig); err != nil {
|
||||||
|
why = "no kubeconfig at " + opKubeconfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if why == "" {
|
||||||
|
out[verb] = map[string]any{"ok": true}
|
||||||
|
} else {
|
||||||
|
out[verb] = map[string]any{"ok": false, "why": why}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// opVerbTo is the state a verb line moves its target to: a commit, an image
|
// opVerbTo is the state a verb line moves its target to: a commit, an image
|
||||||
// reference; "" for a unit, which has no state to move back from.
|
// reference; "" for a unit, which has no state to move back from.
|
||||||
func opVerbTo(v opVerbLine) string {
|
func opVerbTo(v opVerbLine) string {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// ops_verbs_only_linux_test.go — an op with no program (interpreter: none)
|
||||||
|
// runs on a machine with no overlay (the edges), through op_dry_run and an
|
||||||
|
// approved op_apply, with only the verbs' own tools. Stand-ins for systemd
|
||||||
|
// and the probe; nothing here needs root.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerbsOnlyOpRunsWithNoOverlay(t *testing.T) {
|
||||||
|
noOverlay(t)
|
||||||
|
prevProbe := opVerbProbe
|
||||||
|
opVerbProbe = func([]opVerbLine) string { return "" }
|
||||||
|
t.Cleanup(func() { opVerbProbe = prevProbe })
|
||||||
|
var calls []string
|
||||||
|
state := map[string]string{"LoadState": "loaded", "ActiveState": "failed", "SubState": "failed", "MainPID": "0"}
|
||||||
|
prevCtl, prevJournal := opSystemctl, opJournalctl
|
||||||
|
opSystemctl = func(_ context.Context, args ...string) (string, error) {
|
||||||
|
calls = append(calls, strings.Join(args, " "))
|
||||||
|
if args[0] == "show" {
|
||||||
|
var b strings.Builder
|
||||||
|
for k, v := range state {
|
||||||
|
fmt.Fprintf(&b, "%s=%s\n", k, v)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
if args[0] == "restart" {
|
||||||
|
state["ActiveState"], state["SubState"], state["MainPID"] = "active", "running", "42"
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
opJournalctl = func(_ context.Context, _ ...string) (string, error) { return "started\n", nil }
|
||||||
|
t.Cleanup(func() { opSystemctl, opJournalctl = prevCtl, prevJournal })
|
||||||
|
|
||||||
|
dcn, priv := opTestBox(t)
|
||||||
|
enrollFactor(t, dcn)
|
||||||
|
files := verbsOnlyFiles(false)
|
||||||
|
sha, _ := opDirDigest(files)
|
||||||
|
terms := opTerm(opKindRun, sha, `{}`, "", 1, time.Hour, "root")
|
||||||
|
terms["op"] = "restart-web"
|
||||||
|
pair := signTermsPair(t, priv, terms)
|
||||||
|
if _, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_sync", Args: append(pair, bundleB64(files))}, "", dcn); code != 0 {
|
||||||
|
t.Fatalf("held: %s", errOut)
|
||||||
|
}
|
||||||
|
out, errOut, code := driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair}, "", dcn)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("the dry run on a machine with no overlay: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
var rep opReport
|
||||||
|
if err := json.Unmarshal([]byte(out), &rep); err != nil || len(rep.Changes) != 2 || rep.ChangesSHA256 == "" ||
|
||||||
|
!strings.Contains(rep.Changes[0].Note, "failed/failed → restarted") {
|
||||||
|
t.Fatalf("the dry run is the verbs' own read: %v %s", err, out)
|
||||||
|
}
|
||||||
|
for _, c := range calls {
|
||||||
|
if strings.HasPrefix(c, "restart") {
|
||||||
|
t.Fatal("a verb never runs in a dry run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The apply, approved for exactly that change list.
|
||||||
|
terms["changes_sha256"] = rep.ChangesSHA256
|
||||||
|
purpose, err := purposeForOpRun(&opTerms{Server: terms["server"].(string), Op: "restart-web", SHA256: sha,
|
||||||
|
Params: `{}`, Expires: terms["expires"].(string), Changes: rep.ChangesSHA256})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := b32decode(tSecret)
|
||||||
|
approval, err := stepupIssueApproval(dcn, hotp(raw, time.Now().Unix()/stepupPeriod), purpose)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
terms["approval"] = approval
|
||||||
|
out, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_apply", Args: signTermsPair(t, priv, terms)}, "", dcn)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("the apply: %d %s", code, errOut)
|
||||||
|
}
|
||||||
|
rep = opReport{}
|
||||||
|
_ = json.Unmarshal([]byte(out), &rep)
|
||||||
|
if len(rep.Verbs) != 1 || !rep.Verbs[0].OK || len(rep.Checks) != 1 || !rep.Checks[0].OK || rep.VerifyError != "" {
|
||||||
|
t.Fatalf("the unit restarted and its log checked: %s", out)
|
||||||
|
}
|
||||||
|
if n := countPrefix(calls, "restart web.service"); n != 1 {
|
||||||
|
t.Fatalf("restarted once: %v", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine without a verb's tool refuses the run, and that is the
|
||||||
|
// machine's state, never the host's strike.
|
||||||
|
opVerbProbe = func(lines []opVerbLine) string {
|
||||||
|
return "unit.restart web.service runs systemctl, which is not installed here"
|
||||||
|
}
|
||||||
|
pair2 := signTermsPair(t, priv, terms)
|
||||||
|
_, errOut, code = driveJob(t, tAnchor, mgmtJob{Op: "op_dry_run", Args: pair2}, "", dcn)
|
||||||
|
if code != 126 || !strings.Contains(errOut, noVerbToolHere) || refusalCounts("op_dry_run", errOut) {
|
||||||
|
t.Fatalf("a missing tool: %d %q", code, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func countPrefix(xs []string, p string) int {
|
||||||
|
n := 0
|
||||||
|
for _, x := range xs {
|
||||||
|
if strings.HasPrefix(x, p) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerbsReportNamesEachVerbsTool(t *testing.T) {
|
||||||
|
prev := opLookPath
|
||||||
|
opLookPath = func(file string) (string, error) {
|
||||||
|
if file == "systemctl" || file == "journalctl" {
|
||||||
|
return "/usr/bin/" + file, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opLookPath = prev })
|
||||||
|
rep := opVerbsReport()
|
||||||
|
for _, verb := range opVerbNames() {
|
||||||
|
if _, ok := rep[verb]; !ok {
|
||||||
|
t.Fatalf("every verb is on the beat: %v", rep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !processIsRoot() {
|
||||||
|
if v := rep["unit.restart"].(map[string]any); v["ok"] != false {
|
||||||
|
t.Fatalf("a bastion that is not root runs no verb: %v", v)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v := rep["unit.restart"].(map[string]any); v["ok"] != true {
|
||||||
|
t.Fatalf("systemctl here: %v", v)
|
||||||
|
}
|
||||||
|
if v := rep["k8s.set_image"].(map[string]any); v["ok"] != false || !strings.Contains(v["why"].(string), "kubectl") {
|
||||||
|
t.Fatalf("no kubectl here: %v", v)
|
||||||
|
}
|
||||||
|
if v := rep["logs.check"].(map[string]any); v["ok"] != true {
|
||||||
|
t.Fatalf("the journal is enough for a unit's log: %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8s.set_image runs git as well as kubectl (its ancestry check fetches the
|
||||||
|
// source), so a machine with kubectl and no git is told it cannot run the
|
||||||
|
// verb, before any run, never by a failed apply.
|
||||||
|
//
|
||||||
|
// RESULT: red before — k8s.set_image named kubectl alone.
|
||||||
|
func TestSetImageNeedsGitForItsAncestryCheck(t *testing.T) {
|
||||||
|
tools := opVerbTools("k8s.set_image", nil)
|
||||||
|
if strings.Join(tools, ",") != "kubectl,git" {
|
||||||
|
t.Fatalf("k8s.set_image runs kubectl and git: %v", tools)
|
||||||
|
}
|
||||||
|
if got := opVerbTools("tree.checkout", nil); strings.Join(got, ",") != "git" {
|
||||||
|
t.Fatalf("tree.checkout runs git: %v", got)
|
||||||
|
}
|
||||||
|
prev := opLookPath
|
||||||
|
opLookPath = func(file string) (string, error) {
|
||||||
|
if file == "kubectl" {
|
||||||
|
return "/usr/bin/kubectl", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("not found")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { opLookPath = prev })
|
||||||
|
if !processIsRoot() {
|
||||||
|
return // a bastion that is not root runs no verb at all
|
||||||
|
}
|
||||||
|
line := setImageLine("sha256:" + strings.Repeat("2", 64))
|
||||||
|
if why := opVerbProbe([]opVerbLine{line}); !strings.Contains(why, "git") {
|
||||||
|
t.Fatalf("a machine with kubectl and no git cannot run k8s.set_image: %q", why)
|
||||||
|
}
|
||||||
|
if v := opVerbsReport()["k8s.set_image"].(map[string]any); v["ok"] != false || !strings.Contains(v["why"].(string), "git") {
|
||||||
|
t.Fatalf("the beat says so: %v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ func refusalCounts(op, why string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if strings.Contains(why, noWayToApprove) || strings.HasSuffix(why, "not permitted on this host") ||
|
if strings.Contains(why, noWayToApprove) || strings.HasSuffix(why, "not permitted on this host") ||
|
||||||
strings.Contains(why, noSandboxHere) {
|
strings.Contains(why, noSandboxHere) || strings.Contains(why, noVerbToolHere) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
+3
-3
@@ -533,9 +533,9 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// 2d. Git lane: loopback HTTP proxy for git.privatae.ai only.
|
// 2d. Git lane: loopback HTTP proxy for git.privatae.ai only.
|
||||||
// Each local connection = one WebSocket to the host, relayed out the PLATFORM
|
// Each local connection = one WebSocket to the host, which relays it on to
|
||||||
// path there (the forge answers platform/DMZ addresses only). No credential
|
// the forge (gitlane.go). No credential rides this lane; git presents its
|
||||||
// rides this lane; git presents its own via the git-credential helper below.
|
// own via the git-credential helper below.
|
||||||
if cfg.GitProxyAddr != "" {
|
if cfg.GitProxyAddr != "" {
|
||||||
if gitLn, err := net.Listen("tcp", cfg.GitProxyAddr); err != nil {
|
if gitLn, err := net.Listen("tcp", cfg.GitProxyAddr); err != nil {
|
||||||
// Optional lane: a port collision must not take the tunnel, lanes and
|
// Optional lane: a port collision must not take the tunnel, lanes and
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ func opWrites(op string) bool {
|
|||||||
// approval. A lost device must not keep a revoked grant alive.
|
// approval. A lost device must not keep a revoked grant alive.
|
||||||
func opTightens(op string) bool {
|
func opTightens(op string) bool {
|
||||||
return op == "dcn_grant_drop" || op == "pairing_grant_drop" || op == "service_grant_drop" ||
|
return op == "dcn_grant_drop" || op == "pairing_grant_drop" || op == "service_grant_drop" ||
|
||||||
op == "coder_grant_drop"
|
op == "coder_grant_drop" || op == "op_grant_drop"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ownApproval reports whether a WRITE op already carries its own proof of the
|
// ownApproval reports whether a WRITE op already carries its own proof of the
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.57.0
|
0.58.1
|
||||||
|
|||||||
+11
-9
@@ -121,13 +121,15 @@ The file:
|
|||||||
needs. It cannot write to that folder. `backup unshare` takes it back, and
|
needs. It cannot write to that folder. `backup unshare` takes it back, and
|
||||||
unpairing or revoking the new host takes back every folder shared with it.
|
unpairing or revoking the new host takes back every folder shared with it.
|
||||||
|
|
||||||
This bastion does not bring up the new host. Running one on hardware you own
|
This bastion does not bring up the new host. A Host on hardware you own is a
|
||||||
needs infrastructure that is not part of this program, and not built yet:
|
C-Series Host ([A Host on this machine](host-on-this-machine.md)), and it can
|
||||||
|
restore a backup the lost host made, from a file:
|
||||||
|
|
||||||
- a DMZ cluster on your hardware: a Kubernetes cluster with the network
|
1. Copy the backup and its key file (`<stamp>.tar.zst.age` and
|
||||||
policy, admission and storage the host's pod expects;
|
`<stamp>.key.age`) out of the lost host's backup folder here.
|
||||||
- the operator that creates and runs a host's pod on that cluster;
|
2. In the C-Series Host's Backups app, under "Restore from a file", choose
|
||||||
- the host's container images, from a registry that cluster can pull from;
|
the two together and give the lost host's recovery key. The page opens the
|
||||||
- the certificate authority that issues the host its identity, and the
|
key file with it and seals the backup's key to the new Host; the recovery
|
||||||
services a host reaches when it starts, or replacements for them;
|
key never leaves the page.
|
||||||
- a place the new host can fetch the backup from, as above.
|
3. Tick the box and enter the code from your authenticator. When the backup
|
||||||
|
is from another host number, the page asks you first.
|
||||||
|
|||||||
+3
-3
@@ -14,9 +14,9 @@ Run the tests:
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers all three platforms' parsing and judgement logic from a single
|
Most parsing and judgement logic is in files with no build tag and runs under
|
||||||
`go test` on any OS, because every platform-specific decision is kept in files
|
`go test` on any OS. Some platform parsers and the Linux sandbox are in tagged
|
||||||
with no build tag and only the enumerators are tagged.
|
files, and their tests run only on that OS.
|
||||||
|
|
||||||
The walls a host's input has to get through also have fuzz targets
|
The walls a host's input has to get through also have fuzz targets
|
||||||
(`client-go/fuzz_*_test.go`): the path sandbox for store reads and move
|
(`client-go/fuzz_*_test.go`): the path sandbox for store reads and move
|
||||||
|
|||||||
+45
-12
@@ -1,6 +1,7 @@
|
|||||||
# How each claim is tested
|
# How each claim is tested
|
||||||
|
|
||||||
The claims are the points of the [security model](security-model.md).
|
The claims are the points of the [security model](security-model.md), and
|
||||||
|
some further ones that page does not list.
|
||||||
|
|
||||||
Each row names a test in `client-go/` that fails if the claim it sits under
|
Each row names a test in `client-go/` that fails if the claim it sits under
|
||||||
stops being true. Where a claim, or part of one, has no such test, the row
|
stops being true. Where a claim, or part of one, has no such test, the row
|
||||||
@@ -16,7 +17,7 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. `serviceReachAllowed`, the reach check a slot port runs, then admits the granted host to the granted server only; another server, another host or a revoked grant is refused. The check is called directly, not through the slot listener. |
|
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. `serviceReachAllowed`, the reach check a slot port runs, then admits the granted host to the granted server only; another server, another host or a revoked grant is refused. The check is called directly, not through the slot listener. |
|
||||||
| | `claims_unix_test.go`: `TestEachInferenceForwardAdmitsOnlyThePairingThatHoldsInference` | Through each forward's accept loop on an in-memory tunnel: a pairing without `inference` never reaches the upstream, and granting the family lets the next connection through, with no restart. |
|
| | `claims_unix_test.go`: `TestEachInferenceForwardAdmitsOnlyThePairingThatHoldsInference` | Through each forward's accept loop on an in-memory tunnel: a pairing without `inference` never reaches the upstream, and granting the family lets the next connection through, with no restart. |
|
||||||
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: a connection is refused with a reach grant but no `services` family, and with the family but no reach grant for that server; with both it reaches the server's socket. |
|
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: a connection is refused with a reach grant but no `services` family, and with the family but no reach grant for that server; with both it reaches the server's socket. |
|
||||||
| | `claims_static_test.go`: `TestOnlyTheGatedForwardsListenInsideTheTunnel` | Every listener on the tunnel's network stack is one of the four gated forwards, each at one call site; only they are handed the stack, and it is otherwise only dialled out of. |
|
| | `claims_static_test.go`: `TestOnlyTheGatedForwardsListenInsideTheTunnel` | Every listener on the tunnel's network stack is one of the five gated forwards (three inference forwards, the service slot and coder runs), each at one call site; only they are handed the stack, and it is otherwise only dialled out of. |
|
||||||
| | not covered by a test | The reach check's own comparison of a stored grant's machine (`service_reach.go`, `serviceReachAllowed`). |
|
| | not covered by a test | The reach check's own comparison of a stored grant's machine (`service_reach.go`, `serviceReachAllowed`). |
|
||||||
| Scoped tunnel | `claims_tunnel_test.go`: `TestTheTunnelReachesOnlyThePairedHost` | A WireGuard device configured the way a pairing configures it holds one peer, whose only allowed address is the host's /128, before and after an endpoint rotation. |
|
| Scoped tunnel | `claims_tunnel_test.go`: `TestTheTunnelReachesOnlyThePairedHost` | A WireGuard device configured the way a pairing configures it holds one peer, whose only allowed address is the host's /128, before and after an endpoint rotation. |
|
||||||
| | `claims_tunnel_test.go`: `TestATunnelAddressThatIsNotOneHostIsRefused` | A tunnel address that is a prefix, empty, zoned, unspecified, multicast or not an address is refused at tunnel start and at a rotation, and the device is left with no peer. |
|
| | `claims_tunnel_test.go`: `TestATunnelAddressThatIsNotOneHostIsRefused` | A tunnel address that is a prefix, empty, zoned, unspecified, multicast or not an address is refused at tunnel start and at a rotation, and the device is left with no peer. |
|
||||||
@@ -55,16 +56,24 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import or module names MCP; no string in the code is a URL or route with an `/mcp` path, or names JSON-RPC. The agent clients' tool-server config files are named only in `mcpconfig.go`, which reads them, and nothing in the config reader imports or can reach a dialler, an HTTP client, a websocket or a process start. |
|
| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import or module names MCP; no string in the code is a URL or route with an `/mcp` path, or names JSON-RPC. The agent clients' tool-server config files are named only in `mcpconfig.go`, which reads them, and nothing in the config reader imports or can reach a dialler, an HTTP client, a websocket or a process start. |
|
||||||
| | `claims_static_test.go`: `TestMCPReaderCheckCatchesADialAndAStrayBasename` | The check above reports a config reader that starts a process, imports `net/http` or reaches a dialler through another function, a Windows reader that starts a process, opens a socket, loads a DLL by name or opens a remote registry, and a second file that names a client's config file, the servers key, an `/mcp` URL or an `/mcp` route. |
|
| | `claims_static_test.go`: `TestMCPReaderCheckCatchesADialAndAStrayBasename` | The check above reports a config reader that starts a process, imports `net/http` or reaches a dialler through another function, a Windows reader that starts a process, opens a socket, loads a DLL by name or opens a remote registry, and a second file that names a client's config file, the servers key, an `/mcp` URL or an `/mcp` route. |
|
||||||
| Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. |
|
| Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. |
|
||||||
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For every kind of signed record, changing any byte of the record or its signature makes it fail to verify. |
|
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For each of nine kinds of signed record (pairing grant, pairing revoke, anchor consent, management grant, store grant, move plan, reach grant, op run and op grant), changing any byte of the record or its signature makes it fail to verify. |
|
||||||
| | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. |
|
| | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. |
|
||||||
| | `anchor_test.go`: `TestAnchorVerifyKeyPinsFromDCNAndRefusesRepin` | The anchor's verify key cannot be re-pinned to a different key. |
|
| | `anchor_test.go`: `TestAnchorVerifyKeyPinsFromDCNAndRefusesRepin` | The anchor's verify key cannot be re-pinned to a different key. |
|
||||||
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | An op the grant both allows and denies, or a grant that denies `*`, is refused. |
|
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | An op the grant both allows and denies, or a grant that denies `*`, is refused. |
|
||||||
| | `localdeny_test.go`: `TestTheLocalSwitchCanOnlyEverRemoveReach` | Switching a capability off on this machine never turns a refusal into an allow. |
|
| | `localdeny_test.go`: `TestTheLocalSwitchCanOnlyEverRemoveReach` | Switching a capability off on this machine never turns a refusal into an allow. |
|
||||||
| Fixed argv | `observe_ops_test.go`: `TestObserveArgsNeverReachArgv` | Shell metacharacters and flags sent as arguments to every observe op are refused or never reach a command line, and each op runs only its pinned argv. Linux only. |
|
| Fixed argv | `observe_ops_test.go`: `TestObserveArgsNeverReachArgv` | Shell metacharacters and flags sent as arguments to every observe op are refused or never reach a command line, and each op runs only its pinned argv. Linux only. |
|
||||||
| | `contextextract_test.go`: `TestCtxExtractRefusesAnUnknownTool` | Text extraction runs only tool names from its fixed table. |
|
| | `contextextract_test.go`: `TestCtxExtractRefusesAnUnknownTool` | Text extraction runs only tool names from its fixed table. |
|
||||||
|
| | `observe_reads_linux_test.go`: `TestObserveJournalRunsOnlyItsPinnedArgv` | `observe:journal` runs `systemctl list-units`, `systemctl list-unit-files` and one pinned `journalctl`; the unit reaches `journalctl` only as `--unit=` with a name one of those lists holds, and a unit this machine does not have, or knows only as not found, is refused before `journalctl` runs. Linux only. |
|
||||||
|
| | `observe_reads_linux_test.go`: `TestObserveLoginsRunsItsPinnedArgv` | `observe:logins` runs one pinned `journalctl` whose only caller value is a whole number of hours; without access to the system journal it runs nothing and its counts read -1, never 0. Linux only. |
|
||||||
|
| | `observe_reads_linux_test.go`: `TestObserveLoginsCountsOnlyRootsSSHDLines` | Every match `observe:logins` gives `journalctl` also needs `_UID=0`, so a line another account wrote under a process named sshd, or from a shell left in ssh.service, is never counted. Linux only. |
|
||||||
|
| | `observe_reads_linux_test.go`: `TestObserveFoldersReadsOnlyTheFolderItStatd` | A folder swapped for a link between the walk's stat and its open, or reached through a folder above it that was, is not read and is listed as changed. Linux only. |
|
||||||
|
| | `observe_reads_linux_test.go`: `TestObserveFoldersStartsNoWalkPastTheStuckOnes` | Past four walks still inside a read that has not returned, `observe:folders` answers at once and starts no walk; a walk that comes back frees its place. Linux only. |
|
||||||
|
| | `observe_reads_test.go`: `TestObserveReadsRefuseBadValuesWithTheRule` | A relative folder path, one with `..` or under `/proc`, a unit name outside systemd's alphabet, shaped like a flag or naming a template, and a depth, window or port out of range are refused at exit 2 with the rule broken, without repeating the value. |
|
||||||
|
| | `fuzz_observe_test.go`: `FuzzFolderPathIsAbsoluteCleanAndOffTheKernelTrees` | Whatever the input, a folder path that passes is absolute and clean, holds no `..` and no control character, and is outside `/proc`, `/sys` and `/dev`. |
|
||||||
|
| | `fuzz_observe_test.go`: `FuzzUnitNameIsSystemdsAlphabet` | Whatever the input, a unit name that passes is in systemd's unit-name alphabet, at most 255 characters, ends in a unit type, does not start with `-`, `.` or `@`, and is the input itself or the input with `.service`. |
|
||||||
| | `claims_unix_test.go`: `TestTheAllowlistedOpIgnoresTheCallersArguments` | `uptime`, sent through the management lane with hostile arguments, runs its program with none. |
|
| | `claims_unix_test.go`: `TestTheAllowlistedOpIgnoresTheCallersArguments` | `uptime`, sent through the management lane with hostile arguments, runs its program with none. |
|
||||||
| | `claims_static_test.go`: `TestTheAllowlistedOpRunsItsTableArgvOnly` | The allowlisted table is exactly `uptime` with no arguments, and the one command the lane starts is built from the table's argv alone. |
|
| | `claims_static_test.go`: `TestTheAllowlistedOpRunsItsTableArgvOnly` | The allowlisted table is exactly `uptime` with no arguments, and the one command the lane starts is built from the table's argv alone. |
|
||||||
| | `claims_static_test.go`: `TestFileHandlersStartNoShell` | Nothing a context, store or backup handler can reach runs a shell; the one process any of them can start is the local search agent's Python, which `dcn_grant_sync` brings up. |
|
| | `claims_static_test.go`: `TestFileHandlersStartNoShell` | Leaving text extraction (`ctx_extract`) aside, nothing a context, store or backup handler can reach runs a shell, and the one process any of them can start is the local search agent's Python, which `dcn_grant_sync` brings up. |
|
||||||
| Approval on the machine, once enrolled | `stepup_test.go`: `TestStepupVerifyBurnAndApprovals` | With no factor enrolled the gate passes; once enrolled, a code is used once, and an approval passes only for the exact terms it was issued for, and only once. |
|
| Approval on the machine, once enrolled | `stepup_test.go`: `TestStepupVerifyBurnAndApprovals` | With no factor enrolled the gate passes; once enrolled, a code is used once, and an approval passes only for the exact terms it was issued for, and only once. |
|
||||||
| | `modelswap_test.go`: `TestAnApprovalIsSingleUse` | A spent approval does not fund a second model download. |
|
| | `modelswap_test.go`: `TestAnApprovalIsSingleUse` | A spent approval does not fund a second model download. |
|
||||||
| | `modelswap_test.go`: `TestUnenrolledMachineCannotHaveAModelPulled` | With no factor enrolled, a model pull is refused. |
|
| | `modelswap_test.go`: `TestUnenrolledMachineCannotHaveAModelPulled` | With no factor enrolled, a model pull is refused. |
|
||||||
@@ -101,6 +110,24 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `ops_action_linux_test.go`: `TestActionApplyOfAnotherChangeListRunsNothing` | An apply whose change list differs from the approved one is refused before the program starts. Skipped where action ops cannot run. |
|
| | `ops_action_linux_test.go`: `TestActionApplyOfAnotherChangeListRunsNothing` | An apply whose change list differs from the approved one is refused before the program starts. Skipped where action ops cannot run. |
|
||||||
| | `ops_action_test.go`: `TestActionManifestRefusesWhatIsNotDeclaredOrNotBounded` | An unknown verb or argument, a workload, container, unit or tree named by a parameter, an undeclared credential, verbs without root, a destination on loopback, link-local or a metadata service (also inside NAT64 or IPv4-mapped IPv6), the bastion's own unit, a tree on a credential store, a tree or container moved twice by one op, and more, are each refused when the op arrives. |
|
| | `ops_action_test.go`: `TestActionManifestRefusesWhatIsNotDeclaredOrNotBounded` | An unknown verb or argument, a workload, container, unit or tree named by a parameter, an undeclared credential, verbs without root, a destination on loopback, link-local or a metadata service (also inside NAT64 or IPv4-mapped IPv6), the bastion's own unit, a tree on a credential store, a tree or container moved twice by one op, and more, are each refused when the op arrives. |
|
||||||
| | `ops_action_test.go`: `TestSealedCredentialsOpenOnlyForThisRunAndThisOp` | A credential sealed for another run, to another machine, tampered, expired, undeclared, missing or sent twice is refused. |
|
| | `ops_action_test.go`: `TestSealedCredentialsOpenOnlyForThisRunAndThisOp` | A credential sealed for another run, to another machine, tampered, expired, undeclared, missing or sent twice is refused. |
|
||||||
|
| A standing grant holds to its bounds on the machine | `ops_skill_bounds_test.go`: `TestGrantOutsideItsWindowIsNotNowAndSpendsNothing` | An apply outside the grant's window (days, hours, zone, read on the machine's clock) does not run, spends no use and is no strike, and says when the window opens; a dry run is not held; arguments outside the op are still refused. Inside the window it runs and spends one use. |
|
||||||
|
| | `ops_skill_bounds_test.go`: `TestGrantInsideItsMinIntervalDoesNotRun` | An apply inside the approved interval since the last one does not run, spends nothing and is no strike; past it, it runs. |
|
||||||
|
| | `ops_skill_bounds_test.go`: `TestGrantPerDayHoldsAcrossARestart` | The runs are logged in the grant's record on disk, and another process reading that record holds the third run in 24 hours as the first did. |
|
||||||
|
| | `ops_skill_bounds_test.go`: `TestGrantWithATamperedRunLogIsNoGrant` | A record whose run log, uses, approver or seal was edited outside the bastion, or that carries no seal, holds no grant. |
|
||||||
|
| | `ops_skill_bounds_test.go`: `TestGrantDiesWithTheKeyThatApprovedIt` | A grant keeps the key that approved it; with that device key unpinned, or the console factor enrolled again, it is refused, and unpinning drops it so pinning the key again does not bring it back. |
|
||||||
|
| | `ops_skill_bounds_test.go`: `TestGrantDropGoesThroughAQuarantine` | `op_grant_drop`, of one grant or all, goes through while the host's writes are quarantined, with no approval. |
|
||||||
|
| | `fuzz_grants_test.go`: `FuzzAGrantsRunBoundsHoldWhateverTheHostSends` | A window or rate the machine accepts is one it can hold, and no run inside an interval or past a day's count is let through. |
|
||||||
|
| An op with no program needs no sandbox, and a program still does | `ops_skill_bounds_test.go`: `TestProgramOpStillRefusedWithoutTheSandbox` | On a machine with no overlay, a files op and an action op with a program are refused before anything runs. |
|
||||||
|
| | `ops_skill_bounds_test.go`: `TestVerbsOnlyOpShippingRunIsRefused` | An op with `interpreter: none` that ships a `run` file is refused at sync; without one it is held. |
|
||||||
|
| | `ops_verbs_only_linux_test.go`: `TestVerbsOnlyOpRunsWithNoOverlay` | On a machine with no overlay, an op with no program dry-runs as its verbs' own read, applies its verbs once under an approval bound to that list, and is refused, as the machine's own state and no strike, where a verb's tool is missing. |
|
||||||
|
| On your own machine, an action op reaches the internet, never your network | `ops_action_local_test.go`: `TestALocalBoxRefusesAnActionOpThatDeclaresAPrivateNetwork` | Through `op_sync` on a local, unanchored machine: an op declaring `10.0.0.2:80`, a `192.168.0.0/16`, `172.16.0.0/12`, CGNAT, ULA or benchmarking address, or `10.0.0.2` inside NAT64 or 6to4, is refused with the reason and what to do instead, and nothing is held; a public address and a name are held. |
|
||||||
|
| | `ops_action_local_linux_test.go`: `TestALocalBoxRefusesANameThatResolvesToAPrivateNetworkAtApply` | On a local machine a name that resolves to a private address, alone or beside a public one, is refused when an apply resolves its destinations; a public name resolves to its address; on an anchored machine the private name resolves. |
|
||||||
|
| | `ops_action_local_linux_test.go`: `TestAPrivateDestinationHeldWhileAnchoredIsRefusedAtApplyOnceTheAnchorIsGone` | An op whose destination is `10.0.0.2:80` or its IPv4-mapped form resolves at apply on an anchored machine; once the machine is local, the same destinations are refused at apply. |
|
||||||
|
| | `ops_action_local_linux_test.go`: `TestALocalBoxNeverFetchesAVerbsRepositoryFromAPrivateNetwork` | On a local machine a verb's fetch (the ancestry check `k8s.set_image` makes) of a repository whose host resolves to a private address, to a public and an IPv4-mapped private one, or to a link-local one is refused with the reason before git runs; a public host is fetched with git pinned to the checked address (IPv6 bracketed, the URL's port kept), a git that would drop the pin is refused, and an anchored machine fetches from its private forge unpinned. |
|
||||||
|
| | `ops_action_local_linux_test.go`: `TestAnAnchoredBoxNeverFetchesAVerbsRepositoryFromItselfOrTheMetadataService` | On an anchored machine a verb's fetch (the ancestry check `k8s.set_image` makes) of a repository whose host resolves to loopback, to a public address beside an IPv4-mapped loopback one, to a link-local address, to a metadata service or to this machine's own address is refused with the reason before git runs; a private forge is fetched pinned to its address. |
|
||||||
|
| | `ops_action_local_linux_test.go`: `TestALocalBoxNeverPutsLiveAnImageFromARegistryOnItsNetwork` | On a local machine `k8s.set_image`'s plan and apply refuse an image whose registry is a name resolving to a private address, a private address, `localhost` or a link-local address, with the reason, and set nothing; a public registry and a Docker Hub repository pass the registry check; an anchored machine keeps its private registry. |
|
||||||
|
| | `ops_verbs_only_linux_test.go`: `TestSetImageNeedsGitForItsAncestryCheck` | `k8s.set_image` names kubectl and git as the tools it runs, and `tree.checkout` git; as root, a machine with kubectl and no git is told by the verb check and the beat that it cannot run `k8s.set_image`. |
|
||||||
|
| | `ops_action_local_test.go`: `TestAnAnchoredFleetKeepsItsPrivateDestinations` | Through `op_sync` on an anchored machine: ops declaring a private, CGNAT or ULA address are held; loopback is still refused. |
|
||||||
| Root verbs move only forward | `ops_verbs_linux_test.go`: `TestSetImageTouchesOnlyTheNamedContainerAndOnlyForward` | Against a stand-in kubectl: a digest with no receipt, a receipt from another repository, a container the workload lacks, a live image this machine never rolled (a receipt for it included), another image and a backward commit are each refused with nothing set; a forward roll sets exactly the named container and records it; an image already set still waits for its rollout, and fails when it does not finish. The ancestry answer is a stand-in here. |
|
| Root verbs move only forward | `ops_verbs_linux_test.go`: `TestSetImageTouchesOnlyTheNamedContainerAndOnlyForward` | Against a stand-in kubectl: a digest with no receipt, a receipt from another repository, a container the workload lacks, a live image this machine never rolled (a receipt for it included), another image and a backward commit are each refused with nothing set; a forward roll sets exactly the named container and records it; an image already set still waits for its rollout, and fails when it does not finish. The ancestry answer is a stand-in here. |
|
||||||
| | `ops_verbs_linux_test.go`: `TestAncestryByFetchRefusesABackwardOrSidewaysCommit` | With real git, the ancestry check `k8s.set_image` makes accepts a forward commit or the same one, and refuses a backward, sideways or unknown commit. |
|
| | `ops_verbs_linux_test.go`: `TestAncestryByFetchRefusesABackwardOrSidewaysCommit` | With real git, the ancestry check `k8s.set_image` makes accepts a forward commit or the same one, and refuses a backward, sideways or unknown commit. |
|
||||||
| | `ops_verbs_linux_test.go`: `TestTreeCheckoutMovesOnlyForward` | With real git: a tree moves forward to the exact commit, runs none of its own hooks, leaves untracked files and nested repositories alone, and refuses a backward or sideways commit, another origin, local changes and a config that could run a program. |
|
| | `ops_verbs_linux_test.go`: `TestTreeCheckoutMovesOnlyForward` | With real git: a tree moves forward to the exact commit, runs none of its own hooks, leaves untracked files and nested repositories alone, and refuses a backward or sideways commit, another origin, local changes and a config that could run a program. |
|
||||||
@@ -140,7 +167,7 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. The reach check then admits the granted host to the granted server only, and a revoked grant admits nothing. Called directly, not through the slot listener. |
|
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. The reach check then admits the granted host to the granted server only, and a revoked grant admits nothing. Called directly, not through the slot listener. |
|
||||||
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: each connection is checked, and one without a reach grant for that server, or without the `services` family, never reaches the server's socket. |
|
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: each connection is checked, and one without a reach grant for that server, or without the `services` family, never reaches the server's socket. |
|
||||||
| | not covered by a test | The reach check's own comparison of a stored grant's machine. |
|
| | not covered by a test | The reach check's own comparison of a stored grant's machine. |
|
||||||
| Egress for someone else reaches the public internet, never this machine | `coders_linux_test.go`: `TestCoderEgressRefusesEveryInternalClass` | A coder run's proxy refuses every internal class (loopback, RFC 1918, CGNAT, link-local and metadata, ULA, IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64, 6to4, unspecified, 0/8, 198.18/15, 240/4, multicast, fec0::/10), by address and by a name with one such answer among public ones, and `localhost` names and names that do not resolve. |
|
| Egress for someone else reaches the public internet, and nothing on this machine or your network that you did not name | `coders_linux_test.go`: `TestCoderEgressRefusesEveryInternalClass` | A coder run's proxy refuses every internal class (loopback, RFC 1918, CGNAT, link-local and metadata, ULA, IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64, 6to4, unspecified, 0/8, 198.18/15, 240/4, multicast, fec0::/10), by address and by a name with one such answer among public ones, and `localhost` names and names that do not resolve. |
|
||||||
| | `coders_linux_test.go`: `TestCoderEgressRefusesThisMachinesOwnPublicAddress` | A public address on this machine's interface is refused in every spelling (IPv4-mapped, NAT64, 6to4) and through a name; a neighbour on the same subnet passes; `allow_lan` is the one exception. |
|
| | `coders_linux_test.go`: `TestCoderEgressRefusesThisMachinesOwnPublicAddress` | A public address on this machine's interface is refused in every spelling (IPv4-mapped, NAT64, 6to4) and through a name; a neighbour on the same subnet passes; `allow_lan` is the one exception. |
|
||||||
| | `coders_linux_test.go`: `TestCoderEgressLocalAddressesAreReread` | An address the machine gains after start is refused; interfaces that cannot be read refuse. |
|
| | `coders_linux_test.go`: `TestCoderEgressLocalAddressesAreReread` | An address the machine gains after start is refused; interfaces that cannot be read refuse. |
|
||||||
| | `coders_linux_test.go`: `TestCoderEgressDialsTheVettedAddressAndHonoursAllowLAN` | The proxy returns the vetted addresses, IPv4 first, never the name; a LAN host passes only when named in `allow_lan`, and an entry with a port only on that port. |
|
| | `coders_linux_test.go`: `TestCoderEgressDialsTheVettedAddressAndHonoursAllowLAN` | The proxy returns the vetted addresses, IPv4 first, never the name; a LAN host passes only when named in `allow_lan`, and an entry with a port only on that port. |
|
||||||
@@ -155,9 +182,9 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `egress_network_test.go`: `TestLocalEgressRefusesTheLANAndTheMesh` | On a local machine the egress lane refuses the network around it as an anchored one does: private, ULA, CGNAT and benchmarking addresses, a NAT64 address for one, and a name with any one such answer, each answered 403 through the lane with nothing dialled; a public name passes at its vetted address. |
|
| | `egress_network_test.go`: `TestLocalEgressRefusesTheLANAndTheMesh` | On a local machine the egress lane refuses the network around it as an anchored one does: private, ULA, CGNAT and benchmarking addresses, a NAT64 address for one, and a name with any one such answer, each answered 403 through the lane with nothing dialled; a public name passes at its vetted address. |
|
||||||
| | `ownaddr_inside_linux_test.go`: `TestNetworksInsideThisMachineAreThisMachine` | In a private network namespace with two bridges whose only port is a veth (one with an IPv4 and an IPv6 range, one with a public range), a bridge whose port is a dummy, a bridge with no port, a veth routed to directly, a range routed through a veth with this machine's own address on it as the gateway, a veth whose only default route is in another table, a bridge over a VLAN, a route over a dummy link, the network on three veths that carry this machine's main-table default routes (an IPv4 multipath default over two, an IPv6 default over the third: this machine as a container), addresses through those defaults' gateways and a route through a gateway on the dummy-port bridge (a LAN behind a router VM): the real route lookup reads the first seven as this machine and the rest as leaving; a local machine's egress lane refuses the inside addresses, by address and by name, as this machine, refuses the private addresses beyond them as its network, and dials the public ones; a coder run's proxy refuses the public range on an inside network and dials a public address through the gateway; the backup lanes allow the IPv4 and IPv6 addresses through the gateway. |
|
| | `ownaddr_inside_linux_test.go`: `TestNetworksInsideThisMachineAreThisMachine` | In a private network namespace with two bridges whose only port is a veth (one with an IPv4 and an IPv6 range, one with a public range), a bridge whose port is a dummy, a bridge with no port, a veth routed to directly, a range routed through a veth with this machine's own address on it as the gateway, a veth whose only default route is in another table, a bridge over a VLAN, a route over a dummy link, the network on three veths that carry this machine's main-table default routes (an IPv4 multipath default over two, an IPv6 default over the third: this machine as a container), addresses through those defaults' gateways and a route through a gateway on the dummy-port bridge (a LAN behind a router VM): the real route lookup reads the first seven as this machine and the rest as leaving; a local machine's egress lane refuses the inside addresses, by address and by name, as this machine, refuses the private addresses beyond them as its network, and dials the public ones; a coder run's proxy refuses the public range on an inside network and dials a public address through the gateway; the backup lanes allow the IPv4 and IPv6 addresses through the gateway. |
|
||||||
| | `ownaddr_inside_linux_test.go`: `TestInsideMachineReadsTheLinkAndABridgesPorts` | A veth, and a bridge whose every port is a veth, a tap or a dummy (or that has none), keep a packet on this machine; a bridge with a network card, a VLAN or a bridge among its ports, a network card, loopback, a VPN, a tunnel and a macvlan do not. |
|
| | `ownaddr_inside_linux_test.go`: `TestInsideMachineReadsTheLinkAndABridgesPorts` | A veth, and a bridge whose every port is a veth, a tap or a dummy (or that has none), keep a packet on this machine; a bridge with a network card, a VLAN or a bridge among its ports, a network card, loopback, a VPN, a tunnel and a macvlan do not. |
|
||||||
| | `egress_local_test.go`: `TestLocalEgressLaneNeverDialsThisMachine` | Through the lane on a local machine with a real dialer: a request for a loopback listener gets 403 over CONNECT and plain HTTP and never reaches it; a LAN name is dialled at its vetted address; a dual-stack name falls back to its second address. |
|
| | `egress_local_test.go`: `TestLocalEgressLaneNeverDialsThisMachine` | Through the lane on a local machine with a real dialer: a request for a loopback listener gets 403 over CONNECT and plain HTTP and never reaches it; a public name is dialled at its vetted address; a dual-stack name falls back to its second address. |
|
||||||
| | `egress_dial_test.go`: `TestDialEachDoesNotWaitOutAnAddressThatGoesNowhere` | The dial both egress lanes share tries only the vetted addresses, in order; an address that never answers holds up the next for well under its share of the budget, and is cancelled once the next answers. |
|
| | `egress_dial_test.go`: `TestDialEachDoesNotWaitOutAnAddressThatGoesNowhere` | The dial both egress lanes share tries only the vetted addresses, in order; an address that never answers holds up the next for well under its share of the budget, and is cancelled once the next answers. |
|
||||||
| | `egress_relay_test.go`: `TestARefusingHostCannotSpinTheEgressWorkers` | A Host that answers every parked egress worker with a job this machine refuses (a request for `localhost`, a line that is no request) gets at most four workers a second back, not a dial loop, and nothing is dialled out. |
|
| | `egress_relay_test.go`: `TestARefusingHostCannotSpinTheEgressWorkers` | A Host that answers every parked egress worker with a job this machine refuses (a request for `localhost`, a line that is no request) gets at most 16 workers back in two seconds, not a dial loop, and nothing is dialled out. |
|
||||||
| | `egress_relay_test.go`: `TestEgressWorkersParkAgainAtOnceAfterAJobTheyTook` | A worker that took a job parks again at once: 40 accepted jobs through the four workers are each answered 200 well inside the 10 s one wait per job would take. |
|
| | `egress_relay_test.go`: `TestEgressWorkersParkAgainAtOnceAfterAJobTheyTook` | A worker that took a job parks again at once: 40 accepted jobs through the four workers are each answered 200 well inside the 10 s one wait per job would take. |
|
||||||
| | `egress_local_test.go`: `TestLocalModelsSwitchLeavesNoEgressRouteToOllama` | On a local machine with `models` switched off for the host, the ollama forward filters, and the egress lane refuses `/api/pull` to ollama on loopback, `localhost` and this machine's LAN address without a dial. |
|
| | `egress_local_test.go`: `TestLocalModelsSwitchLeavesNoEgressRouteToOllama` | On a local machine with `models` switched off for the host, the ollama forward filters, and the egress lane refuses `/api/pull` to ollama on loopback, `localhost` and this machine's LAN address without a dial. |
|
||||||
| | `egress_local_test.go`: `TestAnchoredEgressStillRefusesTheLAN` | An anchored machine's egress lane still refuses the LAN addresses a local machine reaches, with its own wording, and passes the public internet. |
|
| | `egress_local_test.go`: `TestAnchoredEgressStillRefusesTheLAN` | An anchored machine's egress lane still refuses the LAN addresses a local machine reaches, with its own wording, and passes the public internet. |
|
||||||
@@ -177,7 +204,7 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| A pairing code pins the Host's CA by its fingerprint | `pair_code_linux_test.go`: `TestAPairingCodePinsTheHostCAByFingerprint` | A pairing code whose enroll port presents no CA, another CA, a CA under another fingerprint, a leaf another CA signed, or a leaf without the name `host.cseries.internal` is refused before the token is spent, and nothing is saved. |
|
| A pairing code pins the Host's CA by its fingerprint | `pair_code_linux_test.go`: `TestAPairingCodePinsTheHostCAByFingerprint` | A pairing code whose enroll port presents no CA, another CA, a CA under another fingerprint, a leaf another CA signed, or a leaf without the name `host.cseries.internal` is refused before the token is spent, and nothing is saved. |
|
||||||
| | `pair_code_linux_test.go`: `TestAPairingCodePairsAsTheHostCAFileDoes` | A good code makes the same TCP pairing `--redeem-url` and `--host-ca` make, with that CA pinned; the same code again is "already paired" (exit 3) and changes nothing; a Host of the same name under another CA is a plain refusal. |
|
| | `pair_code_linux_test.go`: `TestAPairingCodePairsAsTheHostCAFileDoes` | A good code makes the same TCP pairing `--redeem-url` and `--host-ca` make, with that CA pinned; the same code again is "already paired" (exit 3) and changes nothing; a Host of the same name under another CA is a plain refusal. |
|
||||||
| | `pair_code_linux_test.go`: `TestAPairingCodeIsReadStrictly` | A code that is garbled, another version, or has an extra, missing or malformed field is refused, and no refusal repeats the token. |
|
| | `pair_code_linux_test.go`: `TestAPairingCodeIsReadStrictly` | A code that is garbled, another version, or has an extra, missing or malformed field is refused, and no refusal repeats the token. |
|
||||||
| | `pair_code_linux_test.go`: `TestACodeTheHostMadeDecodesAndPins` | A code made by the C-Series Host's own `pairing_code` decodes to its address and token, and its fingerprint is the one `caSHA256` computes from the same CA. |
|
| | `pair_code_linux_test.go`: `TestACodeTheHostMadeDecodesAndPins` | A recorded code from the C-Series Host's `pairing_code` decodes to its address and token, and its fingerprint is the one `caSHA256` computes from the same CA. |
|
||||||
| | `pair_code_linux_test.go`: `TestAPairingCodeWithAKeyGivenTwiceIsRefused` | A code that names a field twice is refused. |
|
| | `pair_code_linux_test.go`: `TestAPairingCodeWithAKeyGivenTwiceIsRefused` | A code that names a field twice is refused. |
|
||||||
| | `pair_code_linux_test.go`: `TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint` | A certificate with the fingerprint that is not a CA, an expired CA, or a chain of the leaf alone is refused; the pinned CA after other certificates is the one kept. Called directly, not through TLS. |
|
| | `pair_code_linux_test.go`: `TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint` | A certificate with the fingerprint that is not a CA, an expired CA, or a chain of the leaf alone is refused; the pinned CA after other certificates is the one kept. Called directly, not through TLS. |
|
||||||
| | `pair_code_linux_test.go`: `TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired` | With `--host-ca` too, a Host of the same name under another CA is a plain refusal, not exit 3, and its token is not spent; the same Host under its own CA is exit 3. |
|
| | `pair_code_linux_test.go`: `TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired` | With `--host-ca` too, a Host of the same name under another CA is a plain refusal, not exit 3, and its token is not spent; the same Host under its own CA is exit 3. |
|
||||||
@@ -210,10 +237,10 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `rootexec_test.go`: `TestRootExecStopsWhenAWallFallsMidRun` | Through the handler: a command already running is killed within one recheck when its grant is revoked or expires, `session` is switched off here, or the pairing is quarantined, and answered with 126 and a J that says why; while every wall holds, the recheck leaves it alone. |
|
| | `rootexec_test.go`: `TestRootExecStopsWhenAWallFallsMidRun` | Through the handler: a command already running is killed within one recheck when its grant is revoked or expires, `session` is switched off here, or the pairing is quarantined, and answered with 126 and a J that says why; while every wall holds, the recheck leaves it alone. |
|
||||||
| | `rootexec_test.go`: `TestRootExecDeadlineKillsAChildThatIgnoresSIGTERM` | Through the handler with the real `/bin/sh`: at the deadline a child that ignores SIGTERM, holding the output open or not, is gone when the answer arrives, within the deadline and the kill grace. Every signal goes through a fence that admits only the process group the test started. |
|
| | `rootexec_test.go`: `TestRootExecDeadlineKillsAChildThatIgnoresSIGTERM` | Through the handler with the real `/bin/sh`: at the deadline a child that ignores SIGTERM, holding the output open or not, is gone when the answer arrives, within the deadline and the kill grace. Every signal goes through a fence that admits only the process group the test started. |
|
||||||
| | `rootexec_test.go`: `TestRootExecBadCommandAndTranscriptFailureAreAudited` | A bad command, a bad session id, and a transcript that cannot be opened each leave an audit row with the command (or its size and sha256) and exit 1; none starts. |
|
| | `rootexec_test.go`: `TestRootExecBadCommandAndTranscriptFailureAreAudited` | A bad command, a bad session id, and a transcript that cannot be opened each leave an audit row with the command (or its size and sha256) and exit 1; none starts. |
|
||||||
| | `rootexec_watch_linux_test.go`: `TestRootExecWatchKernel` | In a private network namespace, through the handler with the real `/bin/sh`, nftables and a cgroup: a command's connections to outside addresses (IPv4 and IPv6, TCP and UDP) are reported in `untrusted_connections`; loopback, the machine's own address and listed platform ranges are not, though they stay in the audit row marked own; a grandchild's connections are caught; what a command reached before its deadline killed it is reported; no table or cgroup is left after any of them, and one left by a dead bastion is removed by the next sweep, with any process in it moved to its parent. |
|
| | `rootexec_watch_linux_test.go`: `TestRootExecWatchKernel` | In a private network namespace, through the handler with the real `/bin/sh`, nftables and a cgroup: a command's connections to outside addresses (IPv4 and IPv6, TCP and UDP) are reported in `untrusted_connections`; loopback (except a port listed as a relay), the machine's own address and listed platform ranges are not, though they stay in the audit row marked own; a grandchild's connections are caught; what a command reached before its deadline killed it is reported; no table or cgroup is left after any of them, and one left by a dead bastion is removed by the next sweep, with any process in it moved to its parent. |
|
||||||
| | `rootexec_watch_test.go`: `TestRootExecJCarriesOnlyOutsideConnections` | The J frame lists only the destinations outside the platform list (addresses, CIDRs and resolved names), the stop row lists every one and counts the outside ones, the transcript records them; with only loopback and own hosts the field is absent. |
|
| | `rootexec_watch_test.go`: `TestRootExecJCarriesOnlyOutsideConnections` | The J frame lists only the destinations outside the platform list (addresses, CIDRs and resolved names), the stop row lists every one and counts the outside ones, the transcript records them; with only loopback and own hosts the field is absent. |
|
||||||
| | `rootexec_watch_test.go`: `TestRootExecUnwatchedCountsAsOutside` | A command whose connections could not be watched is reported with one untrusted entry that says why. |
|
| | `rootexec_watch_test.go`: `TestRootExecUnwatchedCountsAsOutside` | A command whose connections could not be watched is reported with one untrusted entry that says why. |
|
||||||
| | `rootexec_watch_test.go`: `TestRootExecOwnHostsList` | No list means only loopback is own; a broken list or a bad entry reads as empty; a name that does not resolve adds nothing; the host's list is capped and the rest counted. |
|
| | `rootexec_watch_test.go`: `TestRootExecOwnHostsList` | No list means only loopback is own; a broken list or a bad entry reads as empty; a name that does not resolve adds nothing; the outside connections reported are capped and the rest counted. |
|
||||||
| | `claims_unix_test.go`: `TestAMemberWithoutTheSessionFamilyGetsNoTerminal` | Through the terminal handler, on an anchored machine: a host other than the anchor without the `session` family is refused and leaves no transcript; once the family is granted the same approval opens the terminal. |
|
| | `claims_unix_test.go`: `TestAMemberWithoutTheSessionFamilyGetsNoTerminal` | Through the terminal handler, on an anchored machine: a host other than the anchor without the `session` family is refused and leaves no transcript; once the family is granted the same approval opens the terminal. |
|
||||||
| | `claims_unix_test.go`: `TestAnAnchoredTerminalApprovalOpensOneSessionOnce` | Through the handler: an anchored machine's terminal approval is refused for another session, opens its own, and is refused when used again. |
|
| | `claims_unix_test.go`: `TestAnAnchoredTerminalApprovalOpensOneSessionOnce` | Through the handler: an anchored machine's terminal approval is refused for another session, opens its own, and is refused when used again. |
|
||||||
| | `claims_unix_test.go`: `TestARootTerminalNeedsTheBastionToRunAsRoot` | Through the handler: with a factor enrolled and an approval for that session, a bastion not running as root refuses a root terminal, and the same approval opens it once the bastion runs as root. |
|
| | `claims_unix_test.go`: `TestARootTerminalNeedsTheBastionToRunAsRoot` | Through the handler: with a factor enrolled and an approval for that session, a bastion not running as root refuses a root terminal, and the same approval opens it once the bastion runs as root. |
|
||||||
@@ -227,7 +254,7 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| On macOS and Windows, a networked bastion is the system's service | `sysservice_test.go`: `TestLaunchdPlistRunsTheDaemonAsRootAtBootAndKeepsItAlive` | The launchd job is well-formed and runs the program as root at boot, with root's home, and launchd restarts it whenever it exits. |
|
| On macOS and Windows, a networked bastion is the system's service | `sysservice_test.go`: `TestLaunchdPlistRunsTheDaemonAsRootAtBootAndKeepsItAlive` | The launchd job is well-formed and runs the program as root at boot, with root's home, and launchd restarts it whenever it exits. |
|
||||||
| | `sysservice_test.go`: `TestBinaryChainMustBeRootOnly` | On macOS the service is refused for a program a non-root user owns, that a group or everyone can write, that is a symlink, or that sits under such a directory. Run against a stand-in for the disk, not on a Mac. |
|
| | `sysservice_test.go`: `TestBinaryChainMustBeRootOnly` | On macOS the service is refused for a program a non-root user owns, that a group or everyone can write, that is a symlink, or that sits under such a directory. Run against a stand-in for the disk, not on a Mac. |
|
||||||
| | `sysservice_test.go`: `TestWindowsServiceBinaryMustLiveUnderProgramFiles` | On Windows the service is refused for a program outside Program Files, including the per-user install folder. |
|
| | `sysservice_test.go`: `TestWindowsServiceBinaryMustLiveUnderProgramFiles` | On Windows the service is refused for a program outside Program Files, including the per-user install folder. |
|
||||||
| | `sysservice_test.go`: `TestServiceGuardRefusesAUserspaceBastionBesideTheService` | With the service installed, `pair`, `run` and `status` from an account without machine authority are refused; an administrator, and a machine with no service, are not. |
|
| | `sysservice_test.go`: `TestServiceGuardRefusesAUserspaceBastionBesideTheService` | With the service installed, `pair`, `run`, `status` and `switch` from an account without machine authority are refused; an administrator, and a machine with no service, are not. |
|
||||||
| | `sysservice_test.go`: `TestServiceModeOpensNothingOnTheDesktop` | As a Windows service, the tray, the blocking dialog and the detached relaunch after an update each return before doing anything. Read from the source, not run. |
|
| | `sysservice_test.go`: `TestServiceModeOpensNothingOnTheDesktop` | As a Windows service, the tray, the blocking dialog and the detached relaunch after an update each return before doing anything. Read from the source, not run. |
|
||||||
| | `runlock_unix_test.go`: `TestARunBesideTheMacServiceIsRefusedBeforeItStarts` | With the macOS daemon installed, a second `run` is refused while the first holds the lock in the state folder, and may run once it is gone; with no daemon nothing is locked. Run against the real lock on Linux; the macOS wiring (`runguard_darwin.go`) is not run off a Mac. |
|
| | `runlock_unix_test.go`: `TestARunBesideTheMacServiceIsRefusedBeforeItStarts` | With the macOS daemon installed, a second `run` is refused while the first holds the lock in the state folder, and may run once it is gone; with no daemon nothing is locked. Run against the real lock on Linux; the macOS wiring (`runguard_darwin.go`) is not run off a Mac. |
|
||||||
| | not covered by a test | That launchd and the Windows service manager actually restart the bastion after it exits (`sysservice_darwin.go`, `sysservice_windows.go`); neither runs off its own platform. |
|
| | not covered by a test | That launchd and the Windows service manager actually restart the bastion after it exits (`sysservice_darwin.go`, `sysservice_windows.go`); neither runs off its own platform. |
|
||||||
@@ -237,6 +264,7 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| Observation changes nothing on the machine | `observe_endpoints_test.go`: `TestObserveEndpointAllowedIsTheInverseOfObserveNet` | The monitoring ops may reach only private addresses, exactly the ones `observe:net` refuses. |
|
| Observation changes nothing on the machine | `observe_endpoints_test.go`: `TestObserveEndpointAllowedIsTheInverseOfObserveNet` | The monitoring ops may reach only private addresses, exactly the ones `observe:net` refuses. |
|
||||||
| | `observe_endpoints_test.go`: `TestObserveDialControlRefusesPublicAtConnectTime` | A public address is refused at the socket, so a DNS answer cannot move a query outside. |
|
| | `observe_endpoints_test.go`: `TestObserveDialControlRefusesPublicAtConnectTime` | A public address is refused at the socket, so a DNS answer cannot move a query outside. |
|
||||||
| | `observe_endpoints_test.go`: `TestObserveNeverFollowsARedirect` | A redirect from a monitoring endpoint is reported as a failure, and its target is never fetched. |
|
| | `observe_endpoints_test.go`: `TestObserveNeverFollowsARedirect` | A redirect from a monitoring endpoint is reported as a failure, and its target is never fetched. |
|
||||||
|
| | `observe_reads_linux_test.go`: `TestObserveCertReadsOnlyThisMachinesListener` | `observe:cert` is refused for a port no listener on this machine holds, and never dials an address a listener is bound to that this machine does not hold. Linux only. |
|
||||||
| | `claims_static_test.go`: `TestObserveOpsWriteNothing` | Nothing an observe or computer op can reach writes, removes, renames, changes permissions on, mounts or signals anything, apart from the bastion's own audit line and its journal of observe results; the programs those ops run are a fixed list of reads. |
|
| | `claims_static_test.go`: `TestObserveOpsWriteNothing` | Nothing an observe or computer op can reach writes, removes, renames, changes permissions on, mounts or signals anything, apart from the bastion's own audit line and its journal of observe results; the programs those ops run are a fixed list of reads. |
|
||||||
| | `claims_static_test.go`: `TestNoInputInjection` | No input-injection program is run, no Windows input API is loaded, and no script the computer ops run asks for a keystroke, a click or an accessibility action. |
|
| | `claims_static_test.go`: `TestNoInputInjection` | No input-injection program is run, no Windows input API is loaded, and no script the computer ops run asks for a keystroke, a click or an accessibility action. |
|
||||||
| A backup's key goes to a host only when you approve it here | `backupreseal_test.go`: `TestBackupResealWaitsForThePersonThenDeliversToTheHostOnly` | Through the management lane: a request is filed pending and hands out nothing; another pairing cannot read it; after approval here the host receives the record sealed to its key only; a refusal hands over nothing; a decided request cannot be decided again. |
|
| A backup's key goes to a host only when you approve it here | `backupreseal_test.go`: `TestBackupResealWaitsForThePersonThenDeliversToTheHostOnly` | Through the management lane: a request is filed pending and hands out nothing; another pairing cannot read it; after approval here the host receives the record sealed to its key only; a refusal hands over nothing; a decided request cannot be decided again. |
|
||||||
@@ -272,7 +300,12 @@ says so. Those parts are enforced only by the code as written.
|
|||||||
| | `backuproot_test.go`: `TestBackupRootsAreEachHostsOwn` | A backup op is refused while the host's backup folder is the same as, inside, or contains another paired host's, also through a link. |
|
| | `backuproot_test.go`: `TestBackupRootsAreEachHostsOwn` | A backup op is refused while the host's backup folder is the same as, inside, or contains another paired host's, also through a link. |
|
||||||
| | `backuproot_test.go`: `TestBackupRootNeverOverlapsTheBastionsOwnState` | A backup folder that is or contains the bastion's configuration folder, keys or backup records, or sits in the configuration folder outside `backups/`, is refused, and none of them can be shared; the default folder is accepted. |
|
| | `backuproot_test.go`: `TestBackupRootNeverOverlapsTheBastionsOwnState` | A backup folder that is or contains the bastion's configuration folder, keys or backup records, or sits in the configuration folder outside `backups/`, is refused, and none of them can be shared; the default folder is accepted. |
|
||||||
| No delete op on shared stores | `dcn_plan_test.go`: `TestDcnPlanCheckApplyVerify` | A move plan that escapes the store, targets an existing path, or touches an excluded file is refused whole; the op list carries the plan op and no `dcn_delete` or `dcn_remove`. |
|
| No delete op on shared stores | `dcn_plan_test.go`: `TestDcnPlanCheckApplyVerify` | A move plan that escapes the store, targets an existing path, or touches an excluded file is refused whole; the op list carries the plan op and no `dcn_delete` or `dcn_remove`. |
|
||||||
| | `fuzz_paths_unix_test.go`: `FuzzMovePlanStaysInsideTheRoot` | Any move the plan check passes stays inside the store, targets a path that does not exist yet, and avoids excluded paths. |
|
| | `fuzz_paths_unix_test.go`: `FuzzMovePlanStaysInsideTheRoot` | Any move the plan check passes stays inside the store, targets a path that does not exist yet, and avoids excluded paths and the search agent's folder. |
|
||||||
|
| A shared folder serves only what its grant leaves in | `dcn_private_test.go`: `TestDcnTheDefaultGcloudExcludeHoldsOnEveryOp` | Through list, stat and read_range on a store granted with the Host's default excludes: nothing in `.config/gcloud`, at the top of the store or inside `home/`, is listed, stated or read, as written, with whitespace, through a `..` or through a link; a move out of or into it is refused; `.config/gcloudx` and the rest of `.config` still read. |
|
||||||
|
| | `dcn_private_test.go`: `TestDcnAMultiFolderExcludeMatchesThatRunAnywhere` | A pattern with a `/` matches that run of folders at any depth, with names compared as Linux, macOS and Windows compare them (case, and Windows' trailing dots and spaces); a near miss (`.config/gcloudx`, `.configx/gcloud`, the two folders apart or in the other order) is not excluded; slashes at either end or doubled name the same run; one-folder patterns match as before. Judged per platform from any OS. |
|
||||||
|
| | `fuzz_paths_unix_test.go`: `FuzzAnExcludedPathIsExcludedHoweverItIsSpelled` | Any path the sandbox accepts and the excludes pass opens neither an excluded name nor the search agent's folder, by its spelling or through a link inside the store. |
|
||||||
|
| | `dcn_private_test.go`: `TestDcnTheAgentsFolderIsNeverServed` | With a grant that excludes nothing: no path in `.agent` at the top of the store, or in one inside a folder of it, is listed, stated or read, as written, with whitespace, through a `..`, or through a link to the folder or to a file in it; a move out of or into one is refused; the bastion's own report of a plan it applied, through the real plan op, is still written to `.agent/plans`. |
|
||||||
|
| | `dcn_private_test.go`: `TestDcnTheAgentsFolderIsMatchedHoweverTheNameIsCased` | `.agent` is refused in any case on every OS, and on Windows with trailing dots, spaces or a stream name; `.agentx`, `agent` and `.agent-old` are not. Judged per platform from any OS. |
|
||||||
| Verified releases | `release_sign_test.go`: `TestATamperedManifestIsRefused` | A manifest changed after signing is refused. |
|
| Verified releases | `release_sign_test.go`: `TestATamperedManifestIsRefused` | A manifest changed after signing is refused. |
|
||||||
| | `release_sign_test.go`: `TestAManifestSignedByAnotherKeyIsRefused` | A manifest signed by a key the build does not pin is refused. |
|
| | `release_sign_test.go`: `TestAManifestSignedByAnotherKeyIsRefused` | A manifest signed by a key the build does not pin is refused. |
|
||||||
| | `release_sign_test.go`: `TestAnUnsignedManifestIsNotRead` | A manifest with no signature reaches neither the updater nor the local page. |
|
| | `release_sign_test.go`: `TestAnUnsignedManifestIsNotRead` | A manifest with no signature reaches neither the updater nor the local page. |
|
||||||
|
|||||||
+8
-2
@@ -28,16 +28,22 @@
|
|||||||
| `ops [--host <host>]` | The ops your host wrote that this machine holds, and the standing op grants. |
|
| `ops [--host <host>]` | The ops your host wrote that this machine holds, and the standing op grants. |
|
||||||
| `ops drop <grant> [--host <host>]` | Take back a standing op grant. The host is told. |
|
| `ops drop <grant> [--host <host>]` | Take back a standing op grant. The host is told. |
|
||||||
| `coders set [--max N] [--mem MB] [--cpus F] [--engines a,b\|all] [--allow-lan host[:port],...] [--image-prefix <registry/path/>\|default]` | Size this machine for coding runs your hosts place here: how many at once, memory and CPUs per run, which engines, LAN hosts a run may reach, and where run images may come from (`<prefix>harness-<engine>@sha256:...`; default `git.privatae.ai/privatae-harness/`). Does not switch them on. |
|
| `coders set [--max N] [--mem MB] [--cpus F] [--engines a,b\|all] [--allow-lan host[:port],...] [--image-prefix <registry/path/>\|default]` | Size this machine for coding runs your hosts place here: how many at once, memory and CPUs per run, which engines, LAN hosts a run may reach, and where run images may come from (`<prefix>harness-<engine>@sha256:...`; default `git.privatae.ai/privatae-harness/`). Does not switch them on. |
|
||||||
| `coders on` / `coders off` | Allow coding runs here (Linux, bastion running as root), filling unset sizes with defaults; or stop new ones. Off by default. Each run is a container with no network of its own; it reaches the public internet through this bastion and never this machine or its LAN. |
|
| `coders on` / `coders off` | Allow coding runs here (Linux, bastion running as root), filling unset sizes with defaults; or stop new ones. Off by default. Each run is a container with no network of its own. Through this bastion it reaches the public internet, and never this machine or its LAN unless you named that host with `--allow-lan`. |
|
||||||
| `coders drop [--host <host>]` | Take back the standing coder grant of every host, or one, and stop their runs. |
|
| `coders drop [--host <host>]` | Take back the standing coder grant of every host, or one, and stop their runs. |
|
||||||
| `coders status [--json]` | The coder settings, grants and runs on this machine. |
|
| `coders status [--json]` | The coder settings, grants and runs on this machine. |
|
||||||
| `quarantine clear <host>` | Reopen a host's write lanes that this machine closed after repeated refusals. |
|
| `quarantine clear <host>` | Reopen a host's write lanes that this machine closed after repeated refusals. |
|
||||||
| `git-setup` | Print the `git config` lines that route `git.privatae.ai` through this bastion. |
|
| `git-setup` | Print the `git config` lines that route `git.privatae.ai` through this bastion. |
|
||||||
| `git-credential` | Git credential helper. Fetches your forge token from your host on every auth. |
|
| `git-credential` | Git credential helper. Fetches your forge token from your host on every auth. |
|
||||||
| `unpair <host>` | Tear down one pairing. Context store and DCN state are left in place. |
|
| `unpair <host>` | Tear down one pairing. Context store and DCN state are left in place. |
|
||||||
| `set-mode <local\|networked>` | Switch modes. Root required. |
|
| `set-mode <local\|networked>` | Switch modes. `networked` needs root; `local` is refused while several hosts are paired or the machine is anchored. |
|
||||||
|
| `service install\|uninstall\|status` | On macOS and Windows, run the bastion as the system's own service. `install` and `uninstall` need machine authority. |
|
||||||
| `claim-anchor [--owner <name>] [--host <host>] [<token>]` | Networked mode: make a host the anchor. No token when the host is already paired here. Also `--rotate <token>`, `--repin`. Root required. |
|
| `claim-anchor [--owner <name>] [--host <host>] [<token>]` | Networked mode: make a host the anchor. No token when the host is already paired here. Also `--rotate <token>`, `--repin`. Root required. |
|
||||||
|
| `release-anchor --host <host> (--host-ca <pem> \| --force)` | Let go of an anchor whose Host is gone: unpair it and set its state aside, so a new Host can be anchored. `--host-ca` is the new Host's CA. Root required. |
|
||||||
| `claim-owner <name>` | State who owns this machine. Root required. Optional; grants nothing. |
|
| `claim-owner <name>` | State who owns this machine. Root required. Optional; grants nothing. |
|
||||||
|
| `root-exec grant [--host <host>] --for <duration>` / `root-exec revoke` | Let the anchor host run commands as root here, with the code from the authenticator enrolled here, until a time you choose (30 days at most); or take it back. `root-exec` alone shows the grant held. Granting needs root. |
|
||||||
|
| `switch <host> <family> on\|off` / `switch --list` | Switch a capability family off for one host on this machine, or back on; `--list` shows every host's families. Switching needs root. |
|
||||||
|
| `approvals-owner --sid <SID> [--replace]` | Windows, beside the system service: name the one account whose tray icon may list and answer approvals. Administrator only. |
|
||||||
|
| `tray` | Windows, beside the system service: your icon and approvals window, started at every sign-in. |
|
||||||
| `observe` | Print the agent, automation and scheduler snapshot the next check-in would carry. |
|
| `observe` | Print the agent, automation and scheduler snapshot the next check-in would carry. |
|
||||||
| `observe:baseline [--force]` | Record what is normal on this machine. Administrator only. |
|
| `observe:baseline [--force]` | Record what is normal on this machine. Administrator only. |
|
||||||
| `observe:baseline:show` | Print the baseline this machine holds. |
|
| `observe:baseline:show` | Print the baseline this machine holds. |
|
||||||
|
|||||||
+5
-4
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
|
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
|
||||||
and reachable from nowhere else: pairings, the anchor, containment state, marks
|
and reachable from nowhere else: pairings, the anchor, containment state, marks
|
||||||
on automated programs, the dry-run policy, and pending approvals. On Windows
|
on automated programs, the dry-run policy, pending approvals, set-up, what this
|
||||||
a tray icon shows a one-line state and offers four actions: open what is
|
machine has given away (where you switch a host's capabilities off), and a
|
||||||
waiting for your approval, open the console, open your context page, and stop
|
disk map. On Windows a tray icon shows a one-line state. Run as you, it opens
|
||||||
the bastion.
|
what is waiting for your approval, the console and your context page, and can
|
||||||
|
stop the bastion. Beside the system service it opens approvals only.
|
||||||
|
|||||||
+13
-5
@@ -13,7 +13,11 @@ your machine, never on the platform.
|
|||||||
Signature-verified store grants for folders you share. The host's verify key is
|
Signature-verified store grants for folders you share. The host's verify key is
|
||||||
pinned on first sync (trust on first use) and a later key change is refused.
|
pinned on first sync (trust on first use) and a later key change is refused.
|
||||||
Every read is sandboxed to the granted path, walks no symlinks out of it, and is
|
Every read is sandboxed to the granted path, walks no symlinks out of it, and is
|
||||||
audited on this machine. The vocabulary is reads plus **one write lane**: a
|
audited on this machine. The grant's excludes hold on every read and move: a
|
||||||
|
pattern of one name (`.ssh`, `*.key`) matches that name at any depth, and a
|
||||||
|
pattern with a `/` (`.config/gcloud`) matches that run of folders at any depth.
|
||||||
|
The bastion agent's own folder, `.agent`, which holds the plaintext of every
|
||||||
|
file the agent read, is never served or moved, whatever the grant excludes. The vocabulary is reads plus **one write lane**: a
|
||||||
signed move plan that you confirmed on this machine, which archives files and
|
signed move plan that you confirmed on this machine, which archives files and
|
||||||
never deletes them. Dropping a grant removes the grant, not your files.
|
never deletes them. Dropping a grant removes the grant, not your files.
|
||||||
**No delete op exists.**
|
**No delete op exists.**
|
||||||
@@ -23,7 +27,11 @@ results, read ranges and catalog cards come from the agent's sanitized text,
|
|||||||
and each reply says how that text was sanitized: `sanitize_mode` is the mode
|
and each reply says how that text was sanitized: `sanitize_mode` is the mode
|
||||||
in the agent's manifest, `sanitized` is true only for `sterilize`, and
|
in the agent's manifest, `sanitized` is true only for `sterilize`, and
|
||||||
`sanitize_note` says what the mode leaves in. The bastion deploys the agent in
|
`sanitize_note` says what the mode leaves in. The bastion deploys the agent in
|
||||||
`flag` mode, which replaces e-mail addresses, phone numbers, card, bank and ID
|
`flag` mode, which replaces only what a pattern can find: e-mail addresses,
|
||||||
numbers, IP and MAC addresses and keys, and does **not** find names, street
|
phone numbers, card numbers, US social security numbers, IBANs, US bank
|
||||||
addresses or dates of birth. So a Host reading your folder through search
|
routing numbers written after a word like "routing", Bitcoin addresses that
|
||||||
sees those.
|
start with 1 or 3, IP and MAC addresses, private keys, and access keys and
|
||||||
|
tokens of the kinds it knows. It does **not** look for names, street
|
||||||
|
addresses, dates of birth, passport, licence and other ID numbers, other bank
|
||||||
|
account numbers or other crypto addresses. So a Host reading your folder
|
||||||
|
through search sees those.
|
||||||
|
|||||||
@@ -62,10 +62,11 @@ carries a tag of the named repository.
|
|||||||
A Host with no platform that runs in a VM on this machine shares no kernel and
|
A Host with no platform that runs in a VM on this machine shares no kernel and
|
||||||
no directory with it. A C-Series Host
|
no directory with it. A C-Series Host
|
||||||
([Privatae/cseries](https://github.com/Privatae/cseries)) is one: it runs in
|
([Privatae/cseries](https://github.com/Privatae/cseries)) is one: it runs in
|
||||||
its own Linux virtual machine. Its installer, `npx cseries`, forwards two ports
|
its own Linux virtual machine. Its installer, `npx cseries`, forwards three
|
||||||
of the VM to this machine's loopback, installs this bastion as the machine's
|
ports of the VM (8800, 8810, 8811) to this machine's loopback, installs this
|
||||||
system service, and on every pairing runs these in one elevated step (on
|
bastion as the machine's system service, and on every pairing runs these in
|
||||||
Windows, from an Administrator PowerShell instead of `sudo -H`):
|
one elevated step (on Windows, from an Administrator PowerShell instead of
|
||||||
|
`sudo -H`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. The service's state, networked.
|
# 1. The service's state, networked.
|
||||||
|
|||||||
+15
-2
@@ -20,8 +20,21 @@ signed in, and only the machine's administrator can stop it:
|
|||||||
On macOS and Windows, `privatae-bastion service install|uninstall|status` does
|
On macOS and Windows, `privatae-bastion service install|uninstall|status` does
|
||||||
the same by hand. An administrator's commands (`sudo -H …` on macOS, an
|
the same by hand. An administrator's commands (`sudo -H …` on macOS, an
|
||||||
Administrator terminal on Windows) act on the service's state. From an account
|
Administrator terminal on Windows) act on the service's state. From an account
|
||||||
that does not administer the machine, `pair`, `run` and `status` are refused,
|
that does not administer the machine, `pair`, `run`, `status` and `switch` are
|
||||||
so that account does not end up with a second, empty bastion of its own.
|
refused, so that account does not end up with a second, empty bastion of its
|
||||||
|
own.
|
||||||
|
|
||||||
|
A local install runs as you. On Windows the installer registers a logon task
|
||||||
|
that starts it each time you sign in.
|
||||||
|
|
||||||
|
A Cloud Host's "Set up a bastion" card gives one line that installs, pairs and
|
||||||
|
starts the bastion: `curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`
|
||||||
|
on macOS and Linux, `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'`
|
||||||
|
on Windows. Installed as you, it then runs as your systemd user unit
|
||||||
|
`privatae-bastion`, your LaunchAgent `ai.privatae.bastion.user`, or the logon
|
||||||
|
task. Where it cannot use one, the installer starts the bastion without it and
|
||||||
|
says how long it will run: on Linux until the machine restarts, on Windows
|
||||||
|
until you sign out.
|
||||||
|
|
||||||
Release binaries are served from `https://dl.privatae.ai`. The release manifest
|
Release binaries are served from `https://dl.privatae.ai`. The release manifest
|
||||||
(`version.json`, read by the installers at `https://dl.privatae.ai/version.json`
|
(`version.json`, read by the installers at `https://dl.privatae.ai/version.json`
|
||||||
|
|||||||
+13
-12
@@ -3,27 +3,28 @@
|
|||||||
## Tunnel
|
## Tunnel
|
||||||
A userspace WireGuard tunnel (gVisor netstack) scoped so this machine can reach
|
A userspace WireGuard tunnel (gVisor netstack) scoped so this machine can reach
|
||||||
**only its own host**. No TUN device is created and `run` does not need root.
|
**only its own host**. No TUN device is created and `run` does not need root.
|
||||||
The tunnel is dialled from this machine. Inside it, the bastion accepts on
|
The tunnel is dialled from this machine. Its WireGuard UDP port, on every
|
||||||
exactly three addresses, all under the `inference` capability family: a
|
address at a port the system picks when the tunnel comes up, drops every
|
||||||
forward to a local ollama (port 11434), a forward to an OpenAI-compatible
|
packet that does not come from the host's key. Inside the tunnel, the
|
||||||
server when one is configured (port 18080), and a forward to a local PII
|
bastion accepts only on gated forwards: ollama (11434), an OpenAI-compatible server (18080) and a PII
|
||||||
sterilizer when one is configured. Nothing else listens.
|
sterilizer (18081) under the `inference` capability family, the last two only
|
||||||
|
when configured; one port per service-slot server (18100-18199); and one per
|
||||||
|
coder run (18200-18299). Nothing else listens.
|
||||||
|
|
||||||
## Git lane
|
## Git lane
|
||||||
A loopback HTTP proxy plus a `git-credential` helper, so your local `git` reaches
|
A loopback HTTP proxy plus a `git-credential` helper, so your local `git` reaches
|
||||||
`git.privatae.ai` through your host. The forge answers platform addresses only,
|
`git.privatae.ai` through your host. `privatae-bastion git-setup` prints the
|
||||||
so this lane is the way in. `privatae-bastion git-setup` prints the three
|
three config lines: the proxy, a reset of the credential-helper chain for that URL
|
||||||
config lines: the proxy, a reset of the credential-helper chain for that URL
|
|
||||||
(so no other helper caches your token on this machine), and the helper itself.
|
(so no other helper caches your token on this machine), and the helper itself.
|
||||||
|
|
||||||
## Worker lanes
|
## Worker lanes
|
||||||
Outbound-only reverse relays to the host: egress, management, harness, and
|
Outbound-only reverse relays to the host: egress, management, harness, and
|
||||||
session. The management lane runs a **fixed-argv allowlist**. Ops that touch
|
session. The management lane runs a **fixed-argv allowlist**. Ops that touch
|
||||||
your files are dedicated Go handlers with no shell. Of those file handlers,
|
your files are dedicated Go handlers with no shell. Two of them start a
|
||||||
the one that runs a program is text extraction: it invokes a local tool named
|
program: text extraction invokes a local tool named in a fixed table
|
||||||
in a fixed table
|
|
||||||
(`pdftotext`, `ffprobe`, and the like) from a fixed argv template, and the
|
(`pdftotext`, `ffprobe`, and the like) from a fixed argv template, and the
|
||||||
caller supplies only the tool's name and the document.
|
caller supplies only the tool's name and the document; `dcn_grant_sync`
|
||||||
|
starts the local search agent's Python.
|
||||||
|
|
||||||
## Session lane
|
## Session lane
|
||||||
An interactive terminal that runs on this machine and streams to the host over
|
An interactive terminal that runs on this machine and streams to the host over
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ deploy/
|
|||||||
release-sign/ the signing tool: runs where the release key is, never here
|
release-sign/ the signing tool: runs where the release key is, never here
|
||||||
release-verify.sh check a manifest's signature against the pinned keys
|
release-verify.sh check a manifest's signature against the pinned keys
|
||||||
verify-release.sh rebuild a published release and compare its digests
|
verify-release.sh rebuild a published release and compare its digests
|
||||||
|
release-assets.sh rebuild a release from its tag and check it against the signed manifest
|
||||||
|
staticcheck.sh staticcheck on linux, darwin and windows
|
||||||
fuzz.sh run every fuzz target for a bounded time
|
fuzz.sh run every fuzz target for a bounded time
|
||||||
check-claims.sh check that every test the security table cites exists
|
check-claims.sh check that every test the security table cites exists
|
||||||
docs/ reference pages, listed in docs/README.md
|
docs/ reference pages, listed in docs/README.md
|
||||||
|
scripts/ the check that no person's data is in the tree
|
||||||
|
.gitea/workflows/ CI on git.privatae.ai
|
||||||
|
.github/workflows/ CI and the release on GitHub
|
||||||
LICENSE GNU GPL v3
|
LICENSE GNU GPL v3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+43
-2
@@ -61,10 +61,51 @@ Read-only, and reported so the host can show **why** rather than a verdict.
|
|||||||
the host. Alert counts also ride the heartbeat, refreshed on their own
|
the host. Alert counts also ride the heartbeat, refreshed on their own
|
||||||
interval, and are absent — not zero — whenever no Alertmanager is
|
interval, and are absent — not zero — whenever no Alertmanager is
|
||||||
configured.
|
configured.
|
||||||
|
- **Machine questions** (Linux: `observe:folders`, `observe:journal`,
|
||||||
|
`observe:logins`, `observe:cert`). What a host would otherwise ask the
|
||||||
|
person to run a command for, answered here. Each value is checked against
|
||||||
|
its written rule before anything is read or run, and a refusal says which
|
||||||
|
rule it broke.
|
||||||
|
- `observe:folders <absolute path> [depth 1-3, default 2]`: the 25 largest
|
||||||
|
folders under the path, down to the depth, and the total, on the path's
|
||||||
|
own filesystem only, counted as `du -x` counts (disk blocks, a file with
|
||||||
|
several links once). Only folders are named, never files. What could not
|
||||||
|
be read, and filesystems mounted under the path, are listed against the
|
||||||
|
folder that holds them, with a reason and a count. A relative path, one
|
||||||
|
containing `..`, and `/proc`, `/sys` and `/dev` (or a link leading into
|
||||||
|
them) are refused. The walk shares the 20-second op budget; one that runs
|
||||||
|
out answers with what it measured, marked incomplete. It follows no link,
|
||||||
|
and a folder swapped for one while it reads is listed as changed. While
|
||||||
|
four earlier walks are still inside a read that has not returned, no new
|
||||||
|
walk starts.
|
||||||
|
- `observe:journal <unit> [minutes 1-60, default 15]`: one systemd unit's
|
||||||
|
journal over the window, up to 5000 lines and 200 KB, newest kept. A name
|
||||||
|
with no type is a service. The unit must be in systemd's unit-name
|
||||||
|
alphabet and one this machine has, loaded or installed; otherwise it is
|
||||||
|
refused, with the units whose names come close.
|
||||||
|
- `observe:logins [hours 1-168, default 24]`: sshd's failed logins counted
|
||||||
|
and ranked by source address and by user name (20 each), and the last 20
|
||||||
|
accepted logins with user, source, method and time. A user name the other
|
||||||
|
side sent is cut to 32 characters, and anything outside the characters
|
||||||
|
account names use reads `?`. No raw line, key fingerprint or password is
|
||||||
|
returned. Only lines sshd wrote as root are read (`_UID=0`), so another
|
||||||
|
account cannot add login lines or bury the real ones.
|
||||||
|
- `observe:cert <port> [server name]`: the certificate this machine's own
|
||||||
|
TCP listener on the port serves: subject, names, issuer, dates, days left,
|
||||||
|
and whether the chain verifies against this machine's roots for that name.
|
||||||
|
The port must be one `ss` shows listening here.
|
||||||
|
|
||||||
|
`observe:journal` and `observe:logins` first check that the bastion can read
|
||||||
|
the system journal (root, as the networked install runs it, or a member of
|
||||||
|
`systemd-journal`). An account that cannot is shown only its own lines by
|
||||||
|
`journalctl`, which would read as a quiet machine, so they answer with the
|
||||||
|
reason instead, and `observe:logins` reports its counts as -1.
|
||||||
|
|
||||||
The observe ops that send traffic are `observe:net`, `observe:k8s` (a
|
The observe ops that send traffic are `observe:net`, `observe:k8s` (a
|
||||||
read-only query of a cluster API) and the three monitoring ops above (private
|
read-only query of a cluster API), the three monitoring ops above (private
|
||||||
addresses only, refused at the socket if a redirect or a DNS answer would move
|
addresses only, refused at the socket if a redirect or a DNS answer would move
|
||||||
the destination after validation). A build with no enumerator for its
|
the destination after validation) and `observe:cert`, which connects only to
|
||||||
|
this machine: loopback first, then an address of this machine the listener is
|
||||||
|
bound to. A build with no enumerator for its
|
||||||
platform **withholds** the capability, so the host can tell a machine that is
|
platform **withholds** the capability, so the host can tell a machine that is
|
||||||
quiet apart from one that is blind.
|
quiet apart from one that is blind.
|
||||||
|
|||||||
+16
-1
@@ -49,7 +49,22 @@ a dry run first, then an apply bound to the change list the dry run showed.
|
|||||||
- UDP is held to the declared addresses but not to their ports, and a port
|
- UDP is held to the declared addresses but not to their ports, and a port
|
||||||
declared for one address is open on every declared address;
|
declared for one address is open on every declared address;
|
||||||
- a destination on this machine, a link-local address, or a cloud's
|
- a destination on this machine, a link-local address, or a cloud's
|
||||||
metadata service is refused, as written and as resolved.
|
metadata service is refused, as written and as resolved;
|
||||||
|
- on a machine no fleet has anchored (your own machine), so is a private,
|
||||||
|
ULA, CGNAT or reserved address: one written as an address is refused when
|
||||||
|
the op arrives, and a name is refused at apply if any address it resolves
|
||||||
|
to is one. A verb's fetch (`tree.checkout`, and `k8s.set_image`'s
|
||||||
|
ancestry check) is held the same way, its host checked once and git held
|
||||||
|
to that address, which needs git 2.37 or newer; on an anchored machine
|
||||||
|
that fetch is held to its checked address too, and is never this
|
||||||
|
machine, its loopback, a link-local address or a metadata service. An
|
||||||
|
image whose registry is on your network or this machine is never put
|
||||||
|
live: it is refused when a program reports it and when `k8s.set_image`
|
||||||
|
would set it. To reach another computer
|
||||||
|
on your network, install a bastion on that computer and run the op there.
|
||||||
|
An anchored machine keeps private
|
||||||
|
destinations, because a fleet's ship steps reach its private control
|
||||||
|
planes.
|
||||||
|
|
||||||
**A dry run has no network at all,** because nobody approved it.
|
**A dry run has no network at all,** because nobody approved it.
|
||||||
- **Memory: no ceiling, and the op goes first.** A run may use whatever
|
- **Memory: no ceiling, and the op goes first.** A run may use whatever
|
||||||
|
|||||||
+64
-11
@@ -1,10 +1,15 @@
|
|||||||
# Security model
|
# Security model
|
||||||
|
|
||||||
- **The bastion dials out.** Every lane is a reverse connection from this
|
- **The bastion dials out.** Every lane is a reverse connection from this
|
||||||
machine to the host. No port is opened on an externally reachable interface;
|
machine to the host. The only ports open on an outside interface are the
|
||||||
the control ports and console bind loopback only, and the optional
|
tunnels' WireGuard UDP ports: one for each host paired over a tunnel, on
|
||||||
memory-mirror forwarder binds loopback plus the replica container's private
|
every IPv4 and IPv6 address, at a port the system picks when that tunnel
|
||||||
gateway address, with every accept source-checked.
|
comes up. WireGuard drops every packet on one that does not come from the
|
||||||
|
paired host's key; the only reply it ever sends anyone else is a cookie
|
||||||
|
under a flood of handshakes, and only to a sender that already knows that
|
||||||
|
tunnel's public key. Every other port the bastion opens binds loopback, except the
|
||||||
|
optional memory-mirror forwarder, which also binds the replica container's
|
||||||
|
private gateway address, with every accept source-checked.
|
||||||
- **Inside the tunnel, only gated listeners.** The host can connect to up to
|
- **Inside the tunnel, only gated listeners.** The host can connect to up to
|
||||||
three inference forwards (local ollama, an OpenAI-compatible server, a PII
|
three inference forwards (local ollama, an OpenAI-compatible server, a PII
|
||||||
sterilizer), each a proxy to a service you run, on loopback by default and
|
sterilizer), each a proxy to a service you run, on loopback by default and
|
||||||
@@ -94,11 +99,46 @@
|
|||||||
changes anything outside its declared file effects. A one-time apply lands
|
changes anything outside its declared file effects. A one-time apply lands
|
||||||
only the change list you saw; a run under a standing grant is held to the
|
only the change list you saw; a run under a standing grant is held to the
|
||||||
grant's bounds.
|
grant's bounds.
|
||||||
|
- **A standing grant is held here to its window and rate, and dies with the
|
||||||
|
key that approved it.** The machine reads the window on its own clock and
|
||||||
|
counts the rate from the runs it logged in the grant's record in `ops.json`,
|
||||||
|
which it seals with a key derived from its own encryption key. A record
|
||||||
|
edited by hand or by a program without that key, or copied from another
|
||||||
|
machine, holds no grant. The seal does not stop the bastion's own account:
|
||||||
|
it can read the key, so it can write a new record or put back an older
|
||||||
|
sealed copy with fewer runs logged and more uses left. That account is root
|
||||||
|
on a networked install, and root on the machine is outside what this model
|
||||||
|
defends. A grant taken back while the machine is offline stops at the Host
|
||||||
|
at once, which routes no run through it; the machine keeps its own record
|
||||||
|
until the drop is sent again or the grant expires (30 days at most). The
|
||||||
|
heartbeat does not yet carry the grants the Host still holds, which would let
|
||||||
|
the machine drop the rest itself as it does for coder grants. `ops drop
|
||||||
|
--all` at the machine takes every one back there.
|
||||||
|
- **On your own machine, an action op reaches the internet, never your
|
||||||
|
network.** On a machine no fleet has anchored, an action op's destination
|
||||||
|
is never a private, ULA, CGNAT or reserved address, also inside a NAT64 or
|
||||||
|
6to4 address: one written as an address is refused when the op arrives
|
||||||
|
(`op_sync`), and a name is refused at apply if any address it resolves to
|
||||||
|
is one. A verb's fetch (`tree.checkout`'s repository, `k8s.set_image`'s
|
||||||
|
ancestry check), which runs with the bastion's root outside the sandbox,
|
||||||
|
is held to the same rule: its host is resolved once, every address checked,
|
||||||
|
and git held to that address (git 2.37 or newer); on an anchored machine
|
||||||
|
that fetch is still never this machine, its loopback, a link-local address
|
||||||
|
or a cloud's metadata service. An image `k8s.set_image` puts live is
|
||||||
|
pulled by the machine's container runtime, by name, outside the bastion's
|
||||||
|
walls, so on your own machine its registry is judged where the bastion
|
||||||
|
takes the image (a program's result, and the verb) and refused on your
|
||||||
|
network or this machine; the runtime's own later lookup is not the
|
||||||
|
bastion's to hold. Another computer of yours
|
||||||
|
is reached through a bastion on that computer. An anchored machine keeps
|
||||||
|
private destinations, since a fleet's ship steps reach its own private
|
||||||
|
control planes. (Before this, a local machine held an op whose destination
|
||||||
|
was on your LAN, and its apply reached it.)
|
||||||
- **A server in the service slot has no network.** The host's tunnel is its
|
- **A server in the service slot has no network.** The host's tunnel is its
|
||||||
only way in, and it cannot reach this machine, your network or the
|
only way in, and it cannot reach this machine, your network or the
|
||||||
internet.
|
internet.
|
||||||
- **Egress for someone else reaches the public internet, never this
|
- **Egress for someone else reaches the public internet, and nothing on
|
||||||
machine.** A coder run's proxy, always, and the egress lane on an anchored
|
this machine or your network that you did not name.** A coder run's proxy, always, and the egress lane on an anchored
|
||||||
machine refuse loopback, link-local (the cloud metadata address included),
|
machine refuse loopback, link-local (the cloud metadata address included),
|
||||||
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
|
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
|
||||||
(`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`)
|
(`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`)
|
||||||
@@ -118,8 +158,10 @@
|
|||||||
own veth, its LAN included. The IPv4 address inside an
|
own veth, its LAN included. The IPv4 address inside an
|
||||||
IPv4-mapped, NAT64 or 6to4 address is checked too, every address a name
|
IPv4-mapped, NAT64 or 6to4 address is checked too, every address a name
|
||||||
resolves to is checked, and the address checked is the one dialled. When the machine cannot tell,
|
resolves to is checked, and the address checked is the one dialled. When the machine cannot tell,
|
||||||
it refuses. A coder run reaches a LAN host only when you named it in
|
it refuses. A coder run reaches a host you named in `coders set
|
||||||
`coders set --allow-lan`; the anchored egress lane has no such exception.
|
--allow-lan`, and skips every one of these refusals for it, so a name or
|
||||||
|
address of this machine you list there is reached too; the anchored egress
|
||||||
|
lane has no such exception.
|
||||||
The anchored lane's refusal of this machine's own non-private addresses is
|
The anchored lane's refusal of this machine's own non-private addresses is
|
||||||
new in 0.50.0; before it, that lane refused only the internal ranges. On a
|
new in 0.50.0; before it, that lane refused only the internal ranges. On a
|
||||||
local (unanchored) machine the egress lane refuses this machine too:
|
local (unanchored) machine the egress lane refuses this machine too:
|
||||||
@@ -189,7 +231,9 @@
|
|||||||
(`observe:alerts`, `observe:metrics`, `observe:logs`), read-only HTTP
|
(`observe:alerts`, `observe:metrics`, `observe:logs`), read-only HTTP
|
||||||
against an Alertmanager/Prometheus/Loki on the box's own network —
|
against an Alertmanager/Prometheus/Loki on the box's own network —
|
||||||
private addresses only, re-checked at the socket so neither a redirect nor
|
private addresses only, re-checked at the socket so neither a redirect nor
|
||||||
a DNS answer can walk a query out of that neighbourhood.
|
a DNS answer can walk a query out of that neighbourhood; and
|
||||||
|
`observe:cert`, a TLS handshake with a listener on this machine, at
|
||||||
|
loopback or an address this machine holds, never one another machine could.
|
||||||
- **A backup's key goes to a host only when you approve it here.** A host's
|
- **A backup's key goes to a host only when you approve it here.** A host's
|
||||||
request to restore files a request and gets nothing until you approve it
|
request to restore files a request and gets nothing until you approve it
|
||||||
on this machine; the key sealed to the host then opens that one backup and
|
on this machine; the key sealed to the host then opens that one backup and
|
||||||
@@ -199,6 +243,14 @@
|
|||||||
backup files in its own folder or a folder you shared with it.
|
backup files in its own folder or a folder you shared with it.
|
||||||
- **No delete op on shared stores.** The one write lane is a move plan you
|
- **No delete op on shared stores.** The one write lane is a move plan you
|
||||||
approved, and it archives rather than deletes.
|
approved, and it archives rather than deletes.
|
||||||
|
- **A shared folder serves only what its grant leaves in.** A host's list,
|
||||||
|
stat, read and move of a shared folder are each held to the grant's
|
||||||
|
excludes, by the name the path opens and wherever a link in it leads. A
|
||||||
|
pattern of one name matches it at any depth; a pattern with a `/`, such as
|
||||||
|
`.config/gcloud`, matches that run of folders at any depth. The local
|
||||||
|
search agent's folder, `.agent`, holds the plaintext of every file the
|
||||||
|
agent read, and no path into one is served or moved, whatever the grant
|
||||||
|
excludes; the bastion still writes its own report of a move plan there.
|
||||||
- **Verified releases.** The self-updater checks the release manifest's
|
- **Verified releases.** The self-updater checks the release manifest's
|
||||||
signature against the release keys compiled into it, then checks the
|
signature against the release keys compiled into it, then checks the
|
||||||
binary's SHA-256 against that manifest before anything is swapped. The
|
binary's SHA-256 against that manifest before anything is swapped. The
|
||||||
@@ -217,8 +269,9 @@
|
|||||||
|
|
||||||
## How each claim is tested
|
## How each claim is tested
|
||||||
|
|
||||||
[claims.md](claims.md) names, for each point above, the test that fails if
|
[claims.md](claims.md) covers each point above and some further claims not
|
||||||
it stops being true, and says where there is none.
|
listed here. For each it names the test that fails if the claim stops being
|
||||||
|
true, and says where there is none.
|
||||||
|
|
||||||
To report a security issue, please do not open a public issue. See
|
To report a security issue, please do not open a public issue. See
|
||||||
[SECURITY.md](../SECURITY.md).
|
[SECURITY.md](../SECURITY.md).
|
||||||
|
|||||||
Reference in New Issue
Block a user