Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: stop reporting refused invites as success; re-register the config-PR hook when its secret changes

invite_user_id mapped every 403 M_FORBIDDEN to Ok(()). The membership
pre-check already skips invited/joined users, so the 403s that reach the
POST are mostly real refusals (banned target, sender without power),
including `hivectl matrix invite`. A 403 is now success only when a
membership re-read shows the user invited or joined; otherwise it is an
error carrying the status and body.

admin_room_send_and_poll read the send response's event_id with
unwrap_or_default() and, when it was missing, walked every recent event
unanchored, so an older bot reply (an earlier reset password) could be
returned as this command's result. A send response without an event_id
is now an error.

run_destroy_bookkeeping discarded fail_pending_for_agent's error; it now
warns like its neighbouring steps.

ensure_config_pr_webhook returned as soon as a hook with the target URL
existed, so a regenerated webhook-secret never reached Forgejo and every
config-PR delivery failed HMAC until the 5-minute poll caught up.
Forgejo's edit-hook API ignores `secret` and never returns it, so the
SHA-256 of the secret last registered is recorded at
forge/config-pr-webhook-secret-sha256; when it doesn't match, the
same-URL hook is deleted and recreated. The paths.rs doc claiming
re-registration on change now describes this.

Refs #4723
This commit is contained in:
atlas 2026-09-26 19:01:15 +02:00 • committed by mara
commit bfd8189900
5 changed files with 291 additions and 61 deletions

View file

@ -418,10 +418,10 @@ 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.
/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` targets
/// hive-c0re's `/webhook/config-pr` and signs with `webhook_secret`. A hook
/// already at that URL is kept if [`crate::webhook_secret::is_registered`],
/// else deleted and recreated (Forgejo's edit-hook API ignores `secret`).
///
/// `hive_domain` is the public domain name of the hive; the webhook URL is
/// `https://<hive_domain>/webhook/config-pr` (routed through the gateway,
@ -442,8 +442,8 @@ pub async fn ensure_all() {
///
/// Returns an error if:
/// - `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).
/// - Deleting the hook being replaced, or `org_create_hook`, 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
@ -467,31 +467,39 @@ pub async fn ensure_config_pr_webhook(
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from));
let listed_ok = listed.is_ok();
match listed {
Ok(hooks) => {
let already_exists = hooks.iter().any(|h| {
h.config
.as_ref()
.and_then(|c| c.get("url"))
.map(String::as_str)
== Some(target_url.as_str())
});
if already_exists {
let secret_registered = crate::webhook_secret::is_registered(webhook_secret);
if config_pr_hook_is_current(&hooks, &target_url, secret_registered) {
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
{
let hook_url = hook_url(h);
let Some(id) = h.id else { continue };
if hook_url == target_url {
// A failed delete must not fall through to create: the
// old hook would stay beside the new one, and a later
// pass sees the URL present and never removes it.
tracing::info!(
hook_url,
org = CONFIG_ORG,
"forge: replacing config-pr webhook (secret not the registered one)"
);
tokio::time::timeout(
HTTP_TIMEOUT,
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| {
format!("delete config-pr webhook {id} to replace its secret")
})?;
} else if hook_url.ends_with("/webhook/config-pr") {
// Our path on a different base (e.g. old loopback hooks
// from before the SSRF-bypass migration).
tracing::info!(
hook_url,
org = CONFIG_ORG,
@ -531,12 +539,63 @@ pub async fn ensure_config_pr_webhook(
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("create config-pr webhook on org {CONFIG_ORG}"))?;
tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}");
// Only a listed pass knows no hook with an older secret survived beside
// this one; an unlisted pass leaves the record stale so the next
// registration lists and replaces again.
if listed_ok && let Err(e) = crate::webhook_secret::record_registered(webhook_secret) {
tracing::warn!(error = ?e, "forge: recording the config-pr webhook secret failed");
}
Ok(())
}
/// The `url` a Forgejo hook delivers to, or `""` when it carries none.
fn hook_url(hook: &forgejo_api::structs::Hook) -> &str {
hook.config
.as_ref()
.and_then(|c| c.get("url"))
.map_or("", String::as_str)
}
/// Whether the config-PR hook needs no work: one targets `target_url`, and
/// the current secret is the one it was registered with.
fn config_pr_hook_is_current(
hooks: &[forgejo_api::structs::Hook],
target_url: &str,
secret_registered: bool,
) -> bool {
secret_registered && hooks.iter().any(|h| hook_url(h) == target_url)
}
#[cfg(test)]
mod tests {
use super::describe_forge_admin;
use super::{config_pr_hook_is_current, describe_forge_admin};
const TARGET: &str = "https://hive.example/webhook/config-pr";
fn hook(url: &str) -> forgejo_api::structs::Hook {
serde_json::from_value(serde_json::json!({ "id": 1, "config": { "url": url } }))
.expect("hook json")
}
#[test]
fn a_hook_at_the_target_with_the_registered_secret_is_kept() {
assert!(config_pr_hook_is_current(&[hook(TARGET)], TARGET, true));
}
/// Forgejo can't be asked which secret a hook signs with, so a hook at
/// the right URL is not enough: without the matching record it is
/// replaced, or every delivery fails HMAC.
#[test]
fn a_hook_at_the_target_with_a_changed_secret_is_replaced() {
assert!(!config_pr_hook_is_current(&[hook(TARGET)], TARGET, false));
}
#[test]
fn a_hook_on_another_base_is_not_ours() {
let stale = hook("http://127.0.0.1:7000/webhook/config-pr");
assert!(!config_pr_hook_is_current(&[stale], TARGET, true));
assert!(!config_pr_hook_is_current(&[], TARGET, true));
}
#[test]
fn describe_keeps_the_verb_path_and_drops_every_value() {