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

@ -56,6 +56,9 @@ pub(crate) use tombstones::emit_tombstones_snapshot;
#[derive(Clone)]
struct AppState {
coord: Arc<Coordinator>,
/// HMAC-SHA256 secret shared with Forgejo webhook registrations.
/// Verified on every incoming `/webhook/*` POST.
webhook_secret: String,
}
#[allow(
@ -65,7 +68,7 @@ struct AppState {
handler; splitting that exhaustive list across helpers would \
obscure the route map for no readability gain"
)]
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
pub async fn serve(port: u16, coord: Arc<Coordinator>, webhook_secret: String) -> Result<()> {
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
let app = Router::new()
@ -213,7 +216,10 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
get(state_snapshot::dashboard_history),
)
// No static fallback — the gateway owns the dist; unmatched paths 404.
.with_state(AppState { coord });
.with_state(AppState {
coord,
webhook_secret,
});
// Binds loopback-only; external access via gateway.
// Rationale: docs/gateway.md::Firewall posture.
let addr = SocketAddr::from(([127, 0, 0, 1], port));