bastion 0.55.3
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m51s
ci / fuzz (push) Successful in 2m55s

This commit is contained in:
Privatae
2026-10-25 23:16:45 +00:00
parent 77b7853ae1
commit 8822d5df85
136 changed files with 8638 additions and 1641 deletions
+15 -4
View File
@@ -67,6 +67,7 @@ says so. Those parts are enforced only by the code as written.
| | `ops_linux_test.go`: `TestOpDryRunShowsTheChangeAndApplyCommitsExactlyThat` | A dry run commits nothing; an apply must name the dry run's change list and carry an approval bound to that op, its parameters, the machine and that change list. Skipped where the sandbox is unavailable. |
| | `ops_test.go`: `TestOpTermsMutationsAreEachRefused` | Each altered field of signed op terms is refused. |
| | `ops_linux_test.go`: `TestOpSandboxShowsOnlyTheBaseAndWhatTheOpDeclared` | In a real sandbox, an op reads the file it declared and the system base; a file beside its own folder, a folder beside it and `/etc/shadow` read as empty; a declared path that has since become a link onto `/etc/ssh` refuses the run. Skipped where the sandbox cannot run. |
| | `ops_taskcap_linux_test.go`: `TestARootOpIsStoppedAtItsTaskCap` | A root op starting 100 background tasks is stopped at its scope's cap, and its report says the cap held. The scope is made by a stand-in for `systemd-run` that creates the cgroup directly. Skipped where the sandbox, systemd as init or a pids controller is unavailable. |
| | `ops_view_test.go`: `TestOpSensitivePathsAreRefusedAtUnderOrAboveAndThroughLinks` | A declared path at, under or above a credential store, as written or through a link, is refused; a malformed `sensitive_paths` refuses every op. |
| | `fuzz_paths_unix_test.go`: `FuzzAnOpEffectNeverCoversTheMachinesOwnRecord` | No accepted effect covers the bastion's own records or the system directories the sandbox masks. |
| An action op's program reaches only what it declared | `ops_action_linux_test.go`: `TestActionProgramReachesOnlyItsDestinations` | In a real unit, an apply reaches its declared destination by name; another port on that address, the declared port on another address, this machine's loopback and the internet are refused. A dry run reaches nothing, its declared destination included, and a name that resolves to the metadata service is refused. Skipped where action ops cannot run. |
@@ -76,7 +77,8 @@ says so. Those parts are enforced only by the code as written.
| | `ops_action_linux_test.go`: `TestActionVerbCredentialNeverReachesTheRealProgram` | In a real unit, a credential a `tree.checkout` names never reaches the program, while the verb uses it and the program's own credential is handed and scrubbed from its output. Skipped where action ops cannot run. |
| An action op is never capped, and yields first | `ops_action_linux_test.go`: `TestActionMemoryIsNotCappedAndTheOpYieldsFirst` | In a real unit, a run holds 1500 MiB resident with nothing limiting it, and the kernel would kill it first (`oom_score_adj` 1000). When the machine's free memory falls to the floor, the op is stopped, with a refusal that names its peak. A machine already under the floor starts no op and spends no uid. Skipped where action ops cannot run or 10 GiB is not free. |
| | `ops_action_linux_test.go`: `TestActionYieldsWhenTheServicesWaitOnMemoryNotForItsOwnStalls` | Against a stand-in tree of pressures: neither the op's own slice waiting nor a pod group already waiting stops it; the services' slice rising to 12.5% during the run stops it within seconds; a group that stays at a level does not stop the next run; a group that spiked as the run began and fell back does not hide a later rise. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestActionProgramSeesNothingItWasNotGiven` | (also) The real unit's program sits in `privatae_ops.slice`, the slice the pressure reading leaves out. |
| | `ops_action_linux_test.go`: `TestActionProgramSeesNothingItWasNotGiven` | (also) The real unit's program sits in the ops' slice (`privatae_ops.slice`; in the suite, a slice of its own), whose own figure the pressure reading leaves out. |
| | `ops_memory_stalls_linux_test.go`: `TestAnActionOpYieldsToARootOpBesideItNeverToItself` | Against stand-in pressures: a root op's scope in the ops' slice rising 12.4% beside an action op counts as another program waiting, while the action op's own unit and the slice's own figure rising never count. |
| | `ops_action_linux_test.go`: `TestTheMemoryWatchReportsOnlyAKillThatLanded` | A kill that finds no unit is not reported as the op stopped. |
| | `ops_action_linux_test.go`: `TestTheMemoryWatchKeepsTryingUntilTheKillLands` | With the machine short before the op's unit exists, the first kill finds nothing; the real unit that starts a second later is still stopped. Skipped where action ops cannot run. |
| | `ops_action_linux_test.go`: `TestMemoryFloorSitsAboveTheKubeletsEviction` | The floor reads the kubelet's eviction line from the k3s config, its drop-ins, the k3s unit's flags, the kubelet config k3s writes, and any kubelet config file or folder a flag names (one named and missing counts as unreadable): percentages, decimals, binary and decimal units, soft as well as hard, and a kubelet config file's form, never a comment. It sits 512 MiB above that line, or at 5% of memory, or at 1 GiB, whichever is largest. A value it cannot read raises the floor to 10% plus 512 MiB. |
@@ -131,8 +133,16 @@ says so. Those parts are enforced only by the code as written.
| | `ownaddr_test.go`: `TestCoderEgressRefusesAnAddressTheKernelDeliversHere` | A coder run is refused an address the kernel delivers here, in every spelling, and refused anything when the lookup fails; `allow_lan` stays the exception. |
| | `ownaddr_netns_linux_test.go`: `TestAnyIPDestinationsAreRefusedInARealNamespace` | In a private network namespace with `local 192.0.2.0/24` and `local 2001:db8:a::/64` on `lo`, plus a broadcast route (198.51.101.255), anycast routes (198.51.102.0/24, 2001:db8:d::/64) and a multicast route: the real route lookup reads each as this machine, and a unicast route or no route as not; a 0.0.0.0 listener answers on both local ranges. A coder run's proxy and the anchored egress lane each refuse those addresses (local, broadcast and anycast), the IPv4-mapped and NAT64 forms of each IPv4 one, and a name resolving to each, with the lane's this-machine reason (a name the lane could not resolve does not count), and pass the unicast and no-route addresses and a name resolving to each, dialling the vetted address; each answers 403 end to end with a real dialer, the proxy over its socket and the anchored lane through its connection handler as the anchor host, to CONNECT by address and by name and to plain HTTP. The backup lanes' URL check refuses the same addresses, forms and names as this machine's or its network's and passes the unicast and no-route ones, and their dial wall refuses the local, broadcast and anycast addresses. Nothing reaches the listener. Skipped where a namespace cannot be made. |
| | `ownaddr_netns_linux_test.go`: `TestOnlyAUnicastRouteLeavesThisMachine` | The kernel's route type is read so that only unicast leaves this machine; local, broadcast, anycast, multicast, unspec, nat and xresolve are this machine. |
| | `hostops_test.go`: `TestAnchoredEgressRefusesLoopbackAndPrivateDestinations` | On an anchored machine the egress lane refuses loopback, `localhost`, private, ULA, link-local, CGNAT and metadata addresses, dials the vetted address, and a local machine keeps the lane it had. |
| | `ownaddr_test.go`: `TestAnchoredEgressRefusesThisMachinesOwnAddresses` | On an anchored machine the egress lane refuses an address on this machine's interface and one a `local` route delivers here, in every spelling, and refuses when the lookup fails; a neighbour passes; a local machine's lane is unchanged. |
| | `hostops_test.go`: `TestAnchoredEgressRefusesLoopbackAndPrivateDestinations` | On an anchored machine the egress lane refuses loopback, `localhost`, private, ULA, link-local, CGNAT and metadata addresses and dials the vetted address; a local machine refuses its own ollama and keeps its LAN. |
| | `ownaddr_test.go`: `TestAnchoredEgressRefusesThisMachinesOwnAddresses` | On an anchored machine the egress lane refuses an address on this machine's interface and one a `local` route delivers here, in every spelling, and refuses when the lookup fails; a neighbour passes; a local machine refuses those addresses too. |
| | `egress_local_test.go`: `TestLocalEgressRefusesThisMachineAndKeepsTheLAN` | On a local machine the egress lane refuses loopback, `localhost`, unspecified, `0.0.0.0/8`, link-local and metadata addresses, this machine's own LAN address and AnyIP range, each also inside an IPv4-mapped, NAT64 or 6to4 address, and a name with one such answer; it dials private, ULA, CGNAT and public addresses, and a name's vetted addresses, never the name. |
| | `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, and dials the others; 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. |
| | `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_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_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_obsolete_test.go`: `TestEveryLaneRefusesTheDeprecatedIPv6Forms` | The anchored and the local egress lane each refuse IPv4-compatible (`::/96`, with a public, a loopback and a LAN address inside), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`) addresses, and a name with one such answer among public ones, and pass the public internet; a coder run's proxy refuses the same addresses. |
| | `ownaddr_test.go`: `TestBackupLanesRefuseThisMachinesOwnAddresses` | The backup lanes' URL check refuses an address on this machine's interface and one a `local` route delivers here, in every spelling and through a name, and their dial wall refuses those addresses; a failed lookup refuses both; a neighbour on the subnet passes. With the kernel's answer supplied. |
| | `backupfetch_test.go`: `TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges` | The backup lanes' classifier, address wall and URL check refuse 10.0.0.1 and 127.0.0.1 inside NAT64, 10.0.0.1 inside 6to4 and in the IPv4-compatible and IPv4-translated forms, the metadata address inside 6to4, a local-use NAT64 address, `fec0::1`, `198.18.0.0/15` (also inside an IPv4-mapped address), `240.0.0.0/4` (also inside NAT64) and `255.255.255.255`; public addresses in the NAT64, 6to4 and IPv4-mapped forms, and the addresses either side of `198.18.0.0/15`, pass the classifier. |
| | not covered by a test | A public address that reaches this machine only through a 1:1 NAT in front of it. Nothing refuses it. |
@@ -153,7 +163,8 @@ says so. Those parts are enforced only by the code as written.
| | `session_test.go`: `TestSessionRootApprovalBindsToSession` | The purpose a root approval is bound to differs between two session ids on the same machine. Compared as strings, not through the terminal handler. |
| | `session_test.go`: `TestSessionFramingResizeAndTranscript` | An opened session writes a transcript and start and stop lines to the audit log. |
| | `hostops_test.go`: `TestTheDeviceSignsOnlyWhatItDerivedItself` | The approval device refuses to take a root-terminal ask. |
| Root commands run only under a grant given here with the code | `rootexec_test.go`: `TestRootExecGrantCeremony` | The grant exists only after a code from the factor enrolled here checks out, once per code, with an expiry no longer than a year; past its expiry or after a revoke it refuses. |
| Root commands run only under a grant given here with the code | `rootexec_test.go`: `TestRootExecGrantCeremony` | The grant exists only after a code from the factor enrolled here checks out, once per code, with an expiry no longer than 30 days; past its expiry or after a revoke it refuses. |
| | `rootexec_test.go`: `TestRootExecGrantIsAtMostThirtyDays` | A 31-day grant is refused and writes nothing, 30 days is granted, and a grant held with a longer term ends 30 days after it was given; one with no grant time is refused. |
| | `rootexec_test.go`: `TestRootExecRefusals` | Through the terminal handler: with no grant, an expired, revoked, other-host or other-machine grant, from a member pairing holding the `session` family, or with the bastion not root, the command is refused, never starts, leaves no transcript and is audited with its command. |
| | `rootexec_test.go`: `TestRootExecLocalSwitchRefuses` | With `session` switched off here, a granted command is refused. |
| | `rootexec_test.go`: `TestRootExecRoundTrip` | A granted command returns its stdout, stderr and exit status, and is written to a transcript and to the audit log at start and stop with the command. |
+10
View File
@@ -17,3 +17,13 @@ audited on this machine. The vocabulary is reads plus **one write lane**: a
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.
**No delete op exists.**
A grant with `search` runs the bastion agent on the folder. Its search
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
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
`flag` mode, which replaces e-mail addresses, phone numbers, card, bank and ID
numbers, IP and MAC addresses and keys, and does **not** find names, street
addresses or dates of birth. So a Host reading your folder through search
sees those.
+19 -4
View File
@@ -60,14 +60,29 @@ carries a tag of the named repository.
## A Host in a VM on this machine
A Host with no platform that runs in a VM on this machine shares no kernel and
no directory with it. Its installer forwards two ports of the VM to this
machine's loopback and hands over a pairing token with the Host's CA:
no directory with it. A C-Series Host
([Privatae/cseries](https://github.com/Privatae/cseries)) is one: it runs in
its own Linux virtual machine. Its installer, `npx cseries`, forwards two ports
of the VM to this machine's loopback, installs this bastion as the machine's
system service, and on every pairing runs these in one elevated step (on
Windows, from an Administrator PowerShell instead of `sudo -H`):
```sh
privatae-bastion pair --redeem-url https://127.0.0.1:8810/v1/local/redeem --host-ca host-ca.pem <token>
privatae-bastion run
# 1. The service's state, networked.
sudo -H privatae-bastion set-mode networked
# 2. Pair over the Host's enroll port, trusting its CA alone; the token is read
# from standard input, never from the arguments.
sudo -H privatae-bastion pair --redeem-url https://127.0.0.1:8810/v1/local/redeem --host-ca host-ca.pem -
# 3. Anchor the machine to that Host: every write and terminal waits for your approval.
sudo -H privatae-bastion claim-anchor
# 4. Your authenticator, enrolled on that console and nowhere else.
sudo -H privatae-bastion stepup-enroll --console-only --host <the Host>
```
No Privatae account is needed. A plain `pair` and `run` as your own user also
pair with such a Host, but leave the machine local: not anchored, and with no
second factor until you enrol one.
| port | what |
|---|---|
| `8810` | `pair` redeems its token here (`POST /v1/local/redeem`, the platform redeem's body) |
+5 -2
View File
@@ -49,8 +49,11 @@ privatae-bastion root-exec revoke [--host <host>] # take
```
The grant names one host (the anchor; a member pairing never gets it) and
this machine, ends at the time you chose, and is written only by that command
with the code; nothing the host sends can create, widen or extend it. Each
this machine, ends at the time you chose, at most 30 days after you gave it
(`--for 31d` is refused; grant again before it ends), and is written only by
that command with the code; nothing the host sends can create, widen or
extend it. A grant given before 0.55.0, when the longest term was a year,
ends 30 days after it was given. Each
command runs as `/bin/sh -c` in its own process group, with a deadline (10
minutes unless the host asks for less or more, never more than 8 hours) and
with stdout and stderr returned up to a size cap (256 KiB each unless the host
+23 -8
View File
@@ -13,11 +13,26 @@ op. A pull is reported available only after a behavioural probe, because a
completed download proves only that bytes arrived.
Note the boundary: those rules govern the `model:*` ops. The host's direct
traffic to the ollama forward inside the tunnel is filtered (store-changing
requests such as `/api/pull` and `/api/delete` refused, model names checked)
only when the pairing is **scoped to named models**, when you have switched
the `models` capability off in the local console, or when a networked
bastion's grant withholds model management. Otherwise, on a local bastion
with an unscoped pairing, that forward is a raw byte-for-byte splice to your
ollama, and the pairing itself is the authorization. Scope the pairing on
your host, or switch `models` off here, if you want the filter.
traffic to the ollama forward inside the tunnel is a separate path, and it is
filtered (store-changing requests such as `/api/pull` and `/api/delete`
refused) in these cases:
- **On an anchored machine**, for every pairing, the anchor included. Changing
which models are there goes through `model:pull` and `model:drop`. A member
host whose grant from the anchor is **scoped to named models** also has
every request's model name checked against that list.
- **On a local machine**, once you switch the `models` capability off for
that host in the local console (`privatae-bastion ui`). On a root install
kept local (`--local`), `sudo -H privatae-bastion switch <host> models off`
does the same. On an install as your own user that command reads root's
state, where your host is not paired, so it switches nothing; use the
console.
Otherwise, on a local machine, that forward is a raw byte-for-byte splice to
your ollama, and the pairing itself is the authorization. A local machine
reads no grant for this, so a scope set on the host does nothing there:
switching `models` off is how you stop a host there changing your models.
The egress lane is no way around that switch: on a local machine it never
reaches this machine's own addresses (see
[the security model](security-model.md)), so ollama on loopback or on this
machine's LAN address cannot be reached through it either.
+2 -2
View File
@@ -61,8 +61,8 @@ a dry run first, then an apply bound to the change list the dry run showed.
kubelet's own eviction line (hard or soft) plus 512 MiB, so the op goes
before any pod is evicted. A kubelet setting it cannot read raises the
floor, never lowers it;
- the bastion also stops the op when the machine's services, pods or users
start waiting on memory: 7 points more of the time than the least they
- the bastion also stops the op when the machine's services, pods, users or
another op running beside it start waiting on memory: 7 points more of the time than the least they
had during the run. The op's own waits don't count, and neither does a group that was
already waiting, such as a pod at its own limit;
- a machine already under the floor starts no op;
+31 -7
View File
@@ -64,19 +64,43 @@
- **Egress for someone else reaches the public internet, never this
machine.** A coder run's proxy, always, and the egress lane on an anchored
machine refuse loopback, link-local (the cloud metadata address included),
private, CGNAT and reserved addresses, and any address this machine takes
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
(`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`)
IPv6 forms, and any address this machine takes
as its own: one on its interfaces (a public one included), and on Linux any
address the kernel's routing table delivers here (a `local`, broadcast,
anycast or multicast route, such as an AnyIP range on `lo`), asked of the
kernel per connection. The IPv4 address inside an 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,
kernel per connection, and on Linux any address the kernel routes straight
onto a link that stays on this machine: a container's veth, or a bridge
whose every port is a veth, a tap or a dummy, which is the bridge network
Docker, Podman, LXD and libvirt each make for their containers and VMs. A
bridge with a network card, a bond or a VLAN among its ports is the LAN, a
VPN or a tunnel to other machines leaves, and so does a route through a
gateway, which sends the packet on. A link an IPv4 or IPv6 default route
of the main table goes out of is never inside: it is this machine's way
out, so a bastion that is itself in a container dials the network on its
own veth, its LAN included. The IPv4 address inside an
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,
it refuses. A coder run reaches a LAN host only when you named it in
`coders set --allow-lan`; the anchored egress lane has no such exception.
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. A
local (unanchored) machine's egress lane is unchanged and vets no
destination. The backup lanes (`backup_fetch` and `backup_push`, anchored
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:
`localhost` names, loopback and unspecified addresses (`0.0.0.0/8` and
`::` included), link-local addresses (the cloud metadata address included),
the rest of `64:ff9b::/32`, the three deprecated IPv6 forms, and any
address this machine takes as its own, checked as above and also inside an
IPv4-mapped, NAT64 or 6to4 address. It
keeps your LAN: a private, CGNAT or reserved address that is not this
machine's is dialled. A container or VM on a network made on this machine
is this machine, not the LAN; on macOS and Windows, which have no route
lookup here, such an address is dialled. Every address a name resolves to is checked there
too, and only those addresses are dialled, in order, the next as soon as
one fails or none has answered within 300 ms. This is new in
0.55.0; before it, a local machine's egress lane vetted no destination, so
a host could reach this machine's own services through it, ollama's
`/api/pull` among them, whatever the `models` switch said. The backup lanes (`backup_fetch` and `backup_push`, anchored
or not) apply the same check of this machine's own addresses, to every
address the URL's name resolves to, to each redirect a fetch follows and to
the address dialled; this too is new in 0.50.0, and before it they refused