873 lines
57 KiB
Markdown
873 lines
57 KiB
Markdown
# 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/2-handbook/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 = {
|
||
swarm = {
|
||
domain = "myswarm.example";
|
||
hives.firsthive = { }; # -> domain = firsthive.myswarm.example
|
||
};
|
||
deploy.hive-controller = {
|
||
enable = true;
|
||
hiveName = "firsthive";
|
||
};
|
||
# 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
|
||
([`2-handbook/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) still applies, 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). A program that ignores the proxy variables still reaches squid
|
||
when it speaks HTTP on port 80 or TLS on port 443 and its agent is `restricted`
|
||
or in `log` mode: the host redirects that traffic into squid's intercept ports
|
||
(see _Intercepted web traffic_ below). Everything else goes straight out through
|
||
the NAT, as before: the `unrestricted` agents, the service containers and every
|
||
other port and protocol. By default nothing blocks that. The host logs the direct
|
||
connections of the `restricted` and `log` agents, and `egress.forward =
|
||
"reject"` refuses them (see _Agent bridge egress log_ below).
|
||
|
||
Squid filters on the destination hostname: a `CONNECT` target or a plain-HTTP
|
||
host. It never decrypts TLS. The one TLS key it loads is a throwaway that the
|
||
unit generates at each start, that signs nothing and that no agent trusts. 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](../../4-implementation/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 !icpt <user> !allowed_dst log_mark` and the same rule for
|
||
the intercept ports, where `log_mark` is an `annotate_transaction` ACL that
|
||
sets `would_deny=1`. The rules come last in the include, so every destination
|
||
deny and `deny !icpt !authed` before them 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 !icpt !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](../web-ui/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
|
||
!icpt <user> <names>`, and a second one for the user the intercept helper names
|
||
(`icpt <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 !icpt !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.
|
||
|
||
### Intercepted web traffic
|
||
|
||
A program that ignores the proxy variables still gets squid's policy for its
|
||
web traffic. In the `inet hive_egress` table (`nix/host-modules/hive-egress.nix`)
|
||
the `intercept` chain, at `prerouting`, redirects IPv4 TCP port 80 to
|
||
`<bridgeIp>:3129`, an intercept listener of squid, and port 443 to
|
||
`<bridgeIp>:3131`, the ClientHello relay (see _ECH_ below), which sends the
|
||
connection on to squid's other intercept listener, `<bridgeIp>:3130`. Squid reads
|
||
the original destination from conntrack. The chain exists where the host runs
|
||
squid (`services.hyperhive.network.egress.intercept.enable`).
|
||
|
||
- **Who gets redirected.** Every flow from the bridge to port 80 or 443, except
|
||
a flow to an address that stays on the bridge, to one of the host's own
|
||
addresses or to the `wg-hive` interface, and a flow from a MAC in the set
|
||
`direct_mac`. That set holds the MACs of the ports of every container that's
|
||
not an agent (the swarm's service containers) and of the agents listed in
|
||
`services.hyperhive.deploy.hive-controller.squid.unrestrictedFile`. So
|
||
`restricted` and `log` agents get redirected, and `unrestricted` agents and
|
||
service containers keep the direct path. Every pass of `hive-priv egress-pin`
|
||
and every `egress-load` refills the set from the bindings it also uses for the
|
||
bridge table, and `hive-egress-direct.path` runs a pass when `hive-c0re`
|
||
rewrites the list; the timer covers the rest. A MAC in no binding is in no
|
||
exemption: the flow gets redirected, and squid refuses it. A new agent that
|
||
the list doesn't name yet counts as restricted until the next pass, and a flag
|
||
change reaches the redirect after the hive's next read of the permissions and
|
||
one pass. IPv6 and the other ports aren't redirected.
|
||
- **Who the flow belongs to.** An intercepted request carries no proxy
|
||
credential, so squid asks its external ACL `hive_agent`, which runs
|
||
`hive-priv egress-whois`. The helper looks the source address up in dnsmasq's
|
||
lease file for a MAC, the MAC in the bindings (`/run/hive-egress-pins`) for a
|
||
machine, and takes the agent from the machine name `h-<agent>`. While
|
||
`antiSpoof` is `drop`, an address on the bridge is the one leased to the MAC
|
||
of the port it came from (see _Agent bridge source check_), so the source says
|
||
which container sent the flow. Squid keeps an answer for 10 seconds and a
|
||
refusal for 2. An address with no lease or an expired one, two MACs with the
|
||
same address, a MAC no port holds, a machine that's no agent: none gets an
|
||
answer, and squid refuses the flow with a `TCP_DENIED/403` and user `-`. A
|
||
host that has no bindings or leases yet refuses every intercepted flow rather
|
||
than guess. With `antiSpoof = log` a container can forge the source, so use that mode
|
||
to diagnose and not to run. The proxy port never takes this identity: a
|
||
request without a credential through port 3128 still gets a 407, whatever its
|
||
address.
|
||
- **Policy.** The same lists as for the proxy port, matched on the agent the
|
||
helper names: the destination denies, the deny list, the base list, the
|
||
allow-list and the agent's own names, and `log` mode with its `would_deny`
|
||
line. `hive-c0re` renders each agent rule twice, once for the credential
|
||
(`!icpt`) and once for the helper's user (`icpt`).
|
||
- **HTTPS.** Squid peeks at the ClientHello for the SNI and splices the
|
||
connection. It never decrypts, and no agent trusts a CA of the host. Squid
|
||
builds a `CONNECT` with the SNI as its host and applies every rule to it. A
|
||
refused name gets a 403 that the client reads as a TLS error. Squid also
|
||
refuses:
|
||
- an SNI that doesn't resolve to the address the agent dialed
|
||
(`host_verify_strict`; squid answers 409 and the client reads a TLS error).
|
||
Squid resolves on the host's resolver, which is the same dnsmasq as the
|
||
agent's, so a name with changing answers can fail now and then;
|
||
- a ClientHello with no SNI, and a URL with an IP literal, which sends none:
|
||
the host of the `CONNECT` stays an address, and the IP-literal denies refuse
|
||
it;
|
||
- bytes on port 80 that aren't an HTTP request. Bytes on port 443 that aren't
|
||
a ClientHello never reach squid (see _ECH_ below).
|
||
- **ECH.** Squid 7.6 can't see the `encrypted_client_hello` extension, only the
|
||
public name in the outer hello. An agent that holds an ECH configuration could
|
||
send a hello with an allowed outer name, a destination address that name
|
||
resolves to, and a hidden inner name that the same address serves. Port 443
|
||
therefore doesn't go to squid directly. `hive-priv egress-ech-relay`
|
||
(`hive-egress-ech-relay.service`, `egress.intercept.refuseEch`, on by default)
|
||
listens on `<bridgeIp>:3131`. It reads the first TLS records of each redirected
|
||
connection, up to four records and 16 KiB of ClientHello within 10 seconds, and
|
||
closes the connection when the ClientHello of a `restricted` agent carries the
|
||
extension (type `0xfe0d`, and the draft types `0xfe08` to `0xfe0c`, `0xff02`
|
||
and `0xffce`). The relay only observes a `log` agent, as everywhere in `log`
|
||
mode: its hello goes on like a clean one, and the relay logs it (see below).
|
||
It closes a connection too, for any agent, when the bytes aren't a well-formed
|
||
ClientHello, when they don't arrive in time, or when the connection wasn't
|
||
redirected. It also closes a connection from a source that already has 256
|
||
open, and any connection beyond 2048 in all, so one agent can't use up the
|
||
relay. Otherwise it connects to the destination the agent dialed and sends the bytes
|
||
on, and squid sees an ordinary intercepted connection:
|
||
- the relay connects from an address in `198.18.0.0/15`, which the unit routes
|
||
to `lo`, and an `output` rule DNATs its marked connections (mark
|
||
`0x45434800`) to squid's port 3130, so squid reads the original destination
|
||
from conntrack as before;
|
||
- the source address is `198.18.0.0` plus the low 17 bits of the agent's
|
||
address, and `egress-whois` maps it back to the lease it came from, so the
|
||
pool needs a bridge subnet of /15 or smaller (an assertion checks) and
|
||
squid's log shows `198.18.x.y` as the client address of an intercepted HTTPS
|
||
flow. The user column still names the agent. The `input` chain drops a pool
|
||
address that arrives from the bridge;
|
||
- the relay logs to the journal of its unit, at most one line a second per
|
||
source address, with the agent named the way squid names it (`-` when no
|
||
lease and binding says). A refusal is
|
||
`hive-egress-ech-refused agent=<name> dst=<address>`; an ECH hello of a `log`
|
||
agent, which goes on, is
|
||
`hive-egress-ech-would-refuse agent=<name> dst=<address>`; a hello that isn't
|
||
well formed is
|
||
`hive-egress-hello-refused agent=<name> dst=<address> reason=<why>`. Squid logs
|
||
nothing for a refused flow;
|
||
- the mode comes from `agent-allow.conf`, the include `hive-c0re` renders: an
|
||
agent is in `log` mode when it holds the line
|
||
`acl agent_log_<name> proxy_auth <name>`. Squid applies the same file, so the
|
||
relay and squid can't disagree about an agent's mode, and the file changes
|
||
when the operator changes the mode, with no second list to keep in step. A
|
||
missing or unreadable file, or an agent with no lease, counts as
|
||
`restricted`, and the relay refuses the hello;
|
||
- while the relay is down the redirect reaches a closed port, so HTTPS from
|
||
`restricted` and `log` agents fails. The unit restarts after 5 seconds.
|
||
|
||
GREASE: a client with no ECH configuration, Chrome and Firefox by default,
|
||
sends the extension anyway with random contents, and the protocol makes the two
|
||
look the same on the wire. The relay can't tell them apart, so it treats both
|
||
as ECH: a `restricted` agent's HTTPS fails when the client ignores the proxy
|
||
variables, and a `log` agent's goes on with a would-refuse line. Through the
|
||
proxy port (`HTTPS_PROXY`, a `CONNECT`) it works as before: that path has no
|
||
interception. A host where that costs more than the gap does can set
|
||
`egress.intercept.refuseEch = false`, which sends port 443 straight to squid
|
||
and leaves ECH unchecked. The relay only sees the hello of a connection that
|
||
the redirect catches. It doesn't touch UDP (QUIC), IPv6 or other ports.
|
||
|
||
- **Reading the log.** Each intercepted HTTPS connection logs twice: first
|
||
`NONE_NONE/000 … CONNECT <address>:443 <agent>`, before the ClientHello, then
|
||
the `CONNECT <name>:443 <agent>` line with `TCP_TUNNEL/200` or `TCP_DENIED/403`
|
||
(`NONE_NONE/409` when the SNI and the address disagree). The client address of
|
||
an intercepted HTTPS line is the relay's `198.18.x.y` (see _ECH_ above), and an
|
||
intercepted HTTP request logs `GET http://<name>/ <agent>` with the agent's own
|
||
address. A tunnel to an intercepted
|
||
destination ends in `ORIGINAL_DST/<address>`. `cache.log` notes an ACL that met
|
||
a flow squid refused before its request.
|
||
- **Rollout.** The redirect, the relay, squid's configuration and `hive-priv` ship
|
||
in one build. A hive that still runs an older build has no redirect, and its
|
||
agents go direct as before. The agents that use the proxy variables see no
|
||
change.
|
||
|
||
## 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 | `nix/host-modules/hive-egress.nix` — own nftables table `inet hive_egress` (whichever firewall the host runs): drops bridge → `127.0.0.0/8` and `::1` traffic; the kernel default `route_localnet=0` is the first layer |
|
||
| Forward log | `nix/host-modules/hive-egress.nix` — the same table's `forward` chain logs and counts what the host routes from the bridge to the uplink, and refuses it with `egress.forward = "reject"` (see _Agent bridge egress log_ below) |
|
||
| Source check | `nix/host-modules/hive-egress.nix` — the `bridge hive_egress` table drops a frame whose source MAC or address differs from the sending container's own (see _Agent bridge source check_ below) |
|
||
| 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 from its own
|
||
nftables table (`inet hive_egress`, `nix/host-modules/hive-egress.nix`, which also
|
||
holds the `bridge hive_egress` table of _Agent bridge source check_;
|
||
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.
|
||
|
||
### Agent bridge egress log
|
||
|
||
`services.hyperhive.network.egress.forward` sets what the `forward` chain of
|
||
the `inet hive_egress` table does with traffic the host routes from the agent
|
||
bridge to anywhere but the bridge and the `wg-hive` mesh interface. It takes
|
||
`accept`, `log` (the default) or `reject`. In `log` mode the chain blocks
|
||
nothing. It logs each new flow and counts every packet. In `reject` mode it
|
||
also refuses the flow.
|
||
|
||
- **Who is exempt.** In `log` and `reject` mode alike, the chain accepts the
|
||
mesh traffic first, then the traffic of every MAC in the `direct_mac` set: the
|
||
`unrestricted` agents and the service containers, which the `intercept` chain
|
||
leaves alone too (see _Intercepted web traffic_). They leave no line, so a
|
||
`hive-egress-would-drop` line is exactly a flow that `reject` would refuse,
|
||
and a service container keeps every port and protocol under `reject`.
|
||
- **What `reject` refuses.** Everything from the bridge to the uplink that the
|
||
chain didn't exempt, whether a flow is new or not: TCP on any port but the
|
||
80 and 443 that squid takes, UDP (DNS to an outside resolver, QUIC and NTP
|
||
included), ICMP and IPv6. A TCP flow gets a reset, so the connect fails at
|
||
once instead of stalling. Everything else gets ICMP administratively
|
||
prohibited. The log lines of this mode carry the prefix `hive-egress-reject `
|
||
(and `hive-egress-reject-unmetered `); the rate limit and the counter work as
|
||
in `log` mode. Still open to every agent are the host's own addresses: the
|
||
proxy ports, DNS and DHCP from the bridge address, and the gateway.
|
||
- **Switching on.** Leave `log` running for about a week, then read the
|
||
`hive-egress-would-drop` lines. Each is a flow that `reject` would refuse; a
|
||
flow that belongs to a legitimate use needs a fix first (a service container
|
||
missing from `direct_mac`, an agent that should be `unrestricted`). Then set
|
||
`services.hyperhive.network.egress.forward = "reject"`. Making it the default
|
||
is a one-line change of the option's default.
|
||
- **What `reject` can break.**
|
||
- A service container with no pin yet isn't in `direct_mac`, so the
|
||
chain refuses it until `hive-egress-pin` binds its port. The bridge table
|
||
drops its frames until then anyway.
|
||
- A change of an agent's `unrestricted` flag reaches `direct_mac` within 30
|
||
seconds at most, on the timer of `hive-egress-pin`. The chain refuses the
|
||
agent in the gap.
|
||
- An unreadable `unrestricted` file lists no agent, so `unrestricted` agents
|
||
lose every non-web flow.
|
||
- An agent in per-agent `log` mode keeps its web traffic, which squid only
|
||
observes, and loses every other port and protocol.
|
||
- **Which traffic.** IPv4 and IPv6 alike (the table is `inet`). The chain never
|
||
sees traffic to the host's own addresses, because the host delivers it
|
||
locally: a connection to squid on `<bridgeIp>:3128`, to dnsmasq or to the
|
||
gateway leaves no line. Nor does anything squid or the host opens itself. The
|
||
web traffic of a `restricted` or `log` agent that the `intercept` chain
|
||
redirects into squid reaches the host too, so squid's log shows it instead
|
||
(see _Intercepted web traffic_).
|
||
- **The log line.** The kernel log carries one line per new flow, with the
|
||
prefix `hive-egress-would-drop ` followed by the usual netfilter fields
|
||
(`IN`, `OUT`, `SRC`, `DST`, `PROTO` and the ports). Read them with
|
||
`journalctl -k -g hive-egress-would-drop`.
|
||
- **Rate limit.** Each source address gets a burst of 20 lines, then one line a
|
||
second, and the chain forgets it after a minute without a new flow. A flood
|
||
from one agent can't hide another's flows, and it adds about 250 bytes of
|
||
journal a second per source address at most. A flow the per-source limit
|
||
can't log (the address is over its rate, or the table of addresses is full)
|
||
gets one line with the prefix `hive-egress-would-drop-unmetered ` instead,
|
||
from a limit shared by all addresses: 5 lines a second after a burst of 50,
|
||
about 100 MB of journal a day at most. A flow never gets both lines. The
|
||
counter in `nft list chain inet hive_egress forward` counts every packet,
|
||
logged or not.
|
||
- **Which agent.** `SRC` is the agent's address on the bridge. Containers get
|
||
theirs from the hive's dnsmasq, which records each lease in
|
||
`/var/lib/dnsmasq/dnsmasq.leases`, one line per lease:
|
||
`<expiry> <mac> <ip> <hostname> <client-id>`. Look the `SRC` address up in the
|
||
third column; the hostname names the container. A lease lasts one hour, so
|
||
look an address up soon after the line appears.
|
||
- **Trust.** A frame that carries another container's source address never
|
||
reaches the forward chain while `antiSpoof` is `drop` (see _Agent bridge
|
||
source check_), so `SRC` names the container that sent the flow. In `log`
|
||
mode a container can still forge it, so a log from a host that ran in `log`
|
||
mode during the soak doesn't show who sent a flow.
|
||
|
||
### Agent bridge source check
|
||
|
||
Every container on the agent bridge is root in its own network namespace and
|
||
shares one L2 segment with the others, so it can send with another container's
|
||
MAC or address. `services.hyperhive.network.egress.antiSpoof` sets what the
|
||
`bridge hive_egress` table (`nix/host-modules/hive-egress.nix`) does about it. It
|
||
takes `drop` (the default) or `log`. Two tables, not one: the bridge carries traffic between two
|
||
ports, and the `inet` hooks never see it as coming from a port. Only a
|
||
`bridge`-family hook at `prerouting` sees which veth a frame came in on, so the
|
||
source check lives in that table and the host-side filtering stays in
|
||
`inet hive_egress`.
|
||
|
||
- **What a port may send.** The frame's MAC is the one bound to the port.
|
||
Then, by protocol: IPv4 from an address leased to that MAC, or DHCP from
|
||
`0.0.0.0` to `255.255.255.255`; ARP whose sender is the port's MAC and an
|
||
address leased to it, or an ARP probe from `0.0.0.0`; IPv6 from a link-local
|
||
address (and from `::` in neighbour solicitations and listener reports, which
|
||
precede an address). A message to UDP port 67 must carry the port's own MAC as
|
||
its `chaddr`, so a container can't lease, renew or release an address for
|
||
another. The table refuses a VLAN tag, and everything else counts as a spoof.
|
||
- **A port nobody owns sends nothing.** Not even DHCP. A container's port is
|
||
bound a moment after its veth appears, and the container's DHCP client
|
||
retries until then.
|
||
- **Where the bindings come from.** Addresses are dynamic, so the table's sets
|
||
fill at run time. `hive-priv egress-pin` binds a port to the MAC of the link at
|
||
the other end of its veth pair, found through the container systemd registers
|
||
for it. udev starts it for each new `vb-*` port
|
||
(`hive-egress-pin@<port>.service`), and a timer (`hive-egress-pin.timer`, every
|
||
30 seconds) runs it for the ports that still have no binding. The timer reads
|
||
no container while every port has one. The `hive-egress` unit loads the tables
|
||
with `hive-priv egress-load`: it looks up the bindings first, while the old
|
||
tables still filter, and puts them and the leases from dnsmasq's lease file
|
||
into the same nft transaction as the tables. A start or reload never leaves
|
||
the bridge with empty sets. If loading the leases fails, the unit logs a
|
||
warning and loads the tables with the bindings alone. A binding stays with the
|
||
port until the port goes: root in the container can't move the port to another
|
||
MAC afterwards (state in `/run/hive-egress-pins`). A port never takes a MAC
|
||
that a binding already holds, so a newcomer can't evict an established
|
||
container. If two new ports claim one MAC, neither gets a binding. dnsmasq's
|
||
`dhcp-script` (`hive-priv egress-lease`) adds each lease as a (MAC, address)
|
||
pair that times out with the lease.
|
||
- **The window.** The binding takes the MAC the container's link has when the
|
||
command first looks, normally within a second of the veth appearing. Until
|
||
then root in the container could change its own MAC, but in that time only
|
||
the container's early init runs, which is nix-built, and the agent's code
|
||
starts later. A container that did change its MAC first gets a binding for
|
||
that MAC only if nobody holds it; a lease follows the `chaddr` the port may
|
||
send. A port refused because it claimed a held MAC sends nothing, and the
|
||
timer binds it once its link has a MAC of its own.
|
||
- **The log.** A refused frame (all of them with `drop`, the same ones with
|
||
`log`) gets a kernel log line with the prefix `hive-egress-spoof `, rate
|
||
limited per port: a burst of 20, then one a second. A frame its port can't log
|
||
gets a line with the prefix `hive-egress-spoof-unmetered ` instead, from a limit
|
||
shared by all ports: 5 lines a second after a burst of 50. Read them with
|
||
`journalctl -k -g hive-egress-spoof`. `IN=` is the port, `vb-<machine>` (`vb-h-<agent>` for
|
||
an agent), and `SRC` and `MAC=<dst>:<src>:<type>` are what the container
|
||
sent; the port names the sender, because a bound port sends only its own MAC.
|
||
The counters in `nft list chain bridge hive_egress spoof` count every refused
|
||
frame.
|
||
- **Why `drop` is the default.** A container that sends from its leased address
|
||
and its own MAC is never refused, so `drop` breaks nothing a container does
|
||
legitimately, and without it the egress log can't tell agents apart. `log` is
|
||
for a live hive where the table refuses a legitimate container (a binding that
|
||
failed shows as `hive-egress-pin` errors in the journal and `IN=` lines for
|
||
that port): the same lines and counters, nothing blocked.
|
||
- **What it doesn't cover.** A container can still send from any IPv6
|
||
link-local address and answer DHCP as a server on its own leased address.
|
||
|
||
Enforcement comes with later changes in the same series: the log only shows
|
||
what such a change would cut.
|
||
|
||
## Cross-references
|
||
|
||
- `docs/4-implementation/networking/gateway.md` — vhost map + the gateway's other duties
|