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:
parent
0f58cdbde2
commit
bfd8189900
5 changed files with 291 additions and 61 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue