diff --git a/Cargo.lock b/Cargo.lock index b6b1966c..76f2dfa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,14 +213,17 @@ 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", ] @@ -4310,6 +4313,15 @@ 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" @@ -4546,7 +4558,10 @@ name = "swarm-controller" version = "0.1.0" dependencies = [ "anyhow", + "async-nats", "axum", + "futures-util", + "reqwest 0.13.1", "serde", "serde_json", "tokio", @@ -5070,6 +5085,16 @@ 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 0bbbf6a8..91d9085f 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -200,8 +200,6 @@ 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). @@ -252,12 +250,6 @@ 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 95776d2d..748478b7 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -107,9 +107,8 @@ 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"; }; # CA-trusted (Let's Encrypt etc.) - edge = { domain = "edge.corp"; certFingerprint = "sha256:…"; }; # self-signed leaf, pinned + pr1ma = { domain = "pr1ma.example.com"; }; # this host, per hiveName + lab = { domain = "lab.example.com"; }; # a second hive in the swarm }; ``` @@ -128,76 +127,30 @@ 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. -**`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. +> **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.) ## What the config does at runtime -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. +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. -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 +2. **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 — independently of `certFingerprint`, - which it never consults. + **container's** trust bundle, independent of this directory. ⚠️ **That container currently trusts no swarm-internal CA**, so a self-signed gateway certificate does not federate. The swarm root @@ -208,6 +161,10 @@ environment and forwarded to agent containers. 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 @@ -264,7 +221,6 @@ 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"; @@ -290,9 +246,10 @@ 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. -- `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. +- `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. - `persistentKeepalive = 25` is set by default; override or null to disable (not needed when both sides have public IPs and no NAT). @@ -330,6 +287,39 @@ 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 @@ -337,6 +327,8 @@ socket-directory constraint that governs where `socketPath` may point: - `docs/conventions.md` § Hive identity — env vars, qualified labels - `docs/matrix.md` — matrix federation, TLS cert auto-generation, firewall posture -- `docs/web-ui/dashboard.md` § P33RS tab — dashboard surface +- `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/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 cfdf1e99..98aac13f 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -18,9 +18,7 @@ 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. 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. + you'll check most. - **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 1428aff2..5d827a8c 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`, `#peers`, `#settings`) drives which pane is +`#permissions`, `#schedules`, `#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,11 +19,10 @@ 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). Peer hives render as a headline under - SW4RM rather than a tab. Count pills on SW4RM + H0M3 hub (not the tab strip). Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions + unread - operator messages), and SCH3DUL3S (active schedules); P33RS and - S3TT1NGS have no count. + operator messages), and SCH3DUL3S (active schedules); S3TT1NGS has 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 @@ -642,44 +641,6 @@ 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 e3f3007d..d9bd8b53 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -100,15 +100,6 @@

loading…

- - -