mirror of
https://codeberg.org/Toasterson/solstice-ci.git
synced 2026-04-10 13:20:41 +00:00
Add webhook crate for extensible signature validation and integration
- Introduce a new `webhook` crate to centralize signature validation for GitHub, Hookdeck, and Forgejo webhooks. - Enable `github-integration` to perform unified webhook signature verification using the `webhook` crate. - Refactor `github-integration`: replace legacy HMAC verification with the reusable `webhook` structure. - Extend Podman configuration for Hookdeck webhook signature handling and improve documentation. - Clean up unused dependencies by migrating to the new implementation. Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
parent
e33ddf2892
commit
4c5a8567a4
8 changed files with 419 additions and 63 deletions
1
.idea/solstice-ci.iml
generated
1
.idea/solstice-ci.iml
generated
|
|
@ -12,6 +12,7 @@
|
||||||
<sourceFolder url="file://$MODULE_DIR$/crates/workflow-runner/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/crates/workflow-runner/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/crates/migration/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/crates/migration/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/crates/logs-service/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/crates/logs-service/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/crates/webhook/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
|
webhook = { path = "../webhook" }
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
miette = { version = "7", features = ["fancy"] }
|
miette = { version = "7", features = ["fancy"] }
|
||||||
tracing = "0.1"
|
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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
# Signature verification
|
|
||||||
hmac = "0.12"
|
|
||||||
sha2 = "0.10"
|
|
||||||
hex = "0.4"
|
|
||||||
# GitHub App auth
|
# GitHub App auth
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
time = { version = "0.3", features = ["formatting"] }
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,11 @@ use axum::{
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use hmac::{Hmac, Mac};
|
|
||||||
use jsonwebtoken::{EncodingKey, Header};
|
use jsonwebtoken::{EncodingKey, Header};
|
||||||
use miette::{IntoDiagnostic, Result};
|
use miette::{IntoDiagnostic, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
use webhook::{SignatureCheck, SignaturePolicy, WebhookError, WebhookInfo};
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
|
|
@ -55,6 +54,10 @@ struct Opts {
|
||||||
#[arg(long, env = "GITHUB_WEBHOOK_SECRET")]
|
#[arg(long, env = "GITHUB_WEBHOOK_SECRET")]
|
||||||
webhook_secret: Option<String>,
|
webhook_secret: Option<String>,
|
||||||
|
|
||||||
|
/// Hookdeck signing secret (proxy signature)
|
||||||
|
#[arg(long, env = "HOOKDECK_SIGNING_SECRET")]
|
||||||
|
hookdeck_signing_secret: Option<String>,
|
||||||
|
|
||||||
/// GitHub API base (e.g., https://api.github.com)
|
/// GitHub API base (e.g., https://api.github.com)
|
||||||
#[arg(long, env = "GITHUB_API_BASE", default_value = "https://api.github.com")]
|
#[arg(long, env = "GITHUB_API_BASE", default_value = "https://api.github.com")]
|
||||||
github_api_base: String,
|
github_api_base: String,
|
||||||
|
|
@ -125,6 +128,7 @@ struct Opts {
|
||||||
struct AppState {
|
struct AppState {
|
||||||
mq_cfg: common::MqConfig,
|
mq_cfg: common::MqConfig,
|
||||||
webhook_secret: Option<String>,
|
webhook_secret: Option<String>,
|
||||||
|
hookdeck_signing_secret: Option<String>,
|
||||||
github_api_base: String,
|
github_api_base: String,
|
||||||
app_id: Option<u64>,
|
app_id: Option<u64>,
|
||||||
app_key_pem: Option<String>,
|
app_key_pem: Option<String>,
|
||||||
|
|
@ -137,8 +141,6 @@ struct AppState {
|
||||||
runs_on_map: std::collections::HashMap<String, String>, // key: owner/repo
|
runs_on_map: std::collections::HashMap<String, String>, // key: owner/repo
|
||||||
}
|
}
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Load internal config (preloads KDL -> env, then reads env)
|
// Load internal config (preloads KDL -> env, then reads env)
|
||||||
|
|
@ -180,10 +182,11 @@ async fn main() -> Result<()> {
|
||||||
let webhook_secret = opts
|
let webhook_secret = opts
|
||||||
.webhook_secret
|
.webhook_secret
|
||||||
.or_else(|| std::env::var("WEBHOOK_SECRET").ok());
|
.or_else(|| std::env::var("WEBHOOK_SECRET").ok());
|
||||||
if webhook_secret.is_none() {
|
let hookdeck_signing_secret = opts
|
||||||
warn!(
|
.hookdeck_signing_secret
|
||||||
"GITHUB_WEBHOOK_SECRET is not set — accepting webhooks without signature validation (dev mode)"
|
.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) {
|
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 {
|
let state = Arc::new(AppState {
|
||||||
mq_cfg,
|
mq_cfg,
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
|
hookdeck_signing_secret,
|
||||||
github_api_base: opts.github_api_base,
|
github_api_base: opts.github_api_base,
|
||||||
app_id: opts.app_id,
|
app_id: opts.app_id,
|
||||||
app_key_pem,
|
app_key_pem,
|
||||||
|
|
@ -455,56 +459,69 @@ async fn handle_webhook(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// Signature validation (X-Hub-Signature-256)
|
let info = WebhookInfo::from_headers(&headers);
|
||||||
if let Some(secret) = state.webhook_secret.as_ref() {
|
let checks = signature_checks(&state);
|
||||||
if let Some(sig_hdr) = headers.get("X-Hub-Signature-256") {
|
let event = info
|
||||||
let sig = match sig_hdr.to_str() {
|
.github
|
||||||
Ok(s) => s.trim(),
|
.event
|
||||||
Err(_) => return StatusCode::UNAUTHORIZED,
|
.as_deref()
|
||||||
};
|
|
||||||
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())
|
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_ascii_lowercase();
|
||||||
|
|
||||||
match event.as_str() {
|
match event.as_str() {
|
||||||
"push" => handle_push(state, body).await,
|
"push" => match webhook::extract_json::<PushPayload>(
|
||||||
"pull_request" => handle_pull_request(state, body).await,
|
&headers,
|
||||||
"ping" => StatusCode::OK,
|
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::<PrPayload>(
|
||||||
|
&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,
|
_ => StatusCode::NO_CONTENT,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_github_hmac(sig_hdr: &str, secret: &[u8], body: &[u8]) -> bool {
|
fn signature_checks(state: &AppState) -> Vec<SignatureCheck> {
|
||||||
let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr);
|
let mut checks = Vec::new();
|
||||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
|
if let Some(secret) = state.webhook_secret.as_deref() {
|
||||||
mac.update(body);
|
checks.push(SignatureCheck::github(secret));
|
||||||
match hex::decode(sig) {
|
|
||||||
Ok(sig) => mac.verify_slice(&sig).is_ok(),
|
|
||||||
Err(_) => false,
|
|
||||||
}
|
}
|
||||||
|
if let Some(secret) = state.hookdeck_signing_secret.as_deref() {
|
||||||
|
checks.push(SignatureCheck::hookdeck(secret));
|
||||||
|
}
|
||||||
|
checks
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_push(state: Arc<AppState>, body: Bytes) -> StatusCode {
|
fn map_webhook_error(err: WebhookError) -> StatusCode {
|
||||||
let payload: PushPayload = match serde_json::from_slice(&body) {
|
let (status, message) = match &err {
|
||||||
Ok(p) => p,
|
WebhookError::Signature(_) => (StatusCode::UNAUTHORIZED, "webhook signature check failed"),
|
||||||
Err(e) => {
|
WebhookError::Json(_) => (StatusCode::BAD_REQUEST, "failed to parse webhook payload"),
|
||||||
warn!(error = %e, "failed to parse push payload");
|
|
||||||
return StatusCode::BAD_REQUEST;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let report = miette::Report::new(err);
|
||||||
|
warn!(error = %report, "{message}");
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_push(state: Arc<AppState>, payload: PushPayload) -> StatusCode {
|
||||||
// Ignore delete events (after = all zeros)
|
// Ignore delete events (after = all zeros)
|
||||||
let is_delete = payload.after.chars().all(|c| c == '0');
|
let is_delete = payload.after.chars().all(|c| c == '0');
|
||||||
if is_delete {
|
if is_delete {
|
||||||
|
|
@ -561,15 +578,7 @@ async fn handle_push(state: Arc<AppState>, body: Bytes) -> StatusCode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_pull_request(state: Arc<AppState>, body: Bytes) -> StatusCode {
|
async fn handle_pull_request(state: Arc<AppState>, payload: PrPayload) -> 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only act on opened/synchronize/reopened
|
// Only act on opened/synchronize/reopened
|
||||||
match payload.action.as_str() {
|
match payload.action.as_str() {
|
||||||
"opened" | "synchronize" | "reopened" => {}
|
"opened" | "synchronize" | "reopened" => {}
|
||||||
|
|
|
||||||
15
crates/webhook/Cargo.toml
Normal file
15
crates/webhook/Cargo.toml
Normal file
|
|
@ -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"
|
||||||
331
crates/webhook/src/lib.rs
Normal file
331
crates/webhook/src/lib.rs
Normal file
|
|
@ -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<Sha256>;
|
||||||
|
|
||||||
|
#[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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SignatureSource>),
|
||||||
|
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<SignatureSource>),
|
||||||
|
#[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<SignatureSource>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<SignatureStatus, SignatureError> {
|
||||||
|
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<String>,
|
||||||
|
pub delivery: Option<String>,
|
||||||
|
pub hook_id: Option<u64>,
|
||||||
|
pub installation_target_id: Option<u64>,
|
||||||
|
pub installation_target_type: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HookdeckInfo {
|
||||||
|
pub event_id: Option<String>,
|
||||||
|
pub request_id: Option<String>,
|
||||||
|
pub idempotency_key: Option<String>,
|
||||||
|
pub original_ip: Option<IpAddr>,
|
||||||
|
pub verified: Option<bool>,
|
||||||
|
pub event_url: Option<String>,
|
||||||
|
pub source_name: Option<String>,
|
||||||
|
pub connection_name: Option<String>,
|
||||||
|
pub destination_name: Option<String>,
|
||||||
|
pub attempt_count: Option<u32>,
|
||||||
|
pub attempt_trigger: Option<String>,
|
||||||
|
pub will_retry_after: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct WebhookInfo {
|
||||||
|
pub github: GithubInfo,
|
||||||
|
pub hookdeck: HookdeckInfo,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub content_length: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T> {
|
||||||
|
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<T: DeserializeOwned>(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
body: &[u8],
|
||||||
|
checks: &[SignatureCheck],
|
||||||
|
policy: SignaturePolicy,
|
||||||
|
) -> Result<WebhookExtraction<T>, 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<String> {
|
||||||
|
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<u64> {
|
||||||
|
header_string(headers, name).and_then(|v| v.parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_u32(headers: &HeaderMap, name: &str) -> Option<u32> {
|
||||||
|
header_string(headers, name).and_then(|v| v.parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
|
||||||
|
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<IpAddr> {
|
||||||
|
header_string(headers, name).and_then(|v| v.parse().ok())
|
||||||
|
}
|
||||||
|
|
@ -75,10 +75,10 @@ FORGEJO_BASE_URL=
|
||||||
# GitHub Integration secrets (set per deployment)
|
# GitHub Integration secrets (set per deployment)
|
||||||
# Shared secret used to validate GitHub webhooks (X-Hub-Signature-256)
|
# Shared secret used to validate GitHub webhooks (X-Hub-Signature-256)
|
||||||
GITHUB_WEBHOOK_SECRET=
|
GITHUB_WEBHOOK_SECRET=
|
||||||
|
# Hookdeck signing secret (when webhooks are proxied via Hookdeck)
|
||||||
|
HOOKDECK_SIGNING_SECRET=
|
||||||
# GitHub App ID (numeric)
|
# GitHub App ID (numeric)
|
||||||
GITHUB_APP_ID=
|
GITHUB_APP_ID=
|
||||||
# GitHub App private key (PEM) or a filesystem path to the PEM
|
|
||||||
GITHUB_APP_KEY=
|
|
||||||
GITHUB_APP_KEY_PATH=
|
GITHUB_APP_KEY_PATH=
|
||||||
# Optional: override GitHub API base (GitHub Enterprise)
|
# Optional: override GitHub API base (GitHub Enterprise)
|
||||||
GITHUB_API_BASE=
|
GITHUB_API_BASE=
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ Forge integration configuration
|
||||||
GitHub 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).
|
- 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).
|
- 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.
|
- 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
|
- The compose file passes these variables to the container. After editing .env, run: podman compose up -d github-integration
|
||||||
|
|
|
||||||
|
|
@ -320,13 +320,15 @@ services:
|
||||||
HTTP_ADDR: 0.0.0.0:8082
|
HTTP_ADDR: 0.0.0.0:8082
|
||||||
WEBHOOK_PATH: /webhooks/github
|
WEBHOOK_PATH: /webhooks/github
|
||||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET}
|
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET}
|
||||||
|
HOOKDECK_SIGNING_SECRET: ${HOOKDECK_SIGNING_SECRET}
|
||||||
GITHUB_APP_ID: ${GITHUB_APP_ID}
|
GITHUB_APP_ID: ${GITHUB_APP_ID}
|
||||||
GITHUB_APP_KEY_PATH: ${GITHUB_APP_KEY_PATH}
|
GITHUB_APP_KEY_PATH: /app/github-app-key.pem
|
||||||
GITHUB_APP_KEY: ${GITHUB_APP_KEY}
|
|
||||||
GITHUB_API_BASE: ${GITHUB_API_BASE:-https://api.github.com}
|
GITHUB_API_BASE: ${GITHUB_API_BASE:-https://api.github.com}
|
||||||
GITHUB_CHECK_NAME: ${GITHUB_CHECK_NAME:-Solstice CI}
|
GITHUB_CHECK_NAME: ${GITHUB_CHECK_NAME:-Solstice CI}
|
||||||
# URL where logs-service is exposed (used for check-run links)
|
# URL where logs-service is exposed (used for check-run links)
|
||||||
LOGS_BASE_URL: https://logs.${ENV}.${DOMAIN}
|
LOGS_BASE_URL: https://logs.${ENV}.${DOMAIN}
|
||||||
|
volumes:
|
||||||
|
- ${GITHUB_APP_KEY_PATH}:/app/github-app-key.pem
|
||||||
depends_on:
|
depends_on:
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue