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).
99 lines
3.8 KiB
Rust
99 lines
3.8 KiB
Rust
//! 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)
|
|
}
|