bastion 0.58.1
This commit is contained in:
+11
-9
@@ -121,13 +121,15 @@ The file:
|
||||
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.
|
||||
|
||||
This bastion does not bring up the new host. Running one on hardware you own
|
||||
needs infrastructure that is not part of this program, and not built yet:
|
||||
This bastion does not bring up the new host. A Host on hardware you own is a
|
||||
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
|
||||
policy, admission and storage the host's pod expects;
|
||||
- the operator that creates and runs a host's pod on that cluster;
|
||||
- the host's container images, from a registry that cluster can pull from;
|
||||
- the certificate authority that issues the host its identity, and the
|
||||
services a host reaches when it starts, or replacements for them;
|
||||
- a place the new host can fetch the backup from, as above.
|
||||
1. Copy the backup and its key file (`<stamp>.tar.zst.age` and
|
||||
`<stamp>.key.age`) out of the lost host's backup folder here.
|
||||
2. In the C-Series Host's Backups app, under "Restore from a file", choose
|
||||
the two together and give the lost host's recovery key. The page opens the
|
||||
key file with it and seals the backup's key to the new Host; the recovery
|
||||
key never leaves the page.
|
||||
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 ./...
|
||||
```
|
||||
|
||||
The suite covers all three platforms' parsing and judgement logic from a single
|
||||
`go test` on any OS, because every platform-specific decision is kept in files
|
||||
with no build tag and only the enumerators are tagged.
|
||||
Most parsing and judgement logic is in files with no build tag and runs under
|
||||
`go test` on any OS. Some platform parsers and the Linux sandbox are in tagged
|
||||
files, and their tests run only on that OS.
|
||||
|
||||
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
|
||||
|
||||
+18
-12
@@ -1,6 +1,7 @@
|
||||
# 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
|
||||
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. |
|
||||
| | `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_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`). |
|
||||
| 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. |
|
||||
@@ -55,7 +56,7 @@ 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. |
|
||||
| | `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. |
|
||||
| | `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. |
|
||||
| | `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. |
|
||||
@@ -72,7 +73,7 @@ says so. Those parts are enforced only by the code as written.
|
||||
| | `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_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. |
|
||||
| | `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. |
|
||||
@@ -166,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. |
|
||||
| | `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. |
|
||||
| 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`: `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. |
|
||||
@@ -181,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. |
|
||||
| | `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. |
|
||||
| | `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_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_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. |
|
||||
@@ -203,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. |
|
||||
| | `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`: `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`: `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. |
|
||||
@@ -236,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`: `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_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`: `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`: `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. |
|
||||
@@ -253,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. |
|
||||
| | `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`: `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. |
|
||||
| | `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. |
|
||||
@@ -299,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`: `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`. |
|
||||
| | `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. |
|
||||
| | `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. |
|
||||
|
||||
+8
-2
@@ -28,16 +28,22 @@
|
||||
| `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. |
|
||||
| `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 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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:baseline [--force]` | Record what is normal on this machine. Administrator only. |
|
||||
| `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`
|
||||
and reachable from nowhere else: pairings, the anchor, containment state, marks
|
||||
on automated programs, the dry-run policy, and pending approvals. On Windows
|
||||
a tray icon shows a one-line state and offers four actions: open what is
|
||||
waiting for your approval, open the console, open your context page, and stop
|
||||
the bastion.
|
||||
on automated programs, the dry-run policy, pending approvals, set-up, what this
|
||||
machine has given away (where you switch a host's capabilities off), and a
|
||||
disk map. On Windows a tray icon shows a one-line state. Run as you, it opens
|
||||
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
|
||||
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
|
||||
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
|
||||
never deletes them. Dropping a grant removes the grant, not your files.
|
||||
**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
|
||||
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.
|
||||
`flag` mode, which replaces only what a pattern can find: e-mail addresses,
|
||||
phone numbers, card numbers, US social security 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. 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
|
||||
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`):
|
||||
its own Linux virtual machine. Its installer, `npx cseries`, forwards three
|
||||
ports of the VM (8800, 8810, 8811) 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
|
||||
# 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
|
||||
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
|
||||
that does not administer the machine, `pair`, `run` and `status` are refused,
|
||||
so that account does not end up with a second, empty bastion of its own.
|
||||
that does not administer the machine, `pair`, `run`, `status` and `switch` are
|
||||
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
|
||||
(`version.json`, read by the installers at `https://dl.privatae.ai/version.json`
|
||||
|
||||
+13
-12
@@ -3,27 +3,28 @@
|
||||
## Tunnel
|
||||
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.
|
||||
The tunnel is dialled from this machine. Inside it, the bastion accepts on
|
||||
exactly three addresses, all under the `inference` capability family: a
|
||||
forward to a local ollama (port 11434), a forward to an OpenAI-compatible
|
||||
server when one is configured (port 18080), and a forward to a local PII
|
||||
sterilizer when one is configured. Nothing else listens.
|
||||
The tunnel is dialled from this machine. Its WireGuard UDP port, on every
|
||||
address at a port the system picks when the tunnel comes up, drops every
|
||||
packet that does not come from the host's key. Inside the tunnel, the
|
||||
bastion accepts only on gated forwards: ollama (11434), an OpenAI-compatible server (18080) and a PII
|
||||
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
|
||||
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,
|
||||
so this lane is the way in. `privatae-bastion git-setup` prints the three
|
||||
config lines: the proxy, a reset of the credential-helper chain for that URL
|
||||
`git.privatae.ai` through your host. `privatae-bastion git-setup` prints the
|
||||
three 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.
|
||||
|
||||
## Worker lanes
|
||||
Outbound-only reverse relays to the host: egress, management, harness, and
|
||||
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,
|
||||
the one that runs a program is text extraction: it invokes a local tool named
|
||||
in a fixed table
|
||||
your files are dedicated Go handlers with no shell. Two of them start a
|
||||
program: text extraction invokes a local tool named in a fixed table
|
||||
(`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
|
||||
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-verify.sh check a manifest's signature against the pinned keys
|
||||
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
|
||||
check-claims.sh check that every test the security table cites exists
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
+26
-10
@@ -1,10 +1,15 @@
|
||||
# Security model
|
||||
|
||||
- **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;
|
||||
the control ports and console bind loopback only, and the optional
|
||||
memory-mirror forwarder binds loopback plus the replica container's private
|
||||
gateway address, with every accept source-checked.
|
||||
machine to the host. The only ports open on an outside interface are the
|
||||
tunnels' WireGuard UDP ports: one for each host paired over a tunnel, on
|
||||
every IPv4 and IPv6 address, at a port the system picks when that tunnel
|
||||
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
|
||||
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
|
||||
@@ -132,8 +137,8 @@
|
||||
- **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
|
||||
internet.
|
||||
- **Egress for someone else reaches the public internet, never this
|
||||
machine.** A coder run's proxy, always, and the egress lane on an anchored
|
||||
- **Egress for someone else reaches the public internet, and nothing on
|
||||
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),
|
||||
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
|
||||
(`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`)
|
||||
@@ -153,8 +158,10 @@
|
||||
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.
|
||||
it refuses. A coder run reaches a host you named in `coders set
|
||||
--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
|
||||
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:
|
||||
@@ -236,6 +243,14 @@
|
||||
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
|
||||
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
|
||||
signature against the release keys compiled into it, then checks the
|
||||
binary's SHA-256 against that manifest before anything is swapped. The
|
||||
@@ -254,8 +269,9 @@
|
||||
|
||||
## How each claim is tested
|
||||
|
||||
[claims.md](claims.md) names, for each point above, the test that fails if
|
||||
it stops being true, and says where there is none.
|
||||
[claims.md](claims.md) covers each point above and some further claims not
|
||||
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
|
||||
[SECURITY.md](../SECURITY.md).
|
||||
|
||||
Reference in New Issue
Block a user