From 584c99ce13a71fa71aa33175438e53d0c687a4ee Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 18:15:49 +0200 Subject: [PATCH 1/4] hive-forge: allow the swarm domain through forgejo's webhook SSRF list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `webhook.ALLOWED_HOST_LIST` named `external` plus the HIVE domain. The swarm-controller's webhooks target the SWARM domain, which is a different host on the same private gateway IP — so `external` denied it and the hive entry did not cover it. Every swarm-controller delivery has been failing there. Found from the sender's log, which names the host and the rule it broke: deny 'constellation.darkest.space(10.42.0.1:443)' webhook can only call allowed HTTP servers (check your webhook.ALLOWED_HOST_LIST setting) This is the layer under the DNS fix: resolution now succeeds and reaches 10.42.0.1:443, and forgejo refuses to dial it. The two failures look identical from the receiving end — no delivery, no log line — which is why reading the sender was what separated them. `optional` because a hive with no swarm has no such domain; `unique` because an all-local deployment can legitimately set both to the same string. --- nix/host-modules/hive-forge/default.nix | 27 ++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index b36cfb45..790658fa 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -818,7 +818,32 @@ in # `external` alone would deny (so they'd only ever be caught by # the 5-min poll fallback). Naming the single gateway host is # tighter than the broad `private` builtin. - webhook.ALLOWED_HOST_LIST = "external,${hyperhiveDomain}"; + # + # ⚠️ The SWARM domain is on this list too, and leaving it off is + # what made every swarm-controller delivery fail. Those webhooks + # target `https:///webhook/forge/*` — a different + # host from the hive's, resolving to the same private gateway IP, + # so `external` denies it and the hive entry does not cover it. + # Measured from the sender's own log, which names both the host + # and the rule: + # + # deny 'constellation.darkest.space(10.42.0.1:443)' + # webhook can only call allowed HTTP servers + # (check your webhook.ALLOWED_HOST_LIST setting) + # + # `optional` because a hive with no swarm has no such domain, and + # `unique` because an all-local deployment can legitimately set + # both to the same string — a duplicate entry is harmless to + # forgejo but reads as a mistake. + webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," ( + lib.unique ( + [ + "external" + hyperhiveDomain + ] + ++ lib.optional (swarmDomain != null) swarmDomain + ) + ); log.LEVEL = "Warn"; # Pinned explicitly rather than left to upstream's default # (currently `bleve`, a separate full-text index Forgejo From b9919164e46ea874ac887adebef483a33535c085 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 18:19:39 +0200 Subject: [PATCH 2/4] hive-forge: read the webhook host from the option the target is built from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swarm.domain` is not the value swarm-controller addresses this hive with — `swarm-controller.nix` builds `SWARM_CONTROLLER_PUBLIC_URL` from `swarm.ui.domain`, which merely defaults to it and is documented as overridable. Allowing the one and being delivered the other would work until someone takes that option up, then fail as a refused delivery with nothing naming the cause. Also states why the entry is NOT gated on `deploy.swarm-ui.enable`, which is what makes the controller register the hooks at all: in a spread deployment the forge and the swarm UI are different hosts, so that per-host flag is false exactly where the allow-list entry is needed. The comment-block lint caught the prose at 35 lines; trimmed to the parts a reader cannot re-derive. --- nix/host-modules/hive-forge/default.nix | 54 +++++++++++++------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 790658fa..beb1a826 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -9,6 +9,13 @@ let gatewayCfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; swarmDomain = config.services.hyperhive.swarm.domain; + # The host the swarm-controller's webhooks actually target. NOT + # `swarm.domain`: `swarm-controller.nix` builds `SWARM_CONTROLLER_PUBLIC_URL` + # from `swarm.ui.domain`, which merely *defaults* to the swarm domain and is + # documented as overridable. Reading the other one would be right until + # someone takes that option up, and then wrong in a way whose only symptom is + # a refused delivery. + swarmUiDomain = config.services.hyperhive.swarm.ui.domain; tlsCfg = config.services.hyperhive.deploy.hive-controller.tls; networkCfg = config.services.hyperhive.network; @@ -807,41 +814,38 @@ in # forge.darkest.space, etc.) were failing every sync # attempt without this. migrations.ALLOWED_DOMAINS = "*"; - # `ALLOWED_HOST_LIST` is forgejo's webhook SSRF allow-list, and - # it's a STRICT whitelist (only listed hosts deliver). Its - # default is the `external` builtin: all public unicast IPs are - # allowed, private/loopback denied. We must KEEP `external` so - # user-repo webhooks to public hosts (github, slack, …) keep - # working, and ADD the hive gateway host on top: the config-PR + - # knowledge webhooks target `https:///webhook/*`, - # which resolves to a private (RFC-1918) gateway IP that - # `external` alone would deny (so they'd only ever be caught by - # the 5-min poll fallback). Naming the single gateway host is + # `ALLOWED_HOST_LIST` is forgejo's webhook SSRF allow-list: a + # STRICT whitelist whose `external` builtin allows public unicast + # IPs and denies private/loopback. `external` STAYS so user-repo + # webhooks to github/slack keep working; the hyperhive hosts are + # added on top, because their webhooks target a private (RFC-1918) + # gateway IP `external` alone denies — naming the two hosts is # tighter than the broad `private` builtin. # - # ⚠️ The SWARM domain is on this list too, and leaving it off is - # what made every swarm-controller delivery fail. Those webhooks - # target `https:///webhook/forge/*` — a different - # host from the hive's, resolving to the same private gateway IP, - # so `external` denies it and the hive entry does not cover it. - # Measured from the sender's own log, which names both the host - # and the rule: + # TWO hosts, not one. The hive's carries the config-PR + knowledge + # hooks; the swarm's carries swarm-controller's, and leaving it + # off denied every one of those: + # deny '(10.42.0.1:443)' — webhook can only call + # allowed HTTP servers # - # deny 'constellation.darkest.space(10.42.0.1:443)' - # webhook can only call allowed HTTP servers - # (check your webhook.ALLOWED_HOST_LIST setting) + # ⚠️ `swarm.ui.domain`, NOT `swarm.domain`: swarm-controller.nix + # builds its `SWARM_CONTROLLER_PUBLIC_URL` from the former, which + # only *defaults* to the latter and is documented as overridable. # - # `optional` because a hive with no swarm has no such domain, and - # `unique` because an all-local deployment can legitimately set - # both to the same string — a duplicate entry is harmless to - # forgejo but reads as a mistake. + # Not gated on `deploy.swarm-ui.enable`, even though that is what + # makes the controller register the hooks: in a spread deployment + # the forge and the UI are different hosts, so that per-host flag + # is false exactly where this entry is needed. + # + # `unique` because an all-local swarm legitimately serves both + # from one name. webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," ( lib.unique ( [ "external" hyperhiveDomain ] - ++ lib.optional (swarmDomain != null) swarmDomain + ++ lib.optional (swarmDomain != null) swarmUiDomain ) ); log.LEVEL = "Warn"; From a44107c617e61cfa2021c313206a3401c8d3abf1 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 18:26:34 +0200 Subject: [PATCH 3/4] hive-forge: inline the webhook-host binding, used once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Her review: don't declare a `let` for a single use. The comment that justified the binding moves to the use site and drops the claim it was carrying — that `swarm.ui.domain` and `swarm.domain` can diverge — keeping only the part that holds either way: this reads the same option swarm-controller builds its public URL from, so the allow-list entry and the target it must match are one value rather than two spellings. --- nix/host-modules/hive-forge/default.nix | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index beb1a826..16909551 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -9,13 +9,6 @@ let gatewayCfg = config.services.hyperhive.gateway; hyperhiveDomain = config.services.hyperhive.domain; swarmDomain = config.services.hyperhive.swarm.domain; - # The host the swarm-controller's webhooks actually target. NOT - # `swarm.domain`: `swarm-controller.nix` builds `SWARM_CONTROLLER_PUBLIC_URL` - # from `swarm.ui.domain`, which merely *defaults* to the swarm domain and is - # documented as overridable. Reading the other one would be right until - # someone takes that option up, and then wrong in a way whose only symptom is - # a refused delivery. - swarmUiDomain = config.services.hyperhive.swarm.ui.domain; tlsCfg = config.services.hyperhive.deploy.hive-controller.tls; networkCfg = config.services.hyperhive.network; @@ -828,9 +821,12 @@ in # deny '(10.42.0.1:443)' — webhook can only call # allowed HTTP servers # - # ⚠️ `swarm.ui.domain`, NOT `swarm.domain`: swarm-controller.nix - # builds its `SWARM_CONTROLLER_PUBLIC_URL` from the former, which - # only *defaults* to the latter and is documented as overridable. + # Read from `swarm.ui.domain` because that is the option + # `swarm-controller.nix` builds `SWARM_CONTROLLER_PUBLIC_URL` + # from. One value, two readers — an allow-list entry and the + # target it must match are two spellings of one address + # otherwise, and the failure is a delivery refused for a host + # nobody typed wrong. # # Not gated on `deploy.swarm-ui.enable`, even though that is what # makes the controller register the hooks: in a spread deployment @@ -845,7 +841,7 @@ in "external" hyperhiveDomain ] - ++ lib.optional (swarmDomain != null) swarmUiDomain + ++ lib.optional (swarmDomain != null) config.services.hyperhive.swarm.ui.domain ) ); log.LEVEL = "Warn"; From dbda6fbde15c3235d0680c77700d47450572533d Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 18:31:47 +0200 Subject: [PATCH 4/4] hive-forge: cut the webhook allow-list comment to what is not obvious 27 lines to 11. What went: the measured deny line (it is in the PR and the commit that made the change), the "one value, two readers" argument for reading the option the target URL is built from, and a note about `unique`. What stayed is the part nobody can re-derive from the code: `external` must not be dropped, both hyperhive hosts are private addresses the builtin denies, and the entry is deliberately not gated on the per-host `deploy.swarm-ui.enable` because a spread deployment has the forge and the swarm host on different machines. --- nix/host-modules/hive-forge/default.nix | 38 +++++++------------------ 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 16909551..6863ce03 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -807,34 +807,18 @@ in # forge.darkest.space, etc.) were failing every sync # attempt without this. migrations.ALLOWED_DOMAINS = "*"; - # `ALLOWED_HOST_LIST` is forgejo's webhook SSRF allow-list: a - # STRICT whitelist whose `external` builtin allows public unicast - # IPs and denies private/loopback. `external` STAYS so user-repo - # webhooks to github/slack keep working; the hyperhive hosts are - # added on top, because their webhooks target a private (RFC-1918) - # gateway IP `external` alone denies — naming the two hosts is - # tighter than the broad `private` builtin. + # Forgejo's webhook SSRF allow-list, a strict whitelist. + # `external` (public unicast only) stays, or user-repo webhooks to + # github/slack break; both hyperhive hosts are added because their + # webhooks target a private gateway IP `external` denies. The + # swarm's host was the missing one, and every swarm-controller + # delivery was refused for it. # - # TWO hosts, not one. The hive's carries the config-PR + knowledge - # hooks; the swarm's carries swarm-controller's, and leaving it - # off denied every one of those: - # deny '(10.42.0.1:443)' — webhook can only call - # allowed HTTP servers - # - # Read from `swarm.ui.domain` because that is the option - # `swarm-controller.nix` builds `SWARM_CONTROLLER_PUBLIC_URL` - # from. One value, two readers — an allow-list entry and the - # target it must match are two spellings of one address - # otherwise, and the failure is a delivery refused for a host - # nobody typed wrong. - # - # Not gated on `deploy.swarm-ui.enable`, even though that is what - # makes the controller register the hooks: in a spread deployment - # the forge and the UI are different hosts, so that per-host flag - # is false exactly where this entry is needed. - # - # `unique` because an all-local swarm legitimately serves both - # from one name. + # Not gated on `deploy.swarm-ui.enable`: in a spread deployment the + # forge and the swarm host are different machines, so that + # per-host flag is false exactly where this entry is needed. + # `swarm.ui.domain` is the option the controller builds its public + # URL from; it is slated to collapse into `swarm.domain`. webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," ( lib.unique ( [