From 991cd24fc8de28a6f0dfd8734a0b930f2ce3d5c5 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 12:42:33 +0200 Subject: [PATCH 01/16] refactor(3202): the gateway takes contributed dns names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `services.hyperhive.gateway.localNames` (internal): hostnames the hive resolver answers with the bridge IP, contributed by the modules that own them. The service says which name, the gateway says where it points — the same split `lib.tlsFor` already makes. No behaviour change yet: the list is empty until the service modules contribute in the following commits. The assertion is not defensive padding. Duplicate `address=` rules do not make dnsmasq complain; it resolves them by precedence, so a name claimed twice silently stops being served by one of its claimants. That failure mode only becomes reachable because contribution is now open, so it gets closed in the same commit that opens it. --- nix/host-modules/hive-gateway/default.nix | 20 ++++++++++++++++++++ nix/host-modules/hive-gateway/dnsmasq.nix | 13 ++++++++++++- nix/host-modules/hive-gateway/options.nix | 23 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 469ca341..db4188b9 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -125,6 +125,25 @@ in Let's Encrypt needs a contact address for the ACME account. ''; } + { + # Two modules claiming one hostname is a real possibility now + # that each service contributes its own name, and dnsmasq would + # not complain: duplicate `address=` rules resolve by precedence, + # so the loser simply stops being served with no error anywhere. + # Fail the build instead — a name is owned by exactly one module. + assertion = lib.length (lib.unique cfg.localNames) == lib.length cfg.localNames; + message = '' + services.hyperhive.gateway.localNames contains a duplicate: + ${lib.concatStringsSep ", " ( + lib.unique (lib.filter (n: lib.count (m: m == n) cfg.localNames > 1) cfg.localNames) + )} + + Each hostname the hive resolver answers for is contributed by + exactly one module. Two modules claiming the same name means + two services believe they serve it — resolve which one does + rather than letting dnsmasq pick. + ''; + } ]; # Ensure the gateway state dirs exist at host boot, before anything @@ -353,6 +372,7 @@ in services.dnsmasq = import ./dnsmasq.nix { inherit lib + cfg networkCfg forgeCfg matrixCfg diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index e790cf27..2cfba396 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -7,6 +7,7 @@ # are computed by hive-network. { lib, + cfg, # services.hyperhive.gateway networkCfg, forgeCfg, matrixCfg, @@ -79,7 +80,17 @@ # Reachability is not the access control here: the vhost's # `auth_request` + authelia's `group:operators` rule are, and an # agent that resolves the name still cannot open the page. - ++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}"; + ++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}" + # Names contributed by the modules that own them + # (`gateway.localNames`). Same address as everything above — the + # bridge IP is the gateway's answer for anything it fronts, and a + # contributing module neither knows nor should know it. + # + # `unique` is not tidiness: two modules claiming one name would + # otherwise emit two `address=` rules for it, and dnsmasq resolves + # that by precedence rather than by complaining. An assertion in + # ./default.nix makes the collision loud instead. + ++ map (name: "/${name}/${networkCfg.bridgeIp}") (lib.unique cfg.localNames); # DHCP pool covering all usable host addresses on the bridge # subnet — bounds computed by hive-network.nix from # bridgeIp/bridgePrefixLength. All containers (agents and service diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index f0945a6b..48de8a1f 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -94,6 +94,29 @@ in ''; }; + localNames = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + internal = true; + description = '' + Extra hostnames the hive's resolver answers with the bridge IP, + contributed by the modules that own those names. + + A service module says **which name**; the gateway decides + **where it points** — the same split as `lib.tlsFor`. A service + that hardcoded the bridge IP would be one more place to fix when + the network layout changes, and it has no business knowing it. + + ⚠️ Contribute a name only when THIS host actually serves it. The + list is not "names the swarm has" — + `services.hyperhive.swarm.serviceDomains` is that, and it is + deliberately broader (it drives certificate issuance, so it + includes names this hive may only be a client of). Publishing an + address record for a service you do not run points every agent + on the bridge at a door that isn't there. + ''; + }; + lib = { listen = lib.mkOption { type = lib.types.listOf (lib.types.attrsOf lib.types.raw); From d60a0585d680f3df11933e04a4b78c918772faa8 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 12:45:47 +0200 Subject: [PATCH 02/16] refactor(3202): the forge declares its own vhost and dns name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `forgeVhost` out of the gateway's vhosts.nix and the forge's `address=` rule out of dnsmasq.nix, into nix/host-modules/hive-forge — the module that already owns everything else about the forge. The gateway keeps what is gateway knowledge (the listen set, which issuer covers a name, the header block) and loses the last reason it had to read `swarm.forge` at all: `forgeCfg` is gone from both files and from the module's `let`. Both halves stay gated on `behindGateway` — with it off the operator fronts forgejo themselves, so this hive must neither claim the vhost nor answer DNS for the name. --- nix/host-modules/hive-forge/default.nix | 33 +++++++++++++++++++++++ nix/host-modules/hive-gateway/default.nix | 3 --- nix/host-modules/hive-gateway/dnsmasq.nix | 16 +++++------ nix/host-modules/hive-gateway/vhosts.nix | 24 ----------------- 4 files changed, 40 insertions(+), 36 deletions(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 2cc01ce6..2527f453 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -397,6 +397,39 @@ in }; config = lib.mkIf config.services.hyperhive.enable { + # This service's own gateway surface: the vhost that fronts it and + # the name the hive resolver answers for. Declared here rather than + # in the gateway so the forge's public face lives with the forge — + # the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`, + # `lib.securityHeaders`) and never needs to know this service by + # name. + # + # Both halves are gated on `behindGateway`: with it off the operator + # fronts forgejo themselves, so this hive must neither claim the + # vhost nor answer DNS for it. + services.hyperhive.gateway.localNames = lib.optional cfg.behindGateway cfg.domain; + + # `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for + # git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB + # clones). SSH stays direct on `forge.sshPort`. See + # `docs/gateway.md`. + services.nginx.virtualHosts = lib.optionalAttrs cfg.behindGateway { + "${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { + listen = gatewayCfg.lib.listen; + extraConfig = gatewayCfg.lib.securityHeaders; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString cfg.httpPort}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 1G; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ''; + }; + }; + }; + assertions = [ { # Fail at EVAL, not at boot. The alternative failure is a login diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index db4188b9..a2cdc95e 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -25,7 +25,6 @@ let autheliaCfg = config.services.hyperhive.swarm.authelia; uiCfg = config.services.hyperhive.swarm.ui; controllerCfg = config.services.hyperhive.swarm.controller; - forgeCfg = config.services.hyperhive.swarm.forge; networkCfg = config.services.hyperhive.network; # Dashboard SPA dist, static-served by nginx. @@ -87,7 +86,6 @@ let inherit lib cfg - forgeCfg matrixCfg autheliaCfg uiCfg @@ -374,7 +372,6 @@ in lib cfg networkCfg - forgeCfg matrixCfg autheliaCfg uiCfg diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index 2cfba396..89e6f952 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -9,7 +9,6 @@ lib, cfg, # services.hyperhive.gateway networkCfg, - forgeCfg, matrixCfg, autheliaCfg, uiCfg, @@ -57,17 +56,16 @@ # + its sub-domains with the bridge IP, where nginx is reachable # from every container netns. # - # The forge / matrix entries are redundant in the common case - # where `forge.domain` / `matrix.gatewayHost` are sub-domains of - # `hyperhive.domain` — dnsmasq's `//` rule already matches - # sub-domains. Kept explicit because operators can override either - # to a cross-domain hostname (e.g. `forge.domain = - # "git.example.com"`); listing them explicitly keeps that case - # routed without needing an extra config block. + # The matrix entry is redundant in the common case where + # `matrix.gatewayHost` is a sub-domain of `hyperhive.domain` — + # dnsmasq's `//` rule already matches sub-domains. Kept + # explicit because an operator can override it to a cross-domain + # hostname (e.g. `git.example.com` for the forge); listing such a + # name explicitly keeps that case routed without an extra config + # block. address = [ "/${hyperhiveDomain}/${networkCfg.bridgeIp}" ] - ++ lib.optional ((forgeCfg.behindGateway or false)) "/${forgeCfg.domain}/${networkCfg.bridgeIp}" ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}" diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index b35e75f7..e2a5a1e9 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -7,7 +7,6 @@ { lib, cfg, # services.hyperhive.gateway - forgeCfg, matrixCfg, autheliaCfg, # services.hyperhive.swarm.authelia uiCfg, # services.hyperhive.swarm.ui @@ -37,28 +36,6 @@ let publicPort = cfg.httpsPort; publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}"; - # Forge sub-domain vhost. `server_name = forge.domain`, proxies - # all `/` → forgejo. Tuned for git: `client_max_body_size 1G`, - # `proxy_read_timeout 1h` (multi-GB clones). SSH stays direct on - # `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the - # forge isn't behind the gateway. - forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) { - "${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // { - listen = vhostListen; - extraConfig = securityHeaders; - locations."/" = { - proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 1G; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ''; - }; - }; - }; - # Authelia sub-domain vhost. `server_name = authelia.domain`, all of # `/` → authelia. Empty attrset unless THIS host runs the container: # every hive knows the swarm's `authelia.url`, but only the one @@ -504,7 +481,6 @@ in ''; }; } - // forgeVhost // autheliaVhost // matrixVhost // swarmUiVhost; From 56ab6d26c127115a161818672b89d9a489ba76ec Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 12:50:55 +0200 Subject: [PATCH 03/16] refactor(3202): authelia declares its own vhost and dns name Moves the authelia vhost out of the gateway's vhosts.nix and its `address=` rule out of dnsmasq.nix, into swarm-authelia.nix. Both land inside that module's existing `cfg.enable` guard, which is the load-bearing part: every hive in a swarm knows `authelia.url`, but only the host that RUNS the container may claim the name. A client hive declaring the vhost would answer for a service it does not run, and publishing the DNS record would point every agent on its bridge at that wrong answer. The kit grows a fourth member, `errorPages`, because the vhost aims its 502/503/504 at the gateway's styled sso-unavailable page. Republished rather than imported per module: a service rendering its own would drift from the rest of the gateway the first time the theme changed. --- nix/host-modules/hive-gateway/default.nix | 18 ++++--- nix/host-modules/hive-gateway/dnsmasq.nix | 2 - nix/host-modules/hive-gateway/options.nix | 16 ++++++ nix/host-modules/hive-gateway/vhost-lib.nix | 8 +++ nix/host-modules/hive-gateway/vhosts.nix | 49 +------------------ nix/host-modules/swarm-authelia.nix | 54 +++++++++++++++++++++ 6 files changed, 91 insertions(+), 56 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index a2cdc95e..0b9deea5 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -64,11 +64,17 @@ let svcCert = "${tlsDir}/swarm-services.pem"; svcKey = "${tlsDir}/swarm-services-key.pem"; + # Styled static error pages. Built once here and reached two ways: + # directly by ./vhosts.nix, and via the published kit by any service + # module that aims an `error_page` at one. + errorPages = import ./error-pages.nix { inherit pkgs; }; + # The vhost construction kit (listen set / per-name TLS attrs / - # security headers). Computed here, published as `cfg.lib` below, and - # handed to ./vhosts.nix **as the published value** — so the tree the - # gateway renders and the kit a service module gets are the same - # object by construction, not by two call sites agreeing. + # security headers / error pages). Computed here, published as + # `cfg.lib` below, and handed to ./vhosts.nix **as the published + # value** — so the tree the gateway renders and the kit a service + # module gets are the same object by construction, not by two call + # sites agreeing. vhostLib = import ./vhost-lib.nix { inherit lib @@ -78,6 +84,7 @@ let svcCert svcKey swarmServiceDomains + errorPages ; }; @@ -86,6 +93,7 @@ let inherit lib cfg + errorPages matrixCfg autheliaCfg uiCfg @@ -94,7 +102,6 @@ let dashboardDist swaggerUiTheme ; - errorPages = import ./error-pages.nix { inherit pkgs; }; }; in { @@ -373,7 +380,6 @@ in cfg networkCfg matrixCfg - autheliaCfg uiCfg hyperhiveDomain ; diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index 89e6f952..80ce77c8 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -10,7 +10,6 @@ cfg, # services.hyperhive.gateway networkCfg, matrixCfg, - autheliaCfg, uiCfg, hyperhiveDomain, }: @@ -69,7 +68,6 @@ ++ lib.optional ( matrixCfg.enable && matrixCfg.gatewayHost != null ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}" - ++ lib.optional autheliaCfg.enable "/${autheliaCfg.domain}/${networkCfg.bridgeIp}" # The swarm UI's name is the swarm APEX by default — a sibling of # the three above, not a child of anything this resolver already # answers for, so the `//` rule does not cover it. diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 48de8a1f..74bef4da 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -150,6 +150,22 @@ in ''; }; + errorPages = lib.mkOption { + type = lib.types.attrsOf lib.types.path; + internal = true; + readOnly = true; + description = '' + Read-only: the gateway's styled static error pages, by name + (`notFound`, `unreachable`, `unauthorized`, `ssoUnavailable`). + + Published so a service module can aim an `error_page` at one + instead of rendering its own — a service that built its own + would drift from the rest of the gateway the first time the + theme changed, and the operator would meet two different + error styles on one hive. + ''; + }; + securityHeaders = lib.mkOption { type = lib.types.lines; internal = true; diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix index d60e52d2..060c14c1 100644 --- a/nix/host-modules/hive-gateway/vhost-lib.nix +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -22,6 +22,7 @@ svcCert, # swarm-services leaf, for names the hive CA cannot sign svcKey, swarmServiceDomains, # which names those are (../swarm.nix derives it) + errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } }: let # nixos `services.nginx.virtualHosts.` ssl attrs for a vhost @@ -98,4 +99,11 @@ in add_header Referrer-Policy "strict-origin-when-cross-origin" always; ${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''} ''; + + # The gateway's styled error pages, re-exported so a service module + # can point an `error_page` at one. Republished rather than imported + # per module for the same reason as everything else in this kit: these + # carry the hive's branding, and a service rendering its own would + # drift from the rest of the gateway the first time the theme changes. + inherit errorPages; } diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index e2a5a1e9..d5d15a4d 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -36,53 +36,6 @@ let publicPort = cfg.httpsPort; publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}"; - # Authelia sub-domain vhost. `server_name = authelia.domain`, all of - # `/` → authelia. Empty attrset unless THIS host runs the container: - # every hive knows the swarm's `authelia.url`, but only the one - # serving it may claim the name — a client hive declaring this vhost - # would answer for a service it does not run. - # - # ⚠️ The server name must be exactly `autheliaCfg.domain`, not a - # near-miss: authelia validates `authelia_url ⊂ session cookie domain` - # at STARTUP, so a mismatch is a container that refuses to boot rather - # than a login that misbehaves. - # - # ⚠️ And deliberately NO `dashboardAuth` here. That block is the - # gateway's `auth_basic`; applying it to the SSO provider would put - # the login page behind the login mechanism it exists to replace. - autheliaVhost = lib.optionalAttrs autheliaCfg.enable { - "${autheliaCfg.domain}" = (vhostTlsFor autheliaCfg.domain) // { - listen = vhostListen; - extraConfig = securityHeaders; - locations."/" = { - proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - # authelia decides by the ORIGINAL request, not by the hop it - # sees — the login redirect and the session cookie's domain - # both derive from these. Without them every request looks - # like it arrived at 127.0.0.1 over plain http. - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Uri $request_uri; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # A dead upstream here means "not bootstrapped" far more often - # than "misconfigured proxy", and a bare 502 says the opposite. - proxy_intercept_errors on; - error_page 502 503 504 = /__hive_sso_unavailable; - ''; - }; - locations."= /__hive_sso_unavailable" = { - extraConfig = '' - internal; - alias ${errorPages.ssoUnavailable}; - default_type text/html; - ''; - }; - }; - }; - # Swarm UI vhost — the swarm's front page, on the swarm apex, and the # FIRST `auth_request` anywhere in this gateway (everything else is # `auth_basic` + htpasswd). @@ -481,7 +434,7 @@ in ''; }; } - // autheliaVhost + // matrixVhost // swarmUiVhost; } diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 30d39fd5..37e6a511 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -35,6 +35,7 @@ let cfg = config.services.hyperhive.swarm.authelia; hyperhiveCfg = config.services.hyperhive; + gatewayCfg = hyperhiveCfg.gateway; hyperhiveDomain = hyperhiveCfg.domain; swarmDomain = hyperhiveCfg.swarm.domain; uiCfg = hyperhiveCfg.swarm.ui; @@ -402,6 +403,59 @@ in }; config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { + # Authelia's own gateway surface: the vhost that fronts it and the + # name the hive resolver answers for. Both live here rather than in + # the gateway, and both are inside `cfg.enable` — that guard is the + # load-bearing part. + # + # ⚠️ Every hive in a swarm knows `authelia.url`, but only the host + # that RUNS the container may claim the name. A client hive + # declaring this vhost would answer for a service it does not run, + # and publishing the DNS record would point every agent on its + # bridge at that wrong answer. + services.hyperhive.gateway.localNames = [ cfg.domain ]; + + # `server_name = authelia.domain`, all of `/` → authelia. + # + # ⚠️ The server name must be exactly `cfg.domain`, not a near-miss: + # authelia validates `authelia_url ⊂ session cookie domain` at + # STARTUP, so a mismatch is a container that refuses to boot rather + # than a login that misbehaves. + # + # ⚠️ And deliberately NO `dashboardAuth` here. That block is the + # gateway's `auth_basic`; applying it to the SSO provider would put + # the login page behind the login mechanism it exists to replace. + services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { + listen = gatewayCfg.lib.listen; + extraConfig = gatewayCfg.lib.securityHeaders; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString cfg.port}/"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + # authelia decides by the ORIGINAL request, not by the hop it + # sees — the login redirect and the session cookie's domain + # both derive from these. Without them every request looks + # like it arrived at 127.0.0.1 over plain http. + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # A dead upstream here means "not bootstrapped" far more often + # than "misconfigured proxy", and a bare 502 says the opposite. + proxy_intercept_errors on; + error_page 502 503 504 = /__hive_sso_unavailable; + ''; + }; + locations."= /__hive_sso_unavailable" = { + extraConfig = '' + internal; + alias ${gatewayCfg.lib.errorPages.ssoUnavailable}; + default_type text/html; + ''; + }; + }; + containers.${cfg.machine} = { autoStart = true; ephemeral = false; From 6caf1774168e79e99d8986f976e7decf311aa9eb Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 14:36:17 +0200 Subject: [PATCH 04/16] refactor(3202): matrix declares its own vhost, dns name and SPA map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the matrix sub-domain vhost out of the gateway's vhosts.nix, its `address=` rule out of dnsmasq.nix, and the Accept-header `$matrix_spa_target` map out of the gateway's appendHttpConfig — all three into hive-matrix.nix. The map is the one that had no business being where it was: it exists solely for the SPA fallback in the vhost's `/` location, and `appendHttpConfig` is a `lines` option, so a module can contribute to it without the gateway assembling it. The `.well-known/matrix/*` delegation deliberately stays on the hive's own vhost. The spec requires it at the SERVER NAME, which is the hive domain: that is the hive answering "where is my homeserver", not the homeserver answering for itself. Moving it would have been the obvious symmetric thing and it would have been wrong. --- nix/host-modules/hive-gateway/default.nix | 3 +- nix/host-modules/hive-gateway/dnsmasq.nix | 12 ---- nix/host-modules/hive-gateway/vhosts.nix | 69 +------------------ nix/host-modules/hive-matrix.nix | 80 +++++++++++++++++++++++ 4 files changed, 82 insertions(+), 82 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 0b9deea5..5ef0ab9b 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -347,7 +347,7 @@ in recommendedTlsSettings = true; recommendedGzipSettings = true; recommendedOptimisation = true; - inherit (nginxTree) appendHttpConfig virtualHosts; + inherit (nginxTree) virtualHosts; }; # ⚠️ NO `SupplementaryGroups = [ "hive-core" ]` on nginx, and its @@ -379,7 +379,6 @@ in lib cfg networkCfg - matrixCfg uiCfg hyperhiveDomain ; diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index 80ce77c8..bf0d6766 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -9,7 +9,6 @@ lib, cfg, # services.hyperhive.gateway networkCfg, - matrixCfg, uiCfg, hyperhiveDomain, }: @@ -54,20 +53,9 @@ # Hive authoritative records — answer queries for the hive domain # + its sub-domains with the bridge IP, where nginx is reachable # from every container netns. - # - # The matrix entry is redundant in the common case where - # `matrix.gatewayHost` is a sub-domain of `hyperhive.domain` — - # dnsmasq's `//` rule already matches sub-domains. Kept - # explicit because an operator can override it to a cross-domain - # hostname (e.g. `git.example.com` for the forge); listing such a - # name explicitly keeps that case routed without an extra config - # block. address = [ "/${hyperhiveDomain}/${networkCfg.bridgeIp}" ] - ++ lib.optional ( - matrixCfg.enable && matrixCfg.gatewayHost != null - ) "/${matrixCfg.gatewayHost}/${networkCfg.bridgeIp}" # The swarm UI's name is the swarm APEX by default — a sibling of # the three above, not a child of anything this resolver already # answers for, so the `//` rule does not cover it. diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index d5d15a4d..6f6c79a0 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -3,7 +3,7 @@ # and authelia sub-domain vhosts, and the Accept-header SPA map for the # matrix GUI. Pure function — called from ./default.nix with the # outer-scope config values as arguments; returns -# `{ virtualHosts, appendHttpConfig }`. +# `{ virtualHosts }`. { lib, cfg, # services.hyperhive.gateway @@ -145,61 +145,6 @@ let }; }; - # Matrix sub-domain vhost. `server_name = matrixCfg.gatewayHost`. - # `/_matrix/*` → tuwunel (CORS *, 50M body cap, 1h long-poll - # timeout). `/` serves fluffychat or 404 if GUI off. nginx - # longer-prefix-wins puts `/_matrix/` ahead of `/`. See - # `docs/gateway.md`. Empty attrset when matrix has no gateway host. - matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) { - "${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // { - listen = vhostListen; - extraConfig = securityHeaders; - locations = { - "/_matrix/" = { - proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}"; - proxyWebsockets = true; - extraConfig = '' - proxy_buffering off; - client_max_body_size 50M; - proxy_read_timeout 1h; - proxy_send_timeout 1h; - ${securityHeaders} - add_header Access-Control-Allow-Origin *; - ''; - }; - } - // lib.optionalAttrs (matrixCfg.gui.enable) ( - { - # fluffychat at sub-domain root, SPA-fallback via - # the Accept-header `$matrix_spa_target` map. - "/" = { - alias = "${matrixCfg.gui.package}/"; - extraConfig = '' - try_files $uri $uri/ $matrix_spa_target =404; - ''; - }; - } - // { - # FluffyChat boot-config pre-fill so the client's - # `.well-known/matrix/client` lookup hits the - # right delegation endpoint. `domain` is required, so - # this is always present. - "= /config.json" = { - extraConfig = '' - default_type application/json; - return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; - ''; - }; - } - ) - // lib.optionalAttrs (!matrixCfg.gui.enable) { - "/" = { - return = "404"; - }; - }; - }; - }; - # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link # shim during the fluffychat sub-domain move). See `docs/gateway.md`. matrixRedirectLocations = @@ -385,17 +330,6 @@ let }; in { - # Accept-header SPA map for the matrix GUI only (see docs/gateway.md - # "SPA fallback"): text/html → index.html, else a sentinel so - # try_files falls through to 404. The dashboard doesn't use an - # Accept-header map — it routes by path (see dashboardProxyLocation). - appendHttpConfig = lib.optionalString (matrixCfg.enable && matrixCfg.gui.enable) '' - map $http_accept $matrix_spa_target { - default "/__matrix_spa_no_html_fallback"; - "~*text/html" "/index.html"; - } - ''; - virtualHosts = { # `tlsFor "_"`, not a separate binding: the default server is a # vhost named `_`, and a name that is not a swarm service domain @@ -435,6 +369,5 @@ in }; } - // matrixVhost // swarmUiVhost; } diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 89780930..4a7e9d5c 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -9,6 +9,7 @@ let networkCfg = config.services.hyperhive.network; tlsCfg = config.services.hyperhive.tls; gatewayCfg = config.services.hyperhive.gateway; + hyperhiveDomain = config.services.hyperhive.domain; # Same runtime→build-time bridge hive-ci and hive-forge already cross: # binds the hive trust bundle (which folds in the swarm root) into the @@ -396,6 +397,85 @@ in }; config = lib.mkIf cfg.enable { + # Matrix's own gateway surface: the sub-domain vhost, the name the + # hive resolver answers for, and the Accept-header map that vhost's + # SPA fallback reads. All three are matrix knowledge and none of + # them is the gateway's business. + # + # `gatewayHost = null` means matrix is reachable directly rather + # than fronted, so there is no name to claim and no vhost to serve — + # every clause below carries that guard. + services.hyperhive.gateway.localNames = lib.optional (cfg.gatewayHost != null) cfg.gatewayHost; + + # Accept-header SPA map, used only by the `/` location below (see + # docs/gateway.md "SPA fallback"): text/html → index.html, else a + # sentinel so `try_files` falls through to 404. `appendHttpConfig` + # is a `lines` option, so this merges with anything else the host + # contributes instead of replacing it. + # + # The dashboard needs no equivalent — it routes by path. + services.nginx.appendHttpConfig = lib.optionalString cfg.gui.enable '' + map $http_accept $matrix_spa_target { + default "/__matrix_spa_no_html_fallback"; + "~*text/html" "/index.html"; + } + ''; + + # `server_name = gatewayHost`. `/_matrix/*` → tuwunel (CORS `*`, 50M + # body cap, 1h long-poll timeout). `/` serves fluffychat, or 404 + # with the GUI off. nginx's longest-prefix rule puts `/_matrix/` + # ahead of `/` with no ordering needed. + # + # ⚠️ The `.well-known/matrix/*` delegation is deliberately NOT here. + # It stays on the hive's own vhost because the spec requires it to + # be served at the *server name*, which is the hive domain — it is + # the hive answering "where is my homeserver", not the homeserver + # answering for itself. + services.nginx.virtualHosts = lib.optionalAttrs (cfg.gatewayHost != null) { + "${cfg.gatewayHost}" = (gatewayCfg.lib.tlsFor cfg.gatewayHost) // { + listen = gatewayCfg.lib.listen; + extraConfig = gatewayCfg.lib.securityHeaders; + locations = { + "/_matrix/" = { + proxyPass = "http://127.0.0.1:${toString cfg.httpPort}"; + proxyWebsockets = true; + extraConfig = '' + proxy_buffering off; + client_max_body_size 50M; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + ${gatewayCfg.lib.securityHeaders} + add_header Access-Control-Allow-Origin *; + ''; + }; + } + // lib.optionalAttrs cfg.gui.enable { + # fluffychat at sub-domain root, SPA-fallback via the + # Accept-header `$matrix_spa_target` map above. + "/" = { + alias = "${cfg.gui.package}/"; + extraConfig = '' + try_files $uri $uri/ $matrix_spa_target =404; + ''; + }; + # FluffyChat boot-config pre-fill so the client's + # `.well-known/matrix/client` lookup hits the right delegation + # endpoint. `domain` is required, so this is always present. + "= /config.json" = { + extraConfig = '' + default_type application/json; + return 200 '{"defaultHomeserver":"${hyperhiveDomain}"}'; + ''; + }; + } + // lib.optionalAttrs (!cfg.gui.enable) { + "/" = { + return = "404"; + }; + }; + }; + }; + # `serverName` is irrevocably embedded in user/room IDs; it derives # from `services.hyperhive.domain` (required, asserted in # hive-network.nix) when not set explicitly, so no separate From 030eef09484742d021564d15509cbf7ef1d2be8c Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 15:24:21 +0200 Subject: [PATCH 05/16] refactor(3202): the swarm UI declares its own vhost and dns name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last of the four. The vhost, its `auth_request` block and the swarm apex's dns record move into swarm-ui.nix; vhosts.nix drops `uiCfg`, `controllerCfg` and `autheliaCfg` and is now 259 lines of hive surface with no swarm service in it. Also collapses a THIRD copy of the per-service list. `networking.hosts` restated every service's name with its own copy of that service's guard, after the vhosts and the dnsmasq records had each done the same. It asks the same question — which names does this host answer for — so it now reads the same answer: a service added later lands in /etc/hosts with no edit, and cannot land there under a different condition than it used for DNS. The `forceSSL`-not-`addSSL` comment travels intact: it records that authelia answers an http auth subrequest with 400 and nginx's auth_request only understands 2xx/401/403, so the scheme is load-bearing for this vhost and no other. --- nix/host-modules/hive-gateway/default.nix | 28 ++--- nix/host-modules/hive-gateway/dnsmasq.nix | 10 -- nix/host-modules/hive-gateway/vhosts.nix | 116 +------------------- nix/host-modules/swarm-ui.nix | 123 ++++++++++++++++++++++ 4 files changed, 134 insertions(+), 143 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index 5ef0ab9b..b6fe8e03 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -22,9 +22,6 @@ let # same list rather than each deciding what "a swarm service" means. swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains; matrixCfg = config.services.hyperhive.swarm.matrix; - autheliaCfg = config.services.hyperhive.swarm.authelia; - uiCfg = config.services.hyperhive.swarm.ui; - controllerCfg = config.services.hyperhive.swarm.controller; networkCfg = config.services.hyperhive.network; # Dashboard SPA dist, static-served by nginx. @@ -95,9 +92,6 @@ let cfg errorPages matrixCfg - autheliaCfg - uiCfg - controllerCfg hyperhiveDomain dashboardDist swaggerUiTheme @@ -379,7 +373,6 @@ in lib cfg networkCfg - uiCfg hyperhiveDomain ; }; @@ -393,19 +386,18 @@ in ]; }; - # `/etc/hosts` entries for local dev — bare hive domain + any - # sub-domain modules that are on. `lib.unique` dedupes if any - # sub-domain happens to equal another. See `docs/gateway.md` + # `/etc/hosts` entries for local dev — the bare hive domain plus + # every name a service module contributed. See `docs/gateway.md` # ("Local dev"). + # + # This used to restate the per-service list a THIRD time (after the + # vhosts and the dnsmasq records), with its own copy of each + # service's guard. It is the same question — "which names does this + # host answer for" — so it reads the same answer; a service added + # later lands here with no edit, and cannot land here with a + # different condition than it used for DNS. networking.hosts = lib.mkIf cfg.localHostsEntry { - "127.0.0.1" = lib.unique ( - [ hyperhiveDomain ] - ++ lib.optional (config.services.hyperhive.swarm.forge.behindGateway or false - ) config.services.hyperhive.swarm.forge.domain - ++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost - ++ lib.optional autheliaCfg.enable autheliaCfg.domain - ++ lib.optional uiCfg.enable uiCfg.domain - ); + "127.0.0.1" = lib.unique ([ hyperhiveDomain ] ++ cfg.localNames); }; }; } diff --git a/nix/host-modules/hive-gateway/dnsmasq.nix b/nix/host-modules/hive-gateway/dnsmasq.nix index bf0d6766..e5de27f5 100644 --- a/nix/host-modules/hive-gateway/dnsmasq.nix +++ b/nix/host-modules/hive-gateway/dnsmasq.nix @@ -9,7 +9,6 @@ lib, cfg, # services.hyperhive.gateway networkCfg, - uiCfg, hyperhiveDomain, }: { @@ -56,15 +55,6 @@ address = [ "/${hyperhiveDomain}/${networkCfg.bridgeIp}" ] - # The swarm UI's name is the swarm APEX by default — a sibling of - # the three above, not a child of anything this resolver already - # answers for, so the `//` rule does not cover it. - # - # Published to agents deliberately (mara: publishing it is fine). - # Reachability is not the access control here: the vhost's - # `auth_request` + authelia's `group:operators` rule are, and an - # agent that resolves the name still cannot open the page. - ++ lib.optional uiCfg.enable "/${uiCfg.domain}/${networkCfg.bridgeIp}" # Names contributed by the modules that own them # (`gateway.localNames`). Same address as everything above — the # bridge IP is the gateway's answer for anything it fronts, and a diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index 6f6c79a0..b8166520 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -8,9 +8,6 @@ lib, cfg, # services.hyperhive.gateway matrixCfg, - autheliaCfg, # services.hyperhive.swarm.authelia - uiCfg, # services.hyperhive.swarm.ui - controllerCfg, # services.hyperhive.swarm.controller hyperhiveDomain, dashboardDist, swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css @@ -36,115 +33,6 @@ let publicPort = cfg.httpsPort; publicPortSuffix = if publicPort == 443 then "" else ":${toString publicPort}"; - # Swarm UI vhost — the swarm's front page, on the swarm apex, and the - # FIRST `auth_request` anywhere in this gateway (everything else is - # `auth_basic` + htpasswd). - # - # ⚠️ `auth_request` answers "is there a session", not "is this an - # operator". The operator-only part is authelia's `access_control` - # rule (../swarm-authelia.nix) requiring `group:operators` — agents - # are getting authelia accounts of their own, and without that rule a - # session alone would open this page. - # - # ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest - # that wrongly denies takes the whole UI away. That is the reason the - # redirect target and the header set below are copied from a measured - # source rather than from an example. - # ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a hardening - # preference, the only way this page works at all. authelia answers the - # auth subrequest for an `http://` target with **400**, and nginx's - # `auth_request` only understands 2xx/401/403, so a plain-http visit - # dies as "auth request unexpected status: 400" with no hint a login - # exists. `vhostListen` binds :80, so without this the door is open on - # a port the lock cannot work on. Serving forge or matrix over http is - # merely insecure rather than broken, so they keep `addSSL` and the - # asymmetry stays local to the vhost whose correctness depends on the - # scheme. `removeAttrs` because nixos asserts on a vhost declaring both. - # Shared with every swarm-UI-vhost location below (`/`, `/api/`, - # `/api/docs/`) — auth_request does not inherit across sibling - # locations, so each one that should be operator-gated repeats this - # verbatim rather than only the page itself being protected while its - # own API and API docs are reachable unauthenticated. - swarmAuthRequest = '' - auth_request /__hive_authelia; - # Captured BEFORE the error_page jump: inside the 401 handler - # `$request_uri` is the internal one, so building the return - # link there sends the operator back to the auth subrequest - # instead of the page they asked for. - auth_request_set $target_url $scheme://$http_host$request_uri; - error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url; - ''; - - swarmUiVhost = lib.optionalAttrs uiCfg.enable { - "${uiCfg.domain}" = (builtins.removeAttrs (vhostTlsFor uiCfg.domain) [ "addSSL" ]) // { - forceSSL = true; - listen = vhostListen; - extraConfig = securityHeaders; - locations = { - "/" = { - root = "${uiCfg.package}"; - extraConfig = '' - ${swarmAuthRequest} - # SPA: any path the bundle routes client-side is served the - # entry document rather than a 404 from the filesystem. - try_files $uri /index.html; - ''; - }; - # swarm-controller's whole HTTP surface, including the live - # `/api/openapi.json` spec — proxied untouched (no URI segment - # after the socket path, same "pass the request through as-is" - # shape as the per-hive dashboard's own `/api/` proxy) so the - # path swarm-controller registered a route at is the path - # nginx forwards, no prefix-stripping to keep in sync by hand. - "/api/" = { - proxyPass = "http://unix:${controllerCfg.socketPath}:"; - extraConfig = swarmAuthRequest; - }; - # Swagger UI: same "nginx hosts the themed dist straight from - # the store, only /api/openapi.json is dynamic" shape as the - # per-hive gateway's `swaggerUiLocations` — see that block's - # comment for why core-equivalent (here, swarm-controller) - # does not also mount its own copy. - "= /api/docs" = { - extraConfig = '' - return 301 /api/docs/; - ''; - }; - "/api/docs/" = { - alias = "${swaggerUiTheme}/"; - extraConfig = '' - index index.html; - ${swarmAuthRequest} - ''; - }; - # The subrequest itself. `auth-request` is the implementation - # name authelia exposes under `/api/authz/`; `/api/verify` is the - # LEGACY path every older example shows. - # - # Header set measured against the pinned binary (4.39.20), not - # copied: `X-Original-URL` and `X-Original-Method` are present as - # literals and are what this implementation reads — - # `X-Forwarded-Uri` does not appear in it at all, so sending it - # would look like configuration and be dead weight. - "= /__hive_authelia" = { - proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; - extraConfig = '' - internal; - # A subrequest carries no body, and forwarding one here makes - # authelia read a payload it will never use. - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - proxy_set_header X-Original-Method $request_method; - proxy_set_header X-Original-URL $scheme://$http_host$request_uri; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $http_host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - ''; - }; - }; - }; - }; - # `/matrix/*` → 301 → `matrix./$1` (legacy deep-link # shim during the fluffychat sub-domain move). See `docs/gateway.md`. matrixRedirectLocations = @@ -367,7 +255,5 @@ in include /var/lib/hive-gateway/conf/agents.conf; ''; }; - } - - // swarmUiVhost; + }; } diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 18820162..87c8f8a1 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -13,6 +13,24 @@ }: let cfg = config.services.hyperhive.swarm.ui; + gatewayCfg = config.services.hyperhive.gateway; + autheliaCfg = config.services.hyperhive.swarm.authelia; + controllerCfg = config.services.hyperhive.swarm.controller; + + # Repeated verbatim by every location that should be operator-gated + # (`/`, `/api/`, `/api/docs/`) rather than set once on the server: + # nginx's `auth_request` does NOT inherit across sibling locations, so + # setting it only on the page would leave this UI's own API and API + # docs reachable without a session. + swarmAuthRequest = '' + auth_request /__hive_authelia; + # Captured BEFORE the error_page jump: inside the 401 handler + # `$request_uri` is the internal one, so building the return + # link there sends the operator back to the auth subrequest + # instead of the page they asked for. + auth_request_set $target_url $scheme://$http_host$request_uri; + error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url; + ''; swarmCfg = config.services.hyperhive.swarm; hiveDomain = config.services.hyperhive.domain; in @@ -93,5 +111,110 @@ in ''; } ]; + + # The swarm UI's own gateway surface. Published to agents on the + # bridge deliberately: reachability is not the access control here — + # the `auth_request` below and authelia's `group:operators` rule + # are, and an agent that resolves the name still cannot open the + # page. + # + # The apex is a SIBLING of `forge.` / `chat.`, not a + # child of anything the resolver already answers for, so the + # `//` rule does not cover it and this record is what + # makes the name resolve at all. + services.hyperhive.gateway.localNames = [ cfg.domain ]; + + # The swarm's front page, and the FIRST `auth_request` anywhere in + # this gateway (everything else is `auth_basic` + htpasswd). + # + # ⚠️ `auth_request` answers "is there a session", not "is this an + # operator". The operator-only part is authelia's `access_control` + # rule (./swarm-authelia.nix) requiring `group:operators` — agents + # have authelia accounts of their own, and without that rule a + # session alone would open this page. + # + # ⚠️ Failure mode here is LOCKED OUT, not unprotected: a subrequest + # that wrongly denies takes the whole UI away. That is why the + # redirect target and the header set below come from a measured + # source rather than an example. + # + # ⚠️ `forceSSL`, not `addSSL` like every other vhost — not a + # hardening preference, the only way this page works at all. + # authelia answers the auth subrequest for an `http://` target with + # **400**, and nginx's `auth_request` only understands 2xx/401/403, + # so a plain-http visit dies as "auth request unexpected status: + # 400" with no hint a login exists. The shared listen set binds :80, + # so without this the door is open on a port the lock cannot work + # on. Serving forge or matrix over http is merely insecure rather + # than broken, so they keep `addSSL` and the asymmetry stays local + # to the vhost whose correctness depends on the scheme. + # `removeAttrs` because nixos asserts on a vhost declaring both. + services.nginx.virtualHosts."${cfg.domain}" = + (builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]) + // { + forceSSL = true; + listen = gatewayCfg.lib.listen; + extraConfig = gatewayCfg.lib.securityHeaders; + locations = { + "/" = { + root = "${cfg.package}"; + extraConfig = '' + ${swarmAuthRequest} + # SPA: any path the bundle routes client-side is served the + # entry document rather than a 404 from the filesystem. + try_files $uri /index.html; + ''; + }; + # swarm-controller's whole HTTP surface, including the live + # `/api/openapi.json` spec — proxied untouched (no URI segment + # after the socket path, same "pass the request through as-is" + # shape as the per-hive dashboard's own `/api/` proxy) so the + # path swarm-controller registered a route at is the path + # nginx forwards, no prefix-stripping to keep in sync by hand. + "/api/" = { + proxyPass = "http://unix:${controllerCfg.socketPath}:"; + extraConfig = swarmAuthRequest; + }; + # Swagger UI: same "nginx hosts the themed dist straight from + # the store, only /api/openapi.json is dynamic" shape as the + # per-hive gateway's `swaggerUiLocations`. + "= /api/docs" = { + extraConfig = '' + return 301 /api/docs/; + ''; + }; + "/api/docs/" = { + alias = "${gatewayCfg.swaggerUiTheme}/"; + extraConfig = '' + index index.html; + ${swarmAuthRequest} + ''; + }; + # The subrequest itself. `auth-request` is the implementation + # name authelia exposes under `/api/authz/`; `/api/verify` is + # the LEGACY path every older example shows. + # + # Header set measured against the pinned binary (4.39.20), not + # copied: `X-Original-URL` and `X-Original-Method` are present + # as literals and are what this implementation reads — + # `X-Forwarded-Uri` does not appear in it at all, so sending it + # would look like configuration and be dead weight. + "= /__hive_authelia" = { + proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + extraConfig = '' + internal; + # A subrequest carries no body, and forwarding one here makes + # authelia read a payload it will never use. + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + proxy_set_header X-Original-URL $scheme://$http_host$request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; + }; + }; }; } From f80facbbe04a6a9b6fe9f62c03e8372277084979 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 16:25:56 +0200 Subject: [PATCH 06/16] refactor(3202): all-local asserts the host's own /etc/hosts entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause 2 of #3202, reading 1 (mara: "the all local stuff and swarm services auto conf belong in those mods, not spread all over"). `gateway.localHostsEntry` is the gateway's only local-deployment knob — `openFirewall` is about EXTERNAL exposure, `tls.acme` needs a public DNS name, `hsts` is a hardening choice. It is now asserted by the mode in local-defaults.nix, beside the three swarm toggles, instead of being the one all-local implication an operator still had to know about. `mkDefault`, so "all local except this" still needs no new option. ⚠️ The non-obvious half: this does NOT change what CONTAINERS resolve. dnsmasq sets `no-hosts = true` unconditionally, so agents keep getting the bridge IP from the authoritative `address=` rules rather than the host's 127.0.0.1 — which would point every agent at its own netns. That guard already existing is what makes this safe to default on; without it this one line would break every agent's access to the forge. --- nix/host-modules/hive-gateway/options.nix | 6 +++++ nix/host-modules/local-defaults.nix | 27 +++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index 74bef4da..b6616656 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -91,6 +91,12 @@ in gateway shape. Off by default — operators running with real DNS shouldn't have a stale `/etc/hosts` entry sticking around. Requires `services.hyperhive.domain` to be set. + + `services.hyperhive.enableAllLocalDefaults` turns this on as + part of saying "this box is the whole deployment": that mode + means there is no real DNS for these names and the operator is + browsing them from the host itself. Set it here explicitly to + override in either direction. ''; }; diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index c423672a..acda256e 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -29,11 +29,15 @@ in example = true; description = '' Run the whole swarm on this host. Turning this on asserts the - swarm-level toggles that an all-on-one-box deployment implies: - the swarm's shared services + toggles that an all-on-one-box deployment implies: the swarm's + shared services (`services.hyperhive.swarm.enableRequiredServices`), the swarm - CA (`services.hyperhive.swarm.ca.autoConfigure`), and the swarm - controller (`services.hyperhive.swarm.controller.enable`). + CA (`services.hyperhive.swarm.ca.autoConfigure`), the swarm + controller (`services.hyperhive.swarm.controller.enable`), and the + host's `/etc/hosts` entries for the names this hive serves + (`services.hyperhive.gateway.localHostsEntry`) — with no real DNS + for those names, the operator is browsing them from the same box + that answers for them. **Off by default, and that is the load-bearing part.** A swarm's services and its hives can live on different hosts, and a host has @@ -52,6 +56,21 @@ in # own `default` (1500) and loses to any explicit definition, which is # exactly the precedence a deployment mode wants: it fills in for an # operator who hasn't spoken, and never argues with one who has. + # The gateway's own all-local bit. `localHostsEntry` maps every name + # this hive answers for to 127.0.0.1 in the HOST's /etc/hosts, which is + # exactly what "this box is the whole deployment" implies: there is no + # real DNS for these names, and the operator is browsing them from the + # same machine that serves them. + # + # ⚠️ It does NOT affect what containers resolve. dnsmasq sets + # `no-hosts = true` unconditionally (see hive-gateway/dnsmasq.nix), so + # agents keep getting the bridge IP from the authoritative `address=` + # rules rather than the host's 127.0.0.1 — an entry that would point + # every agent at its own netns. That guard already existing is what + # makes turning this on by default safe; without it this line would + # break every agent's access to the forge. + config.services.hyperhive.gateway.localHostsEntry = lib.mkDefault cfg.enableAllLocalDefaults; + config.services.hyperhive.swarm = { enableRequiredServices = lib.mkDefault cfg.enableAllLocalDefaults; ca.autoConfigure = lib.mkDefault cfg.enableAllLocalDefaults; From fbffccbbb2ede71c780ca23b85cfd369e30d3e94 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 16:51:22 +0200 Subject: [PATCH 07/16] feat(nix): a flake check that actually covers nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other check in nix/checks.nix is a Rust derivation, so a .nix-only diff moves no hash, the whole set is cache hits, and `nix flake check` reports green without evaluating what changed. `checks.module-eval` is one derivation holding a table of cases, each named by the PROPERTY it defends. Its builder text embeds the evaluated results, so the derivation's hash is a function of them: a nix change that flips a property rebuilds the check and fails in the builder, naming that property. PROVEN, not assumed — the mechanism was executed before the cases were written. Same expression with one property true vs false: drvPath true -> 5v11mnbv…-module-eval.drv drvPath false -> ivm3dvv8…-module-eval.drv (differs) build false -> FAILS, stderr names the property and the table itself was mutation-tested: inverting one case's expectation gives `FAILED: a hive that has not opted into all-local runs no swarm controller / module-eval: 1 of 5 properties broke`. A check that cannot go red on a broken tree is not evidence. Cases are named by property and never by ticket: a case named after the ticket that prompted it has that ticket's lifetime; one named after the property lives as long as the property does. ⚠️ It evaluates, it does not execute. Where the artifact is a command line, a request or a certificate, a value assertion cannot stand in — that is written into the file's header, because the gap is exactly what made two earlier outages evaluable-but-broken. --- flake.nix | 1 + nix/checks.nix | 15 +++++- nix/module-eval.nix | 119 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 nix/module-eval.nix diff --git a/flake.nix b/flake.nix index 37f2c26a..b439cdf3 100644 --- a/flake.nix +++ b/flake.nix @@ -204,6 +204,7 @@ system treefmt-eval ; + inherit (nixpkgs.lib) nixosSystem; } ); }; diff --git a/nix/checks.nix b/nix/checks.nix index d73b205c..c8ac66e6 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -1,6 +1,6 @@ # Flake checks: formatting, the clippy gate, the workspace test run, -# the nix-options docs eval, and the hivectl CLI-reference freshness -# check. Imported per system from flake.nix. +# the nix-options docs eval, the hivectl CLI-reference freshness check, +# and the module-eval property table. Imported per system from flake.nix. { pkgs, craneLib, @@ -8,6 +8,7 @@ self, system, treefmt-eval, + nixosSystem, }: let inherit (rust) cleanSrc cargoArtifacts nativeBuildInputs; @@ -15,6 +16,16 @@ in { formatting = treefmt-eval.config.build.check self; + # The only check here that covers **nix**. Every other one is a Rust + # derivation, so a `.nix`-only diff moves no hash and the whole set is + # cache hits — green without evaluating what changed. See the file's + # header for what belongs in it and what needs something that executes + # rather than evaluates. + module-eval = import ./module-eval.nix { + inherit pkgs self nixosSystem; + inherit (pkgs) lib; + }; + # Clippy via crane's first-class `cargoClippy` builder. Reuses the # shared `cargoArtifacts` (deps already built) and runs # `cargo clippy --workspace --all-targets` directly. diff --git a/nix/module-eval.nix b/nix/module-eval.nix new file mode 100644 index 00000000..5b017afe --- /dev/null +++ b/nix/module-eval.nix @@ -0,0 +1,119 @@ +# `checks.module-eval` — the flake check that covers **nix**. +# +# Why this exists: every other check in ./checks.nix is a Rust +# derivation, so a `.nix`-only diff moves no hash, every check is a +# cache hit, and `nix flake check` reports green **without evaluating +# what changed**. This one's derivation hash is a function of the +# evaluated *results* below, so a nix change that flips a property +# rebuilds it and the builder fails naming that property. +# +# ## What belongs here, and what does not +# +# Anything expressible as a module `assertion` **should be one instead**: +# an assertion fires at deploy time for a real operator, not only in CI. +# What cannot be an assertion is the **absence class** — "a hive that +# hasn't opted in renders exactly what it did before", "this unit does +# not exist unless X". Those are claims about the *rendered config* +# rather than about a config being invalid, so they need an evaluator. +# +# ⚠️ **Cases are named by the PROPERTY they defend, never by the ticket +# that prompted them.** A case named after a ticket has the ticket's +# lifetime; a case named after a property lives as long as the property. +# +# ⚠️ **This check evaluates. It does not execute.** Where the artifact is +# a command line, an HTTP request or a certificate, a value assertion +# cannot stand in — those need something that *runs* them. And a case +# that needs a **rendered file** must stub the packages that file drags +# in (`swarm.ui.package = pkgs.emptyDirectory`), or it costs a full +# frontend build to answer a question about a listen directive. +{ + pkgs, + lib, + self, + nixosSystem, +}: +let + # Stub host, same shape ./docs/default.nix already uses: enough for a + # `nixosSystem` to evaluate, nothing that pulls a real disk or + # bootloader in. + hive = + extra: + (nixosSystem { + system = pkgs.stdenv.hostPlatform.system; + modules = [ + self.nixosModules.default + { + fileSystems."/" = { + device = "/dev/null"; + fsType = "tmpfs"; + }; + boot.loader.grub.enable = false; + system.stateVersion = "25.11"; + services.hyperhive = { + enable = true; + hiveName = "h1"; + swarm.domain = "t.local"; + swarm.hives.h1.domain = "h1.t.local"; + } + // extra; + } + ]; + }).config; + + allLocal = hive { enableAllLocalDefaults = true; }; + bare = hive { }; + + # Each case: a name stating the property, and `ok`. + cases = [ + { + name = "a hive that has not opted into all-local runs no swarm controller"; + ok = !bare.services.hyperhive.swarm.controller.enable; + } + { + name = "the all-local mode turns the swarm controller on"; + ok = allLocal.services.hyperhive.swarm.controller.enable; + } + { + # The gateway's per-name issuer choice. If this ever collapses to a + # constant, every swarm-service vhost serves a certificate its CA + # is name-constrained out of — which evaluates cleanly and fails in + # a browser. + name = "a swarm service name gets the swarm-services leaf and the default server does not"; + ok = + let + l = allLocal.services.hyperhive.gateway.lib; + in + (l.tlsFor "t.local").sslCertificate != (l.tlsFor "_").sslCertificate; + } + { + # nixos asserts when a vhost declares both, so this is also a + # statement that the `removeAttrs` upstream of it still happens. + name = "the swarm UI vhost forces TLS instead of merely adding it"; + ok = + let + v = allLocal.services.nginx.virtualHosts."t.local"; + in + v.forceSSL && !(v.addSSL or false); + } + { + name = "a hive with matrix off serves no matrix discovery endpoint"; + ok = + !(builtins.hasAttr "= /.well-known/matrix/client" bare.services.nginx.virtualHosts."_".locations); + } + ]; + + bad = builtins.filter (c: !c.ok) cases; + report = lib.concatMapStringsSep "\n" (c: " echo 'FAILED: ${c.name}' >&2") bad; +in +# The results are embedded in the builder text on purpose: that is what +# makes this derivation's hash depend on them, so a nix-only change that +# flips a case cannot be answered from cache. +pkgs.runCommand "hyperhive-module-eval" { } '' + ${report} + ${ + if bad == [ ] then + "echo '${toString (builtins.length cases)} module properties hold' && touch $out" + else + "echo 'module-eval: ${toString (builtins.length bad)} of ${toString (builtins.length cases)} properties broke' >&2 && exit 1" + } +'' From 351341e87c14a68509d0ad18214d52c710dcb5d4 Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 13 Aug 2026 18:12:35 +0200 Subject: [PATCH 08/16] hive-c0re: replace json! with typed structs in dashboard handlers --- hive-c0re/src/dashboard/health.rs | 13 ++-- hive-c0re/src/dashboard/misc_api.rs | 73 +++++++++++++++-------- hive-c0re/src/dashboard/schedules.rs | 20 +++++-- hive-c0re/src/dashboard/state_snapshot.rs | 14 ++++- hive-c0re/src/stores/audit_log.rs | 3 +- 5 files changed, 86 insertions(+), 37 deletions(-) diff --git a/hive-c0re/src/dashboard/health.rs b/hive-c0re/src/dashboard/health.rs index f1cc19a8..6f5837fc 100644 --- a/hive-c0re/src/dashboard/health.rs +++ b/hive-c0re/src/dashboard/health.rs @@ -28,19 +28,20 @@ use utoipa::ToSchema; use crate::host_stats::ServerWarning; +#[derive(Serialize, ToSchema)] +struct LiveBody { + status: &'static str, +} + /// Liveness. Always `200`; no further checks. #[utoipa::path( get, path = "/health/live", - responses((status = 200, description = "process is up", body = serde_json::Value)), + responses((status = 200, description = "process is up", body = LiveBody)), tag = "health" )] pub(super) async fn get_health_live() -> Response { - ( - StatusCode::OK, - axum::Json(serde_json::json!({ "status": "ok" })), - ) - .into_response() + (StatusCode::OK, axum::Json(LiveBody { status: "ok" })).into_response() } #[derive(Serialize, ToSchema)] diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index c335cae4..51695b9a 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -8,26 +8,41 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use super::{AppState, Ident, error_response, scan_validated_paths}; +use crate::audit_log::AuditEntry; use crate::container_stats::ContainerResource; use crate::hive_stats::HiveStats; +#[derive(Serialize, ToSchema)] +pub(super) struct OperatorInboxItem { + id: i64, + from: String, + body: String, + at: chrono::DateTime, + in_reply_to: Option, + file_refs: Vec, +} + +#[derive(Serialize, ToSchema)] +pub(super) struct OperatorInboxBody { + messages: Vec, +} + /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. /// /// Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing /// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped /// tokens are validated so the client renders file links like the -/// terminal does. Shape: `{ "messages": [{ id, from, body, at, -/// in_reply_to, file_refs }] }`. +/// terminal does. #[utoipa::path( get, path = "/api/operator-inbox", responses( - (status = 200, description = "unread operator-directed messages", body = serde_json::Value), + (status = 200, description = "unread operator-directed messages", body = OperatorInboxBody), (status = 500, description = "broker read failed"), ), tag = "misc_api" @@ -40,7 +55,7 @@ pub(super) async fn api_operator_inbox(State(state): State) -> Respons .unread_for_recipient("operator", INBOX_LIMIT) { Ok(messages) => { - let items: Vec = messages + let messages: Vec = messages .into_iter() .filter_map(|m| { let crate::broker::MessageEvent::Sent { @@ -55,17 +70,17 @@ pub(super) async fn api_operator_inbox(State(state): State) -> Respons return None; }; let file_refs = scan_validated_paths(&body); - Some(serde_json::json!({ - "id": id, - "from": from, - "body": body, - "at": hive_sh4re::wire_time::from_secs(at), - "in_reply_to": in_reply_to, - "file_refs": file_refs, - })) + Some(OperatorInboxItem { + id, + from, + at: hive_sh4re::wire_time::from_secs(at), + body, + in_reply_to, + file_refs, + }) }) .collect(); - axum::Json(serde_json::json!({ "messages": items })).into_response() + axum::Json(OperatorInboxBody { messages }).into_response() } Err(e) => error_response(&format!("operator-inbox failed: {e:#}")), } @@ -114,18 +129,23 @@ pub(super) async fn api_container_resources() -> Response { axum::Json(crate::container_stats::gather().await).into_response() } +#[derive(Serialize, ToSchema)] +pub(super) struct AuditLogBody { + entries: Vec, + total: i64, +} + /// Most-recent agent-initiated privileged-action /// audit entries, newest first (server-clamped to 500). /// -/// Backs the operator dashboard's audit view. Returns -/// `{ "entries": [AuditEntry…], "total": N }` so the UI can show +/// Backs the operator dashboard's audit view. `total` lets the UI show /// "latest 500 of N" rather than silently capping. `ts_unix` is in /// **seconds**. #[utoipa::path( get, path = "/api/audit-log", responses( - (status = 200, description = "recent audit entries + total count", body = serde_json::Value), + (status = 200, description = "recent audit entries + total count", body = AuditLogBody), (status = 500, description = "sqlite read failed"), ), tag = "misc_api" @@ -140,7 +160,12 @@ pub(super) async fn api_audit_log(State(state): State) -> Response { Ok(n) => n, Err(e) => return error_response(&format!("audit-log count: {e:#}")), }; - axum::Json(serde_json::json!({ "entries": entries, "total": total })).into_response() + axum::Json(AuditLogBody { entries, total }).into_response() +} + +#[derive(Serialize, ToSchema)] +pub(super) struct MarkAllReadBody { + marked: u64, } /// Operator-driven "clear this agent's inbox" — backs the side-panel @@ -148,14 +173,14 @@ pub(super) async fn api_audit_log(State(state): State) -> Response { /// /// Marks every message addressed to the agent as acked (backfilling /// `delivered_at` for any still-pending rows so vacuum can collect -/// them). Returns `{ "marked": N }` so the frontend can show "cleared -/// N messages" feedback without an extra fetch. +/// them). `marked` lets the frontend show "cleared N messages" +/// feedback without an extra fetch. #[utoipa::path( post, path = "/api/agent/{name}/mark-all-read", params(("name" = String, Path, description = "agent name")), responses( - (status = 200, description = "count of messages marked read", body = serde_json::Value), + (status = 200, description = "count of messages marked read", body = MarkAllReadBody), (status = 400, description = "bad agent name"), (status = 500, description = "broker write failed"), ), @@ -172,9 +197,9 @@ pub(super) async fn post_mark_all_read( } }; match state.coord.broker.mark_all_read(name.as_str()) { - Ok(n) => { - tracing::info!(%name, marked = n, "operator marked all messages read"); - axum::Json(serde_json::json!({ "marked": n })).into_response() + Ok(marked) => { + tracing::info!(%name, marked, "operator marked all messages read"); + axum::Json(MarkAllReadBody { marked }).into_response() } Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")), } diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs index 6ea9ea30..79322a72 100644 --- a/hive-c0re/src/dashboard/schedules.rs +++ b/hive-c0re/src/dashboard/schedules.rs @@ -18,6 +18,16 @@ use crate::scheduled_prompts_worker::FireNowReport; use super::{AppState, error_problem, error_response}; +#[derive(serde::Serialize, utoipa::ToSchema)] +pub(super) struct NewScheduleBody { + id: i64, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +pub(super) struct CancelResultBody { + cancelled: bool, +} + /// Snapshot of every schedule for the /// scheduled-prompts tab. /// @@ -74,7 +84,7 @@ pub(super) async fn api_schedules(State(state): State) -> Response { // `api_schedules` above. request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"), responses( - (status = 200, description = "created; body carries the new row id", body = serde_json::Value), + (status = 200, description = "created; body carries the new row id", body = NewScheduleBody), (status = 400, description = "no targets, empty body, or interval_seconds == 0"), (status = 500, description = "submit failed"), ), @@ -108,7 +118,7 @@ pub(super) async fn post_schedule_new( match state.coord.scheduled_prompts.submit(&new) { Ok(id) => { state.coord.emit_schedules_snapshot(); - Ok(axum::Json(serde_json::json!({"id": id})).into_response()) + Ok(axum::Json(NewScheduleBody { id }).into_response()) } Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))), } @@ -177,7 +187,7 @@ pub(super) async fn post_schedule_fire_now( post, path = "/api/rebuild-queue/{id}/cancel", params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")), - responses((status = 200, description = "whether the DAG was cancelled", body = serde_json::Value)), + responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)), tag = "schedules" )] pub(super) async fn post_rebuild_queue_cancel( @@ -188,9 +198,9 @@ pub(super) async fn post_rebuild_queue_cancel( // Any terminal side effect is the DAG's own spared tail node, which the // scheduler picks up on its next pass — nothing to fire from here. state.coord.emit_rebuild_queue_snapshot(); - axum::Json(serde_json::json!({"cancelled": true})).into_response() + axum::Json(CancelResultBody { cancelled: true }).into_response() } else { - axum::Json(serde_json::json!({"cancelled": false})).into_response() + axum::Json(CancelResultBody { cancelled: false }).into_response() } } diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index d3d4bc08..56b74cc5 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -726,6 +726,18 @@ pub(super) async fn jobq_rollup( axum::Json(state.coord.job_queue.state_rollup()) } +/// Response body for `/api/dashboard/history`. No `ToSchema` — its +/// `events` field wraps [`crate::dashboard_events::DashboardEvent`], +/// which doesn't derive `ToSchema` either (a large enum with many +/// variants; see that type's doc comment for why annotating it is +/// out of scope here). The `responses(...)` doc below spells out the +/// shape in prose instead of a `body = ...` reference. +#[derive(Serialize)] +struct DashboardHistoryBody { + seq: u64, + events: Vec, +} + #[utoipa::path( get, path = "/api/dashboard/history", @@ -798,7 +810,7 @@ pub(super) async fn dashboard_history(State(state): State) -> Response } }) .collect(); - axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response() + axum::Json(DashboardHistoryBody { seq, events }).into_response() } Err(e) => error_response(&format!("dashboard/history failed: {e:#}")), } diff --git a/hive-c0re/src/stores/audit_log.rs b/hive-c0re/src/stores/audit_log.rs index eb1cd377..e2928f2b 100644 --- a/hive-c0re/src/stores/audit_log.rs +++ b/hive-c0re/src/stores/audit_log.rs @@ -27,6 +27,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use rusqlite::{Connection, params}; use serde::Serialize; +use utoipa::ToSchema; /// Process-singleton handle, set once at coordinator startup. Mirrors /// `build_logs::GLOBAL` — lets recording sites write without threading an @@ -80,7 +81,7 @@ impl AuditOutcome { } /// One audit row as returned to the dashboard. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct AuditEntry { pub id: i64, pub ts_unix: DateTime, From 127846ef1b56c5ae54d8dd0e9618841d9d77dc79 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 18:41:04 +0200 Subject: [PATCH 09/16] mount the applied config repo, not the proposed one /agents//config bound the working clone a config change is staged in, so an agent could see a proposal that was never approved -- a config that does not govern its container. Both objects already exist; this repoints the bind at the deployed one. Both mounts (own + child) now resolve through config_bind_source() so they cannot drift, and agent_proposed_dir's doc-comment is corrected: it claimed to be manager-editable and bind-mounted, and neither is true. --- hive-c0re/src/coordinator.rs | 9 +++-- hive-c0re/src/lifecycle/host_config.rs | 46 +++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 1e8eb46e..8c227a16 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -1567,8 +1567,13 @@ impl Coordinator { crate::paths::agent_runtime_dir(name).join("mcp.sock") } - /// Manager-editable proposed config repo. Bind-mounted into the manager - /// container as `/agents//config/`. + /// The *proposed* config repo: where a config change lands before it is + /// applied, and what an approved deploy promotes into `applied_dir`. + /// + /// **Not bind-mounted into any container.** An agent that edits a config + /// clones it from the forge itself; `/agents//config` shows the + /// applied (deployed) tree instead — see `config_bind_source` in + /// `lifecycle/host_config.rs`. pub fn agent_proposed_dir(name: &hive_types::Ident) -> PathBuf { crate::paths::agent_state_dir(name).join("config") } diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 8d5ac725..cedf84cf 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -2,7 +2,7 @@ //! network isolation, forwarded credentials), the systemd resource-limits //! drop-in, and the `write_dropins` verb that re-applies both. -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use hive_priv_sock::{BindMount, CredentialMount}; @@ -71,6 +71,19 @@ async fn systemd_daemon_reload() -> Result<()> { /// inside the container. pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied"; +/// Host path behind every `/agents//config` mount: the **applied** +/// (deployed) repo, not the working clone at `agents//config`. That +/// clone is where a config change is staged, so it can hold a proposal +/// that is still under review or was rejected outright — mounting it shows +/// an agent a config which does not govern it. Both mounts (an agent's own +/// and a parent's view of a child's) go through here so they cannot drift. +/// +/// Never empty under a live container: `provision_container` runs +/// `setup_applied` before `create_only` makes the container at all. +fn config_bind_source(name: &str) -> PathBuf { + crate::paths::applied_dir(name) +} + /// Append bind flags for `child`'s state and config dirs into `binds`. /// See docs/persistence.md ("Parent access to child state") for what a /// parent may touch and why. Creates missing host-side directories so @@ -104,8 +117,10 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { return; }; let child_root = crate::paths::agent_state_dir(&child); - for (sub, read_only) in [("state", false), ("config", true)] { - let host = child_root.join(sub); + for (sub, host, read_only) in [ + ("state", child_root.join("state"), false), + ("config", config_bind_source(child.as_str()), true), + ] { let _ = std::fs::create_dir_all(&host); binds.push(BindMount { host_path: host.to_string_lossy().into_owned(), @@ -249,9 +264,9 @@ async fn set_nspawn_flags( read_only: false, }); } - let agent_id = hive_types::Ident::parse(agent_name) + hive_types::Ident::parse(agent_name) .map_err(|e| anyhow::anyhow!("invalid agent name {agent_name:?}: {e}"))?; - let own_config = crate::paths::agent_state_dir(&agent_id).join("config"); + let own_config = config_bind_source(agent_name); std::fs::create_dir_all(&own_config) .with_context(|| format!("create {}", own_config.display()))?; binds.push(BindMount { @@ -381,6 +396,27 @@ mod tests { assert_eq!(paths, ["/agents/kiddo/state", "/agents/kiddo/config"]); } + /// The `config` mount names the **deployed** tree, not the working + /// clone the proposal is staged in. Asserted as "outside the child's + /// own dir" rather than by equality: the point is that the two are + /// different objects, which is what makes the mount unable to show a + /// config that was never approved. Equality with `applied_dir` would + /// restate the implementation and pass under any future relocation. + #[test] + fn child_config_mount_is_the_deployed_tree_not_the_working_clone() { + let working_clone = + crate::paths::agent_state_dir(&hive_types::Ident::parse("kiddo").expect("valid ident")); + let config = child_binds() + .into_iter() + .find(|b| b.container_path.ends_with("/config")) + .expect("a config bind"); + assert!( + !std::path::Path::new(&config.host_path).starts_with(&working_clone), + "config mount must not come from the child's working clone: {}", + config.host_path + ); + } + /// The regression this exists for. `harness` holds the child's own /// runtime material and was only ever mounted because one loop /// treated all three dirs alike — re-adding it to that loop is a From 786e4610f0515bc240ef39a25e939c54629021c0 Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 13 Aug 2026 19:21:05 +0200 Subject: [PATCH 10/16] hive-agent: replace json! with typed structs in web_ui handlers --- hive-agent/src/web_ui/actions.rs | 9 +++++++-- hive-agent/src/web_ui/stats.rs | 9 +++++++-- hive-agent/src/web_ui/stream.rs | 31 ++++++++++++++++++++----------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/hive-agent/src/web_ui/actions.rs b/hive-agent/src/web_ui/actions.rs index 56c7ea8d..f05905e0 100644 --- a/hive-agent/src/web_ui/actions.rs +++ b/hive-agent/src/web_ui/actions.rs @@ -7,7 +7,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::{AppState, SigintOutcome, error_response}; @@ -202,5 +202,10 @@ pub(super) async fn post_mark_todos_done(Form(form): Form) -> acked += count; } } - axum::Json(serde_json::json!({ "acked": acked })).into_response() + axum::Json(MarkTodosDoneBody { acked }).into_response() +} + +#[derive(Serialize)] +struct MarkTodosDoneBody { + acked: u64, } diff --git a/hive-agent/src/web_ui/stats.rs b/hive-agent/src/web_ui/stats.rs index f2f83614..9dd6796d 100644 --- a/hive-agent/src/web_ui/stats.rs +++ b/hive-agent/src/web_ui/stats.rs @@ -2,7 +2,7 @@ use axum::extract::State; use axum::response::{IntoResponse, Response}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::AppState; @@ -40,6 +40,11 @@ async fn fetch_reminder_stats(window_secs: u64) -> Option, +} + /// `GET /api/todos` — snapshot of this agent's local todos (loose-ends v2). /// /// Connects to the in-agent harness socket (`HIVE_AGENT_SOCKET`) and calls @@ -54,5 +59,5 @@ pub(super) async fn api_todos() -> Response { Some(hive_agent_sock::Response::LooseEnds { loose_ends }) => loose_ends, _ => Vec::new(), }; - axum::Json(serde_json::json!({ "todos": todos })).into_response() + axum::Json(TodosBody { todos }).into_response() } diff --git a/hive-agent/src/web_ui/stream.rs b/hive-agent/src/web_ui/stream.rs index 3c881cd4..89c4ba31 100644 --- a/hive-agent/src/web_ui/stream.rs +++ b/hive-agent/src/web_ui/stream.rs @@ -5,11 +5,23 @@ use std::convert::Infallible; use axum::Json; use axum::extract::{Query, State}; use axum::response::sse::{Event, KeepAlive, Sse}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use super::AppState; +/// Response body for `GET /api/events/history`. `seq` is omitted from the +/// wire entirely on a paginated (non-initial) load — matches the old +/// `json!` shape, which only ever set the `"seq"` key when `Some`. +#[derive(Serialize)] +pub(super) struct EventsHistoryBody { + events: Vec, + min_id: Option, + has_more: bool, + #[serde(skip_serializing_if = "Option::is_none")] + seq: Option, +} + /// Query params for the paginated history endpoint. #[derive(Debug, Deserialize)] pub(super) struct HistoryParams { @@ -23,7 +35,7 @@ pub(super) struct HistoryParams { pub(super) async fn events_history( State(state): State, Query(params): Query, -) -> Json { +) -> Json { use crate::events::HISTORY_CAPACITY; let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY); let before = params.before; @@ -51,15 +63,12 @@ pub(super) async fn events_history( se }) .collect(); - let mut resp = serde_json::json!({ - "events": events, - "min_id": min_id, - "has_more": has_more, - }); - if let Some(s) = seq { - resp["seq"] = serde_json::json!(s); - } - Json(resp) + Json(EventsHistoryBody { + events, + min_id, + has_more, + seq, + }) } pub(super) async fn events_stream( From c32a9367e468c8ab462b3a075e1d931d117b5df1 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 19:34:12 +0200 Subject: [PATCH 11/16] gateway: reject unmatched Host instead of serving the dashboard The `_` vhost was serving the hive's own surface, so every dashboard and agent-UI request matched the default server rather than a named vhost -- and so did a request for any name at all, including a raw IP. Split it: `_` keeps only `return 444`, and the hive surface moves to a vhost named for the hive domain. `_` is `mkDefault` so an operator can claim default_server themselves, plus an assertion for the case where they add one without turning ours off -- nginx refuses to start on a duplicate default_server and nixpkgs asserts nothing, so that would otherwise surface as a gateway outage at rebuild time. --- nix/host-modules/hive-gateway/default.nix | 40 +++++++++++++++++++++++ nix/host-modules/hive-gateway/vhosts.nix | 38 ++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index b6fe8e03..7e64ff24 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -24,6 +24,13 @@ let matrixCfg = config.services.hyperhive.swarm.matrix; networkCfg = config.services.hyperhive.network; + # Every vhost claiming `default_server`, ours and the operator's + # alike. Computed once so the assertion below and the message it + # prints cannot disagree about what they found. + defaultVhosts = lib.filter (n: config.services.nginx.virtualHosts.${n}.default or false) ( + lib.attrNames config.services.nginx.virtualHosts + ); + # Dashboard SPA dist, static-served by nginx. dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; @@ -143,6 +150,39 @@ in rather than letting dnsmasq pick. ''; } + { + # nginx refuses to start with two `default_server`s on one + # address ("a duplicate default server for 0.0.0.0:", + # exit 1) and nixpkgs asserts nothing — `vhost.default` is a + # plain bool rendered straight into the listen line. So without + # this, an operator adding their own default vhost gets a + # gateway that fails its config test at rebuild time, which + # takes the forge, dashboard, matrix and swarm UI with it, and + # reports a port rather than a cause. + # + # Not covered by our `mkDefault`: an operator's own vhost is a + # different option path, so nothing merges and nothing + # conflicts — priority only helps someone who already knows + # ours exists. Fail at eval and name both, so the fix + # (`services.nginx.virtualHosts..default = false`) is + # readable from the error. + assertion = lib.length defaultVhosts <= 1; + message = '' + More than one nginx virtual host is marked `default = true`: + ${lib.concatStringsSep ", " defaultVhosts} + + nginx allows exactly one default server per listen address + and refuses to start otherwise, so this would fail at + service start rather than here — taking every site behind + the gateway down with it. + + The gateway's own catch-all (`_`, which returns 444) is set + with `mkDefault`, so to make yours the default server turn + ours off explicitly: + + services.nginx.virtualHosts."_".default = false; + ''; + } ]; # Ensure the gateway state dirs exist at host boot, before anything diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index b8166520..ee855739 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -219,11 +219,41 @@ let in { virtualHosts = { - # `tlsFor "_"`, not a separate binding: the default server is a - # vhost named `_`, and a name that is not a swarm service domain - # (`_` never is) resolves to the hive's own leaf — which is what - # this vhost has always served. + # The catch-all, and now *only* a catch-all: anything whose `Host` + # matches no vhost gets an immediate 444 (close without a response) + # rather than being served the hive's dashboard. + # + # `_` is the idiomatic spelling because it is not a legal hostname, + # so it can never match a request by name — it serves traffic solely + # by being `default_server`. + # + # ⚠️ It still needs TLS attrs. It listens on the https port, so a + # client connecting by IP completes a TLS handshake *before* nginx + # can look at `Host` and reject it; with no cert the vhost fails to + # load. The certificate will not match what such a client asked for + # — that is unavoidable and correct: nothing can present a valid + # cert for a name the operator never issued one for. + # + # `mkDefault` per the operator: an operator with their own + # `default = true` vhost must be able to win without fighting + # priorities. The assertion in ./default.nix catches the case where + # they add one *without* turning this off, which nginx would + # otherwise only report at runtime as a failed config test. "_" = (vhostTlsFor "_") // { + listen = vhostListen; + default = lib.mkDefault true; + extraConfig = '' + return 444; + ''; + }; + + # The hive's own surface, now reachable by NAME. This used to be + # served by the `_` vhost above: no vhost was named for the hive + # domain, so every dashboard and agent-UI request matched the + # default server instead (confirmed against 24h of nginx's access + # log — `server: _` on requests whose Host *was* the hive domain). + # Naming it is what lets the catch-all start rejecting. + ${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // { listen = vhostListen; locations = matrixRedirectLocations From 9f26c416c0a403ce88e089ba897e736d9c67d02c Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 13 Aug 2026 21:01:08 +0200 Subject: [PATCH 12/16] hide .git on the agent config mounts, like /knowledge already does An agent's config mount is a git repo, so it could read every branch and the full history of a config whose currently deployed value is the only thing it may act on -- and an abandoned branch looks no different from a live one. The knowledge bind already solved this with an empty tmpfs overlaid on its .git. Same rule, extended: an agent is handed a working tree, never a repository. Folds both cases into git_overlay_flags so the reason is stated once instead of hardcoded per mount. Config mounts are matched by shape rather than a name list because the set grows at runtime with each child bound into a parent. --- hive-priv/src/main.rs | 93 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 66d1f803..a5ca3d75 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -2335,6 +2335,35 @@ fn validate_bind_path(path: &str) -> Result<()> { /// write network isolation settings, then append `EXTRA_NSPAWN_FLAGS`. /// When `isolation` is `Some`, writes `PRIVATE_NETWORK=1` + veth wiring; /// when `None`, writes `PRIVATE_NETWORK=0`. +/// `--tmpfs=/.git` for every bound git repo, hiding its metadata +/// from inside the container. +/// +/// Two kinds of mount qualify, for one reason: **the agent is given a +/// working tree, never a repository.** `/knowledge` is the hive's shared +/// docs — whose `.git/config` has held a credential the host-side worker +/// embedded — and `/agents//config` is a config repo, an agent's own +/// or a parent's read-only view of a child's. In both cases `.git` carries +/// every branch and the full history of a document whose *currently +/// deployed* value is the only thing a reader may act on, and an abandoned +/// branch is indistinguishable from a live one. +/// +/// An overlay rather than an exported copy: there is no second tree to +/// keep in sync, so nothing can go stale, and no code path has to remember +/// to refresh it. +/// +/// ⚠️ Ordering matters — these must be appended **after** the `--bind` +/// flags so nspawn mounts them on top of the already-mounted trees. +/// Config mounts are matched by shape, not by a name list: the set is +/// dynamic, growing with each child bound into a parent. +fn git_overlay_flags(binds: &[BindMount]) -> Vec { + binds + .iter() + .map(|b| b.container_path.as_str()) + .filter(|p| *p == "/knowledge" || (p.starts_with("/agents/") && p.ends_with("/config"))) + .map(|p| format!("--tmpfs={p}/.git")) + .collect() +} + fn write_nspawn_flags( container: &str, binds: &[BindMount], @@ -2395,19 +2424,7 @@ fn write_nspawn_flags( format!("{flag}={}:{}", b.host_path, b.container_path) }) .collect(); - // Defense-in-depth for the knowledge bind-mount: overlay an empty tmpfs - // on /knowledge/.git so the repo metadata (including any credentials the - // host-side git worker embedded in .git/config) is invisible inside agent - // containers. Agents only need the working-tree documents; .git/ has no - // legitimate use in-container. The --tmpfs must come after the --bind-ro - // so nspawn processes it as an overlay on top of the already-mounted tree. - // `crate::knowledge::CONTAINER_MOUNT` is "/knowledge" (hive-c0re const). - if binds - .iter() - .any(|b| b.container_path.as_str() == "/knowledge") - { - flags.push("--tmpfs=/knowledge/.git".to_owned()); - } + flags.extend(git_overlay_flags(binds)); // Credential forwarding: nspawn loads each host secret into the // container's credential store under ``; inner units inherit it // via `LoadCredential=`. Validated (name charset + bind-path @@ -2573,12 +2590,58 @@ async fn sync_agent_tmpfiles(agents: &[AgentTmpfilesEntry]) -> Result<(String, S #[cfg(test)] mod tests { use super::{ - OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, contains_secret_shaped_run, - limits_dropin_body, redact_secret_line, remove_marker_in, write_state_file_nofollow, + BindMount, OwnedFd, PAUSED_MARKER_FILE, PrivRequest, check_fd_agreement, + contains_secret_shaped_run, git_overlay_flags, limits_dropin_body, redact_secret_line, + remove_marker_in, write_state_file_nofollow, }; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; + fn bind(container_path: &str) -> BindMount { + BindMount { + host_path: "/var/lib/hyperhive/whatever".to_owned(), + container_path: container_path.to_owned(), + read_only: true, + } + } + + /// Every bound git repo gets its `.git` overlaid — the knowledge tree + /// and *each* config mount, an agent's own plus every child's. + /// + /// The child case is the one worth pinning: that set grows at runtime + /// as agents gain children, so a rule written as a list of names would + /// silently stop covering new ones. + #[test] + fn every_bound_git_repo_gets_its_dot_git_hidden() { + let flags = git_overlay_flags(&[ + bind("/knowledge"), + bind("/agents/atlas/config"), + bind("/agents/kiddo/config"), + ]); + assert_eq!( + flags, + [ + "--tmpfs=/knowledge/.git", + "--tmpfs=/agents/atlas/config/.git", + "--tmpfs=/agents/kiddo/config/.git", + ] + ); + } + + /// ...and nothing else does. A blanket "overlay .git on every bind" + /// would mask a real `.git` under `state/`, where an agent legitimately + /// keeps working clones of its own. + #[test] + fn non_repo_mounts_are_left_alone() { + let flags = git_overlay_flags(&[ + bind("/agents/atlas/state"), + bind("/shared"), + bind("/applied"), + bind("/agents/atlas/config-notes"), + ]); + assert!(flags.is_empty(), "overlaid a non-repo mount: {flags:?}"); + } + /// A request that streams into a caller-supplied descriptor. fn fd_taking_request() -> PrivRequest { PrivRequest::SendAgentSnapshotToFd { From fa658567db84424dcda3d0ebd4fed2787f04d84b Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 13 Aug 2026 19:53:34 +0200 Subject: [PATCH 13/16] hive-priv: replace json! with typed structs for account sidecar files --- Cargo.lock | 1 + hive-priv/Cargo.toml | 1 + hive-priv/src/main.rs | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bcab7b39..693b1121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1795,6 +1795,7 @@ dependencies = [ "anyhow", "hive-priv-sock", "libc", + "serde", "serde_json", "tokio", "tracing", diff --git a/hive-priv/Cargo.toml b/hive-priv/Cargo.toml index a322d0f9..c63d98b2 100644 --- a/hive-priv/Cargo.toml +++ b/hive-priv/Cargo.toml @@ -11,6 +11,7 @@ workspace = true anyhow.workspace = true hive-priv-sock.workspace = true libc.workspace = true +serde.workspace = true serde_json.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index a5ca3d75..7abaf198 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -27,6 +27,7 @@ use hive_priv_sock::{ NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream, PrivStreamLine, SIBLING_CONTAINERS, }; +use serde::Serialize; use tokio::io::{AsyncWriteExt, BufReader}; use tokio::net::unix::OwnedWriteHalf; use tokio::net::{UnixListener, UnixStream}; @@ -468,7 +469,10 @@ async fn exec( // when both `account` and `homeserver` are present; the account // suffix is already validated above. if let (Some(a), Some(hs)) = (account, homeserver) { - let meta = serde_json::json!({ "homeserver": hs }).to_string(); + let meta = serde_json::to_string(&MatrixAccountSidecar { + homeserver: hs.as_str(), + }) + .context("serialize matrix account sidecar")?; write_agent_state_file(agent_name, &format!("matrix-account-{a}.json"), &meta)?; } Ok(res) @@ -497,7 +501,10 @@ async fn exec( )?; // Sidecar carries the base URL — there's no host-side nix config // for extra forges, so this is the only place it's persisted. - let meta = serde_json::json!({ "base_url": base_url }).to_string(); + let meta = serde_json::to_string(&ForgeSidecar { + base_url: base_url.as_str(), + }) + .context("serialize forge account sidecar")?; write_agent_state_file(agent_name, &format!("forge-{label}.json"), &meta)?; Ok(res) } @@ -957,6 +964,32 @@ fn write_state_file_nofollow(dir: &Path, filename: &str, content: &str) -> Resul Ok(file) } +/// Sidecar written alongside an extra matrix account's token +/// (`matrix-account-.json`) so `hive-matrix-mcp` can auto-discover +/// the account's homeserver without a static `matrixAccounts` config +/// entry. Read side: `hive-matrix-mcp/src/accounts.rs`'s +/// `read_account_homeserver` (deliberately reads via a bare +/// `serde_json::Value` rather than this shape — that side treats a +/// malformed/missing sidecar as "skip this account" rather than an +/// error, so it stays loosely typed; this side is the one place the +/// file is written, so it gets the precise shape). +#[derive(Serialize)] +struct MatrixAccountSidecar<'a> { + homeserver: &'a str, +} + +/// Sidecar written alongside a dashboard-provisioned extra forge +/// account's token (`forge-