fix(#2164): domain-URL webhooks + HMAC + config-PR polling fallback

Both webhook registrations (knowledge push + config-PR pull_request) now
use the public hive domain instead of loopback:
  https://<HYPERHIVE_HIVE_DOMAIN>/webhook/{knowledge,config-pr}

This routes deliveries through the gateway, bypassing the Forgejo SSRF
guard that blocked loopback delivery and silently broke the config-PR
merge flow since launch.

Changes:
- webhook_secret: new module — auto-generate + persist a 32-byte HMAC
  secret to STATE_ROOT/webhook-secret on first startup; verify
  X-Hub-Signature-256 on every incoming webhook POST (HMAC-SHA256).
- forge/mod.rs: ensure_config_pr_webhook now takes hive_domain +
  webhook_secret; sets secret in Forgejo hook config.
- workers/knowledge.rs: ensure_webhook same update.
- dashboard/webhook.rs: both handlers read raw Bytes first, verify HMAC,
  then parse JSON. Returns 401 on signature mismatch.
- dashboard/mod.rs: AppState carries webhook_secret; serve() takes it.
- main.rs: load/generate secret at startup; pass to registration tasks
  + dashboard; add 5-minute config-PR polling fallback task.
- forge/config_pr_poll.rs: new — scan agent-configs/* for open PRs with
  no pending MergeConfigPr approval; queue them. Idempotent.
- stores/approvals.rs: has_pending_merge_config_pr() for poll dedup.
- nix/modules/hive-gateway.nix: remove dashboardAuth from /webhook/
  location (HMAC replaces basic auth for webhook endpoints; Forgejo
  cannot send HTTP Basic credentials with webhook deliveries).
This commit is contained in:
atlas 2026-07-11 23:28:16 +02:00
commit 79a29873e3
14 changed files with 434 additions and 37 deletions

View file

@ -4,6 +4,7 @@
//! collaborator access to for operator-curated shared content.
//! No-op when `hive-forge` isn't running. Full design: `docs/forge.md`.
pub mod config_pr_poll;
mod pr_merge;
mod repos;
mod users;
@ -298,9 +299,16 @@ pub async fn ensure_all() {
/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` exists and
/// points at hive-c0re's `/webhook/config-pr` endpoint. Idempotent — lists
/// existing hooks first and skips creation when one is already targeting the
/// correct URL. `dashboard_port` is the TCP port hive-c0re's dashboard listens
/// on (default 7000); the webhook URL is
/// `http://127.0.0.1:<port>/webhook/config-pr`.
/// correct URL.
///
/// `hive_domain` is the public domain name of the hive (e.g.
/// `pr1ma.darkest.space`); the webhook URL is
/// `https://<hive_domain>/webhook/config-pr` (routed through the gateway,
/// avoiding the Forgejo SSRF guard that blocks loopback delivery).
///
/// `webhook_secret` is the HMAC secret Forgejo will attach as
/// `X-Hub-Signature-256` on each delivery; hive-c0re verifies this header
/// in [`crate::dashboard::webhook::post_webhook_config_pr`].
///
/// An org-level hook covers every repo in `agent-configs` automatically,
/// so no per-repo setup is needed as new agents are provisioned.
@ -311,21 +319,24 @@ pub async fn ensure_all() {
/// # Errors
///
/// Returns an error if:
/// - `dashboard_port` produces a URL that `url::Url::parse` rejects (should
/// never happen for a valid port number).
/// - `hive_domain` produces a URL that `url::Url::parse` rejects.
/// - The Forgejo `org_create_hook` API call fails (transport error, auth
/// failure, or the `agent-configs` org does not exist).
/// - The HTTP call times out (10 s limit).
///
/// Listing failures are treated as best-effort: they fall through to the
/// create attempt rather than surfacing an error.
pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) -> Result<()> {
pub async fn ensure_config_pr_webhook(
core_token: &str,
hive_domain: &str,
webhook_secret: &str,
) -> Result<()> {
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
use std::collections::BTreeMap;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/config-pr");
let target_url = format!("https://{hive_domain}/webhook/config-pr");
let client = api(core_token)?;
// List existing org hooks — skip creation if ours is already there.
@ -353,6 +364,9 @@ pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) ->
}
}
let mut additional = BTreeMap::new();
additional.insert("secret".to_owned(), webhook_secret.to_owned());
let hook = CreateHookOption {
active: Some(true),
authorization_header: None,
@ -360,7 +374,7 @@ pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) ->
config: CreateHookOptionConfig {
content_type: "json".to_owned(),
url: Url::parse(&target_url).context("parse config-pr webhook target url")?,
additional: BTreeMap::new(),
additional,
},
events: Some(vec!["pull_request".to_owned()]),
r#type: CreateHookOptionType::Forgejo,