diff --git a/Cargo.lock b/Cargo.lock index d01b827d..e505921b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1520,7 +1520,6 @@ dependencies = [ "clap_complete", "forgejo-api", "hive-sh4re", - "hmac", "indicatif", "libc", "listenfd", @@ -1530,7 +1529,6 @@ dependencies = [ "rusqlite", "serde", "serde_json", - "sha2", "tempfile", "tokio", "tokio-stream", diff --git a/Cargo.toml b/Cargo.toml index 01aacc3e..c9b9238d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,5 +89,3 @@ 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 cbf13907..5f9fdfe2 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -22,8 +22,6 @@ 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 c68e0336..dd2ab2ca 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -56,11 +56,6 @@ 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. - /// `None` when the secret could not be loaded at startup — all - /// `/webhook/*` requests are rejected with 503 in that case. - webhook_secret: Option, } #[allow( @@ -70,11 +65,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, - webhook_secret: Option, -) -> Result<()> { +pub async fn serve(port: u16, coord: Arc) -> 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() @@ -222,10 +213,7 @@ pub async fn serve( get(state_snapshot::dashboard_history), ) // No static fallback — the gateway owns the dist; unmatched paths 404. - .with_state(AppState { - coord, - webhook_secret, - }); + .with_state(AppState { coord }); // 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 76ae260f..c5024d5c 100644 --- a/hive-c0re/src/dashboard/webhook.rs +++ b/hive-c0re/src/dashboard/webhook.rs @@ -6,42 +6,20 @@ //! repo queue a [`hive_sh4re::ApprovalKind::MergeConfigPr`] approval row //! so the operator can review + approve the merge from 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()`]. +//! 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). use axum::{ - body::Bytes, extract::State, - http::{HeaderMap, StatusCode}, + http::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()) -} - // ── knowledge webhook ────────────────────────────────────────────────────────── /// Minimal Forgejo push-webhook payload — only the fields we care about. @@ -62,36 +40,14 @@ pub(super) struct PushWebhookRepo { /// agents see up-to-date documents on their next turn. /// /// Expected Forgejo webhook configuration: -/// - URL: `https:///webhook/knowledge` -/// - Content type: `application/json` +/// - URL: `http://127.0.0.1:/webhook/knowledge` /// - Event: "Push" (fires on merge commits to main as well) -/// - Secret: auto-generated HMAC key (see [`crate::webhook_secret`]) /// -/// The gateway routes `/webhook/` → hive-c0re; the HMAC secret protects -/// the endpoint from unauthenticated callers. +/// No signature verification for now; the endpoint is loopback-only +/// and only triggers a read-only `git pull` on an operator-curated repo. pub(super) async fn post_webhook_knowledge( - State(state): State, - headers: HeaderMap, - body: Bytes, + axum::extract::Json(payload): axum::extract::Json, ) -> Response { - if let Err(e) = verify_hmac(&state, &headers, &body) { - tracing::warn!("webhook/knowledge: 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::(&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 @@ -168,37 +124,17 @@ struct PrWebhookRepo { /// retry the delivery. Failures are logged at `warn` level. /// /// Expected Forgejo webhook configuration: -/// - URL: `https:///webhook/config-pr` +/// - URL: `http://127.0.0.1:/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, - headers: HeaderMap, - body: Bytes, + axum::extract::Json(payload): axum::extract::Json, ) -> 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::(&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 deleted file mode 100644 index 7c075f82..00000000 --- a/hive-c0re/src/forge/config_pr_poll.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! 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 79cf118f..112dc5d1 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -4,7 +4,6 @@ //! 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; @@ -299,16 +298,9 @@ 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. -/// -/// `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`]. +/// 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`. /// /// An org-level hook covers every repo in `agent-configs` automatically, /// so no per-repo setup is needed as new agents are provisioned. @@ -319,24 +311,21 @@ pub async fn ensure_all() { /// # Errors /// /// Returns an error if: -/// - `hive_domain` produces a URL that `url::Url::parse` rejects. +/// - `dashboard_port` produces a URL that `url::Url::parse` rejects (should +/// never happen for a valid port number). /// - 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, - hive_domain: &str, - webhook_secret: &str, -) -> Result<()> { +pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) -> 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!("https://{hive_domain}/webhook/config-pr"); + let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/config-pr"); let client = api(core_token)?; // List existing org hooks — skip creation if ours is already there. @@ -358,39 +347,12 @@ pub async fn ensure_config_pr_webhook( tracing::debug!(%target_url, "forge: config-pr webhook already configured"); return Ok(()); } - // Delete stale hooks that point at our path but a different base - // (e.g. old loopback hooks from before the SSRF-bypass migration). - for h in &hooks { - let hook_url = h - .config - .as_ref() - .and_then(|c| c.get("url")) - .map_or("", String::as_str); - if hook_url.ends_with("/webhook/config-pr") - && hook_url != target_url - && let Some(id) = h.id - { - tracing::info!( - hook_url, - org = CONFIG_ORG, - "forge: deleting stale config-pr webhook (wrong base)" - ); - let _ = tokio::time::timeout( - HTTP_TIMEOUT, - client.org_delete_hook(CONFIG_ORG, id).send(), - ) - .await; - } - } } Err(e) => { tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create"); } } - let mut additional = BTreeMap::new(); - additional.insert("secret".to_owned(), webhook_secret.to_owned()); - let hook = CreateHookOption { active: Some(true), authorization_header: None, @@ -398,7 +360,7 @@ pub async fn ensure_config_pr_webhook( config: CreateHookOptionConfig { content_type: "json".to_owned(), url: Url::parse(&target_url).context("parse config-pr webhook target url")?, - additional, + additional: BTreeMap::new(), }, 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 384b8669..6c1f717e 100644 --- a/hive-c0re/src/lib.rs +++ b/hive-c0re/src/lib.rs @@ -40,7 +40,6 @@ 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 27b6c61a..07421b5b 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -303,79 +303,23 @@ 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: Option = match hive_c0re::webhook_secret::load_or_generate() { - Ok(s) => Some(s), - Err(e) => { - tracing::error!( - error = ?e, - "webhook secret load/generate failed; /webhook/* endpoints disabled and hooks not registered" - ); - None - } - }; // 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. - // 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, or when the HMAC - // secret is unavailable (load failure). - let webhook_secret_reg = webhook_secret.clone(); + // No-op when the core token or forge are absent. + let webhook_port = dashboard_port; tokio::spawn(async move { - let Some(webhook_secret_reg) = webhook_secret_reg else { - tracing::debug!("webhook secret unavailable; skipping hook registration"); - return; - }; let Some(token) = forge::core_token() else { return; }; - 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 { + if let Err(e) = knowledge::ensure_webhook(&token, webhook_port).await { tracing::warn!(error = ?e, "knowledge: ensure_webhook failed"); } - if let Err(e) = forge::ensure_config_pr_webhook(&token, &domain, &webhook_secret_reg).await - { + if let Err(e) = forge::ensure_config_pr_webhook(&token, webhook_port).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). @@ -558,9 +502,8 @@ 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, dash_secret).await { + if let Err(e) = dashboard::serve(dashboard_port, dash_coord).await { tracing::error!(error = ?e, "dashboard failed"); } }); diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 99bea27b..cbcf1a7e 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -61,15 +61,6 @@ 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 1f511e35..cb7e8ce3 100644 --- a/hive-c0re/src/stores/approvals.rs +++ b/hive-c0re/src/stores/approvals.rs @@ -136,21 +136,6 @@ 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 deleted file mode 100644 index c71fd701..00000000 --- a/hive-c0re/src/webhook_secret.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! 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 4cd348e4..8e535298 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -144,30 +144,20 @@ 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. -/// -/// `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`]. +/// 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`. /// /// 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, - hive_domain: &str, - webhook_secret: &str, -) -> Result<()> { +pub async fn ensure_webhook(core_token: &str, dashboard_port: u16) -> 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!("https://{hive_domain}/webhook/knowledge"); + let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/knowledge"); let client = crate::forge::api(core_token)?; // List existing hooks — skip creation if ours is already there. @@ -190,26 +180,6 @@ pub async fn ensure_webhook( tracing::debug!(%target_url, "knowledge: push webhook already configured"); return Ok(()); } - // Delete stale hooks that point at our path but a different base - // (e.g. old loopback hooks from before the SSRF-bypass migration). - for h in &hooks { - let hook_url = h - .config - .as_ref() - .and_then(|c| c.get("url")) - .map_or("", String::as_str); - if hook_url.ends_with("/webhook/knowledge") - && hook_url != target_url - && let Some(id) = h.id - { - tracing::info!(hook_url, "knowledge: deleting stale webhook (wrong base)"); - let _ = tokio::time::timeout( - HTTP_TIMEOUT, - client.repo_delete_hook(ORG, REPO, id).send(), - ) - .await; - } - } } Err(e) => { tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create"); @@ -217,9 +187,6 @@ pub async fn ensure_webhook( } // 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, @@ -227,7 +194,7 @@ pub async fn ensure_webhook( config: CreateHookOptionConfig { content_type: "json".to_owned(), url: url::Url::parse(&target_url).context("parse webhook target url")?, - additional, + additional: BTreeMap::new(), }, 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 f459d9f4..c494b1ae 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -741,10 +741,8 @@ in }; # Shared auth block — separate locations don't inherit auth_basic, so - # 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. + # each dashboard location (`/`, `/api/`, `/webhook/`) needs it or that + # surface is unauthed. dashboardAuth = lib.optionalString cfg.auth.enable '' auth_basic "${cfg.auth.realm}"; auth_basic_user_file /run/hive-state/gateway.htpasswd; @@ -756,9 +754,8 @@ 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/` (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. + # `/webhook/` (the knowledge webhook) — 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 @@ -784,10 +781,8 @@ 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