Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/webhook.rs
atlas 68b38a9d44 swarm-controller: keep retrying the forge webhook secret and registration after a startup outage
The webhook secret slot is filled in the background, on the forge token's
capped unbounded backoff, when the store can't answer at start. The hook
registration waits for the secret and retries on the same backoff instead
of giving up after five tries.

Closes #6101
2026-11-06 09:21:59 +01:00

1228 lines
49 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 std::sync::Arc;
use crate::jobq::Late;
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
}
/// The slot the webhook secret lands in: filled before this returns if the
/// store answers, otherwise from a background task that reads it again until it
/// does. The webhook endpoint answers 503 until the slot is filled, and the
/// registration of the forge hooks waits for it.
///
/// Every attempt is [`load_or_generate_secret`], so a retry only reads again: a
/// secret is minted only when the store answers that it holds none, never on a
/// failed read.
pub(crate) async fn load() -> Late<Arc<str>> {
load_with(|| async { load_or_generate_secret().await.map(Arc::from) }).await
}
/// [`load`], with `load` standing in for the store.
async fn load_with<F, Fut>(mut load: F) -> Late<Arc<str>>
where
F: FnMut() -> Fut + Send + 'static,
Fut: Future<Output = Result<Arc<str>>> + Send,
{
let slot = Late::<Arc<str>>::default();
match load().await {
Ok(secret) => {
slot.get_or_init(|| secret);
}
Err(e) => {
tracing::warn!(
error = %format!("{e:#}"),
"webhook secret unavailable; swarm-wide forge webhooks are off until it is read \
from the swarm secret store in the background"
);
let filling = Arc::clone(&slot);
tokio::spawn(async move {
crate::forge::fill_when_ready(&filling, "the webhook secret", load).await;
tracing::info!("the webhook secret is loaded; forge webhooks are on");
});
}
}
slot
}
/// 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.get().map(AsRef::as_ref),
&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, load_with, 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 _;
use std::sync::Arc;
/// 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>,
/// How many more reads fail as an unreachable store does, before it answers.
unreachable_reads: 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),
unreachable_reads: std::sync::Mutex::new(0),
}
}
fn answering(empty: fn() -> anyhow::Result<Stored>) -> Self {
Self {
empty,
..Self::holding(None)
}
}
/// Reads fail as an unreachable store does until `reads` have been made.
fn unreachable_for(self, reads: u32) -> Self {
*self.unreachable_reads.lock().expect("unpoisoned") = reads;
self
}
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> {
{
let mut left = self.unreachable_reads.lock().expect("unpoisoned");
if *left > 0 {
*left -= 1;
anyhow::bail!("connection refused");
}
}
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);
}
/// `load_with`'s source: `resolve` against `slot` and the file at
/// `path`, as `load_or_generate_secret` does against the store.
fn resolving(
slot: &Arc<FakeSlot>,
path: &std::path::Path,
) -> impl FnMut() -> std::pin::Pin<Box<dyn Future<Output = anyhow::Result<Arc<str>>> + Send>>
+ Send
+ 'static {
let (slot, path) = (Arc::clone(slot), path.to_owned());
move || {
let (slot, path) = (Arc::clone(&slot), path.clone());
Box::pin(async move { resolve(&*slot, &path).await.map(Arc::from) })
}
}
// Paused time: each sleep completes at once and advances the clock.
#[tokio::test(start_paused = true)]
async fn a_store_down_at_start_fills_the_slot_once_it_answers() {
let (_dir, path) = legacy_file(None);
let store = Arc::new(FakeSlot::holding(Some(IN_STORE)).unreachable_for(3));
let slot = load_with(resolving(&store, &path)).await;
assert!(slot.get().is_none(), "the first read failed");
tokio::time::sleep(std::time::Duration::from_mins(10)).await;
assert_eq!(slot.get().map(|s| &**s), Some(IN_STORE));
assert_eq!(store.creates(), 0);
assert!(!path.exists(), "nothing was minted on disk");
}
#[tokio::test(start_paused = true)]
async fn a_store_that_stays_down_mints_nothing_however_often_it_is_read() {
let (_dir, path) = legacy_file(None);
let store = Arc::new(FakeSlot::holding(None).unreachable_for(u32::MAX));
let slot = load_with(resolving(&store, &path)).await;
tokio::time::sleep(std::time::Duration::from_hours(3)).await;
assert!(slot.get().is_none());
assert_eq!(store.creates(), 0);
assert_eq!(store.held(), None);
assert!(!path.exists());
assert!(
*store.unreachable_reads.lock().expect("unpoisoned") < u32::MAX - 10,
"the background task kept reading"
);
}
#[tokio::test(start_paused = true)]
async fn a_store_answering_at_start_fills_the_slot_without_a_background_task() {
let (_dir, path) = legacy_file(None);
let store = Arc::new(FakeSlot::holding(Some(IN_STORE)));
let slot = load_with(resolving(&store, &path)).await;
assert_eq!(slot.get().map(|s| &**s), Some(IN_STORE));
}
}