Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/webhook.rs

1110 lines
45 KiB
Rust

//! Forgejo webhook receipt at the swarm level.
//!
//! A Forgejo webhook has exactly **one** `target_url`, so every hive
//! registering the same swarm-wide hooks is last-writer-wins rather than
//! idempotent, and every hive but the most recent silently stops receiving
//! deliveries. One owner is the only non-racing shape, and the only
//! swarm-wide thing in the deployment is this daemon.
//!
//! **This daemon interprets a delivery; it does not forward it.** It parses
//! the payload and emits a semantic message — *the knowledge repo changed*,
//! *deploy agent X at rev Y* — addressed to the hives that need it. One place
//! decides what a forge payload means, so no hive re-derives it.
//!
//! The `issues` and `pull_request` hooks are the exception: their bodies are
//! kept unchanged in the forge events stream (`crate::forge_events`), and the
//! notification router decides what they mean.
use anyhow::{Context as _, Result};
use axum::{
body::Bytes,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse as _, Response},
};
use hmac::{Hmac, KeyInit as _, Mac as _};
use sha2::Sha256;
use crate::routes::AppState;
/// The legacy home of the HMAC secret, relative to the daemon's state directory.
///
/// The secret lives in the swarm secret store; this file is where it lived
/// before, and is read to seed the store. The secret is handed to Forgejo when
/// the hook is registered, so it must not change — a rotated secret would make
/// every subsequent delivery fail verification with a correctly configured
/// hook on both sides.
const SECRET_FILE: &str = "webhook-secret";
/// Fallback state directory, used only when `STATE_DIRECTORY` is unset (a
/// dev run outside systemd). Under the unit it is always set, because
/// `StateDirectory = "swarm-controller"` is declared there.
const DEFAULT_STATE_DIR: &str = "/var/lib/swarm-controller";
/// Path to the HMAC secret file.
///
/// Reads systemd's `STATE_DIRECTORY` rather than introducing a
/// `SWARM_CONTROLLER_*` env var for it: the unit already declares
/// `StateDirectory=`, and a second env var would be a second declaration of
/// the same fact, free to drift from the first.
///
/// ⚠️ `StateDirectory=` accepts a *list*, in which case systemd exports the
/// paths colon-separated. Only the first is taken — a bare `PathBuf::from`
/// of the whole variable would silently produce a path containing a colon
/// the day someone adds a second directory to the unit.
fn secret_path() -> std::path::PathBuf {
secret_path_from(std::env::var("STATE_DIRECTORY").ok().as_deref())
}
/// The pure half of [`secret_path`], split out so the colon-list handling is
/// testable **without touching the environment**.
///
/// Not a stylistic split: `std::env::set_var` mutates process-global state,
/// and cargo runs a crate's tests on parallel threads in one process, so two
/// tests setting it race — which is not hypothetical here, it is how the
/// first version of this module's tests failed.
fn secret_path_from(raw: Option<&str>) -> std::path::PathBuf {
state_dir_from(raw).join(SECRET_FILE)
}
/// This daemon's state directory, read the same way [`secret_path`] reads
/// it. Other state files (the forge avatar marker in
/// `crate::forge::objects`) live beside the secret, so both share this one
/// reading of `STATE_DIRECTORY`.
pub fn state_dir() -> std::path::PathBuf {
state_dir_from(std::env::var("STATE_DIRECTORY").ok().as_deref())
}
fn state_dir_from(raw: Option<&str>) -> std::path::PathBuf {
let dir = raw
.and_then(|raw| raw.split(':').next())
.filter(|first| !first.is_empty())
.unwrap_or(DEFAULT_STATE_DIR);
std::path::PathBuf::from(dir)
}
/// Load the swarm's webhook HMAC secret from the store, creating it there if
/// the store holds none.
///
/// Returns a hex-encoded 32-byte secret (64 hex chars). Resolution, first
/// match wins:
///
/// 1. The store's value, whatever the legacy file holds.
/// 2. The store holds none: the legacy file's value is written to the store and
/// returned, or a fresh one when there is no legacy file. Nothing rotates,
/// because hooks already registered with the current secret are never
/// re-registered and would fail every delivery's HMAC check.
/// 3. The store is unreachable: the legacy file's value, or an error when
/// there is none. A secret minted while the store may hold another would
/// rotate it.
/// 4. The deployment has no store, or its policy does not cover the path yet
/// (the store's host is deployed after this one): the legacy file as before,
/// generated if absent. The next start, under the new policy, seeds the
/// store from it.
///
/// The legacy file stays where it is; this never writes it except in case 4.
///
/// # Errors
///
/// Returns an error, leaving the legacy file untouched, when an existing file
/// cannot be read, when the store is unreachable and no legacy file exists, and
/// when the secret cannot be generated or persisted. A malformed legacy file is
/// **not** an error: it counts as absent, because a secret that cannot be
/// parsed is indistinguishable from one that was never written, and failing
/// startup over it would strand the daemon with no path forward.
pub(super) async fn load_or_generate_secret() -> Result<String> {
resolve(&StoreSlot, &secret_path()).await
}
/// What the store holds at the webhook secret's path.
pub(super) enum Stored {
/// The secret.
Value(String),
/// Nothing is stored there.
Absent,
/// No store is configured, or its policy for this principal does not cover
/// the path.
NoGrant,
}
/// The webhook secret's place in the store. A test stands in for the store
/// through this trait.
pub(super) trait SecretSlot {
/// What is stored now.
fn read(&self) -> impl Future<Output = Result<Stored>> + Send;
/// Store `value` only if nothing is stored yet.
fn create(&self, value: &str) -> impl Future<Output = Result<()>> + Send;
}
/// The slot at [`swarm_secret_client::forge::controller_webhook_secret_path`],
/// reached under the controller's own store login.
struct StoreSlot;
impl StoreSlot {
async fn read_once() -> Result<Stored> {
let path = swarm_secret_client::forge::controller_webhook_secret_path()?;
let store = match crate::store::connect().await {
Ok(store) => store,
Err(swarm_secret_client::Error::MissingEnv(_)) => return Ok(Stored::NoGrant),
Err(e) => return Err(e).context("logging in to the swarm secret store"),
};
match store
.read_optional::<swarm_secret_client::forge::WebhookSecret>(&path)
.await
{
Ok(Some(stored)) if stored.value.trim().is_empty() => {
anyhow::bail!("{path} holds an empty value")
}
Ok(Some(stored)) => Ok(Stored::Value(stored.value.trim().to_owned())),
Ok(None) => Ok(Stored::Absent),
Err(e) if e.is_denied() => Ok(Stored::NoGrant),
Err(e) => Err(e).with_context(|| format!("reading {path}")),
}
}
}
impl SecretSlot for StoreSlot {
/// Retried through a store that is restarting alongside this daemon. A
/// denial is an answer, not an outage, so it is not retried.
async fn read(&self) -> Result<Stored> {
swarm_secret_client::read_with_startup_retry(
"reading the webhook secret from the swarm secret store",
Self::read_once,
)
.await
}
async fn create(&self, value: &str) -> Result<()> {
let path = swarm_secret_client::forge::controller_webhook_secret_path()?;
let store = crate::store::connect()
.await
.context("logging in to the swarm secret store")?;
store
.create(
&path,
&swarm_secret_client::forge::WebhookSecret {
value: value.to_owned(),
},
)
.await
.with_context(|| format!("creating {path}"))
}
}
/// [`load_or_generate_secret`]'s resolution, against any slot and legacy file.
async fn resolve(slot: &impl SecretSlot, legacy: &std::path::Path) -> Result<String> {
let stored = match slot.read().await {
Ok(stored) => stored,
Err(e) => {
return match read_legacy(legacy)? {
Some(secret) => {
tracing::warn!(
error = %format!("{e:#}"),
path = %legacy.display(),
"swarm secret store unreachable; using the webhook secret file"
);
Ok(secret)
}
None => Err(e.context("no webhook secret file to fall back on")),
};
}
};
match stored {
Stored::Value(secret) => Ok(secret),
Stored::Absent => seed(slot, legacy).await,
Stored::NoGrant => load_or_generate_at(legacy),
}
}
/// Store the legacy file's secret, or a new one when there is none, and return
/// what the store then holds.
async fn seed(slot: &impl SecretSlot, legacy: &std::path::Path) -> Result<String> {
let secret = if let Some(secret) = read_legacy(legacy)? {
tracing::info!(path = %legacy.display(), "storing the webhook secret file's secret in the store");
secret
} else {
tracing::info!("no webhook secret anywhere; generating one in the store");
generate_hex_secret()?
};
if let Err(e) = slot.create(&secret).await {
tracing::warn!(error = %format!("{e:#}"), "creating the webhook secret failed; reading back what the store holds");
}
match slot.read().await? {
Stored::Value(stored) => Ok(stored),
Stored::Absent | Stored::NoGrant => {
anyhow::bail!("the store holds no webhook secret after creating it")
}
}
}
/// The legacy file's secret, or `None` when it is absent or malformed.
fn read_legacy(path: &std::path::Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(raw) => {
let trimmed = raw.trim().to_owned();
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(Some(trimmed));
}
tracing::warn!(
path = %path.display(),
"webhook-secret file malformed (wrong length/chars); ignoring it"
);
Ok(None)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("read webhook secret {}", path.display())),
}
}
/// The file-only resolution: the secret in `path`, generated and persisted
/// when the file is absent or malformed.
fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
if let Some(secret) = read_legacy(path)? {
return Ok(secret);
}
let secret = generate_hex_secret()?;
std::fs::create_dir_all(path.parent().unwrap_or(path))
.with_context(|| format!("create dir for {}", path.display()))?;
write_secret_0600(path, format!("{secret}\n").as_bytes())
.with_context(|| format!("write webhook secret to {}", path.display()))?;
tracing::info!(path = %path.display(), "webhook secret generated and persisted");
Ok(secret)
}
/// Write `contents` to a sibling temp file opened `0600` and rename it over
/// `path`, so the secret is never readable by anyone else and a crash never
/// leaves half of it at `path`. Errors carry paths, never `contents`.
fn write_secret_0600(path: &std::path::Path, contents: &[u8]) -> Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut tmp_name = path
.file_name()
.with_context(|| format!("{} has no file name", path.display()))?
.to_owned();
tmp_name.push(".tmp");
let tmp = path.with_file_name(tmp_name);
// A temp file left by a crashed earlier write would fail `create_new`.
let _ = std::fs::remove_file(&tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("create {}", tmp.display()))?;
f.write_all(contents)
.and_then(|()| f.sync_all())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename onto {}", path.display()))
}
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
///
/// `pub(super)` rather than private: `forge::Client::ensure_agent_user`
/// reuses this for the throwaway account password Forgejo's admin
/// create-user API requires (unlike the `forgejo admin` CLI's
/// `--random-password`, the HTTP endpoint has no "generate one for me"
/// option — see that function's doc comment). Same shape as the webhook
/// secret above: a value nothing ever reads back, so 32 random bytes is
/// as good as any other generator.
pub(super) 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
}
/// 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)
}
/// Which swarm-wide hook a delivery arrived on.
///
/// The kind comes from the URL path rather than from Forgejo's
/// `X-Forgejo-Event` header, so the routing key is decided by *the
/// registration we made* instead of by a field the sender chooses. Each
/// registered hook gets its own `target_url`, exactly as the per-hive hooks
/// do today.
#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)]
pub(super) enum DeliveryKind {
/// Push events on the hive-wide knowledge repo.
#[strum(serialize = "knowledge")]
Knowledge,
/// `pull_request` events on the agent-config repos.
#[strum(serialize = "config-pr")]
ConfigPr,
/// `push` events on every repo in the instance — see
/// `crate::vcs_metrics`'s doc comment for why this exists as its own
/// kind rather than folding into [`Self::Knowledge`]'s already-`push`
/// hook: that one is repo-scoped to the knowledge repo alone, and this
/// one is instance-wide (registered via `admin_create_hook`, not
/// `repo_create_hook`) — different scope, same event name.
#[strum(serialize = "vcs-activity")]
VcsActivity,
/// `pull_request_review_request` events on every repo in the instance,
/// for the review classifier's account (`crate::review_classifier::review`).
#[strum(serialize = "review-request")]
ReviewRequest,
/// Every `issues` event on every repo in the instance (opened, closed,
/// assigned, …), kept for the notification router (`crate::forge_events`).
#[strum(serialize = "issues")]
Issues,
/// Every `pull_request` event, and every submitted review, on every repo in
/// the instance, kept the same way.
#[strum(serialize = "pull-request")]
PullRequest,
}
/// The route prefix a registered `target_url` must point at.
///
/// ⚠️ Deliberately **not** `/webhook/knowledge` or `/webhook/config-pr`.
/// Older `hive-c0re` releases delete, on every boot, any hook whose URL ends
/// with one of those paths on a base other than their own; a swarm-level
/// hook there would silently stop existing while such a hive runs.
/// `webhook_urls_survive_the_hive_side_reapers` pins that.
const ROUTE_PREFIX: &str = "/webhook/forge/";
impl DeliveryKind {
/// Every kind, for the registration sweep. An array rather than a
/// hand-written list at the call site, so adding a kind cannot leave one
/// hook unregistered.
pub(super) const ALL: [Self; 6] = [
Self::Knowledge,
Self::ConfigPr,
Self::VcsActivity,
Self::ReviewRequest,
Self::Issues,
Self::PullRequest,
];
/// The `target_url` to register with Forgejo for this kind, given the
/// swarm's public base URL.
///
/// Built here rather than at the registration call site so the URL that
/// is *registered* and the route that *serves* it are the same fact in
/// one place. A trailing slash on the base is tolerated — it arrives from
/// config, and `https://swarm//webhook/...` would be a hook that 404s.
pub(super) fn target_url(self, public_base: &str) -> String {
format!(
"{}{ROUTE_PREFIX}{}",
public_base.trim_end_matches('/'),
<&str>::from(self)
)
}
}
/// Why a delivery was refused.
///
/// A value rather than a string, so the status code is chosen by matching on
/// the reason instead of on message text — `hive-c0re`'s equivalent does
/// `e.contains("unavailable")`, which couples an HTTP response to the exact
/// wording of a log message.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Refusal {
/// No secret was loaded at startup: the endpoint cannot verify anything,
/// which is this daemon's fault and not the caller's. 503, so a retry is
/// meaningful.
SecretUnavailable,
/// The signature was missing, malformed, or did not match. 401.
BadSignature(String),
}
impl Refusal {
fn status(&self) -> StatusCode {
match self {
Self::SecretUnavailable => StatusCode::SERVICE_UNAVAILABLE,
Self::BadSignature(_) => StatusCode::UNAUTHORIZED,
}
}
fn message(&self) -> String {
match self {
Self::SecretUnavailable => {
"webhook HMAC secret unavailable; endpoint disabled".to_owned()
}
Self::BadSignature(detail) => detail.clone(),
}
}
}
/// Verify the `X-Hub-Signature-256` header Forgejo attaches to a delivery
/// (`sha256=<hex>`).
///
/// The body is the raw wire bytes, not a re-serialised structure: an HMAC is
/// over exactly what was sent, and any parse-then-reserialise step would
/// change whitespace or key order and fail every signature.
///
/// The refusal messages are safe to log — they never contain the secret or
/// the expected digest, so a caller can surface *why* a delivery was refused
/// without handing an attacker the answer.
pub(super) fn verify(
secret: Option<&str>,
headers: &HeaderMap,
body: &Bytes,
) -> Result<(), Refusal> {
let secret = secret.ok_or(Refusal::SecretUnavailable)?;
let sig = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if sig.is_empty() {
return Err(Refusal::BadSignature(
"missing X-Hub-Signature-256 header".to_owned(),
));
}
let Some(sig_hex) = sig.strip_prefix("sha256=") else {
return Err(Refusal::BadSignature(
"X-Hub-Signature-256 missing 'sha256=' prefix".to_owned(),
));
};
let Some(expected) = hex_decode(sig_hex) else {
return Err(Refusal::BadSignature(
"X-Hub-Signature-256 contains non-hex chars".to_owned(),
));
};
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.map_err(|e| Refusal::BadSignature(format!("HMAC key error: {e}")))?;
mac.update(body);
// `verify_slice` is constant-time and also rejects a wrong-length digest,
// which is why the comparison is not written by hand here.
mac.verify_slice(&expected)
.map_err(|_| Refusal::BadSignature("X-Hub-Signature-256 mismatch".to_owned()))
}
/// POST `/webhook/forge/{kind}` — a swarm-wide Forgejo delivery.
///
/// Verifies the HMAC over the raw body, then acts per `kind`: a `Knowledge`
/// delivery announces the change to every hive (see
/// [`announce_knowledge_change`]); a `ConfigPr` delivery updates
/// `crate::config_pr::ConfigPrCache` immediately (see that module's doc
/// comment for why both this and a periodic poll write the same cache), and a
/// merge queues a deploy of the merged commit on the agent's hive. A
/// `ReviewRequest` delivery naming the review classifier's account starts a
/// review run in the background. An `Issues` or `PullRequest` delivery is
/// kept in the forge events stream for the notification router
/// (`crate::forge_events`).
///
/// Returns 200 on an accepted delivery so Forgejo does not retry. A refused
/// one answers 401 (bad signature) or 503 (this daemon has no secret), and
/// an unknown `{kind}` answers 404 rather than a cheerful 200. An `Issues` or
/// `PullRequest` delivery the stream did not keep answers 503, so the forge's
/// delivery log shows it failed and a redelivery can keep it.
#[utoipa::path(
post,
path = "/webhook/forge/{kind}",
params(
("kind" = String, Path, description = "which swarm-wide hook this delivery arrived on: \
`knowledge`, `config-pr`, `vcs-activity`, \
`review-request`, `issues` or `pull-request`")
),
request_body(
content = String,
content_type = "application/json",
description = "Forgejo webhook payload, taken as raw bytes so HMAC \
verification runs over the exact wire bytes rather \
than over a reserialised copy"
),
responses(
(status = 200, description = "delivery accepted", body = String),
(status = 401, description = "bad or missing HMAC signature"),
(status = 404, description = "unknown hook kind in the path"),
(status = 503, description = "HMAC secret unavailable at startup, or an `issues` or \
`pull-request` delivery the forge events stream did not \
keep"),
),
tag = "webhook"
)]
pub(super) async fn post_webhook_forge(
State(state): State<AppState>,
Path(kind): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// Order matters: verify BEFORE looking at the path. An unauthenticated
// caller learning which `{kind}` values exist, from the difference
// between 404 and 401, is a small leak — but it is free to close.
if let Err(refusal) = verify(state.webhook_secret.as_deref(), &headers, &body) {
let message = refusal.message();
tracing::warn!(%kind, %message, "webhook: refused delivery");
return (refusal.status(), message).into_response();
}
// Unknown values are rejected rather than accepted-and-ignored: a typo in
// a registered `target_url` must be *observable*, and a 200 for an
// unrecognised path is exactly the silence this issue exists to remove.
let Some(kind) = kind.parse::<DeliveryKind>().ok() else {
tracing::warn!(%kind, "webhook: delivery on an unknown hook kind");
return (StatusCode::NOT_FOUND, "unknown hook kind").into_response();
};
tracing::info!(
kind = <&str>::from(kind),
bytes = body.len(),
"webhook: verified delivery"
);
match kind {
DeliveryKind::Knowledge => announce_knowledge_change(&state).await,
DeliveryKind::ConfigPr => {
if let Some(cache) = state.config_prs.get() {
cache.apply_webhook_delivery(&body);
}
if let Some(merged) = crate::config_pr::merged(&body) {
crate::jobq::queue_merged_config_deploy(&state, merged).await;
}
if let Some(changed) = crate::config_pr::changed_head(&body) {
crate::jobq::queue_config_build(&state, changed);
}
}
DeliveryKind::VcsActivity => {
if let Some(activity) = crate::vcs_metrics::parse_push(&body) {
crate::vcs_metrics::record_push(&activity);
} else {
tracing::warn!("webhook: vcs-activity delivery did not parse as a push payload");
}
}
DeliveryKind::ReviewRequest => crate::review_classifier::review::on_delivery(&state, &body),
DeliveryKind::Issues | DeliveryKind::PullRequest => {
if let Err(not_kept) = crate::forge_events::publish(&state, &headers, body).await {
tracing::warn!(kind = <&str>::from(kind), reason = ?not_kept, "webhook: forge event not kept");
return (StatusCode::SERVICE_UNAVAILABLE, "forge event not kept").into_response();
}
}
}
(StatusCode::OK, "ok").into_response()
}
/// Tell every hive in the swarm that the knowledge repository changed.
///
/// The event carries **no payload**, because there is nothing to carry: the
/// hive-side handler this replaces read two fields from Forgejo's webhook and
/// used neither — both were filters — and then ran `git pull`, which re-derives
/// everything from the repository itself. So what crosses the queue is an edge
/// trigger, and adding fields to it would invent a contract nobody reads.
///
/// One publish to one shared subject, not one per hive: every subscriber gets
/// the same empty event, so the roster is never consulted and this daemon does
/// not need to know who the hives are in order to tell them.
///
/// # Failure
///
/// Returns nothing and fails soft. A missed announcement costs a hive stale
/// knowledge until its next boot pull — the same cost as a webhook delivery to
/// a hive that happened to be down, which is what this replaces.
///
/// ⚠️ A **permission** failure cannot be observed here. `publish` hands the
/// message to the connection's buffer, and a NATS authorization violation is
/// reported asynchronously on the connection rather than as an error on this
/// call — it reaches a client as a timeout, or as nothing at all. The `flush`
/// below therefore proves the bytes left this process, and nothing more; if
/// hives stop hearing events, the server log is the place that knows why.
async fn announce_knowledge_change(state: &AppState) {
let Some(status) = state.status.as_ref() else {
// Verified, accepted, and dropped. Worth a warning rather than
// silence: the forge will report a 200 and nobody would otherwise
// learn that the delivery reached a controller with nowhere to put it.
tracing::warn!(
"webhook: knowledge delivery verified but no swarm queue is \
configured; no hive will be told"
);
return;
};
let client = status.queue_client();
let subject = swarm_queue_client::KNOWLEDGE_SUBJECT;
// A queue that is configured but not (yet, or no longer) connected does
// not fail `publish`/`flush` below outright — it *hangs* them, per
// `ensure_connected`'s own doc — and this fn is awaited inline from the
// webhook handler, so that hang would sit inside forgejo's own delivery
// timeout instead of failing soft the way every other branch here does.
if let Err(e) = swarm_queue_client::ensure_connected(&client) {
tracing::warn!(%subject, error = %e, "webhook: swarm queue is not connected; not publishing");
return;
}
// One publish, not one per hive: every subscriber gets the same empty
// event, so the roster is not consulted at all. The controller does not
// need to know who the hives are in order to say the repository moved.
if let Err(e) = client.publish(subject, Vec::new().into()).await {
tracing::warn!(%subject, error = %e, "webhook: publishing the knowledge event failed");
return;
}
// Logged after the flush rather than after the publish: `publish` only
// hands the message to the client's write buffer, so a line printed there
// would claim delivery this end cannot yet know about.
if let Err(e) = client.flush().await {
tracing::warn!(%subject, error = %e, "webhook: flushing the knowledge event failed");
return;
}
tracing::info!(%subject, "webhook: knowledge event published");
}
#[cfg(test)]
mod tests {
use super::{
DeliveryKind, Refusal, SecretSlot, Stored, load_or_generate_at, resolve, secret_path_from,
verify,
};
use axum::{body::Bytes, http::HeaderMap, http::StatusCode};
use hmac::{Hmac, KeyInit as _, Mac as _};
use sha2::Sha256;
use std::fmt::Write as _;
/// Mint the header Forgejo would send. Written out rather than reusing
/// the verifier's internals so a test cannot pass by sharing a bug with
/// the code under test.
fn sign(secret: &str, body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("key");
mac.update(body);
let mut hex = String::new();
for byte in mac.finalize().into_bytes() {
write!(hex, "{byte:02x}").expect("writing to a String cannot fail");
}
format!("sha256={hex}")
}
fn headers_with(sig: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("x-hub-signature-256", sig.parse().expect("header value"));
h
}
/// Each refusal reached separately, so a change that collapses them into
/// one always-refuse path is visible — and the accept case alongside
/// them, so a verifier that refuses *everything* cannot pass either.
#[test]
fn a_delivery_is_admitted_only_with_a_matching_signature() {
let body = Bytes::from_static(b"{\"action\":\"opened\"}");
let good = sign("s3cret", &body);
assert_eq!(
verify(Some("s3cret"), &headers_with(&good), &body),
Ok(()),
"a correctly signed delivery must be admitted"
);
assert!(
matches!(
verify(Some("other"), &headers_with(&good), &body),
Err(Refusal::BadSignature(_))
),
"a signature minted with a different secret must be refused"
);
assert!(
matches!(
verify(
Some("s3cret"),
&headers_with(&good),
&Bytes::from_static(b"tampered")
),
Err(Refusal::BadSignature(_))
),
"a body that does not match the digest must be refused"
);
assert!(
matches!(
verify(
Some("s3cret"),
&headers_with(good.trim_start_matches("sha256=")),
&body
),
Err(Refusal::BadSignature(_))
),
"a bare hex digest with no 'sha256=' prefix is not what Forgejo sends"
);
assert!(
matches!(
verify(Some("s3cret"), &HeaderMap::new(), &body),
Err(Refusal::BadSignature(_))
),
"a delivery with no signature header at all must be refused"
);
assert_eq!(
verify(None, &headers_with(&good), &body),
Err(Refusal::SecretUnavailable),
"with no secret loaded the endpoint must refuse, not admit"
);
}
/// The status codes, asserted on the value rather than on message text.
#[test]
fn a_missing_secret_is_this_daemons_fault_and_a_bad_signature_is_the_callers() {
assert_eq!(
Refusal::SecretUnavailable.status(),
StatusCode::SERVICE_UNAVAILABLE,
"no secret is a server-side condition: a retry is meaningful"
);
assert_eq!(
Refusal::BadSignature(String::new()).status(),
StatusCode::UNAUTHORIZED,
"a bad signature is the caller's, and a retry will not help"
);
}
/// An unknown segment must not parse. The whole point of the 404 is that
/// a mistyped `target_url` is observable rather than silently accepted.
#[test]
fn only_the_registered_hook_kinds_parse() {
assert_eq!(
"knowledge".parse::<DeliveryKind>().ok(),
Some(DeliveryKind::Knowledge)
);
assert_eq!(
"config-pr".parse::<DeliveryKind>().ok(),
Some(DeliveryKind::ConfigPr)
);
assert_eq!(
"config_pr".parse::<DeliveryKind>().ok(),
None,
"underscore is not the registered spelling; accepting both would make a journal line ambiguous"
);
assert_eq!("".parse::<DeliveryKind>().ok(), None);
assert_eq!("../knowledge".parse::<DeliveryKind>().ok(), None);
}
/// A malformed stored secret is replaced rather than fatal — and the
/// replacement is a *usable* secret that survives a reload, because a
/// version rotating on every call would pass a naive length check while
/// silently breaking every registration made with the previous value.
///
#[test]
fn a_malformed_secret_file_is_regenerated_and_then_stable() {
let dir = std::env::temp_dir().join(format!("swarm-hook-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join("webhook-secret");
std::fs::write(&path, "not-a-hex-secret\n").expect("seed");
let secret = load_or_generate_at(&path).expect("regenerates");
let again = load_or_generate_at(&path).expect("second load");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(secret.len(), 64, "hex-encoded 32 bytes");
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(
again, secret,
"a valid secret must be read back, not rotated"
);
}
/// A generated secret lands at `0600` whatever the umask, holds exactly
/// what was returned, and leaves no temp file beside it.
#[test]
fn a_generated_secret_is_0600_with_no_temp_left() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!("swarm-hook-mode-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join("webhook-secret");
let secret = load_or_generate_at(&path).expect("generates");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
let content = std::fs::read_to_string(&path).expect("read");
let entries: Vec<_> = std::fs::read_dir(&dir)
.expect("readdir")
.map(|e| e.expect("entry").file_name())
.collect();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(mode, 0o600);
assert_eq!(content, format!("{secret}\n"));
assert_eq!(entries, [std::ffi::OsString::from("webhook-secret")]);
}
/// A path that exists but cannot be read is an error, and nothing is
/// written over it. A directory stands in for the unreadable file because
/// its read fails the same way for root and non-root test runners.
#[test]
fn an_unreadable_secret_path_is_an_error_and_left_untouched() {
let dir =
std::env::temp_dir().join(format!("swarm-hook-unreadable-{}", std::process::id()));
let path = dir.join("webhook-secret");
std::fs::create_dir_all(&path).expect("mkdir at the secret path");
let result = load_or_generate_at(&path);
let still_dir = path.is_dir();
let entries = std::fs::read_dir(&path).map(Iterator::count).ok();
std::fs::remove_dir_all(&dir).ok();
let err = result.expect_err("an unreadable secret must not be replaced");
assert!(
format!("{err:#}").contains("webhook-secret"),
"the error names the path: {err:#}"
);
assert!(still_dir, "the existing path was replaced");
assert_eq!(entries, Some(0), "something was written under the path");
}
/// `StateDirectory=` may name several directories, in which case systemd
/// exports them colon-separated — take the first, never the raw value.
///
/// Asserted against the pure half, so this never touches the process
/// environment. The first version of these tests *did* use
/// `std::env::set_var`, and it failed: cargo runs a crate's tests on
/// parallel threads within **one** process, so the sibling env-setting
/// test removed the variable mid-run and the other silently fell back to
/// `/var/lib/swarm-controller`.
#[test]
fn the_secret_path_survives_a_multi_valued_state_directory() {
assert_eq!(
secret_path_from(Some("/var/lib/a:/var/lib/b")),
std::path::Path::new("/var/lib/a/webhook-secret")
);
assert_eq!(
secret_path_from(Some("/var/lib/only")),
std::path::Path::new("/var/lib/only/webhook-secret")
);
assert_eq!(
secret_path_from(None),
std::path::Path::new("/var/lib/swarm-controller/webhook-secret"),
"unset falls back to the directory the unit declares"
);
assert_eq!(
secret_path_from(Some("")),
std::path::Path::new("/var/lib/swarm-controller/webhook-secret"),
"an empty value must not resolve to a relative path"
);
}
/// The URL that gets registered must match the route that serves it, and
/// a trailing slash on the configured base must not produce `//webhook`.
#[test]
fn a_target_url_is_the_route_this_module_serves() {
assert_eq!(
DeliveryKind::Knowledge.target_url("https://swarm.example"),
"https://swarm.example/webhook/forge/knowledge"
);
assert_eq!(
DeliveryKind::ConfigPr.target_url("https://swarm.example/"),
"https://swarm.example/webhook/forge/config-pr",
"a trailing slash on the base must not double up"
);
for kind in DeliveryKind::ALL {
let url = kind.target_url("https://swarm.example");
let segment = url.rsplit('/').next().expect("a last segment");
assert_eq!(
segment.parse::<DeliveryKind>().ok(),
Some(kind),
"the last path segment of a registered URL must parse back \
to the kind that built it"
);
}
}
/// A cross-daemon invariant with nothing else to enforce it: older
/// `hive-c0re` releases **delete** hooks whose URL ends with
/// `/webhook/knowledge` or `/webhook/config-pr` but carries a base other
/// than their own. A controller URL matching such a suffix would be
/// deleted by every such hive on every boot, with the cause in a
/// different daemon's startup sweep. Serving these under
/// `/webhook/forge/` is what avoids it, and this is the only place that
/// says so in a form that fails.
///
/// Current `hive-c0re` removes only hooks matching its own full URL. The
/// hazard is what is *deployed*, not this tree: drop this test once no
/// hive running an older release can exist.
#[test]
fn webhook_urls_survive_the_hive_side_reapers() {
for suffix in ["/webhook/knowledge", "/webhook/config-pr"] {
for kind in DeliveryKind::ALL {
let url = kind.target_url("https://swarm.example");
assert!(
!url.ends_with(suffix),
"{url} ends with {suffix}, which every hive's startup \
sweep deletes as a stale hook"
);
}
}
}
/// A store that records what it is asked to do.
struct FakeSlot {
held: std::sync::Mutex<Option<String>>,
/// What `read` answers while it holds nothing.
empty: fn() -> anyhow::Result<Stored>,
creates: std::sync::Mutex<u32>,
}
impl FakeSlot {
fn holding(secret: Option<&str>) -> Self {
Self {
held: std::sync::Mutex::new(secret.map(str::to_owned)),
empty: || Ok(Stored::Absent),
creates: std::sync::Mutex::new(0),
}
}
fn answering(empty: fn() -> anyhow::Result<Stored>) -> Self {
Self {
empty,
..Self::holding(None)
}
}
fn held(&self) -> Option<String> {
self.held.lock().expect("unpoisoned").clone()
}
fn creates(&self) -> u32 {
*self.creates.lock().expect("unpoisoned")
}
}
impl SecretSlot for FakeSlot {
async fn read(&self) -> anyhow::Result<Stored> {
match self.held() {
Some(secret) => Ok(Stored::Value(secret)),
None => (self.empty)(),
}
}
async fn create(&self, value: &str) -> anyhow::Result<()> {
*self.creates.lock().expect("unpoisoned") += 1;
self.held
.lock()
.expect("unpoisoned")
.get_or_insert_with(|| value.to_owned());
Ok(())
}
}
const ON_DISK: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const IN_STORE: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
/// A scratch legacy file, holding `contents` when given.
fn legacy_file(contents: Option<&str>) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("webhook-secret");
if let Some(contents) = contents {
std::fs::write(&path, format!("{contents}\n")).expect("seed");
}
(dir, path)
}
#[tokio::test]
async fn an_empty_store_is_seeded_from_the_legacy_file_unchanged() {
let (_dir, path) = legacy_file(Some(ON_DISK));
let slot = FakeSlot::holding(None);
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret, ON_DISK, "the effective secret must not change");
assert_eq!(slot.held().as_deref(), Some(ON_DISK));
assert_eq!(
std::fs::read_to_string(&path).expect("file kept"),
format!("{ON_DISK}\n")
);
}
#[tokio::test]
async fn the_store_wins_when_both_hold_a_secret() {
let (_dir, path) = legacy_file(Some(ON_DISK));
let slot = FakeSlot::holding(Some(IN_STORE));
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret, IN_STORE);
assert_eq!(slot.creates(), 0, "a held secret is never written over");
}
#[tokio::test]
async fn neither_holding_one_generates_it_in_the_store_only() {
let (_dir, path) = legacy_file(None);
let slot = FakeSlot::holding(None);
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret.len(), 64);
assert_eq!(slot.held().as_deref(), Some(secret.as_str()));
assert!(!path.exists(), "a new secret is not mirrored to disk");
}
#[tokio::test]
async fn a_malformed_legacy_file_does_not_seed_the_store() {
let (_dir, path) = legacy_file(Some("not-a-hex-secret"));
let slot = FakeSlot::holding(None);
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret.len(), 64);
assert_ne!(secret, "not-a-hex-secret");
assert_eq!(slot.held().as_deref(), Some(secret.as_str()));
}
#[tokio::test]
async fn without_a_grant_the_legacy_file_is_used_and_never_replaced() {
let (_dir, path) = legacy_file(Some(ON_DISK));
let slot = FakeSlot::answering(|| Ok(Stored::NoGrant));
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret, ON_DISK);
assert_eq!(slot.creates(), 0);
}
#[tokio::test]
async fn without_a_grant_and_a_file_the_old_behaviour_generates_one_on_disk() {
let (_dir, path) = legacy_file(None);
let slot = FakeSlot::answering(|| Ok(Stored::NoGrant));
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret.len(), 64);
assert_eq!(
std::fs::read_to_string(&path).expect("persisted"),
format!("{secret}\n"),
"the next start, under a granting policy, seeds the store from it"
);
assert_eq!(slot.creates(), 0);
}
#[tokio::test]
async fn an_unreachable_store_uses_the_legacy_file() {
let (_dir, path) = legacy_file(Some(ON_DISK));
let slot = FakeSlot::answering(|| anyhow::bail!("connection refused"));
let secret = resolve(&slot, &path).await.expect("resolves");
assert_eq!(secret, ON_DISK);
}
#[tokio::test]
async fn an_unreachable_store_and_no_file_mints_nothing() {
let (_dir, path) = legacy_file(None);
let slot = FakeSlot::answering(|| anyhow::bail!("connection refused"));
resolve(&slot, &path)
.await
.expect_err("no secret may be minted while the store may hold one");
assert!(!path.exists());
assert_eq!(slot.creates(), 0);
}
}