Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
3 changed files with 47 additions and 47 deletions

View file

@ -17,7 +17,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next
| `auth.<swarm>/` | `auth.<swarm>` | authelia (`9091`) | `deploy.authelia` | | `auth.<swarm>/` | `auth.<swarm>` | authelia (`9091`) | `deploy.authelia` |
| `<swarm>/` | `<swarm>` | swarm-ui dist (static), behind an authelia subrequest | `deploy.swarm-ui` | | `<swarm>/` | `<swarm>` | swarm-ui dist (static), behind an authelia subrequest | `deploy.swarm-ui` |
Only the host that **runs** authelia declares the authelia vhost, not every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. The authelia vhost is declared only by the host that **runs** authelia, not by every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees.
⚠️ **A `502` from this vhost typically means authelia has no users yet, not that the proxy is misconfigured.** Authelia treats an empty user store as a fatal startup error, so an enabled-but-unbootstrapped swarm crash-loops the container while the vhost in front of it works perfectly. Check `journalctl -M swarm-authelia -u authelia-swarm` before suspecting anything here; the bootstrap step is in [`swarm/sso.md`](../swarm/sso.md). ⚠️ **A `502` from this vhost typically means authelia has no users yet, not that the proxy is misconfigured.** Authelia treats an empty user store as a fatal startup error, so an enabled-but-unbootstrapped swarm crash-loops the container while the vhost in front of it works perfectly. Check `journalctl -M swarm-authelia -u authelia-swarm` before suspecting anything here; the bootstrap step is in [`swarm/sso.md`](../swarm/sso.md).
@ -28,7 +28,7 @@ Per-agent UIs stay sub-path, forge and matrix get sub-domains — see
Operator points client at `<hive>`. Sequence: Operator points client at `<hive>`. Sequence:
1. Client fetches `https://<hive>/.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat.<swarm>"}}` (no port suffix when gateway listens on 443). The gateway always terminates TLS, so the scheme is always `https`; a non-default `httpsPort` shows up as the port suffix. 1. Client fetches `https://<hive>/.well-known/matrix/client` → `{"m.homeserver":{"base_url":"https://chat.<swarm>"}}` (no port suffix when gateway listens on 443). The gateway always terminates TLS, so the scheme is always `https`; a non-default `httpsPort` is reflected as the port suffix.
2. Client connects to `chat.<swarm>/_matrix/client/...`. 2. Client connects to `chat.<swarm>/_matrix/client/...`.
3. Gateway routes `/_matrix/*` → tuwunel at `127.0.0.1:8008`. 3. Gateway routes `/_matrix/*` → tuwunel at `127.0.0.1:8008`.
@ -95,8 +95,8 @@ All agents bind their web UI on a unix-domain socket at
`/run/hive-agent/<name>/web.sock` — the `HIVE_WEB_SOCKET` env var is `/run/hive-agent/<name>/web.sock` — the `HIVE_WEB_SOCKET` env var is
now set unconditionally for every agent. The mechanism: now set unconditionally for every agent. The mechanism:
1. **Agent side**. The nix module sets `HIVE_WEB_SOCKET=/run/hive-agent/<name>/web.sock` 1. **Agent side**. `HIVE_WEB_SOCKET=/run/hive-agent/<name>/web.sock`
on every harness service env; `web_ui::serve` binds a is set on every harness service env; `web_ui::serve` binds a
`UnixListener` at that path. `UnixListener` at that path.
2. **Host side**. `hive-c0re` bind-mounts the per-agent subdir 2. **Host side**. `hive-c0re` bind-mounts the per-agent subdir
(`/run/hive-agent/<name>/`) into the agent's container. Dir (`/run/hive-agent/<name>/`) into the agent's container. Dir
@ -137,9 +137,9 @@ now set unconditionally for every agent. The mechanism:
c0re regenerates `agents.conf` (and triggers a reload) on two c0re regenerates `agents.conf` (and triggers a reload) on two
triggers: every topology change (new/removed agents) and every 10s triggers: every topology change (new/removed agents) and every 10s
marker poll tick (`agent_sockets::spawn_poll`). `write()` is marker poll tick (`agent_sockets::spawn_poll`). `write()` is
idempotent — skips the rename when content is unchanged. idempotent — skips the rename when content is unchanged. Failed reloads
`gateway_nginx::reload_if_pending` automatically retries failed reloads are retried automatically on subsequent poll ticks via
on subsequent poll ticks. `gateway_nginx::reload_if_pending`.
`agents.conf` uses atomic `<path>.tmp` + `rename()` writes so a crashing `agents.conf` uses atomic `<path>.tmp` + `rename()` writes so a crashing
c0re process never leaves a partial or unparseable file behind. c0re process never leaves a partial or unparseable file behind.
@ -153,7 +153,7 @@ as same-origin `/agent/<name>/…` URLs instead of the legacy direct
`HIVE_GATEWAY_ENABLED` env the c0re NixOS module now always sets `HIVE_GATEWAY_ENABLED` env the c0re NixOS module now always sets
(`services.hyperhive.gateway.enable` was removed — the gateway runs (`services.hyperhive.gateway.enable` was removed — the gateway runs
unconditionally alongside hyperhive), so this is effectively always unconditionally alongside hyperhive), so this is effectively always
true; the `false` branch stays as a defensive fallback for the true; the `false` branch is retained as a defensive fallback for the
env being unset. Three render sites env being unset. Three render sites
flip together: the primary agent-name link, the favicon fetch flip together: the primary agent-name link, the favicon fetch
(`<url>/icon`), and the nav-strip `container`-kind links from (`<url>/icon`), and the nav-strip `container`-kind links from
@ -216,15 +216,15 @@ swarm-services fallback below is for.
**Why a CA, not a bare leaf**: a bare self-signed leaf is its own trust anchor, so every regeneration is a new anchor every consumer must re-trust — and a runtime-generated leaf can't be wired into an agent's build-time trust store at all. With a stable CA, agents and federation peers trust it *once*; leaf rotation never re-breaks them. **Why a CA, not a bare leaf**: a bare self-signed leaf is its own trust anchor, so every regeneration is a new anchor every consumer must re-trust — and a runtime-generated leaf can't be wired into an agent's build-time trust store at all. With a stable CA, agents and federation peers trust it *once*; leaf rotation never re-breaks them.
**What consumers trust**: `trust-bundle.pem` in the same state dir, not `ca.pem`. The hive CA is itself issued under the swarm root ([`swarm/ca.md`](../swarm/ca.md) has the hierarchy), and an intermediate isn't a chain a verifier can terminate at — so the bundle carries the hive CA plus whatever it's rooted at. `hive-gateway-self-signed-cert` hands nginx the leaf with the hive CA appended for the same reason. Everything that trusts the hive's TLS reads the bundle: agents (via `security.pki.certificateFiles`), the CI and forge containers, and a federating peer. **What consumers trust**: `trust-bundle.pem` in the same state dir, not `ca.pem`. The hive CA is itself issued under the swarm root ([`swarm/ca.md`](../swarm/ca.md) has the hierarchy), and an intermediate isn't a chain a verifier can terminate at — so the bundle carries the hive CA plus whatever it's rooted at. nginx is handed the leaf with the hive CA appended for the same reason. Everything that trusts the hive's TLS reads the bundle: agents (via `security.pki.certificateFiles`), the CI and forge containers, and a federating peer.
**Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client can't bootstrap. **Why on by default**: matrix-dart-sdk (FluffyChat's SDK) hardcodes `https://<host>/.well-known/matrix/client` for homeserver discovery and refuses to fall back to plain http. Without TLS the browser client can't bootstrap.
**Cert shape**: leaf subject CN = bare hive domain; subjectAltName is `<hive>` plus wildcard `*.<hive>`, so all current and future sub-domain vhosts validate under the same leaf + the hive CA. A swarm service whose name is *not* under this hive's domain can't be added here — the hive CA is name-constrained to `<hive>`, and a violating SAN invalidates the whole leaf, not just that name. Those names get the swarm-services leaf instead ([`swarm/ca.md`](../swarm/ca.md)). **Cert shape**: leaf subject CN = bare hive domain; subjectAltName is `<hive>` plus wildcard `*.<hive>`, so all current and future sub-domain vhosts validate under the same leaf + the hive CA. A swarm service whose name is *not* under this hive's domain can't be added here — the hive CA is name-constrained to `<hive>`, and a violating SAN invalidates the whole leaf, not just that name. Those names get the swarm-services leaf instead ([`swarm/ca.md`](../swarm/ca.md)).
**Rotation**: `hive-tls-ca.service` is idempotent — it re-signs the leaf when it's missing or within 30 days of expiry, always under the same CA (so consumer trust is undisturbed). It regenerates the CA itself only if missing or already expired. To force a leaf rotation, delete `gateway.pem` under the state dir and restart the unit, then reload `nginx`. **Rotation**: `hive-tls-ca.service` is idempotent — it re-signs the leaf when it's missing or within 30 days of expiry, always under the same CA (so consumer trust is undisturbed). The CA itself is regenerated only if missing or already expired. To force a leaf rotation, delete `gateway.pem` under the state dir and restart the unit, then reload `nginx`.
**Cert prompts**: browsers still warn once per host until the operator adds the hive's `trust-bundle.pem` to the browser/OS trust store (an anchor, not the leaf, is the thing to trust). Agent trust is wired separately (see the agent-trust work for `/run/hive-ca`). **Cert prompts**: browsers still warn once per host until the hive's `trust-bundle.pem` is added to the browser/OS trust store (an anchor, not the leaf, is the thing to trust). Agent trust is wired separately (see the agent-trust work for `/run/hive-ca`).
### Operator-provided cert (`tls.certDir`) ### Operator-provided cert (`tls.certDir`)
@ -451,13 +451,13 @@ so they see the same store.
**Graceful degradation**: if `HIVE_AGENT_FRONTEND_DIR` is empty or **Graceful degradation**: if `HIVE_AGENT_FRONTEND_DIR` is empty or
unset (for example a build that predates `cfg.frontend`), each agent gets the unset (for example a build that predates `cfg.frontend`), each agent gets the
legacy single-proxy block and nginx forwards all traffic to the agent legacy single-proxy block and all traffic is forwarded to the agent
daemon as before. daemon as before.
**`extraFiles`**: per-agent `hyperhive.frontend.extraFiles` are in **`extraFiles`**: per-agent `hyperhive.frontend.extraFiles` are in
`mergedDist`, not in the base `cfg.frontend` dist. They're not under `mergedDist`, not in the base `cfg.frontend` dist. They're not under
the nix-store `alias` path, so requests for them fall through the nix-store `alias` path, so requests for them fall through
`try_files` to `@<name>_dynamic`, and the agent daemon serves them `try_files` to `@<name>_dynamic` and are served by the agent daemon
as before. as before.
## Per-agent error pages ## Per-agent error pages
@ -476,9 +476,9 @@ HTML pages instead of nginx's default error chrome:
recovery, etc.). `proxy_intercept_errors on` + `error_page 502 503 recovery, etc.). `proxy_intercept_errors on` + `error_page 502 503
504 = /__hive_agent_unreachable` rewrites to `unreachable.html`. 504 = /__hive_agent_unreachable` rewrites to `unreachable.html`.
`pkgs.runCommand` builds both pages at deploy time (one nix Both pages are built at deploy time via `pkgs.runCommand` (one nix
derivation `hyperhive-agent-error-pages` with `not-found.html` + derivation `hyperhive-agent-error-pages` with `not-found.html` +
`unreachable.html` inside), served via two `internal` nginx `unreachable.html` inside) and served via two `internal` nginx
locations with `alias` to the exact file. `internal` keeps the locations with `alias` to the exact file. `internal` keeps the
files from being directly request-able by operators — only nginx's files from being directly request-able by operators — only nginx's
own error-handling can reach them. own error-handling can reach them.
@ -564,7 +564,7 @@ ordering, preventing the subrequest from looping back through
## Security headers ## Security headers
The gateway emits the following headers at server scope on every The following headers are emitted at server scope on every gateway
vhost (`_`, `forge.<swarm-domain>`, `chat.<swarm-domain>`): vhost (`_`, `forge.<swarm-domain>`, `chat.<swarm-domain>`):
| Header | Value | | Header | Value |
@ -592,8 +592,8 @@ services.hyperhive.gateway.hsts = {
}; };
``` ```
When enabled, the gateway adds a `Strict-Transport-Security: max-age=...[; includeSubDomains]` When enabled, a `Strict-Transport-Security: max-age=...[; includeSubDomains]`
header alongside the other security headers. header is added alongside the other security headers.
**Opt-in rationale**: HSTS pins HTTPS in the browser's preload cache; **Opt-in rationale**: HSTS pins HTTPS in the browser's preload cache;
enabling it on a deployment that later loses TLS locks browsers out enabling it on a deployment that later loses TLS locks browsers out
@ -624,16 +624,16 @@ the opposite arm run as a control:
that's every service on the hive. that's every service on the hive.
- `server_name on` — sends SNI, or the far end can't pick a cert. - `server_name on` — sends SNI, or the far end can't pick a cert.
**⚠️ Session-cache footgun**: the module leaves `proxy_ssl_session_reuse` at its **⚠️ Session-cache footgun**: `proxy_ssl_session_reuse` is left at its
default (on), deliberately — nginx uses this on per-request auth default (on), deliberately — this is used on per-request auth
subrequests, so every request pays the handshake it avoids. Worth subrequests, so the handshake it avoids is paid on every request. Worth
knowing when testing though: nginx keys the session cache by upstream knowing when testing though: the session cache is keyed by upstream
address and NOT by trust config, so two locations pointing at one address and NOT by trust config, so two locations pointing at one
upstream with different trust don't verify independently. upstream with different trust don't verify independently.
**⚠️ Host-header clobber footgun**: `verifiedProxyTo` also pins `Host` **⚠️ Host-header clobber footgun**: `verifiedProxyTo` also pins `Host`
(and reinstates the rest of nginx's `recommendedProxySettings` header (and reinstates the rest of nginx's `recommendedProxySettings` header
set) to the target `name` rather than letting nginx fill it in later. set) to the target `name` rather than leaving it to be filled in later.
`name` here resolves back to THIS gateway — every consumer dials another `name` here resolves back to THIS gateway — every consumer dials another
vhost on the same nginx, not a separate host — and nginx picks the vhost vhost on the same nginx, not a separate host — and nginx picks the vhost
to answer an HTTPS request from the `Host` header, not from the TLS SNI to answer an HTTPS request from the `Host` header, not from the TLS SNI

View file

@ -55,8 +55,8 @@ The flows, end to end:
- **DHCP** — agent `dhcpcd` broadcasts on `eth0` → veth → bridge → - **DHCP** — agent `dhcpcd` broadcasts on `eth0` → veth → bridge →
host firewall (udp 67 hole) → dnsmasq pool → lease + router option. host firewall (udp 67 hole) → dnsmasq pool → lease + router option.
- **DNS** — agents and the service containers query `bridgeIp:53`; dnsmasq - **DNS** — agents and the service containers query `bridgeIp:53`; hive
answers hive zones authoritatively with the bridge IP, everything else zones are answered authoritatively with the bridge IP, everything else
forwards to the host's resolvers (see _Resolver behaviour_ below). Each forwards to the host's resolvers (see _Resolver behaviour_ below). Each
container points its own `resolv.conf` there, and one that instead container points its own `resolv.conf` there, and one that instead
inherits the host's resolves no swarm name at all — those records exist inherits the host's resolves no swarm name at all — those records exist
@ -99,7 +99,7 @@ listener on `bridgeIp` is on the host's bridge interface.
} }
``` ```
You must set `services.hyperhive.domain` — the dnsmasq resolver Requires `services.hyperhive.domain` to be set — the dnsmasq resolver
is authoritative for `<hive-domain>` and its sub-domains. You don't is authoritative for `<hive-domain>` and its sub-domains. You don't
write it: it's read from this hive's entry in the swarm directory write it: it's read from this hive's entry in the swarm directory
(`docs/swarm/README.md` § Hive identity config). (`docs/swarm/README.md` § Hive identity config).
@ -123,8 +123,8 @@ when it runs authelia — answering each with the bridge IP (where nginx
is reachable). Note forge and matrix are swarm-domain names, not is reachable). Note forge and matrix are swarm-domain names, not
sub-domains of `<hive-domain>`: a swarm runs one forge and one sub-domains of `<hive-domain>`: a swarm runs one forge and one
homeserver, so their names belong to the swarm rather than to whichever homeserver, so their names belong to the swarm rather than to whichever
hive happens to host them. dnsmasq forwards everything else to the host's hive happens to host them. Everything else is forwarded to the host's
own resolvers: it runs on the host and reads the host's own resolvers: dnsmasq runs on the host and reads the host's
`/etc/resolv.conf` directly. Containers don't need to know the `/etc/resolv.conf` directly. Containers don't need to know the
upstream — they query the bridge IP and dnsmasq does the right thing upstream — they query the bridge IP and dnsmasq does the right thing
per-name. per-name.
@ -224,11 +224,11 @@ address arithmetic.
`hive-c0re` reads `HIVE_NETWORK_BRIDGE` + `HIVE_NETWORK_SUBNET` and passes `hive-c0re` reads `HIVE_NETWORK_BRIDGE` + `HIVE_NETWORK_SUBNET` and passes
`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=` (empty), `HOST_ADDRESS=<bridge-ip>`, `PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=` (empty), `HOST_ADDRESS=<bridge-ip>`,
and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when and `HOST_BRIDGE=<bridgeName>` via `lifecycle::set_nspawn_flags` when
creating or updating containers. hive-c0re validates both variables **once at creating or updating containers. Both variables are validated **once at
daemon startup**, not per container: they're process-global, so a daemon startup**, not per container: they're process-global, so a
missing or malformed value is a misconfigured daemon rather than one bad missing or malformed value is a misconfigured daemon rather than one bad
container, and failing at boot gives a single diagnostic instead of one container, and failing at boot gives a single diagnostic instead of one
per agent. No non-isolated mode exists to fall back to. hive-c0re leaves `LOCAL_ADDRESS` empty so the per agent. No non-isolated mode exists to fall back to. `LOCAL_ADDRESS` is left empty so the
container's dhcpcd acquires an address from the bridge dnsmasq pool container's dhcpcd acquires an address from the bridge dnsmasq pool
(`networking.useDHCP = true` in `nix/agent-modules/network.nix`). This applies uniformly (`networking.useDHCP = true` in `nix/agent-modules/network.nix`). This applies uniformly
to all containers — agents and service containers alike. to all containers — agents and service containers alike.
@ -238,8 +238,8 @@ to all containers — agents and service containers alike.
so a non-`.1` operator override still resolves to wherever the bridge so a non-`.1` operator override still resolves to wherever the bridge
actually lives). It's **load-bearing**: nixos-container's container-side actually lives). It's **load-bearing**: nixos-container's container-side
network setup only installs a default route (`ip route add default via network setup only installs a default route (`ip route add default via
$HOST_ADDRESS`) when `HOST_ADDRESS` is non-empty. In bridge mode $HOST_ADDRESS`) when `HOST_ADDRESS` is non-empty. In bridge mode the
nixos-container skips the host-side address/route setup, so writing it only affects the host-side address/route setup is skipped, so writing it only affects the
container's default route — without it the container comes up with an IP container's default route — without it the container comes up with an IP
but no path off the bridge subnet (no internet, no `api.anthropic.com`). but no path off the bridge subnet (no internet, no `api.anthropic.com`).
@ -249,8 +249,8 @@ nixos-container copies the **host's** `/etc/resolv.conf` into the container
at every start. The host resolver (for example `127.0.0.53` from systemd-resolved, at every start. The host resolver (for example `127.0.0.53` from systemd-resolved,
or a LAN router) is unreachable from a private netns and isn't or a LAN router) is unreachable from a private netns and isn't
authoritative for the hive's own zones, so it's replaced with the authoritative for the hive's own zones, so it's replaced with the
bridge dnsmasq at boot. Because the copy happens on every start, it bridge dnsmasq at boot. Because the copy happens on every start, a
would clobber a declarative `environment.etc."resolv.conf"` — so the declarative `environment.etc."resolv.conf"` would be clobbered — so the
wiring is runtime: wiring is runtime:
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the - `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the

View file

@ -10,9 +10,9 @@ assumptions:
- **It's not the swarm controller**, and doesn't depend on one. It's - **It's not the swarm controller**, and doesn't depend on one. It's
a NixOS host role: a btrfs subvolume tree, a socket-activated a NixOS host role: a btrfs subvolume tree, a socket-activated
receiver, and the `wg-hive` interface the swarm module already brings receiver, and the `wg-hive` interface the swarm module already brings
up. That's why you can deploy it before any controller exists. up. That's why it can be deployed before any controller exists.
- **It's not a backup product.** It happens to hold the data a backup - **It's not a backup product.** It happens to hold the data a backup
would hold, and you should operate it accordingly (see would hold, and it should be operated accordingly (see
[Operating it](#operating-it)) --- but nothing in it does scheduling, [Operating it](#operating-it)) --- but nothing in it does scheduling,
verification, or restore orchestration. verification, or restore orchestration.
@ -37,14 +37,14 @@ The store host is a swarm member like any other: it gets an entry in
`services.hyperhive.swarm.hives`, the same directory every host holds. See `services.hyperhive.swarm.hives`, the same directory every host holds. See
[swarm/](../swarm/README.md) for the mesh itself. [swarm/](../swarm/README.md) for the mesh itself.
Note that `deploy.wireguard.enable` gates the mesh, **not** Note that the mesh is gated on `deploy.wireguard.enable`, **not** on
`c0re.enable` --- a store host runs no hive and would otherwise get no `c0re.enable` --- a store host runs no hive and would otherwise get no
`wg-hive` interface at all. `wg-hive` interface at all.
## Pointing a hive at it ## Pointing a hive at it
The block above configures the host that _receives_. You must tell every The block above configures the host that _receives_. Every hive that
hive that _pushes_ separately where the store is: _pushes_ separately needs to be told where the store is:
```nix ```nix
services.hyperhive.swarm.snapshotStore = { services.hyperhive.swarm.snapshotStore = {
@ -84,9 +84,9 @@ deliberate rather than an omission.
WireGuard's cryptokey routing already binds a peer's source address to WireGuard's cryptokey routing already binds a peer's source address to
its public key: the swarm module configures each peer with its public key: the swarm module configures each peer with
`allowedIPs = [ peer.wireguardAddress ]`, so a packet arriving from `allowedIPs = [ peer.wireguardAddress ]`, so a packet arriving from
that address provably came from the holder of that private key. By the that address provably came from the holder of that private key. A
time a packet reaches the receiver, the kernel has already packet that reaches the receiver has therefore already been
authenticated it. authenticated by the kernel.
Layering TLS client certs on top would authenticate _the same fact_ a Layering TLS client certs on top would authenticate _the same fact_ a
second time, and add a credential with an expiry --- a migration that second time, and add a credential with an expiry --- a migration that
@ -95,7 +95,7 @@ you need to move an agent.
## One subvolume per agent, not per hive ## One subvolume per agent, not per hive
The receiver keys the destination by **agent**. The destination is keyed by **agent**.
This isn't cosmetic. After a migration, an agent's next incremental This isn't cosmetic. After a migration, an agent's next incremental
send arrives from a _different_ hive than the previous one. Keying by send arrives from a _different_ hive than the previous one. Keying by
@ -130,7 +130,7 @@ state.
Binding isn't sufficient on its own. NixOS's firewall is default-deny Binding isn't sufficient on its own. NixOS's firewall is default-deny
and filters in netfilter, _before_ a packet reaches a bound socket, so and filters in netfilter, _before_ a packet reaches a bound socket, so
the module explicitly opens the port --- scoped to the mesh interface: the port is opened explicitly --- and scoped to the mesh interface:
```nix ```nix
networking.firewall.interfaces.wg-hive.allowedTCPPorts = [ cfg.port ]; networking.firewall.interfaces.wg-hive.allowedTCPPorts = [ cfg.port ];
@ -159,8 +159,8 @@ The boundary is the machine. The intended deployments are:
host. host.
The second is worth keeping deliberately, and not only for The second is worth keeping deliberately, and not only for
convenience: it means every local deployment exercises the confined convenience: it means the confined path is exercised by every local
path. The usual failure mode for an isolated variant is that deployment. The usual failure mode for an isolated variant is that
nobody runs it day to day, so it rots and is discovered broken in nobody runs it day to day, so it rots and is discovered broken in
production. production.
@ -186,7 +186,7 @@ The snapshot covers an agent's **state subvolume**, which is the parent
of `state/`, `claude/` and `harness/` (see of `state/`, `claude/` and `harness/` (see
[`docs/agent-lifecycle/persistence.md`'s btrfs subvolume [`docs/agent-lifecycle/persistence.md`'s btrfs subvolume
section](../agent-lifecycle/persistence.md#btrfs-subvolumes-for-varlibhyperhiveagentsname) section](../agent-lifecycle/persistence.md#btrfs-subvolumes-for-varlibhyperhiveagentsname)
for how and when hive-c0re creates that subvolume). Consequences: for how and when that subvolume is created). Consequences:
- The Claude session (`claude/`) travels, so a restored agent keeps its - The Claude session (`claude/`) travels, so a restored agent keeps its
live `--continue` session rather than needing to log in again. live `--continue` session rather than needing to log in again.