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:
parent
a99508719e
commit
79a29873e3
14 changed files with 434 additions and 37 deletions
|
|
@ -303,23 +303,71 @@ async fn cmd_serve(
|
|||
tokio::spawn(async move {
|
||||
forge::ensure_all().await;
|
||||
});
|
||||
// Webhook HMAC secret: load from state dir or generate on first run.
|
||||
// Used by both the webhook handlers (verification) and the Forgejo
|
||||
// hook registrations (so Forgejo signs deliveries with the same key).
|
||||
let webhook_secret = match hive_c0re::webhook_secret::load_or_generate() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "webhook secret load/generate failed; webhooks will not verify HMAC");
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
// Webhook setup: ensure Forgejo webhooks are registered for both
|
||||
// `internal/knowledge` (push → git pull) and the `agent-configs` org
|
||||
// (pull_request → queue MergeConfigPr approval). Both run after
|
||||
// forge::ensure_all so the core token + repos + org are present.
|
||||
// No-op when the core token or forge are absent.
|
||||
let webhook_port = dashboard_port;
|
||||
// URLs use the public hive domain (HYPERHIVE_HIVE_DOMAIN) so Forgejo
|
||||
// delivers through the gateway, bypassing the SSRF loopback guard.
|
||||
// No-op when the core token or domain are absent.
|
||||
let webhook_secret_reg = webhook_secret.clone();
|
||||
tokio::spawn(async move {
|
||||
let Some(token) = forge::core_token() else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = knowledge::ensure_webhook(&token, webhook_port).await {
|
||||
let domain = std::env::var("HYPERHIVE_HIVE_DOMAIN")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(domain) = domain else {
|
||||
tracing::debug!("HYPERHIVE_HIVE_DOMAIN unset; skipping webhook registration");
|
||||
return;
|
||||
};
|
||||
if let Err(e) = knowledge::ensure_webhook(&token, &domain, &webhook_secret_reg).await {
|
||||
tracing::warn!(error = ?e, "knowledge: ensure_webhook failed");
|
||||
}
|
||||
if let Err(e) = forge::ensure_config_pr_webhook(&token, webhook_port).await {
|
||||
if let Err(e) = forge::ensure_config_pr_webhook(&token, &domain, &webhook_secret_reg).await
|
||||
{
|
||||
tracing::warn!(error = ?e, "forge: ensure_config_pr_webhook failed");
|
||||
}
|
||||
});
|
||||
// Config-PR polling fallback: scan agent-configs org every 5 minutes
|
||||
// for open PRs that have no pending MergeConfigPr approval. Catches
|
||||
// anything the webhook missed (c0re was down when PR opened, delivery
|
||||
// failed, etc.). First sweep fires immediately on startup.
|
||||
let poll_coord = coord.clone();
|
||||
let mut poll_shutdown = coord.shutdown_rx();
|
||||
tokio::spawn(async move {
|
||||
let interval = std::time::Duration::from_mins(5);
|
||||
loop {
|
||||
if let Some(token) = forge::core_token() {
|
||||
let result = Box::pin(forge::config_pr_poll::poll_open_config_prs(
|
||||
&token,
|
||||
&poll_coord,
|
||||
))
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
tracing::debug!(error = ?e, "config-pr poll: sweep failed (forge may be absent)");
|
||||
}
|
||||
}
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(interval) => {}
|
||||
_ = poll_shutdown.changed() => {
|
||||
tracing::info!("config-pr poll: shutdown signal received");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Knowledge periodic pull: hourly fallback in case the webhook is
|
||||
// missed (e.g. hive-c0re was down during a push). First fires at
|
||||
// startup (immediate pull after the clone is already present).
|
||||
|
|
@ -502,8 +550,9 @@ async fn cmd_serve(
|
|||
// channel (used by `recv_blocking_batch`) stays untouched.
|
||||
spawn_broker_to_dashboard_forwarder(coord.clone());
|
||||
let dash_coord = coord.clone();
|
||||
let dash_secret = webhook_secret.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await {
|
||||
if let Err(e) = dashboard::serve(dashboard_port, dash_coord, dash_secret).await {
|
||||
tracing::error!(error = ?e, "dashboard failed");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue