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/migration/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" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
#[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<String>,
|
||||
hookdeck_signing_secret: Option<String>,
|
||||
github_api_base: String,
|
||||
app_id: Option<u64>,
|
||||
app_key_pem: Option<String>,
|
||||
|
|
@ -137,8 +141,6 @@ struct AppState {
|
|||
runs_on_map: std::collections::HashMap<String, String>, // key: owner/repo
|
||||
}
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[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::<PushPayload>(
|
||||
&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::<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,
|
||||
}
|
||||
}
|
||||
|
||||
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<SignatureCheck> {
|
||||
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<AppState>, 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<AppState>, 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<AppState>, body: Bytes) -> StatusCode {
|
|||
}
|
||||
}
|
||||
|
||||
async fn handle_pull_request(state: Arc<AppState>, 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<AppState>, payload: PrPayload) -> StatusCode {
|
||||
// Only act on opened/synchronize/reopened
|
||||
match payload.action.as_str() {
|
||||
"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)
|
||||
# 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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue