diff --git a/Cargo.lock b/Cargo.lock index 76f2dfa4..b6b1966c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,17 +213,14 @@ dependencies = [ "rustls-webpki", "serde", "serde_json", - "serde_nanos", "serde_repr", "thiserror 2.0.18", - "time", "tokio", "tokio-rustls", "tokio-stream", "tokio-util", "tokio-websockets", "tracing", - "tryhard", "url", ] @@ -4313,15 +4310,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_nanos" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" -dependencies = [ - "serde", -] - [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -4558,10 +4546,7 @@ name = "swarm-controller" version = "0.1.0" dependencies = [ "anyhow", - "async-nats", "axum", - "futures-util", - "reqwest 0.13.1", "serde", "serde_json", "tokio", @@ -5085,16 +5070,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tryhard" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" -dependencies = [ - "pin-project-lite", - "tokio", -] - [[package]] name = "tungstenite" version = "0.29.0" diff --git a/docs/gateway.md b/docs/gateway.md index 91d9085f..0bbbf6a8 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -200,6 +200,8 @@ nginx obtains and auto-renews certs via the ACME HTTP-01 challenge on `port` (de Mutual exclusion: `tls.certDir` set together with `tls.acme.enable = true` fails an assertion — pick one external TLS source (or neither, for the self-signed default). +**Swarm peers**: CA-signed certs are trusted by default — this hive's entry in `swarm.hives` needs no `certFingerprint`. + ### Self-signed TLS (default) On by default, and listens on `httpsPort` (default 443) on every vhost beside the plain-http `port` (default 80). @@ -250,6 +252,12 @@ security.acme.certs."example.com".group = "nginx"; or make the key world-readable (`0644`) if your threat model allows it. nginx errors out at startup on a key it can't read — the error is explicit in the journal, not a silent failure. +**Swarm directory entry**: when using a CA-signed cert, this hive's entry needs no `certFingerprint` — the standard CA bundle validates: + +```nix +services.hyperhive.swarm.hives.example = { domain = "example.com"; }; # no certFingerprint needed +``` + ### Fronting with an external TLS terminator There is no http-only mode (see [TLS modes](#tls-modes) above). Two paths diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 748478b7..95776d2d 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -107,8 +107,9 @@ swarm service name has to be wired into: [`ui.md`](ui.md). ```nix services.hyperhive.swarm.hives = { - pr1ma = { domain = "pr1ma.example.com"; }; # this host, per hiveName - lab = { domain = "lab.example.com"; }; # a second hive in the swarm + pr1ma = { domain = "pr1ma.example.com"; }; # this host, per hiveName + lab = { domain = "lab.example.com"; }; # CA-trusted (Let's Encrypt etc.) + edge = { domain = "edge.corp"; certFingerprint = "sha256:…"; }; # self-signed leaf, pinned }; ``` @@ -127,30 +128,76 @@ contain you derives every hive as a peer and you peer with yourself. conventionally `.`, but a wrong domain that evaluates cleanly points at a real machine that isn't the one you meant. -> **There is no per-hive CA field, and no per-hive cert pinning.** Trust -> inside a swarm comes from the swarm root ([`ca.md`](ca.md)): every -> hive chains to it, so one anchor replaces per-hive pinning entirely. -> What that genuinely drops is trusting a hive whose root this swarm -> does *not* own — another swarm's, or one keeping its own CA. That is -> a cross-swarm problem and wants a mechanism designed for it. (An -> earlier `certFingerprint` field existed for exactly that gap, pinning -> a peer's TLS leaf for hive-c0re's own peer HTTPS checks — removed -> along with the dashboard feature it existed to serve, since nothing -> else ever consumed it.) +**`certFingerprint`** (`"sha256:…"`, optional) pins that hive's TLS +_leaf_. Scopes **only** to hive-c0re's own peer HTTPS checks (the P33RS +dashboard links + agent peer discovery below); matrix federation never +consults it. Omit it for any hive under the swarm root CA or a public +CA — which is the normal case. + +> **There is no per-hive CA field.** Trust inside a swarm comes from the +> swarm root ([`ca.md`](ca.md)): every hive chains to it, so one anchor +> replaces the O(n²) pinning. What that genuinely drops is trusting a +> hive whose root this swarm does *not* own — another swarm's, or one +> keeping its own CA. That is a cross-swarm problem and wants a +> mechanism designed for it, not a field that happened to work. + +### Fingerprint format + +The value is the string `sha256:` followed by exactly 64 hexadecimal +digits — the SHA-256 digest of the peer's DER-encoded TLS leaf +certificate. The hex is case-insensitive (upper or lower both parse), +carries no colon separators between bytes, and any value not matching +this shape is ignored with a warning rather than weakening trust. + +``` +sha256:b1946ac92492d2347c6235b4d2611184a3f5b6cae6c19d6e3c2f0a8e7d4c9f12 +``` + +Generate it from the peer's certificate with openssl. The +`-fingerprint -sha256` output is uppercase and colon-separated, so +strip the colons, lowercase, and prepend the `sha256:` prefix: + +```sh +# from a PEM/CRT file +openssl x509 -in peer.crt -noout -fingerprint -sha256 \ + | sed 's/^.*=//; s/://g' | tr 'A-Z' 'a-z' | sed 's/^/sha256:/' + +# straight from the live endpoint (port 443) +echo | openssl s_client -connect peer.example.com:443 -servername peer.example.com 2>/dev/null \ + | openssl x509 -noout -fingerprint -sha256 \ + | sed 's/^.*=//; s/://g' | tr 'A-Z' 'a-z' | sed 's/^/sha256:/' +``` + +Pin the leaf certificate, not an intermediate or the CA — the +digest must match the exact cert the peer serves on its HTTPS +endpoint. When the peer rotates its cert, update the pin to the new +fingerprint (or switch the peer to a CA-trusted cert and drop the +field). + +The nix module serialises the attrset to a `HYPERHIVE_PEERS` JSON +array (`[{ domain, cert_fingerprint }]`) injected into the c0re +environment and forwarded to agent containers. ## What the config does at runtime -1. **Swarm-wide hive roster** — swarm-controller reads this same - directory and serves it at `GET /api/hives`; `swarm-ui`'s overview - page renders it (`docs/swarm/ui.md`). This is the operator-facing - "what hives exist" surface — a per-hive dashboard "peer hives" - display existed here once and was removed in favour of it. +1. **Dashboard P33RS tab** — hive-c0re reads `HYPERHIVE_PEERS` and + surfaces it as the peer list in the dashboard's state API. The + dashboard shows a P33RS tab (hidden when the list is empty) with a + card per peer linking to `https://{domain}/`. Wire format + module + pointer: `docs/web-ui/dashboard.md` § P33RS tab. -2. **Matrix federation** — when `matrix.enable` is on, tuwunel +2. **Agent identity** — the same `HYPERHIVE_PEERS` env var is + forwarded to agent containers, so agent code can discover peer + hives and address them with qualified names (`agent@domain`). See + `hive-agent/src/identity.rs`'s module doc for the label/domain + helpers. + +3. **Matrix federation** — when `matrix.enable` is on, tuwunel federates with the peer's matrix server (discovered via the peer's `.well-known/matrix/server` delegation, which the gateway serves). Federation validates the peer's TLS certificate against the matrix - **container's** trust bundle, independent of this directory. + **container's** trust bundle — independently of `certFingerprint`, + which it never consults. ⚠️ **That container currently trusts no swarm-internal CA**, so a self-signed gateway certificate does not federate. The swarm root @@ -161,10 +208,6 @@ evaluates cleanly points at a real machine that isn't the one you meant. issue. Until then, federation needs CA-issued certs (ACME). See `docs/matrix.md` for federation firewall + TLS requirements. -3. **WireGuard mesh** (optional) — `swarm.wireguard.enable` reads each - entry's `wireguardPublicKey`/`wireguardEndpoint`/`wireguardAddress` - to configure `wg-hive`. See "WireGuard inter-hive mesh" below. - ## One directory, not a bilateral declaration Both hives hold the **same** `hives` attrset; neither declares the @@ -221,6 +264,7 @@ services.hyperhive = { }; edge = { domain = "edge.corp"; + certFingerprint = "sha256:…"; # TLS trust (unchanged) wireguardPublicKey = "base64keyB="; wireguardEndpoint = "203.0.113.42:51820"; wireguardAddress = "10.100.0.2/32"; @@ -246,10 +290,9 @@ services.hyperhive = { (not inside agent containers; containers reach peers via the host's routing table). - UDP port 51820 (or `listenPort`) is opened on the host firewall. -- `swarm-wireguard.nix` reads each entry's `wireguardAddress` directly - from `services.hyperhive.swarm.peerHives` to build `wg-hive`'s - `allowedIPs`, so intra-swarm traffic can route over the mesh address - rather than the public domain. +- `HYPERHIVE_PEERS` gains a `wireguard_address` field for each mesh + peer so hive-c0re can reach intra-swarm services without a public + DNS round-trip. - `persistentKeepalive = 25` is set by default; override or null to disable (not needed when both sides have public IPs and no NAT). @@ -287,39 +330,6 @@ What it serves, why it is a unix socket rather than a port, and the socket-directory constraint that governs where `socketPath` may point: [`swarm-controller/README.md`](../../swarm-controller/README.md). -### Per-hive status (`GET /api/hives/status`) - -One row per hive in `swarm.hives`, saying when it last reported and what -it said. Hives publish upward through the swarm queue; the controller -never reaches down to collect, so a hive that cannot reach the swarm -still knows its own state — you just cannot see it from here. - -⚠️ **Nothing publishes yet.** The read path is in place; the hive-side -publisher lands in a later change. Until it does, every hive reads -`never_reported`. - -| freshness | what to do about it | -|---|---| -| `fresh` | nothing — reported within `staleAfterSeconds` | -| `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say | -| `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage | -| `unknown` | something is publishing under a name that is not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running | - -Every row also carries `last_seen_unix` and `age_seconds` if you want to -apply your own threshold. The timestamp is the one the queue recorded on -arrival, not one the hive put in its own payload. - -Set `services.hyperhive.swarm.controller.staleAfterSeconds` (default -`120`) **above the rate hives publish at**, or everything reads `stale` -between reports. It takes effect on the next request; nothing has to -re-publish. - -The endpoint answers **503** when this host has no swarm queue -configured, or has one and cannot read it — deliberately not an empty -list, which would look like a silent swarm rather than a controller that -cannot see. The body says which. Status survives a controller restart: -it is stored in the queue, not in the daemon. - ## Cross-references - `docs/snapshot-store.md` — the swarm's `btrfs receive` endpoint, and @@ -327,8 +337,6 @@ it is stored in the queue, not in the daemon. - `docs/conventions.md` § Hive identity — env vars, qualified labels - `docs/matrix.md` — matrix federation, TLS cert auto-generation, firewall posture -- `docs/swarm/ui.md` — the swarm-wide hive roster, now the operator - surface for "what hives exist" (superseded the per-hive dashboard's - old "peer hives" display) +- `docs/web-ui/dashboard.md` § P33RS tab — dashboard surface - `docs/gateway.md` — nginx vhosts and the `.well-known/matrix/` auto-discovery scheme diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md index 98aac13f..cfdf1e99 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -18,7 +18,9 @@ The **dashboard** itself (`/dashboard.html`) is where you'll spend most of your time. It's a single page with exactly four tabs: - **SW4RM** — every agent, live. This is the default tab and the one - you'll check most. + you'll check most. When the hive has peer hives configured, they show + up here too, as a card list under the main container list — not a + separate tab. - **Y3R C4LL** — anything waiting on *you*: pending approvals and agent questions. If an agent needs a decision from you, it's here. - **P3RM1SS10NS** — what tools and system-level access each agent has. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 5d827a8c..1428aff2 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -7,7 +7,7 @@ The dashboard is served at `/dashboard.html` (with the home page at `/`). It has a fixed chrome header at the top and a `
` that shows exactly one tab pane at a time. The URL hash (`#swarm`, `#call`, `#system`, -`#permissions`, `#schedules`, `#settings`) drives which pane is +`#permissions`, `#schedules`, `#peers`, `#settings`) drives which pane is active; hash changes don't reload the page. FL0W, L0GS, and the optional M4TR1X client are separate pages reachable from the H0M3 hub at `/`, not from the dashboard tab strip. @@ -19,10 +19,11 @@ from the dashboard tab strip. `◆ SCH3DUL3S ◆`. In-page tabs only — the SYST3M panels moved to the standalone **C0R3** page (`/core.html`), and FL0W / L0GS / ST4TS / S3TT1NGS / M4TR1X live on their own pages too, all reachable from the - H0M3 hub (not the tab strip). Count pills on SW4RM + H0M3 hub (not the tab strip). Peer hives render as a headline under + SW4RM rather than a tab. Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions + unread - operator messages), and SCH3DUL3S (active schedules); S3TT1NGS has no - count. + operator messages), and SCH3DUL3S (active schedules); P33RS and + S3TT1NGS have no count. - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) — sits below the tab strip. - **Server-warnings banner** — a generic, sticky top-of-page strip shown @@ -641,6 +642,44 @@ the model id, longest match wins) mapping to `{ input, output, cache_read, cache_write }` USD-per-million-token prices. Models not covered fall back to hive-c0re's built-in estimate. +## P33R H1V3S (within the SW4RM tab) + +Peer hives in this swarm. Not its own tab — a headline block +(`#peers-block`) rendered under the SW4RM tab's container list (see +"Chrome header" above: "Peer hives render as a headline under SW4RM +rather than a tab"). The block is hidden when the +`state.peer_hives` array from `/api/state` is empty — i.e. when +`services.hyperhive.swarm.hives` holds no hive other than this one. +When at least one peer is present the `hidden` attribute is removed +and the cards render. + +**P33R H1V3S** — each peer renders as a card row: a hexagon icon +(`⬡`), the peer's DNS domain as the primary name, and the peer +dashboard HTTPS URL as a clickable secondary link. Clicking the +URL opens the peer hive's dashboard in a new tab. + +### Backend wiring + +The host daemon reads `services.hyperhive.swarm.peerHives` from the +nix config (the `swarm.hives` directory minus this hive), serialises +each entry as `{ name, url }` into `state.peer_hives: Vec`, +and includes the field in the `/api/state` snapshot. `tabs.js` +reads `state.peer_hives` on every `refreshState` call and calls +`swarm.js::renderPeerHives(peers)`, which rebuilds the `#peers-section` +div from scratch. + +The `name` field is the peer's DNS domain (its entry's `domain`, not +the attrset key — the key is the hive's name); `url` +is `https://{domain}/`. Both are derived from the env var +`HYPERHIVE_PEERS` (a JSON array of `{ domain, cert_fingerprint }` +objects) that the nix module writes into the c0re container +environment. `cert_fingerprint` is null for CA-trusted (e.g. +Let's Encrypt) peers and non-null to pin a self-signed cert. +`parse_peer_hives()` in `hive-c0re/src/dashboard/state_snapshot.rs` +converts each entry to the +`PeerHiveView { name: domain, url: "https://domain/" }` shape the +frontend reads. + ## S3TT1NGS tab Operator-local preferences. State lives in the browser's diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index d9bd8b53..e3f3007d 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -100,6 +100,15 @@

loading…

+ + +