hyperhive/hive-c0re/src/dashboard/webhook.rs
atlas d2a550e685 feat(#3255): hives stop owning the knowledge webhook, and clean up their own
A webhook has exactly one target URL, so every hive registering one
against the shared internal/knowledge repository was last-writer-wins
rather than idempotent: all but the most recent silently stopped
receiving deliveries. The swarm controller holds the single registration
and now addresses an event to each hive over the queue instead.

This is a migration, not a deletion. Not registering any more fixes
nothing on a hive that has already run — the hook it created persists on
the forge, so the contention would survive on exactly the deployments
that have it while fresh installs looked fixed. The hive that created a
hook removes it.

It removes only its OWN, matched on the full URL rather than the
/webhook/knowledge suffix. A hook with that suffix and a different base
belongs to another hive, possibly one not yet upgraded, and deleting it
would break that hive's knowledge sync until it caught up. Reaping a
neighbour's registration is the behaviour being removed here; doing it
while fixing it would only invert the direction.

The predecessor did reap by suffix, to clear loopback hooks left by an
older single-hive layout. That was safe when a hive was alone on its
forge and is not safe now. The hive-side registrars also acted as reapers
of hooks under their own path, which is why the swarm hook lives under
/webhook/forge/; removing this registrar removes that reaper too.
Intended, and stated because no reviewer would infer it from the diff.

The receive endpoint goes with it. A live HMAC-verified
/webhook/knowledge that nothing can legitimately reach would tell the
next reader that this is how a hive learns about knowledge changes.

Docs move in the same commit: docs/swarm/README.md said two hooks exist
per swarm-wide repo and neither should be deleted, which is now true for
agent-configs and wrong for internal/knowledge — a half-correct
description being worse than an uncorrected one.
2026-08-19 21:05:52 +02:00

224 lines
8.7 KiB
Rust

//! Forgejo webhook endpoints.
//!
//! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*`
//! repo queue a [`hive_sh4re::approvals::ApprovalKind::MergeConfigPr`] approval row
//! so the operator can review + approve the merge from the dashboard.
//!
//! There was a second endpoint here, `/webhook/knowledge`, which pulled the
//! local `internal/knowledge` clone on push. It is gone along with the
//! per-hive registration that fed it: a webhook has exactly one target URL,
//! so every hive registering one against the shared repository was
//! last-writer-wins. The swarm controller now holds the single registration
//! and addresses an event to each hive over the queue, which
//! [`crate::workers::knowledge`] documents.
//!
//! The endpoint is reached via the gateway (HTTPS, public domain URL) so
//! Forgejo's SSRF guard does not block delivery. Each delivery is verified
//! against the `X-Hub-Signature-256` HMAC header Forgejo attaches; the
//! shared secret is auto-generated at startup and persisted to
//! [`crate::paths::webhook_secret_file()`].
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde::Deserialize;
use super::AppState;
// ── HMAC helper ───────────────────────────────────────────────────────────────
/// Verify the `X-Hub-Signature-256` header on an incoming Forgejo webhook.
/// Returns `Err` (with a safe-to-log message) on mismatch, missing header,
/// or when the HMAC secret is unavailable (load failure at startup).
fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<(), String> {
let secret = state
.webhook_secret
.as_deref()
.ok_or_else(|| "webhook HMAC secret unavailable; endpoint disabled".to_owned())?;
let sig = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if sig.is_empty() {
return Err("missing X-Hub-Signature-256 header".to_owned());
}
crate::webhook_secret::verify_signature(secret, body, sig).map_err(|e| e.to_string())
}
// ── config-PR webhook ──────────────────────────────────────────────────────────
/// Minimal Forgejo `pull_request`-webhook payload.
///
/// Forgejo fires this for actions: `opened`, `closed`, `reopened`,
/// `synchronized`, `assigned`, `unassigned`, `label_updated`,
/// `label_cleared`, `milestoned`, `demilestoned`, `review_requested`,
/// `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`.
/// We only act on `opened` and `synchronized` (Forgejo spells the
/// push-update action past-tense, unlike GitHub's `synchronize`).
#[derive(Deserialize)]
pub(super) struct PrWebhookPayload {
/// What triggered this event (`opened`, `closed`, `synchronized`, …).
action: Option<String>,
/// PR index on the repo.
number: Option<u64>,
pull_request: Option<PrWebhookPr>,
repository: Option<PrWebhookRepo>,
}
#[derive(Deserialize)]
struct PrWebhookPr {
head: Option<PrWebhookHead>,
}
#[derive(Deserialize)]
struct PrWebhookHead {
sha: Option<String>,
}
#[derive(Deserialize)]
struct PrWebhookRepo {
full_name: Option<String>,
}
/// POST `/webhook/config-pr` — Forgejo `pull_request` webhook for
/// `agent-configs/*` repos.
///
/// On `opened` or `synchronized` for an `agent-configs/<agent>` PR:
/// fetches the current PR head sha, queues a `MergeConfigPr` approval row,
/// and emits the `ApprovalAdded` event so the dashboard card appears
/// immediately.
///
/// All other actions (closed, label changes, etc.) are silently ignored —
/// the operator can deny a pending approval if the PR is later closed.
///
/// Always returns HTTP 200 (even on queue failure) so Forgejo does not
/// retry the delivery. Failures are logged at `warn` level.
///
/// Expected Forgejo webhook configuration:
/// - URL: `https://<HYPERHIVE_HIVE_DOMAIN>/webhook/config-pr`
/// - Content type: `application/json`
/// - Events: "Pull Request" only
/// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`])
/// - Organisation: `agent-configs` (org-level hook covers all config repos)
///
/// hive-c0re registers this hook automatically at startup via
/// [`crate::forge::ensure_config_pr_webhook`].
#[utoipa::path(
post,
path = "/webhook/config-pr",
request_body(
content = String,
content_type = "application/json",
description = "Forgejo pull_request-webhook payload, taken as raw \
bytes (not a typed extractor) so HMAC verification \
runs over the exact wire bytes before any JSON \
parsing"
),
responses(
(status = 200, description = "processed (approval queued or ignored)", body = String),
(status = 400, description = "invalid JSON payload"),
(status = 401, description = "bad or missing HMAC signature"),
(status = 503, description = "HMAC secret unavailable at startup"),
),
tag = "webhook"
)]
pub(super) async fn post_webhook_config_pr(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if let Err(e) = verify_hmac(&state, &headers, &body) {
tracing::warn!("webhook/config-pr: HMAC verification failed: {e}");
let status = if e.contains("unavailable") {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::UNAUTHORIZED
};
return (status, e).into_response();
}
let payload = match serde_json::from_slice::<PrWebhookPayload>(&body) {
Ok(p) => p,
Err(e) => {
tracing::warn!("webhook/config-pr: JSON parse error: {e}");
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
}
};
let action = payload.action.as_deref().unwrap_or("");
// Only act on a newly-opened PR or a new push to its branch. Forgejo's
// webhook payload spells the push-update action `synchronized` (past
// tense), NOT GitHub's `synchronize` — matching only the GitHub spelling
// silently dropped every PR-update delivery (the whole reason updates were
// "only found by poll"). Accept both so the handler is correct against
// Forgejo and stays GitHub-compatible.
if action != "opened" && action != "synchronize" && action != "synchronized" {
tracing::debug!(action, "webhook/config-pr: ignoring action");
return (StatusCode::OK, "ignored").into_response();
}
let full_name = payload
.repository
.as_ref()
.and_then(|r| r.full_name.as_deref())
.unwrap_or("");
// Expect `agent-configs/<agent>`.
let agent = match full_name.strip_prefix(&format!("{}/", crate::forge::CONFIG_ORG)) {
Some(name) if !name.is_empty() && !name.contains('/') => name,
_ => {
tracing::debug!(
full_name,
"webhook/config-pr: ignoring non-config-repo event"
);
return (StatusCode::OK, "ignored").into_response();
}
};
let pr_number = match payload.number {
Some(n) if n > 0 => n,
_ => {
tracing::warn!(full_name, "webhook/config-pr: missing or zero PR number");
return (StatusCode::OK, "ignored").into_response();
}
};
// The payload already carries the head sha — use it as an early hint for
// logging, but the canonical sha comes from `submit_merge_config_pr`'s
// fresh forge API call so we don't trust a potentially-stale payload sha.
let payload_sha = payload
.pull_request
.as_ref()
.and_then(|pr| pr.head.as_ref())
.and_then(|h| h.sha.as_deref())
.unwrap_or("<unknown>");
tracing::info!(
%full_name, %agent, %pr_number, %payload_sha, %action,
"webhook/config-pr: queuing MergeConfigPr approval"
);
// Queue the approval. The description surfaces the action and PR number
// on the dashboard card so the operator has context without opening the
// forge PR.
let description = format!("PR #{pr_number} on {full_name} ({action})");
if let Err(e) = crate::socket_server::submit_merge_config_pr(
&state.coord,
agent,
pr_number,
Some(&description),
"forge", // submitter — identifies the webhook path in the audit trail
)
.await
{
tracing::warn!(
%agent, %pr_number, error = ?e,
"webhook/config-pr: failed to queue MergeConfigPr approval"
);
}
(StatusCode::OK, "ok").into_response()
}