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
|
|
@ -44,6 +44,36 @@ fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
|
|||
Ok(secret)
|
||||
}
|
||||
|
||||
/// Whether `secret` is the one the config-PR hook was last registered with,
|
||||
/// per [`crate::paths::forge_config_pr_webhook_fingerprint()`]. A missing or
|
||||
/// unreadable record counts as "not registered".
|
||||
pub fn is_registered(secret: &str) -> bool {
|
||||
is_registered_at(&crate::paths::forge_config_pr_webhook_fingerprint(), secret)
|
||||
}
|
||||
|
||||
/// Record `secret` as the one the config-PR hook is now registered with.
|
||||
pub fn record_registered(secret: &str) -> Result<()> {
|
||||
record_registered_at(&crate::paths::forge_config_pr_webhook_fingerprint(), secret)
|
||||
}
|
||||
|
||||
fn is_registered_at(path: &std::path::Path, secret: &str) -> bool {
|
||||
std::fs::read_to_string(path).is_ok_and(|raw| raw.trim() == fingerprint(secret))
|
||||
}
|
||||
|
||||
fn record_registered_at(path: &std::path::Path, secret: &str) -> Result<()> {
|
||||
std::fs::create_dir_all(path.parent().unwrap_or(path))
|
||||
.with_context(|| format!("create dir for {}", path.display()))?;
|
||||
std::fs::write(path, format!("{}\n", fingerprint(secret)))
|
||||
.with_context(|| format!("write webhook secret fingerprint to {}", path.display()))
|
||||
}
|
||||
|
||||
/// Hex SHA-256 of `secret` — stored in its place so the record on disk is
|
||||
/// not a second copy of the key.
|
||||
fn fingerprint(secret: &str) -> String {
|
||||
use sha2::{Digest as _, Sha256};
|
||||
hex_encode(&Sha256::digest(secret.as_bytes()))
|
||||
}
|
||||
|
||||
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
|
||||
fn generate_hex_secret() -> Result<String> {
|
||||
use std::io::Read as _;
|
||||
|
|
@ -106,7 +136,46 @@ fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hex_encode, load_or_generate_at, verify_signature};
|
||||
use super::{
|
||||
hex_encode, is_registered_at, load_or_generate_at, record_registered_at, verify_signature,
|
||||
};
|
||||
|
||||
/// No record is the state of every hive before its first replacement,
|
||||
/// and must read as "not registered" so that hook gets replaced once.
|
||||
#[test]
|
||||
fn a_secret_with_no_fingerprint_on_record_is_not_registered() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forge").join("fingerprint");
|
||||
assert!(!is_registered_at(&path, &"a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_secret_is_registered_and_a_changed_one_is_not() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("forge").join("fingerprint");
|
||||
let old = "a".repeat(64);
|
||||
let new = "b".repeat(64);
|
||||
|
||||
record_registered_at(&path, &old).expect("record");
|
||||
assert!(
|
||||
is_registered_at(&path, &old),
|
||||
"unchanged secret: keep the hook"
|
||||
);
|
||||
assert!(
|
||||
!is_registered_at(&path, &new),
|
||||
"changed secret: replace the hook"
|
||||
);
|
||||
assert!(
|
||||
!std::fs::read_to_string(&path)
|
||||
.expect("read back")
|
||||
.contains(&old),
|
||||
"the record must not be a copy of the secret"
|
||||
);
|
||||
|
||||
record_registered_at(&path, &new).expect("re-record");
|
||||
assert!(is_registered_at(&path, &new));
|
||||
assert!(!is_registered_at(&path, &old));
|
||||
}
|
||||
|
||||
/// Compute the `sha256=<hex>` header Forgejo would send for `secret` +
|
||||
/// `body`, so the "matches" test below isn't asserting against a
|
||||
|
|
|
|||
Loading…
Reference in a new issue