diff --git a/.idea/solstice-ci.iml b/.idea/solstice-ci.iml
index 928b227..eac4bb5 100644
--- a/.idea/solstice-ci.iml
+++ b/.idea/solstice-ci.iml
@@ -12,6 +12,7 @@
+
diff --git a/crates/github-integration/Cargo.toml b/crates/github-integration/Cargo.toml
index 0c0851e..6308abb 100644
--- a/crates/github-integration/Cargo.toml
+++ b/crates/github-integration/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
common = { path = "../common" }
+webhook = { path = "../webhook" }
clap = { version = "4", features = ["derive", "env"] }
miette = { version = "7", features = ["fancy"] }
tracing = "0.1"
@@ -14,10 +15,6 @@ axum = { version = "0.8", features = ["macros"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-# Signature verification
-hmac = "0.12"
-sha2 = "0.10"
-hex = "0.4"
# GitHub App auth
jsonwebtoken = "9"
time = { version = "0.3", features = ["formatting"] }
diff --git a/crates/github-integration/src/main.rs b/crates/github-integration/src/main.rs
index e70edd1..cf17439 100644
--- a/crates/github-integration/src/main.rs
+++ b/crates/github-integration/src/main.rs
@@ -13,12 +13,11 @@ use axum::{
use base64::Engine;
use clap::{Parser, Subcommand};
use futures_util::StreamExt;
-use hmac::{Hmac, Mac};
use jsonwebtoken::{EncodingKey, Header};
use miette::{IntoDiagnostic, Result};
use serde::{Deserialize, Serialize};
-use sha2::Sha256;
use tracing::{error, info, warn};
+use webhook::{SignatureCheck, SignaturePolicy, WebhookError, WebhookInfo};
#[derive(Subcommand, Debug)]
enum Cmd {
@@ -55,6 +54,10 @@ struct Opts {
#[arg(long, env = "GITHUB_WEBHOOK_SECRET")]
webhook_secret: Option,
+ /// Hookdeck signing secret (proxy signature)
+ #[arg(long, env = "HOOKDECK_SIGNING_SECRET")]
+ hookdeck_signing_secret: Option,
+
/// GitHub API base (e.g., https://api.github.com)
#[arg(long, env = "GITHUB_API_BASE", default_value = "https://api.github.com")]
github_api_base: String,
@@ -125,6 +128,7 @@ struct Opts {
struct AppState {
mq_cfg: common::MqConfig,
webhook_secret: Option,
+ hookdeck_signing_secret: Option,
github_api_base: String,
app_id: Option,
app_key_pem: Option,
@@ -137,8 +141,6 @@ struct AppState {
runs_on_map: std::collections::HashMap, // key: owner/repo
}
-type HmacSha256 = Hmac;
-
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
// Load internal config (preloads KDL -> env, then reads env)
@@ -180,10 +182,11 @@ async fn main() -> Result<()> {
let webhook_secret = opts
.webhook_secret
.or_else(|| std::env::var("WEBHOOK_SECRET").ok());
- if webhook_secret.is_none() {
- warn!(
- "GITHUB_WEBHOOK_SECRET is not set — accepting webhooks without signature validation (dev mode)"
- );
+ let hookdeck_signing_secret = opts
+ .hookdeck_signing_secret
+ .or_else(|| std::env::var("HOOKDECK_SECRET").ok());
+ if webhook_secret.is_none() && hookdeck_signing_secret.is_none() {
+ warn!("GITHUB_WEBHOOK_SECRET and HOOKDECK_SIGNING_SECRET are not set — accepting webhooks without signature validation (dev mode)");
}
let app_key_pem = match (&opts.app_key_pem, &opts.app_key_path) {
@@ -202,6 +205,7 @@ async fn main() -> Result<()> {
let state = Arc::new(AppState {
mq_cfg,
webhook_secret,
+ hookdeck_signing_secret,
github_api_base: opts.github_api_base,
app_id: opts.app_id,
app_key_pem,
@@ -455,56 +459,69 @@ async fn handle_webhook(
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
- // Signature validation (X-Hub-Signature-256)
- if let Some(secret) = state.webhook_secret.as_ref() {
- if let Some(sig_hdr) = headers.get("X-Hub-Signature-256") {
- let sig = match sig_hdr.to_str() {
- Ok(s) => s.trim(),
- Err(_) => return StatusCode::UNAUTHORIZED,
- };
- if !verify_github_hmac(sig, secret.as_bytes(), &body) {
- warn!("invalid webhook signature");
- return StatusCode::UNAUTHORIZED;
- }
- } else {
- warn!("missing signature header");
- return StatusCode::UNAUTHORIZED;
- }
- }
-
- let event = headers
- .get("X-GitHub-Event")
- .and_then(|v| v.to_str().ok())
+ let info = WebhookInfo::from_headers(&headers);
+ let checks = signature_checks(&state);
+ let event = info
+ .github
+ .event
+ .as_deref()
.unwrap_or("")
- .to_lowercase();
+ .to_ascii_lowercase();
match event.as_str() {
- "push" => handle_push(state, body).await,
- "pull_request" => handle_pull_request(state, body).await,
- "ping" => StatusCode::OK,
+ "push" => match webhook::extract_json::(
+ &headers,
+ body.as_ref(),
+ &checks,
+ SignaturePolicy::Any,
+ ) {
+ Ok(extract) => handle_push(state, extract.payload).await,
+ Err(err) => map_webhook_error(err),
+ },
+ "pull_request" => match webhook::extract_json::(
+ &headers,
+ body.as_ref(),
+ &checks,
+ SignaturePolicy::Any,
+ ) {
+ Ok(extract) => handle_pull_request(state, extract.payload).await,
+ Err(err) => map_webhook_error(err),
+ },
+ "ping" => match webhook::verify_signatures(
+ &headers,
+ body.as_ref(),
+ &checks,
+ SignaturePolicy::Any,
+ ) {
+ Ok(_) => StatusCode::OK,
+ Err(err) => map_webhook_error(WebhookError::Signature(err)),
+ },
_ => StatusCode::NO_CONTENT,
}
}
-fn verify_github_hmac(sig_hdr: &str, secret: &[u8], body: &[u8]) -> bool {
- let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr);
- let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
- mac.update(body);
- match hex::decode(sig) {
- Ok(sig) => mac.verify_slice(&sig).is_ok(),
- Err(_) => false,
+fn signature_checks(state: &AppState) -> Vec {
+ let mut checks = Vec::new();
+ if let Some(secret) = state.webhook_secret.as_deref() {
+ checks.push(SignatureCheck::github(secret));
}
+ if let Some(secret) = state.hookdeck_signing_secret.as_deref() {
+ checks.push(SignatureCheck::hookdeck(secret));
+ }
+ checks
}
-async fn handle_push(state: Arc, body: Bytes) -> StatusCode {
- let payload: PushPayload = match serde_json::from_slice(&body) {
- Ok(p) => p,
- Err(e) => {
- warn!(error = %e, "failed to parse push payload");
- return StatusCode::BAD_REQUEST;
- }
+fn map_webhook_error(err: WebhookError) -> StatusCode {
+ let (status, message) = match &err {
+ WebhookError::Signature(_) => (StatusCode::UNAUTHORIZED, "webhook signature check failed"),
+ WebhookError::Json(_) => (StatusCode::BAD_REQUEST, "failed to parse webhook payload"),
};
+ let report = miette::Report::new(err);
+ warn!(error = %report, "{message}");
+ status
+}
+async fn handle_push(state: Arc, payload: PushPayload) -> StatusCode {
// Ignore delete events (after = all zeros)
let is_delete = payload.after.chars().all(|c| c == '0');
if is_delete {
@@ -561,15 +578,7 @@ async fn handle_push(state: Arc, body: Bytes) -> StatusCode {
}
}
-async fn handle_pull_request(state: Arc, body: Bytes) -> StatusCode {
- let payload: PrPayload = match serde_json::from_slice(&body) {
- Ok(p) => p,
- Err(e) => {
- warn!(error = %e, "failed to parse pull_request payload");
- return StatusCode::BAD_REQUEST;
- }
- };
-
+async fn handle_pull_request(state: Arc, payload: PrPayload) -> StatusCode {
// Only act on opened/synchronize/reopened
match payload.action.as_str() {
"opened" | "synchronize" | "reopened" => {}
diff --git a/crates/webhook/Cargo.toml b/crates/webhook/Cargo.toml
new file mode 100644
index 0000000..740b5ea
--- /dev/null
+++ b/crates/webhook/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "webhook"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+base64 = "0.22"
+hex = "0.4"
+hmac = "0.12"
+http = "1"
+miette = { version = "7", features = ["fancy"] }
+serde = "1"
+serde_json = "1"
+sha2 = "0.10"
+thiserror = "2"
diff --git a/crates/webhook/src/lib.rs b/crates/webhook/src/lib.rs
new file mode 100644
index 0000000..38c82ea
--- /dev/null
+++ b/crates/webhook/src/lib.rs
@@ -0,0 +1,331 @@
+use std::net::IpAddr;
+use base64::Engine;
+use hmac::{Hmac, Mac};
+use http::HeaderMap;
+use serde::de::DeserializeOwned;
+use sha2::Sha256;
+use miette::Diagnostic;
+use thiserror::Error;
+
+type HmacSha256 = Hmac;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SignatureSource {
+ Github,
+ Hookdeck,
+ Forgejo,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum SignatureFormat {
+ Hex { prefix: Option<&'static str> },
+ Base64,
+}
+
+#[derive(Debug, Clone)]
+pub struct SignatureCheck {
+ source: SignatureSource,
+ headers: &'static [&'static str],
+ format: SignatureFormat,
+ secret: Vec,
+}
+
+impl SignatureCheck {
+ pub fn github(secret: impl AsRef<[u8]>) -> Self {
+ Self {
+ source: SignatureSource::Github,
+ headers: &["X-Hub-Signature-256"],
+ format: SignatureFormat::Hex {
+ prefix: Some("sha256="),
+ },
+ secret: secret.as_ref().to_vec(),
+ }
+ }
+
+ pub fn hookdeck(secret: impl AsRef<[u8]>) -> Self {
+ Self {
+ source: SignatureSource::Hookdeck,
+ headers: &["X-Hookdeck-Signature"],
+ format: SignatureFormat::Base64,
+ secret: secret.as_ref().to_vec(),
+ }
+ }
+
+ pub fn forgejo(secret: impl AsRef<[u8]>) -> Self {
+ Self {
+ source: SignatureSource::Forgejo,
+ headers: &["X-Gitea-Signature", "X-Forgejo-Signature"],
+ format: SignatureFormat::Hex { prefix: None },
+ secret: secret.as_ref().to_vec(),
+ }
+ }
+
+ fn verify(&self, headers: &HeaderMap, body: &[u8]) -> Result<(), SignatureCheckError> {
+ let mut saw_header = false;
+ for name in self.headers {
+ if let Some(value) = headers.get(*name) {
+ saw_header = true;
+ let sig = value.to_str().map_err(|_| SignatureCheckError::Invalid)?;
+ let ok = match self.format {
+ SignatureFormat::Hex { prefix } => {
+ verify_hmac_hex(sig, prefix, &self.secret, body)
+ }
+ SignatureFormat::Base64 => verify_hmac_base64(sig, &self.secret, body),
+ };
+ if ok {
+ return Ok(());
+ }
+ }
+ }
+ if saw_header {
+ Err(SignatureCheckError::Invalid)
+ } else {
+ Err(SignatureCheckError::Missing)
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum SignaturePolicy {
+ Any,
+ All,
+}
+
+#[derive(Debug, Clone)]
+pub enum SignatureStatus {
+ Verified(Vec),
+ Skipped,
+}
+
+#[derive(Debug, Error, Diagnostic)]
+pub enum SignatureError {
+ #[error("missing signature headers for {0:?}")]
+ #[diagnostic(
+ code("webhook.signature.missing"),
+ help("Ensure the webhook sender or proxy includes the expected signature header(s) for one of the enabled signature sources.")
+ )]
+ Missing(Vec),
+ #[error("invalid signature for {0:?}")]
+ #[diagnostic(
+ code("webhook.signature.invalid"),
+ help("Check that the signing secret matches the sender/proxy configuration and that the request body is unmodified.")
+ )]
+ Invalid(Vec),
+}
+
+#[derive(Debug, Error, Diagnostic)]
+enum SignatureCheckError {
+ #[error("missing signature header")]
+ #[diagnostic(
+ code("webhook.signature.header_missing"),
+ help("Configure the webhook sender or proxy to include the expected signature header(s).")
+ )]
+ Missing,
+ #[error("signature verification failed")]
+ #[diagnostic(
+ code("webhook.signature.verify_failed"),
+ help("Confirm the signing secret and ensure the request body is not modified by intermediaries.")
+ )]
+ Invalid,
+}
+
+pub fn verify_signatures(
+ headers: &HeaderMap,
+ body: &[u8],
+ checks: &[SignatureCheck],
+ policy: SignaturePolicy,
+) -> Result {
+ if checks.is_empty() {
+ return Ok(SignatureStatus::Skipped);
+ }
+
+ let mut verified = Vec::new();
+ let mut missing = Vec::new();
+ let mut invalid = Vec::new();
+
+ for check in checks {
+ match check.verify(headers, body) {
+ Ok(()) => verified.push(check.source),
+ Err(SignatureCheckError::Missing) => missing.push(check.source),
+ Err(SignatureCheckError::Invalid) => invalid.push(check.source),
+ }
+ }
+
+ match policy {
+ SignaturePolicy::Any => {
+ if let Some(first) = verified.first().copied() {
+ return Ok(SignatureStatus::Verified(vec![first]));
+ }
+ if !invalid.is_empty() {
+ return Err(SignatureError::Invalid(invalid));
+ }
+ Err(SignatureError::Missing(missing))
+ }
+ SignaturePolicy::All => {
+ if invalid.is_empty() && missing.is_empty() {
+ return Ok(SignatureStatus::Verified(verified));
+ }
+ if !invalid.is_empty() {
+ return Err(SignatureError::Invalid(invalid));
+ }
+ Err(SignatureError::Missing(missing))
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct GithubInfo {
+ pub event: Option,
+ pub delivery: Option,
+ pub hook_id: Option,
+ pub installation_target_id: Option,
+ pub installation_target_type: Option,
+ pub user_agent: Option,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct HookdeckInfo {
+ pub event_id: Option,
+ pub request_id: Option,
+ pub idempotency_key: Option,
+ pub original_ip: Option,
+ pub verified: Option,
+ pub event_url: Option,
+ pub source_name: Option,
+ pub connection_name: Option,
+ pub destination_name: Option,
+ pub attempt_count: Option,
+ pub attempt_trigger: Option,
+ pub will_retry_after: Option,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct WebhookInfo {
+ pub github: GithubInfo,
+ pub hookdeck: HookdeckInfo,
+ pub content_type: Option,
+ pub content_length: Option,
+}
+
+impl WebhookInfo {
+ pub fn from_headers(headers: &HeaderMap) -> Self {
+ let github = GithubInfo {
+ event: header_string(headers, "X-GitHub-Event"),
+ delivery: header_string(headers, "X-GitHub-Delivery"),
+ hook_id: header_u64(headers, "X-GitHub-Hook-Id"),
+ installation_target_id: header_u64(headers, "X-GitHub-Hook-Installation-Target-Id"),
+ installation_target_type: header_string(headers, "X-GitHub-Hook-Installation-Target-Type"),
+ user_agent: header_string(headers, "User-Agent"),
+ };
+
+ let hookdeck = HookdeckInfo {
+ event_id: header_string(headers, "X-Hookdeck-EventId"),
+ request_id: header_string(headers, "X-Hookdeck-RequestId"),
+ idempotency_key: header_string(headers, "Idempotency-Key"),
+ original_ip: header_ip(headers, "X-Hookdeck-Original-Ip"),
+ verified: header_bool(headers, "X-Hookdeck-Verified"),
+ event_url: header_string(headers, "X-Hookdeck-Event-Url"),
+ source_name: header_string(headers, "X-Hookdeck-Source-Name"),
+ connection_name: header_string(headers, "X-Hookdeck-Connection-Name"),
+ destination_name: header_string(headers, "X-Hookdeck-Destination-Name"),
+ attempt_count: header_u32(headers, "X-Hookdeck-Attempt-Count"),
+ attempt_trigger: header_string(headers, "X-Hookdeck-Attempt-Trigger"),
+ will_retry_after: header_string(headers, "X-Hookdeck-Will-Retry-After")
+ .filter(|v| v.to_ascii_lowercase() != "empty"),
+ };
+
+ Self {
+ github,
+ hookdeck,
+ content_type: header_string(headers, "Content-Type"),
+ content_length: header_u64(headers, "Content-Length"),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct WebhookExtraction {
+ pub payload: T,
+ pub info: WebhookInfo,
+ pub signature: SignatureStatus,
+}
+
+#[derive(Debug, Error, Diagnostic)]
+pub enum WebhookError {
+ #[error("signature verification failed")]
+ #[diagnostic(
+ code("webhook.signature.failed"),
+ help("Provide a valid signature header or disable verification only in trusted development environments.")
+ )]
+ Signature(#[from] SignatureError),
+ #[error("failed to parse json payload")]
+ #[diagnostic(
+ code("webhook.payload.json_invalid"),
+ help("Verify the webhook payload is valid JSON and matches the expected event schema.")
+ )]
+ Json(#[from] serde_json::Error),
+}
+
+pub fn extract_json(
+ headers: &HeaderMap,
+ body: &[u8],
+ checks: &[SignatureCheck],
+ policy: SignaturePolicy,
+) -> Result, WebhookError> {
+ let info = WebhookInfo::from_headers(headers);
+ let signature = verify_signatures(headers, body, checks, policy)?;
+ let payload = serde_json::from_slice(body)?;
+ Ok(WebhookExtraction {
+ payload,
+ info,
+ signature,
+ })
+}
+
+fn verify_hmac_hex(sig: &str, prefix: Option<&'static str>, secret: &[u8], body: &[u8]) -> bool {
+ let sig = sig.trim();
+ let sig = prefix.and_then(|p| sig.strip_prefix(p)).unwrap_or(sig);
+ let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
+ mac.update(body);
+ match hex::decode(sig) {
+ Ok(decoded) => mac.verify_slice(&decoded).is_ok(),
+ Err(_) => false,
+ }
+}
+
+fn verify_hmac_base64(sig: &str, secret: &[u8], body: &[u8]) -> bool {
+ let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
+ mac.update(body);
+ match base64::engine::general_purpose::STANDARD.decode(sig.trim()) {
+ Ok(decoded) => mac.verify_slice(&decoded).is_ok(),
+ Err(_) => false,
+ }
+}
+
+fn header_string(headers: &HeaderMap, name: &str) -> Option {
+ headers
+ .get(name)
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+}
+
+fn header_u64(headers: &HeaderMap, name: &str) -> Option {
+ header_string(headers, name).and_then(|v| v.parse().ok())
+}
+
+fn header_u32(headers: &HeaderMap, name: &str) -> Option {
+ header_string(headers, name).and_then(|v| v.parse().ok())
+}
+
+fn header_bool(headers: &HeaderMap, name: &str) -> Option {
+ header_string(headers, name).and_then(|v| match v.to_ascii_lowercase().as_str() {
+ "true" => Some(true),
+ "false" => Some(false),
+ _ => None,
+ })
+}
+
+fn header_ip(headers: &HeaderMap, name: &str) -> Option {
+ header_string(headers, name).and_then(|v| v.parse().ok())
+}
diff --git a/deploy/podman/.env.sample b/deploy/podman/.env.sample
index 07511b8..c36d88d 100644
--- a/deploy/podman/.env.sample
+++ b/deploy/podman/.env.sample
@@ -75,10 +75,10 @@ FORGEJO_BASE_URL=
# GitHub Integration secrets (set per deployment)
# Shared secret used to validate GitHub webhooks (X-Hub-Signature-256)
GITHUB_WEBHOOK_SECRET=
+# Hookdeck signing secret (when webhooks are proxied via Hookdeck)
+HOOKDECK_SIGNING_SECRET=
# GitHub App ID (numeric)
GITHUB_APP_ID=
-# GitHub App private key (PEM) or a filesystem path to the PEM
-GITHUB_APP_KEY=
GITHUB_APP_KEY_PATH=
# Optional: override GitHub API base (GitHub Enterprise)
GITHUB_API_BASE=
diff --git a/deploy/podman/README.md b/deploy/podman/README.md
index 067c79a..977c46c 100644
--- a/deploy/podman/README.md
+++ b/deploy/podman/README.md
@@ -166,6 +166,7 @@ Forge integration configuration
GitHub integration configuration
- Set GITHUB_WEBHOOK_SECRET in deploy/podman/.env to validate webhook signatures (X-Hub-Signature-256). If unset, webhooks are accepted without validation (dev mode).
+- If you proxy webhooks through Hookdeck, set HOOKDECK_SIGNING_SECRET to validate Hookdeck's signature header (X-Hookdeck-Signature). Either GitHub or Hookdeck signatures can satisfy verification.
- To enable check runs and workflow fetches, configure a GitHub App and set GITHUB_APP_ID plus either GITHUB_APP_KEY (PEM contents) or GITHUB_APP_KEY_PATH (path inside the container).
- Optional overrides: GITHUB_API_BASE for GitHub Enterprise and GITHUB_CHECK_NAME to customize the check run title.
- The compose file passes these variables to the container. After editing .env, run: podman compose up -d github-integration
diff --git a/deploy/podman/compose.yml b/deploy/podman/compose.yml
index acd1bf5..11dc5e3 100644
--- a/deploy/podman/compose.yml
+++ b/deploy/podman/compose.yml
@@ -320,13 +320,15 @@ services:
HTTP_ADDR: 0.0.0.0:8082
WEBHOOK_PATH: /webhooks/github
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET}
+ HOOKDECK_SIGNING_SECRET: ${HOOKDECK_SIGNING_SECRET}
GITHUB_APP_ID: ${GITHUB_APP_ID}
- GITHUB_APP_KEY_PATH: ${GITHUB_APP_KEY_PATH}
- GITHUB_APP_KEY: ${GITHUB_APP_KEY}
+ GITHUB_APP_KEY_PATH: /app/github-app-key.pem
GITHUB_API_BASE: ${GITHUB_API_BASE:-https://api.github.com}
GITHUB_CHECK_NAME: ${GITHUB_CHECK_NAME:-Solstice CI}
# URL where logs-service is exposed (used for check-run links)
LOGS_BASE_URL: https://logs.${ENV}.${DOMAIN}
+ volumes:
+ - ${GITHUB_APP_KEY_PATH}:/app/github-app-key.pem
depends_on:
rabbitmq:
condition: service_healthy