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
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
130
hive-c0re/src/forge/config_pr_poll.rs
Normal file
130
hive-c0re/src/forge/config_pr_poll.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>` /
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
99
hive-c0re/src/webhook_secret.rs
Normal file
99
hive-c0re/src/webhook_secret.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue