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
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue