From 79a29873e392abd1c38d8de0550c3712aa7d11b1 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 23:28:16 +0200 Subject: [PATCH] fix(#2164): domain-URL webhooks + HMAC + config-PR polling fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both webhook registrations (knowledge push + config-PR pull_request) now use the public hive domain instead of loopback: https:///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). --- Cargo.lock | 2 + Cargo.toml | 2 + hive-c0re/Cargo.toml | 2 + hive-c0re/src/dashboard/mod.rs | 10 +- hive-c0re/src/dashboard/webhook.rs | 72 +++++++++++--- hive-c0re/src/forge/config_pr_poll.rs | 130 ++++++++++++++++++++++++++ hive-c0re/src/forge/mod.rs | 30 ++++-- hive-c0re/src/lib.rs | 1 + hive-c0re/src/main.rs | 59 +++++++++++- hive-c0re/src/paths.rs | 9 ++ hive-c0re/src/stores/approvals.rs | 15 +++ hive-c0re/src/webhook_secret.rs | 99 ++++++++++++++++++++ hive-c0re/src/workers/knowledge.rs | 25 +++-- nix/modules/hive-gateway.nix | 15 ++- 14 files changed, 434 insertions(+), 37 deletions(-) create mode 100644 hive-c0re/src/forge/config_pr_poll.rs create mode 100644 hive-c0re/src/webhook_secret.rs diff --git a/Cargo.lock b/Cargo.lock index e505921b..d01b827d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1520,6 +1520,7 @@ dependencies = [ "clap_complete", "forgejo-api", "hive-sh4re", + "hmac", "indicatif", "libc", "listenfd", @@ -1529,6 +1530,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "tempfile", "tokio", "tokio-stream", diff --git a/Cargo.toml b/Cargo.toml index c9b9238d..01aacc3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,3 +89,5 @@ matrix-sdk = { version = "0.14", default-features = false, features = [ "e2e-encryption", ] } futures-util = "0.3" +hmac = "0.12" +sha2 = "0.10" diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 5f9fdfe2..cbf13907 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -22,6 +22,8 @@ hive-sh4re.workspace = true libc.workspace = true listenfd = "1" petgraph.workspace = true +hmac.workspace = true +sha2.workspace = true rusqlite.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index dd2ab2ca..655c2145 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -56,6 +56,9 @@ pub(crate) use tombstones::emit_tombstones_snapshot; #[derive(Clone)] struct AppState { coord: Arc, + /// 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) -> Result<()> { +pub async fn serve(port: u16, coord: Arc, 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) -> 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)); diff --git a/hive-c0re/src/dashboard/webhook.rs b/hive-c0re/src/dashboard/webhook.rs index c5024d5c..863c1a52 100644 --- a/hive-c0re/src/dashboard/webhook.rs +++ b/hive-c0re/src/dashboard/webhook.rs @@ -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:`) 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:/webhook/knowledge` +/// - URL: `https:///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, + State(state): State, + 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::(&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:/webhook/config-pr` +/// - URL: `https:///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, - axum::extract::Json(payload): axum::extract::Json, + 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::(&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" { diff --git a/hive-c0re/src/forge/config_pr_poll.rs b/hive-c0re/src/forge/config_pr_poll.rs new file mode 100644 index 00000000..7c075f82 --- /dev/null +++ b/hive-c0re/src/forge/config_pr_poll.rs @@ -0,0 +1,130 @@ +//! Polling fallback for the config-PR webhook. +//! +//! The webhook (`/webhook/config-pr`) is the primary path for detecting open +//! PRs on `agent-configs/*` repos and queuing `MergeConfigPr` approvals. +//! But webhooks can be missed — hive-c0re might be down when a PR is opened, +//! or Forgejo might fail a delivery. +//! +//! This module provides [`poll_open_config_prs`], called periodically from +//! `main.rs`, which scans all `agent-configs/*` repos for open PRs that have +//! no pending `MergeConfigPr` approval yet, and queues one. Idempotent: PRs +//! that already have a pending approval are skipped. + +use std::sync::Arc; + +use anyhow::Result; +use forgejo_api::structs::{RepoListPullRequestsQuery, RepoListPullRequestsQueryState}; + +use crate::coordinator::Coordinator; +use crate::forge::CONFIG_ORG; + +const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// Scan every repo in `agent-configs` for open PRs that have no pending +/// `MergeConfigPr` approval yet, and queue one for each gap found. +/// +/// Designed to be called on a periodic timer (e.g. every 5 minutes) as a +/// fault-tolerance backstop for the Forgejo webhook. The webhook fires +/// immediately; this catches anything the webhook missed. +pub async fn poll_open_config_prs(core_token: &str, coord: &Arc) -> Result<()> { + let client = crate::forge::api(core_token)?; + + // List all repos in agent-configs org. + let repos = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_repos(CONFIG_ORG).all()) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r.map_err(anyhow::Error::from))?; + + for repo in repos { + let Some(repo_name) = repo.name.as_deref() else { + continue; + }; + // The repo name is the agent name (agent-configs/). + let agent = repo_name; + + let query = RepoListPullRequestsQuery { + state: Some(RepoListPullRequestsQueryState::Open), + sort: None, + milestone: None, + labels: None, + poster: None, + base: None, + head: None, + }; + + let prs = match tokio::time::timeout( + HTTP_TIMEOUT, + client + .repo_list_pull_requests(CONFIG_ORG, repo_name, query) + .all(), + ) + .await + { + Ok(Ok(prs)) => prs, + Ok(Err(e)) => { + tracing::debug!( + %agent, error = %e, + "config-pr poll: listing PRs failed, skipping repo" + ); + continue; + } + Err(_) => { + tracing::debug!( + %agent, + "config-pr poll: timeout listing PRs, skipping repo" + ); + continue; + } + }; + + for pr in prs { + let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else { + continue; + }; + + // Skip if a pending approval already exists for this PR. + match coord + .approvals + .has_pending_merge_config_pr(agent, pr_number) + { + Ok(true) => { + tracing::debug!( + %agent, %pr_number, + "config-pr poll: approval already pending, skipping" + ); + continue; + } + Ok(false) => {} + Err(e) => { + tracing::warn!( + %agent, %pr_number, error = ?e, + "config-pr poll: DB check failed, skipping" + ); + continue; + } + } + + tracing::info!( + %agent, %pr_number, + "config-pr poll: queuing missed MergeConfigPr approval" + ); + let description = format!("PR #{pr_number} on {CONFIG_ORG}/{agent} (poll fallback)"); + if let Err(e) = crate::socket_server::submit_merge_config_pr( + coord, + agent, + pr_number, + Some(&description), + "poll", // submitter — identifies the polling path in the audit trail + ) + .await + { + tracing::warn!( + %agent, %pr_number, error = ?e, + "config-pr poll: failed to queue MergeConfigPr approval" + ); + } + } + } + + Ok(()) +} diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 112dc5d1..e718652e 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -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:/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:///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, diff --git a/hive-c0re/src/lib.rs b/hive-c0re/src/lib.rs index 6c1f717e..384b8669 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -40,6 +40,7 @@ pub mod server; pub mod socket_server; pub mod stats; pub mod stores; +pub mod webhook_secret; pub mod workers; // Root re-exports: keep every pre-grouping `crate::` / diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 07421b5b..311b44dd 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -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"); } }); diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index cbcf1a7e..99bea27b 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -61,6 +61,15 @@ pub fn db_dir() -> PathBuf { state_root().join("db") } +/// `webhook-secret` — hex-encoded 32-byte HMAC secret shared between +/// hive-c0re's webhook handlers and the Forgejo webhook registrations. +/// Generated on first startup and persisted; Forgejo is re-registered +/// whenever the secret changes. +#[must_use] +pub fn webhook_secret_file() -> PathBuf { + state_root().join("webhook-secret") +} + /// `forge/` — hive-c0re's own forge provisioning markers. #[must_use] pub fn forge_dir() -> PathBuf { diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs index cb7e8ce3..1f511e35 100644 --- a/hive-c0re/src/stores/approvals.rs +++ b/hive-c0re/src/stores/approvals.rs @@ -136,6 +136,21 @@ impl Approvals { Ok(()) } + /// Return `true` when there is already a `pending` `merge_config_pr` + /// approval for `(agent, pr_number)`. Used by the polling fallback to + /// skip re-submitting approvals that were already queued by the webhook. + pub fn has_pending_merge_config_pr(&self, agent: &str, pr_number: u64) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM approvals \ + WHERE agent = ?1 AND kind = 'merge_config_pr' \ + AND commit_ref = ?2 AND status = 'pending'", + params![agent, pr_number.to_string()], + |row| row.get(0), + )?; + Ok(count > 0) + } + /// Last `limit` resolved approvals (approved / denied / failed), /// newest-first. Drives the history tab on the dashboard. pub fn recent_resolved(&self, limit: u64) -> Result> { diff --git a/hive-c0re/src/webhook_secret.rs b/hive-c0re/src/webhook_secret.rs new file mode 100644 index 00000000..c71fd701 --- /dev/null +++ b/hive-c0re/src/webhook_secret.rs @@ -0,0 +1,99 @@ +//! Webhook HMAC secret — load-or-generate, persist, verify. +//! +//! A 32-byte secret is generated on first startup, hex-encoded, and stored at +//! [`crate::paths::webhook_secret_file()`]. On subsequent starts the same file +//! is read back so Forgejo and hive-c0re always share the same key without any +//! operator configuration. +//! +//! The secret is used in two places: +//! - **Registration**: passed as the `secret` config key when hive-c0re +//! creates (or re-creates) the Forgejo org/repo webhook. +//! - **Verification**: each incoming webhook POST is verified against the +//! `X-Hub-Signature-256` header Forgejo attaches (`sha256=`). + +use anyhow::{Context as _, Result}; + +/// Load the webhook HMAC secret from disk; generate and persist it if absent. +/// +/// Returns a hex-encoded 32-byte secret string (64 hex chars). +pub fn load_or_generate() -> Result { + let path = crate::paths::webhook_secret_file(); + if let Ok(raw) = std::fs::read_to_string(&path) { + let trimmed = raw.trim().to_owned(); + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(trimmed); + } + // File exists but is malformed — regenerate. + tracing::warn!( + path = %path.display(), + "webhook-secret file malformed (wrong length/chars); regenerating" + ); + } + let secret = generate_hex_secret()?; + std::fs::create_dir_all(path.parent().unwrap_or(&path)) + .with_context(|| format!("create dir for {}", path.display()))?; + std::fs::write(&path, format!("{secret}\n")) + .with_context(|| format!("write webhook secret to {}", path.display()))?; + tracing::info!(path = %path.display(), "webhook secret generated and persisted"); + Ok(secret) +} + +/// Read 32 random bytes from `/dev/urandom` and hex-encode them. +fn generate_hex_secret() -> Result { + use std::io::Read as _; + + let mut buf = [0u8; 32]; + let mut f = + std::fs::File::open("/dev/urandom").context("open /dev/urandom for secret generation")?; + f.read_exact(&mut buf) + .context("read 32 bytes from /dev/urandom")?; + Ok(hex_encode(&buf)) +} + +/// Hex-encode `bytes` as a lowercase string. +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0')); + out.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0')); + } + out +} + +/// Verify a Forgejo `X-Hub-Signature-256` header against `body` using +/// `secret`. Returns `Ok(())` when the signature matches, or an error +/// describing the mismatch (safe to log; does not expose the secret). +/// +/// Forgejo sends: `sha256=`. +pub fn verify_signature(secret: &str, body: &[u8], header: &str) -> Result<()> { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let sig_hex = header + .strip_prefix("sha256=") + .ok_or_else(|| anyhow::anyhow!("X-Hub-Signature-256 missing 'sha256=' prefix"))?; + + let expected = hex_decode(sig_hex) + .ok_or_else(|| anyhow::anyhow!("X-Hub-Signature-256 contains non-hex chars"))?; + + let mut mac = Hmac::::new_from_slice(secret.as_bytes()) + .map_err(|e| anyhow::anyhow!("HMAC key error: {e}"))?; + mac.update(body); + mac.verify_slice(&expected) + .map_err(|_| anyhow::anyhow!("X-Hub-Signature-256 mismatch")) +} + +/// Decode a lowercase hex string into bytes; returns `None` on invalid input. +fn hex_decode(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let mut chars = s.chars(); + while let (Some(hi), Some(lo)) = (chars.next(), chars.next()) { + let hi = u8::try_from(hi.to_digit(16)?).ok()?; + let lo = u8::try_from(lo.to_digit(16)?).ok()?; + out.push((hi << 4) | lo); + } + Some(out) +} diff --git a/hive-c0re/src/workers/knowledge.rs b/hive-c0re/src/workers/knowledge.rs index 8e535298..f8cf2489 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -144,20 +144,30 @@ async fn seed_readme(core_token: &str) -> Result<()> { /// Ensure a Forgejo push webhook for `internal/knowledge` exists and /// points at hive-c0re's `/webhook/knowledge` 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:/webhook/knowledge`. +/// targeting the correct URL. +/// +/// `hive_domain` is the public domain name of the hive; the webhook URL is +/// `https:///webhook/knowledge` (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_knowledge`]. /// /// Called at startup alongside [`ensure_local_clone`]. No-op when the /// core token is absent (forge not yet provisioned). -pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> { +pub async fn ensure_webhook( + core_token: &str, + hive_domain: &str, + webhook_secret: &str, +) -> Result<()> { // The typed client carries no per-request timeout, so each call is // wrapped in one: this runs as a detached startup task, and a forge // that accepts connections but never answers would otherwise hang // it forever (and the hourly pull fallback masks the missing hook). const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge"); + let target_url = format!("https://{hive_domain}/webhook/knowledge"); let client = crate::forge::api(core_token)?; // List existing hooks — skip creation if ours is already there. @@ -187,6 +197,9 @@ pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> } // Create the webhook. + let mut additional = BTreeMap::new(); + additional.insert("secret".to_owned(), webhook_secret.to_owned()); + let hook = CreateHookOption { active: Some(true), authorization_header: None, @@ -194,7 +207,7 @@ pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> Result<()> config: CreateHookOptionConfig { content_type: "json".to_owned(), url: url::Url::parse(&target_url).context("parse webhook target url")?, - additional: BTreeMap::new(), + additional, }, events: Some(vec!["push".to_owned()]), r#type: CreateHookOptionType::Forgejo, diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index c494b1ae..f459d9f4 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -741,8 +741,10 @@ in }; # Shared auth block — separate locations don't inherit auth_basic, so - # each dashboard location (`/`, `/api/`, `/webhook/`) needs it or that - # surface is unauthed. + # each dashboard location (`/`, `/api/`) needs it or that surface is + # unauthed. `/webhook/` is intentionally excluded: Forgejo cannot + # send HTTP Basic credentials with webhook deliveries, and the HMAC + # secret (`X-Hub-Signature-256`) protects those endpoints instead. dashboardAuth = lib.optionalString cfg.auth.enable '' auth_basic "${cfg.auth.realm}"; auth_basic_user_file /run/hive-state/gateway.htpasswd; @@ -754,8 +756,9 @@ in # Dashboard: nginx static-serves the dist, c0re is API-only. Routing # is by PATH, never content-type. c0re serves exactly two prefixes — # `/api/` (all dashboard data + actions + the SSE streams) and - # `/webhook/` (the knowledge webhook) — so those proxy to c0re and - # everything else serves the dist with an SPA fallback to index.html. + # `/webhook/` (knowledge push + config-PR approval triggers, HMAC- + # guarded) — so those proxy to c0re and everything else serves the + # dist with an SPA fallback to index.html. # The earlier `map $http_accept` Accept-header split made the SAME # url behave differently by content-type (e.g. `/api/state` fetched # with `Accept: text/html` wrongly got index.html); path routing is @@ -781,8 +784,10 @@ in ''; }; "/webhook/" = { + # No dashboardAuth here: Forgejo cannot send HTTP Basic credentials + # with webhook deliveries. HMAC (X-Hub-Signature-256) is the auth + # for these endpoints; hive-c0re verifies it in the handler. proxyPass = "http://${cfg.upstreamHost}:${toString cfg.upstreamPort}"; - extraConfig = dashboardAuth; }; }; in