docs: sort the top-level directories into five sections by what the reader wants
Every docs directory moves under 1-getting-started, 2-handbook, 4-implementation or 5-project; the options reference (section 3) comes from the website and has no directory here. trust-boundary stays whole under the handbook for now. web-ui, swarm and tools split between handbook and implementation, and the command-line references move to 2-handbook/reference. Links, path references in code and nix, and the docs index follow the moves; no page content changes.
This commit is contained in:
parent
53ccbcb4b3
commit
6739bb903d
1 changed files with 736 additions and 701 deletions
580
docs/4-implementation/networking/network.md
Normal file
580
docs/4-implementation/networking/network.md
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
# hive-network
|
||||
|
||||
The bridge every container on a host attaches to, and the private-netns
|
||||
isolation of agent and CI containers behind it. It comes up on any host that runs agents or swarm services:
|
||||
`services.hyperhive.network.enable` defaults to off, and the modules that
|
||||
need the bridge set it with `mkDefault true` — the hive controller, CI,
|
||||
the hive collector, and the gateway's resolver, which every swarm service
|
||||
turns on. Configured via `services.hyperhive.network.*`.
|
||||
|
||||
Agent containers always run in a private netns.
|
||||
|
||||
## Network map
|
||||
|
||||
One picture of the whole hive — two planes: **infra
|
||||
containers share the host netns** and bind host ports directly;
|
||||
**compute containers (agents + CI) each get a private netns** behind
|
||||
the bridge. The unix-socket control plane rides the VFS, and none of
|
||||
that touches it.
|
||||
|
||||
```
|
||||
internet
|
||||
│ uplink NIC — NAT MASQUERADE for the
|
||||
│ bridge subnet (10.42.0.0/24 default)
|
||||
┌──────────────────────────┴─────────────────────────────────────────┐
|
||||
│ host netns — the host itself plus gateway / forge │
|
||||
│ │
|
||||
│ nginx :80/:443 [hive-gateway] │
|
||||
│ dnsmasq 10.42.0.1:53 (DNS) + :67 (DHCP) [hive-gateway] │
|
||||
│ squid 10.42.0.1:3128 (egress proxy) [hive-squid] │
|
||||
│ forgejo :3000 http, :2222 git-ssh [hive-forge] │
|
||||
│ hive-c0re dashboard 127.0.0.1:7000 (host service) │
|
||||
│ wg-hive :51820/udp — swarm mesh, when enabled (host iface) │
|
||||
│ │
|
||||
│ hive-br0 10.42.0.1/24 │
|
||||
│ ┌──────────┼──────────────┐ │
|
||||
└──────────────┼──────────┼──────────────┼───────────────────────────┘
|
||||
vb-h-<a> vb-h-<b> vb-<runner> veth pairs
|
||||
│ │ │
|
||||
┌────┴────┐ ┌───┴─────┐ ┌──────┴──┐ one private netns
|
||||
│ agent a │ │ agent b │ │ runner │ each; eth0 leases
|
||||
│ eth0 │ │ eth0 │ │ eth0 │ from the DHCP pool
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
| container | netns | IPv4 | listens / reached via |
|
||||
| ------------------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| gateway (host) | host | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge |
|
||||
| egress proxy (host) | host | `bridgeIp` | squid `bridgeIp:3128`; agents with a credential send HTTP and HTTPS through it |
|
||||
| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost |
|
||||
| `hive-matrix` | private, veth on bridge | DHCP pool | tuwunel on UDS `/run/hive-matrix/tuwunel.sock` → the matrix vhost; out to store, SSO, peers |
|
||||
| `swarm-ci-runner` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface |
|
||||
| `swarm-review-classifier` | private, veth on bridge | DHCP pool | outbound only (classifier → queue, secret store, swarm collector); no inbound surface |
|
||||
| `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 8100–8999 |
|
||||
|
||||
The flows, end to end:
|
||||
|
||||
- **DHCP** — agent `dhcpcd` broadcasts on `eth0` → veth → bridge →
|
||||
host firewall (udp 67 hole) → dnsmasq pool → lease + router option.
|
||||
`dhcpcd` runs with `noipv4ll` and waits for an IPv4 address, so
|
||||
`network-online.target` means a lease, never a 169.254.x fallback; units
|
||||
that look up a swarm name at start order after it.
|
||||
- **DNS** — agents and the service containers query `bridgeIp:53`; dnsmasq
|
||||
answers hive zones authoritatively with the bridge IP, everything else
|
||||
forwards to the host's resolvers (see _Resolver behaviour_ below). Each
|
||||
container points its own `resolv.conf` there, and one that instead
|
||||
inherits the host's resolves no swarm name at all — those records exist
|
||||
only on the bridge.
|
||||
- **HTTP** — `forge.` and `chat.` (under `swarm.domain`) plus the hive's
|
||||
own dashboard name resolve to the bridge IP, land on nginx
|
||||
`:80`/`:443`, and proxy to forgejo
|
||||
`:3000`, tuwunel's unix socket, hive-c0re `127.0.0.1:7000`, or a per-agent
|
||||
UI unix socket.
|
||||
- **Internet egress** — agent default route points at the bridge IP;
|
||||
the host forwards + masquerades out its uplink.
|
||||
- **Swarm** — peer hives connect over the `wg-hive` WireGuard mesh
|
||||
and reach each other's gateway/forge across it
|
||||
([`docs/swarm/`](../../2-handbook/swarm/README.md)).
|
||||
- **Control plane (no network)** — per-agent broker socket
|
||||
`/run/hive/mcp.sock`, privileged helper `/run/hive/priv.sock`,
|
||||
operator admin `/run/hyperhive/host.sock`, and the per-agent UI
|
||||
sockets under `/run/hive-agent/` are unix domain sockets
|
||||
bind-mounted through the VFS; private netns doesn't affect them.
|
||||
|
||||
## Container shape (where dnsmasq lives)
|
||||
|
||||
dnsmasq runs on the host itself, next to nginx — both come from the
|
||||
gateway module, one front door for DNS and HTTP. Its `bind-interfaces`
|
||||
listener sits on `bridgeIp`, on the host's bridge interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
```nix
|
||||
{
|
||||
services.hyperhive = {
|
||||
deploy.hive-controller.enable = true;
|
||||
deploy.hive-controller.hiveName = "pr1ma";
|
||||
swarm.domain = "darkest.space";
|
||||
swarm.hives.pr1ma = { }; # -> domain = pr1ma.darkest.space
|
||||
# network.bridgeIp = "10.42.0.1"; # default
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The hive domain (`services.hyperhive.domain`, which the resolver answers
|
||||
for) comes from this hive's entry in the swarm directory:
|
||||
`swarm.hives.<hiveName>.domain`, default `<hiveName>.<swarm.domain>`.
|
||||
Eval fails until `swarm.domain` and that entry exist
|
||||
([`swarm/README.md`](../../2-handbook/swarm/README.md) § Hive identity config).
|
||||
|
||||
## Bridge addressing
|
||||
|
||||
Default subnet is `10.42.0.0/24`, host-side gateway at `10.42.0.1`.
|
||||
RFC 1918 space, unlikely to clash with operator's existing setup;
|
||||
override `bridgeIp` + `bridgePrefixLength` if a different range is
|
||||
already in use. `/24` gives 254 usable per-agent addresses — enough
|
||||
for any single-host hive; bigger swarms or tighter addressing
|
||||
schemes pick their own.
|
||||
|
||||
## Resolver behaviour
|
||||
|
||||
dnsmasq is **authoritative** for the hive's own zone (`<hive-domain>`)
|
||||
plus whatever swarm-service names this host contributes via
|
||||
`gateway.localNames` — `forge.<swarm-domain>` and `chat.<swarm-domain>`
|
||||
(matrix) when this host runs those services, `auth.<swarm-domain>`
|
||||
when it runs authelia, and `hive-collector.hive` on every hive
|
||||
— answering each with the bridge IP (where nginx
|
||||
is reachable, and where the hive's own collector listens). Note forge and
|
||||
matrix are swarm-domain names, not sub-domains of `<hive-domain>`: a swarm
|
||||
runs one forge and one homeserver, so their names belong to the swarm rather
|
||||
than to whichever hive happens to host them.
|
||||
|
||||
Every domain answered this way is also `local=` for dnsmasq, so the other
|
||||
record types (AAAA, TXT, MX, HTTPS) for those names get an empty answer from
|
||||
dnsmasq itself instead of waiting on the host's upstream resolver. Without it,
|
||||
dnsmasq 2.86 and later send them upstream, and a host whose upstream doesn't
|
||||
answer leaves each lookup to its client's own timeout.
|
||||
|
||||
dnsmasq forwards everything else to the host's
|
||||
own resolvers: it runs on the host and reads the host's
|
||||
`/etc/resolv.conf` directly. Containers don't need to know the
|
||||
upstream — they query the bridge IP and dnsmasq does the right thing
|
||||
per-name.
|
||||
|
||||
No fallback `server=` exists, by design: dnsmasq queries all known
|
||||
upstreams in parallel, so a hardcoded public resolver would take a share
|
||||
of normal traffic, not just cover the gap.
|
||||
|
||||
dnsmasq runs on the host and reads the host's `/etc/resolv.conf`
|
||||
directly, so a network change (new router, new lease, laptop moving
|
||||
networks) reaches it the moment openresolv rewrites the file. Nothing
|
||||
needs synchronising, and no unit watches for it.
|
||||
|
||||
`bind-interfaces` + `interface = [ bridgeName "lo" ]` means the
|
||||
listener only accepts queries from the bridge interface (plus lo for
|
||||
container health-checks). External hosts can't reach it — no
|
||||
DNS-amplification surface even when the operator opens port 80 for
|
||||
gateway HTTP.
|
||||
|
||||
`resolveLocalQueries = false` keeps dnsmasq out of the host's own
|
||||
resolution stack — the host's resolver (systemd-resolved, plain
|
||||
glibc nss, dnscrypt-proxy, etc.) keeps doing whatever the operator
|
||||
configured. The hive resolver is purely for inbound queries from
|
||||
agent containers.
|
||||
|
||||
## Firewall posture
|
||||
|
||||
`networking.firewall.interfaces.<bridge>.allowedUDPPorts = [ 53 67 ]`
|
||||
`networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 53 80 443 ]`
|
||||
|
||||
- Port 53 opens the resolver on the bridge interface only. Other
|
||||
interfaces stay closed. The hive resolver isn't an external-facing
|
||||
service.
|
||||
- Port 67 (UDP) admits DHCP requests to the dnsmasq pool. dnsmasq
|
||||
receives DHCP via a regular UDP socket (it doesn't use a
|
||||
netfilter-bypassing raw socket), so the hole is mandatory — without
|
||||
it containers never get a lease and fall back to 169.254.x.x.
|
||||
- Ports 80 and 443 let isolated agents reach nginx (on the host) for the forge sub-domain, per-agent
|
||||
UI proxies, and any other HTTP services.
|
||||
|
||||
The **host** firewall is the only firewall. The swarm service
|
||||
containers that share the host netns (forge, bao, the
|
||||
metrics and log stores) run with `networking.firewall.enable = false`
|
||||
(`nix/container-modules/swarm-container.nix`): a NixOS firewall inside a
|
||||
shared-netns container runs against the _host_ ruleset — at container
|
||||
boot its `firewall-start` flushes the `nixos-fw` chains, rebuilds them
|
||||
from the container's (empty) port list, and deletes the host's
|
||||
`nixos-nat-*` chains without recreating them, silently wiping the
|
||||
bridge holes above plus the agents' NAT. Private-netns containers
|
||||
(every container with `privateNetwork = true`: agents and the service containers on the bridge) keep their own firewall — it's scoped to their
|
||||
namespace.
|
||||
|
||||
### Reaching host services (`exposeHostPorts`)
|
||||
|
||||
By default agents can only reach the host on 80/443 (+53 DNS), so a
|
||||
host-side service on another port — for example a dev OTLP collector you want
|
||||
agents to reach directly — is unreachable. (hyperhive's own services
|
||||
need none of this: a module that means to be reachable from agents opens
|
||||
its own port here, which the hive collector and `nats.enable` both do. Note
|
||||
that `deploy.swarm-otel.upstream.endpoint` is the _upstream_, which no agent ever dials. See
|
||||
`docs/4-implementation/scheduler/observability.md`.)
|
||||
|
||||
`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each
|
||||
listed TCP port `P` on the bridge-interface `allowedTCPPorts`, so an
|
||||
agent can connect to `<bridgeIp>:P` (for a dev OTLP collector, point its
|
||||
endpoint at `http://<bridgeIp>:4318`, default `http://10.42.0.1:4318`).
|
||||
The hive's own collector needs none of this: agents dial it by
|
||||
`otel.collector.host`.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
This is **firewall-only**: the host service must bind an address
|
||||
reachable from the bridge — `0.0.0.0` or the bridge IP — not loopback
|
||||
only. The bridge→`127.0.0.0/8` DROP rule (below) is unchanged, so a
|
||||
service bound to `127.0.0.1` only stays unreachable; rebind it to
|
||||
`0.0.0.0`.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
The port is reachable by **every** agent on the bridge subnet (like
|
||||
DNS/gateway), so only expose services safe for any agent to reach.
|
||||
|
||||
## Egress proxy (squid)
|
||||
|
||||
Every hive host runs squid in the host netns, listening on `<bridgeIp>:3128`
|
||||
(`nix/host-modules/hive-squid.nix`). The port is open on the bridge interface
|
||||
only. An agent sends its outbound HTTP and HTTPS through it once its hive says
|
||||
the proxy is ready and it has fetched its proxy credential (see _Agents and the
|
||||
proxy_ below). Any other agent goes straight out through the NAT, as before,
|
||||
and so does any program that ignores the proxy variables: nothing blocks direct
|
||||
egress yet.
|
||||
|
||||
Squid filters on the destination hostname: a `CONNECT` target or a plain-HTTP
|
||||
host. It never decrypts TLS, so it holds no signing key. It caches nothing.
|
||||
|
||||
- **Credentials.** A request for a listed name must carry a per-agent proxy
|
||||
credential (HTTP Basic, the agent's name and its secret). `swarm-controller`
|
||||
mints one per agent at `swarm/agents/<agent>/proxy-credential`, and never
|
||||
replaces it. `hive-c0re` hashes the credentials of this hive's agents, and
|
||||
one more for the self-check, into `services.hyperhive.deploy.hive-controller.squid.usersFile`
|
||||
every 30 seconds. Squid's `basic_ncsa_auth` helper re-reads the file when it
|
||||
changes. A stopped agent stays in the file. Squid caches a good login for a
|
||||
minute. Nothing rotates a credential: to replace one, delete its leaf in the
|
||||
store and the next pass mints a new one. HTTP Basic crosses the bridge in
|
||||
clear between an agent and the proxy. See
|
||||
[the credentials reference](../../2-handbook/swarm/credentials-reference.md).
|
||||
A request without a valid credential gets a 407, once the destination
|
||||
checks below have passed it.
|
||||
|
||||
- **Agents and the proxy.** An agent uses the proxy only when its hive says
|
||||
this host's squid is ready for it. `hive-c0re` puts two facts in every
|
||||
container's `/etc/hyperhive-hive-facts`, only on a hive that holds a store
|
||||
identity: `HIVE_SQUID_PROXY` (`services.hyperhive.deploy.hive-controller.squid.listen`, the bridge IP
|
||||
and port) and `HIVE_EGRESS_NO_PROXY` (the direct hosts below). A hive that
|
||||
predates them writes neither, and its agents go direct.
|
||||
`hive-agent-proxy-credential.service` (`nix/agent-modules/egress-proxy.nix`)
|
||||
runs in each agent's container, as the agent user, at start and every 10
|
||||
minutes. With both facts present it logs in to the secret store with the
|
||||
agent's own certificate, reads `swarm/agents/<agent>/proxy-credential` and
|
||||
writes `/run/hive-agent-proxy-credential/proxy.env` (`0600`) with
|
||||
`HTTPS_PROXY`, `HTTP_PROXY`, their lower-case forms, `NODE_USE_ENV_PROXY=1`
|
||||
and `NO_PROXY`/`no_proxy`. The proxy URL is
|
||||
`http://<agent>:<credential>@<bridgeIp>:3128`; Claude Code, Node, curl, git
|
||||
and reqwest send the userinfo as `Proxy-Authorization`. The harness, its bash
|
||||
tool, the subagents, the MCP servers and the pollers load the file with
|
||||
`EnvironmentFile=-` when they start. Until the facts and the credential both
|
||||
exist nothing writes the file, so those units have no proxy variable and go
|
||||
direct; a failed read keeps the file already in place. Nothing restarts on
|
||||
deploy: an agent takes the proxy at its next start, which is its next rebuild
|
||||
or restart. The credential is in the environment of every process those units
|
||||
start, so `env` in the agent's shell prints it. Login shells
|
||||
(`nixos-container root-login`, SSH) don't load the file.
|
||||
- **Direct destinations.** `HIVE_EGRESS_NO_PROXY` is the hosts this agent must
|
||||
reach without the proxy, and `NO_PROXY` is `localhost,127.0.0.1,::1` followed
|
||||
by that list. `hive-c0re` computes it from the host's options: the bridge IP,
|
||||
the hive's domain (`services.hyperhive.domain`), the secret store and queue
|
||||
names (`services.hyperhive.swarm.bao.domain`, `swarm.nats.domain`),
|
||||
`services.hyperhive.swarm.serviceDomains` (the swarm UI and controller apex,
|
||||
the forge, matrix, attic, logs, metrics and otel names) and every name the
|
||||
gateway answers with the bridge IP (`services.hyperhive.gateway.localNames`).
|
||||
Squid refuses every destination that resolves to the bridge, and the hive's
|
||||
resolver answers the bridge address for those names, so they never go through
|
||||
the proxy. A bare name matches itself and its subdomains. Node's `NO_PROXY`
|
||||
has no CIDR support, so the list holds the bridge as an address.
|
||||
|
||||
- **Two lists.** The credential decides the policy. `hive-c0re` writes a
|
||||
second file beside the users file, `services.hyperhive.deploy.hive-controller.squid.unrestrictedFile`
|
||||
(`/var/lib/hive-squid/unrestricted`, `hive-core:squid`, `0640`): the names of
|
||||
the users whose agent holds the `unrestricted_internet` flag in the
|
||||
permissions the hive last read from the swarm, one per line, rewritten when
|
||||
it changes. The default is permissive: an agent with no record, or a record
|
||||
that never set the flag, holds it, the same default as the
|
||||
`hyperhive.agent.unrestricted_internet` metric. While the hive's read of the
|
||||
permissions is stale or failing, a new agent that already holds a
|
||||
credential counts as unrestricted. Squid reads the list when it
|
||||
parses its configuration, so `squid-unrestricted-reload.path` reloads squid
|
||||
when the file changes. A missing or empty file lists no one, and squid
|
||||
starts either way. A flag change reaches squid after the hive's next read of
|
||||
the permissions and its next 30-second pass.
|
||||
An older `hive-c0re` writes no list, so every credential on that hive is
|
||||
restricted. Agents must not start sending credentials until every hive runs
|
||||
the `hive-c0re` that writes it, or a legacy agent loses general internet.
|
||||
|
||||
- **Log mode.** The operator can put an agent in `log` mode in the swarm UI.
|
||||
Its record keeps `unrestricted_internet` at `false` and adds
|
||||
`internet_log: true`, so a `hive-c0re` that doesn't know the field still
|
||||
enforces the list. For such an agent `hive-c0re` adds to `agent-allow.conf`,
|
||||
after the allow rules for every agent's own names,
|
||||
`http_access allow <user> !allowed_dst log_mark`, where `log_mark` is an
|
||||
`annotate_transaction` ACL that sets `would_deny=1`. The rule comes last in
|
||||
the include, so every destination deny and `deny !authed` before it still
|
||||
applies, and a name the agent may reach never carries the mark. A request the
|
||||
rule lets through gets a second log line,
|
||||
`would_deny user=<agent> method=<m> host=<h> port=<p> result=<r>`, from the
|
||||
`would_denied` `access_log` in `hive-squid.nix`; the usual request line keeps
|
||||
its format. The **would-deny** tab of the `egress` dashboard in Grafana lists the hosts per agent. Only
|
||||
the operator changes the mode: nothing moves an agent to `restricted` after a
|
||||
time or a count. The self-check's users aren't agents,
|
||||
so `deny-canary.invalid` stays a 403.
|
||||
|
||||
- **Modes.** An agent is in one of three modes, and the deny list (next bullet)
|
||||
applies in all of them:
|
||||
|
||||
| mode | the deny list | a name not on the allow list |
|
||||
| -------------- | ------------- | ---------------------------- |
|
||||
| `restricted` | blocked | blocked |
|
||||
| `log` | blocked | allowed and logged |
|
||||
| `unrestricted` | blocked | allowed |
|
||||
|
||||
- **Deny list.** The swarm's deny list names destinations the proxy refuses
|
||||
whatever else allows them. The swarm controller keeps it as its own value
|
||||
(the `deny` key of the `egress-allow` bucket), each entry for every agent or
|
||||
for one, and serves it at `GET`/`POST`/`DELETE /api/egress/denied-domains`
|
||||
with the same body as the allow-list (`{"domain": ".example.com", "agent":
|
||||
"atlas"}`). Write a name as in the allow-list: at least two labels, one
|
||||
leading dot for the name and its subdomains. The controller refuses IP
|
||||
addresses, other wildcards, ports and paths. Unlike the allow-list it doesn't
|
||||
refuse the base list's names: denying one is the point. Every 30 seconds
|
||||
`hive-c0re` reads the list and renders `services.hyperhive.deploy.hive-controller.squid.denyFile`
|
||||
(`/var/lib/hive-squid/deny.conf`, `hive-core:squid`, `0640`) for the agents it
|
||||
runs, and `squid-unrestricted-reload.path` reloads squid when it changes.
|
||||
- **Precedence.** The configuration includes the file after the destination
|
||||
denies and before `deny !authed` and every allow, so a denied name is
|
||||
refused even when the base list, the hive's `allowedDomains`, the swarm's
|
||||
allow-list, the unrestricted flag or `log` mode would allow it. A swarm-wide
|
||||
entry names no user, so every client gets a 403 for it, one without a
|
||||
credential too. An agent's entry also names the agent's squid user, so it
|
||||
binds that agent: a client that sent no credential gets the usual 407 for
|
||||
it. An agent's entry only adds to the swarm-wide ones; no entry of one scope
|
||||
lifts another.
|
||||
- **Log line.** A request a deny rule refused gets a second log line,
|
||||
`egress_denied user=<agent or -> method=<m> host=<h> port=<p> result=<r>`,
|
||||
in every mode. It differs from the `would_deny` line of `log` mode and
|
||||
from the `TCP_DENIED/403` line every refusal has, so you can tell a
|
||||
deny-list hit from a name that isn't on the allow-list.
|
||||
- **Who writes it.** Only an operator's request to the controller writes the
|
||||
list. It has no automatic entries: nothing derives a deny from an agent's
|
||||
API-key environment or a linked account, and the stored type has no such
|
||||
field. An agent has no queue grant that reaches the key, and the controller's
|
||||
API sits behind the operator's SSO login: nginx checks it before it hands
|
||||
a request to the socket, which only nginx and root can open.
|
||||
- **Empty list.** With no entry `deny.conf` is empty and squid configures as
|
||||
it did without the list: the configuration gains one `include` line and the
|
||||
`egress_denied` log lines, and no rule. A hive on a `hive-c0re` that
|
||||
predates the list never reads the key and keeps its old configuration, so
|
||||
nothing on its wire changes.
|
||||
- **Applied.** Each hive reports the revision of the list it last rendered as
|
||||
`egress_deny_applied` in its status snapshot (see [the hive
|
||||
snapshot](../../2-handbook/swarm/README.md)). `GET /api/egress/denied-domains` answers the
|
||||
list's `revision` and `behind`, the hives that haven't reported it: an older
|
||||
`hive-c0re` never does, so it shows there rather than silently allowing the
|
||||
names. A hive that reads the list reports within about a minute. Reporting
|
||||
means the file holds the rules; squid reloads moments later.
|
||||
|
||||
- **Base list.** `services.hyperhive.deploy.hive-controller.squid.baseAllowedDomains` is open to every
|
||||
authenticated user, whatever its flag: what the agent harness needs off the
|
||||
swarm. The default is the hosts Claude Code requires
|
||||
(<https://code.claude.com/docs/en/network-config>): `api.anthropic.com` for
|
||||
the model API and the usage probe (`hive-agent/src/claude_usage_watch.rs`),
|
||||
`claude.ai` for the account login, and `platform.claude.com` for the OAuth
|
||||
token exchange and refresh. An agent whose `ANTHROPIC_BASE_URL` points
|
||||
elsewhere needs no entry here: the swarm controller adds that host to the
|
||||
agent's own swarm entries (see _Swarm allow-list_ below). The list omits the plugin marketplace clone from `github.com`, since a failed add
|
||||
isn't fatal. The rules below still refuse an entry that resolves to a
|
||||
private address.
|
||||
- **Allow-list.** `services.hyperhive.deploy.hive-controller.squid.allowedDomains` defaults to empty,
|
||||
plus the self-check canary (`services.hyperhive.deploy.hive-controller.squid.selfcheck.canary`), so
|
||||
the canary widens the restricted list by that one name. The swarm's own
|
||||
service names aren't on it: they resolve to the bridge or a private address,
|
||||
which the rules below refuse, and agents reach them directly. Each entry
|
||||
matches that exact name, or with a leading dot the name and its subdomains. A user on the unrestricted list reaches any
|
||||
other name too, on ports 80 and 443. Everyone else gets a 403 for it.
|
||||
`allowedDomains` is the hive's own list, always allowed whether or not the
|
||||
swarm has a list of its own (next bullet).
|
||||
- **Swarm allow-list.** The operator adds domains in the swarm UI
|
||||
([allowed domains](../../2-handbook/swarm/ui.md#allowed-domains)); the swarm controller
|
||||
keeps them in one value of the `egress-allow` bucket, each for every agent
|
||||
or for one agent. Every 30 seconds `hive-c0re` reads it and writes two files
|
||||
beside the users file, `services.hyperhive.deploy.hive-controller.squid.allowedFile`
|
||||
(`/var/lib/hive-squid/allowed`) and `services.hyperhive.deploy.hive-controller.squid.agentAllowFile`
|
||||
(`/var/lib/hive-squid/agent-allow.conf`), `hive-core:squid`, `0640`, and
|
||||
logs `squid allow list: 2 swarm, 3 agent entries` when they change. The
|
||||
swarm-wide names join the allow ACL, so a restricted user reaches them. A
|
||||
per-agent entry is a rule for that agent's squid user, `http_access allow
|
||||
<user> <names>`, in the generated `agent-allow.conf` that the configuration
|
||||
includes at the point where the base list gets its allow: after every
|
||||
destination deny and
|
||||
`deny !authed`, so the rules under _Denied before any allow_ still bind it,
|
||||
and before the unrestricted rule. Only this hive's agents get a rule.
|
||||
`squid-unrestricted-reload.path` reloads squid when either file changes, so
|
||||
there is no deploy or restart.
|
||||
A name is a DNS name of at least two labels. One leading dot (`.example.com`)
|
||||
also allows every subdomain, and is the only wildcard; the controller refuses
|
||||
IP addresses, other wildcards, ports, paths and the base list's names when
|
||||
you add the entry, and `hive-c0re` checks each name again and skips one that
|
||||
fails, or that the base list or the canary covers, or that another entry of
|
||||
the same list covers. A name that resolves to a private address stays a 403.
|
||||
The controller also adds entries itself, and only from the operator's own
|
||||
actions in the swarm UI. Saving an agent's API-key environment makes it take
|
||||
the host from `ANTHROPIC_BASE_URL` and from every variable named `*_BASE_URL` (the
|
||||
address an ACP agent that loads the same file reads), and keeps one
|
||||
`derived` entry for that agent per host. Each host passes the same check as
|
||||
an operator's entry, so an IP address, `localhost` and a base-list name get
|
||||
none. A save or a delete of the environment replaces the agent's derived
|
||||
entries, and destroying the agent drops all its entries. The agent can
|
||||
create its own `backend-env` leaf while the leaf is empty, so text found in
|
||||
the store never creates an entry: when the controller starts, it only
|
||||
drops derived entries whose exact host the stored environment no longer
|
||||
names, and logs a host it didn't derive. An environment saved before this
|
||||
existed gets its entries when the operator saves it once more. An agent on
|
||||
the hand-placed `backendEnvironmentFile` alone, and the `opencode` preset's
|
||||
`acp.opencode.provider.baseUrl`, get no derived entry, so add one in the
|
||||
swarm UI. A `hive-c0re` that predates the field renders a derived entry
|
||||
like any other.
|
||||
Linking a GitHub token, an external matrix account or an external forge
|
||||
account adds a derived entry for that agent per host it reaches, and
|
||||
deleting the account removes it: `github.com`, `api.github.com`,
|
||||
`raw.githubusercontent.com`, `codeload.github.com` and
|
||||
`objects.githubusercontent.com` for GitHub, and the host of the matrix account's `homeserver` or the forge
|
||||
account's `url`. Each entry's `source` (`link:github`,
|
||||
`link:matrix:<account>`, `link:forge:<label>`) keeps it apart from the
|
||||
environment's, which a save of the environment leaves alone; an entry
|
||||
without a `source` counts as the environment's. Only the controller writes
|
||||
these leaves, so when it starts it also adds the entries of accounts linked
|
||||
before this existed and drops those whose account no longer exists, and logs
|
||||
the counts (`link egress: entries match the linked accounts`). A store it
|
||||
can't read changes nothing. An `agent.nix` `matrixAccounts` homeserver gets
|
||||
no entry, since the controller never reads it; add one in the swarm UI.
|
||||
The swarm's list adds to the hive's `allowedDomains`: `allowedFile` holds
|
||||
those names plus the swarm-wide entries, so removing an entry never removes a
|
||||
name the hive lists itself. Until the controller has a list (it predates the
|
||||
feature, or nobody added an entry) `allowedFile` holds `allowedDomains` and
|
||||
`agent-allow.conf` is empty. A `hive-c0re` older than this rule replaces
|
||||
`allowedDomains` with the swarm's list once the controller has one, so every
|
||||
hive must run the new `hive-c0re` before the controller's first entry exists. A queue that stops answering for the bucket
|
||||
leaves a file the swarm wrote untouched, and a failed read leaves both
|
||||
files untouched. The first line of a file written from the swarm's list is a
|
||||
`# source: swarm` comment. The queue grant a hive needs to read the list
|
||||
comes with the controller's `swarm-nats-auth`; a hive that can't read it
|
||||
times out, keeps its files and warns after about 90 seconds.
|
||||
- **Unlisted names.** For a user who isn't on the unrestricted list, refused
|
||||
after the destination checks, which look the name up on the host.
|
||||
- **Denied before any allow.** Destinations that resolve to loopback,
|
||||
link-local, the bridge subnet, a private range (`10.0.0.0/8`,
|
||||
`172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), shared address space
|
||||
(`100.64.0.0/10`), `0.0.0.0/8`, `::`, multicast or reserved space, and IP-literal
|
||||
hosts, for every user including the unrestricted ones and for listed names.
|
||||
Squid resolves names on the host and connects from it, so without these a
|
||||
name pointing at `127.0.0.1`, at the host's own LAN address or at a private
|
||||
neighbour would walk around the bridge→loopback DROP rule and the swarm's
|
||||
network. Squid checks the address it connects to, so a name that changes its
|
||||
answer between a check and the connect gains nothing. A listed name must
|
||||
therefore resolve to a public address on the host's resolver: a split-horizon
|
||||
name that answers a LAN address gets a 403, and so does the self-check
|
||||
canary, which is why it isn't a swarm service name. Only `CONNECT` to port
|
||||
443 and plain HTTP to ports 80 and 443 pass.
|
||||
- **Metrics.** `squid-exporter` serves `prometheus-squid-exporter` on
|
||||
`127.0.0.1:9301`. The hive collector scrapes it as job `squid` through
|
||||
`services.hyperhive.otel.scrapeTargets`. `squid_up` and
|
||||
`squid_client_http_requests_total` are the two to watch.
|
||||
- **Logs.** One syslog line per request reaches the journal as
|
||||
`squid.service`, and `services.hyperhive.otel.journalUnits` forwards that
|
||||
unit and the self-check's to the swarm's log store. A refusal shows as `TCP_DENIED/403` with the
|
||||
client's source address. A request that a `log`-mode agent's rule let
|
||||
through also logs a `would_deny` line (see _Log mode_). A request a deny-list rule refused logs an `egress_denied` line (see _Deny list_).
|
||||
- **Self-check.** `squid-selfcheck.timer` runs `squid-selfcheck.service`
|
||||
every minute from the bridge IP, logging in as `selfcheck.squid`: `CONNECT`
|
||||
to `services.hyperhive.deploy.hive-controller.squid.selfcheck.canary` (default: `cache.nixos.org`,
|
||||
a public name that's also on the allow-list)
|
||||
must answer 200, to `deny-canary.invalid` 403, and to the canary without a
|
||||
credential 407. It then logs in as `selfcheck-unrestricted.squid`, which has
|
||||
the same secret and is always on the unrestricted list, and a `CONNECT` to
|
||||
`services.hyperhive.deploy.hive-controller.squid.selfcheck.unrestrictedCanary` (default:
|
||||
`example.com`, a name outside the allow-list) must answer 200. It writes one
|
||||
`squid-selfcheck ok=… allow_status=… deny_status=… anon_status=…
|
||||
credential=present|absent unrestricted_canary=… unrestricted_status=…` line
|
||||
per run and fails the unit when any is wrong. The password is the hive's
|
||||
`swarm/hives/<hive>/squid-selfcheck` secret, which
|
||||
`squid-selfcheck-credential` lands at `/run/credstore/hive-squid.selfcheck`.
|
||||
Before `swarm-controller` has minted it, or while `hive-c0re` hasn't
|
||||
written the users file, the line reads `credential=absent` or
|
||||
`allow_status=407` and `ok=false`, and nothing else breaks. The
|
||||
host's resolver looks up the canary, so a host whose resolver maps
|
||||
that name to a loopback, bridge or private address fails the check.
|
||||
|
||||
## Container isolation
|
||||
|
||||
Each agent container runs in a private network namespace with a dedicated
|
||||
veth pair attached to the bridge. The following table summarises what
|
||||
the nix side sets up unconditionally:
|
||||
|
||||
| effect | mechanism |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` |
|
||||
| Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC |
|
||||
| Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks |
|
||||
| Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host |
|
||||
| c0re signal | `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` — both **required**; `hive-c0re` refuses to start without them |
|
||||
|
||||
`HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g.
|
||||
`10.42.0.1/24`), **not** the canonical network address. The Rust side
|
||||
must normalise (bitwise-AND with mask) before subnet membership checks or
|
||||
address arithmetic.
|
||||
|
||||
### What the Rust side does
|
||||
|
||||
`hive-c0re` reads `HIVE_NETWORK_BRIDGE` + `HIVE_NETWORK_SUBNET` and passes
|
||||
`PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=` (empty), `HOST_ADDRESS=<bridge-ip>`,
|
||||
and `HOST_BRIDGE=<bridgeName>` when creating or updating containers:
|
||||
`lifecycle::set_nspawn_flags` hands them to hive-priv, which rewrites
|
||||
`/etc/nixos-containers/<container>.conf`. hive-c0re validates both variables **once at
|
||||
daemon startup**, not per container: they're process-global, so a
|
||||
missing or malformed value is a misconfigured daemon rather than one bad
|
||||
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
|
||||
container's dhcpcd acquires an address from the bridge dnsmasq pool
|
||||
(`networking.useDHCP = true` in `nix/agent-modules/network.nix`). This applies to
|
||||
every agent container, manager included.
|
||||
|
||||
`HOST_ADDRESS` is the bridge gateway IP (the address part of
|
||||
`HIVE_NETWORK_SUBNET`, via `lifecycle::bridge_gateway_ip` — taken verbatim
|
||||
so a non-`.1` operator override still resolves to wherever the bridge
|
||||
actually lives). It's **load-bearing**: nixos-container's container-side
|
||||
network setup only installs a default route (`ip route add default via
|
||||
$HOST_ADDRESS`) when `HOST_ADDRESS` is non-empty. In bridge mode
|
||||
nixos-container skips the host-side address/route setup, so writing it only affects the
|
||||
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`).
|
||||
|
||||
### How the isolated container gets its resolver
|
||||
|
||||
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,
|
||||
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
|
||||
bridge dnsmasq at boot. Because the copy happens on every start, it
|
||||
would clobber a declarative `environment.etc."resolv.conf"` — so the
|
||||
wiring is runtime:
|
||||
|
||||
- `hive-priv` drops a marker file (`/etc/hyperhive-bridge-dns`, carrying the
|
||||
gateway IP) into each container's `/etc`.
|
||||
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
|
||||
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
||||
It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
||||
the forge-token fetch so the resolver is correct before the first DNS lookup.
|
||||
|
||||
**Why isolation is safe**: hive-c0re's control-plane sockets are unix
|
||||
domain sockets bind-mounted into containers, not network listeners — see
|
||||
the _Control plane (no network)_ bullet under [Network
|
||||
map](#network-map) above. `PRIVATE_NETWORK=1` has no effect on a path
|
||||
that never touches the network stack.
|
||||
|
||||
The nix side also enables IP forwarding + NAT (agents reach the internet
|
||||
through the host) and drops bridge-subnet → loopback traffic (defence-in-depth
|
||||
against a compromised agent reaching the c0re dashboard HTTP at
|
||||
`127.0.0.1`). Agents have no legitimate reason to reach the dashboard over
|
||||
loopback — the hive-c0re admin socket is a UDS, not TCP.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- `docs/4-implementation/networking/gateway.md` — vhost map + the gateway's other duties
|
||||
Loading…
Reference in a new issue