Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/webhook.rs
atlas 0f7b403803 agents get a todo when assigned to an issue or PR, rewritten when unassigned and cleared on close
A new swarm service, swarm-notify-router, in its own container on the host
that sets deploy.notify-router.enable (defaulted on by allSwarmServices where
the host has the router's store leaf, like the claude login minter).

- swarm-controller registers instance-wide hooks for `issues` +
  `issue_assign` and `pull_request` + `pull_request_assign`, updates the
  event list of a hook already registered with fewer (leaving its active flag as it is), and keeps each
  verified body unchanged in the SWARM_EVENTS stream on
  $SWARM.events.forge.<X-Forgejo-Event-Type>, deduped by X-Forgejo-Delivery.
  A delivery the stream did not keep answers 503.
- swarm-notify-router drains its durable consumer on that stream, runs its
  flows (source -> match -> todo sink, stored as nodes plus edges and checked
  to be a tree) and queues each op on $SWARM.router.todo.<agent> in the
  work-queue stream ROUTER_TODOS. Built-in flows: assigned, unassigned (same
  key, rewritten) and closed (prefix-clears every holder's router todos). A
  per-issue snapshot in its notify-router-state bucket holds the last
  assignees and the todo holders; only agents in the agent-status bucket get
  ops, and an assignee that was not one is left out of the snapshot so the
  next assign event retries it. After the first event that must be retried,
  the rest of the batch is nakked so redelivery keeps stream order.
- hive-agent creates its own durable consumer todo-<agent> and applies each
  op to its todo store as subsystem `router`, then acks; a refused op stops
  the rest of its batch the same way. The store's (subsystem, key) dedup
  makes a redelivery harmless. New Todos::clear_prefix.
- swarm-nats-auth: a --notify-router-client arm; the controller may publish
  $SWARM.events.forge.*; each agent gets three {agent} templates (extended
  consumer create whose filter the server checks, MSG.NEXT, ACK).
- Credentials: a notify-router store leaf minted by glue-bao-tls, a
  swarm-notify-router bao policy (read on its own queue client secret), an
  authelia machine client, the secret publisher entry, a journal forwarder
  route. No manual step.

hive-forge-notify is unchanged, so agents also still get forge's own
notifications.

Refs #5571
2026-10-22 07:28:53 +02:00

801 lines
34 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;
/// Where the HMAC secret is kept, relative to the daemon's state directory.
///
/// The secret is generated on first start and handed to Forgejo when the
/// hook is registered, so it must survive a restart — 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, generating and persisting it if the
/// file is absent or malformed.
///
/// Returns a hex-encoded 32-byte secret (64 hex chars).
///
/// # Errors
///
/// Returns an error, leaving the file untouched, when an existing file cannot
/// be read: hooks already registered with the stored secret are never
/// re-registered, so replacing it would fail every delivery's HMAC check.
/// Also errors when the secret cannot be generated (no `/dev/urandom`) or
/// cannot be persisted. A malformed existing file is **not** an error: it
/// is regenerated, 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) fn load_or_generate_secret() -> Result<String> {
load_or_generate_at(&secret_path())
}
/// The path-taking half of [`load_or_generate_secret`], split out for the
/// same reason as [`secret_path_from`]: a test can point it at a scratch
/// file without mutating a process-global env var.
fn load_or_generate_at(path: &std::path::Path) -> Result<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(trimmed);
}
tracing::warn!(
path = %path.display(),
"webhook-secret file malformed (wrong length/chars); regenerating"
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(e).with_context(|| format!("read webhook secret {}", path.display()));
}
}
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 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, load_or_generate_at, 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"
);
}
}
}
}