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
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -6,20 +6,38 @@
|
|||
//! repo queue a [`hive_sh4re::ApprovalKind::MergeConfigPr`] approval row
|
||||
//! so the operator can review + approve the merge from the dashboard.
|
||||
//!
|
||||
//! Both endpoints are loopback-only (the axum listener binds
|
||||
//! `127.0.0.1:<port>`) and have no signature verification (the risk is low:
|
||||
//! loopback access implies host compromise already, and the config-PR path
|
||||
//! still requires the operator to approve on the dashboard).
|
||||
//! Both endpoints are 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::StatusCode,
|
||||
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 or missing header.
|
||||
fn verify_hmac(state: &AppState, headers: &HeaderMap, body: &Bytes) -> Result<(), String> {
|
||||
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(&state.webhook_secret, body, sig)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ── knowledge webhook ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Minimal Forgejo push-webhook payload — only the fields we care about.
|
||||
|
|
@ -40,14 +58,31 @@ pub(super) struct PushWebhookRepo {
|
|||
/// agents see up-to-date documents on their next turn.
|
||||
///
|
||||
/// Expected Forgejo webhook configuration:
|
||||
/// - URL: `http://127.0.0.1:<dashboard_port>/webhook/knowledge`
|
||||
/// - URL: `https://<HYPERHIVE_HIVE_DOMAIN>/webhook/knowledge`
|
||||
/// - Content type: `application/json`
|
||||
/// - Event: "Push" (fires on merge commits to main as well)
|
||||
/// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`])
|
||||
///
|
||||
/// No signature verification for now; the endpoint is loopback-only
|
||||
/// and only triggers a read-only `git pull` on an operator-curated repo.
|
||||
/// The gateway routes `/webhook/` → hive-c0re; the HMAC secret protects
|
||||
/// the endpoint from unauthenticated callers.
|
||||
pub(super) async fn post_webhook_knowledge(
|
||||
axum::extract::Json(payload): axum::extract::Json<PushWebhookPayload>,
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
||||
tracing::warn!("webhook/knowledge: HMAC verification failed: {e}");
|
||||
return (StatusCode::UNAUTHORIZED, "signature mismatch").into_response();
|
||||
}
|
||||
|
||||
let payload = match serde_json::from_slice::<PushWebhookPayload>(&body) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("webhook/knowledge: JSON parse error: {e}");
|
||||
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO);
|
||||
let full_name = payload
|
||||
.repository
|
||||
|
|
@ -124,17 +159,32 @@ struct PrWebhookRepo {
|
|||
/// retry the delivery. Failures are logged at `warn` level.
|
||||
///
|
||||
/// Expected Forgejo webhook configuration:
|
||||
/// - URL: `http://127.0.0.1:<dashboard_port>/webhook/config-pr`
|
||||
/// - 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`].
|
||||
pub(super) async fn post_webhook_config_pr(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Json(payload): axum::extract::Json<PrWebhookPayload>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if let Err(e) = verify_hmac(&state, &headers, &body) {
|
||||
tracing::warn!("webhook/config-pr: HMAC verification failed: {e}");
|
||||
return (StatusCode::UNAUTHORIZED, "signature mismatch").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 newly-opened or force-updated PRs.
|
||||
if action != "opened" && action != "synchronize" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue