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

2
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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

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));

View file

@ -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" {

View file

@ -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<Coordinator>) -> 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/<agent>).
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(())
}

View file

@ -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:<port>/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://<hive_domain>/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,

View file

@ -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::<module>` /

View file

@ -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");
}
});

View file

@ -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 {

View file

@ -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<bool> {
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<Vec<Approval>> {

View file

@ -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=<hex>`).
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<String> {
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<String> {
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=<hex>`.
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::<Sha256>::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<Vec<u8>> {
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)
}

View file

@ -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:<port>/webhook/knowledge`.
/// targeting the correct URL.
///
/// `hive_domain` is the public domain name of the hive; the webhook URL is
/// `https://<hive_domain>/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,

View file

@ -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