From d838fda004e5fd56edecd1eb73166a5cf8ff9d37 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 8 Mar 2026 12:37:56 -0400 Subject: [PATCH] Add GitHub webhook listener via Tailscale funnel Adds a webhook subsystem that receives GitHub App webhooks when configured in server.toml with [git.webhooks] strategy = "tailscale_funnel". On startup, it binds a local HTTP listener on a random port, exposes it via `tailscale funnel`, and patches the GitHub App webhook URL. Incoming webhooks are verified with HMAC-SHA256 before processing. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 4 + Cargo.toml | 3 + crates/arc-api/Cargo.toml | 6 +- crates/arc-api/src/demo/mod.rs | 1 + crates/arc-api/src/github_webhooks.rs | 439 ++++++++++++++++++++++++++ crates/arc-api/src/lib.rs | 1 + crates/arc-api/src/serve.rs | 54 +++- crates/arc-api/src/server_config.rs | 38 +++ 8 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 crates/arc-api/src/github_webhooks.rs diff --git a/Cargo.lock b/Cargo.lock index 2135e1075..8106519dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,10 +142,13 @@ dependencies = [ "arc-workflows", "axum", "base64", + "bytes", "chrono", "clap", "dirs", "futures-util", + "hex", + "hmac", "http-body-util", "hyper", "hyper-util", @@ -158,6 +161,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "sqlx", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d2a0496ee..d9d8c78b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,9 @@ indicatif = "0.18" termimad = "0.34" toml = "0.8" jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" openssh = "0.11" diff --git a/crates/arc-api/Cargo.toml b/crates/arc-api/Cargo.toml index f54ad9178..49e0fdc73 100644 --- a/crates/arc-api/Cargo.toml +++ b/crates/arc-api/Cargo.toml @@ -42,6 +42,11 @@ toml.workspace = true tracing.workspace = true ulid.workspace = true uuid.workspace = true +hmac.workspace = true +sha2.workspace = true +hex.workspace = true +reqwest.workspace = true +bytes = "1" [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } @@ -50,4 +55,3 @@ http-body-util = "0.1" tempfile = "3" openapiv3 = "2" serde_yaml = "0.9" -reqwest = { workspace = true } diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index a05e66b97..34a7c04d4 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -3062,6 +3062,7 @@ mod settings { client_id: Some("Iv1.abc123".into()), slug: Some("arc-dev".into()), author: Default::default(), + webhooks: None, }, feature_flags: FeatureFlags { session_sandboxes: false, diff --git a/crates/arc-api/src/github_webhooks.rs b/crates/arc-api/src/github_webhooks.rs new file mode 100644 index 000000000..879c30413 --- /dev/null +++ b/crates/arc-api/src/github_webhooks.rs @@ -0,0 +1,439 @@ +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::Router; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use tokio::net::TcpListener; +use tracing::{debug, error, info, warn}; + +type HmacSha256 = Hmac; + +/// Verify a GitHub webhook HMAC-SHA256 signature. +/// +/// `signature_header` is the value of the `X-Hub-Signature-256` header, +/// expected in the form `sha256=`. +pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool { + let hex_digest = match signature_header.strip_prefix("sha256=") { + Some(h) => h, + None => return false, + }; + + let expected = match hex::decode(hex_digest) { + Ok(b) => b, + Err(_) => return false, + }; + + let mut mac = match HmacSha256::new_from_slice(secret) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(body); + mac.verify_slice(&expected).is_ok() +} + +#[derive(Clone)] +struct WebhookState { + secret: Vec, +} + +async fn webhook_handler( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let signature = match headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()) + { + Some(s) => s, + None => { + let delivery_id = headers + .get("x-github-delivery") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + warn!(delivery = %delivery_id, "Webhook signature verification failed"); + return StatusCode::UNAUTHORIZED; + } + }; + + if !verify_signature(&state.secret, &body, signature) { + let delivery_id = headers + .get("x-github-delivery") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + warn!(delivery = %delivery_id, "Webhook signature verification failed"); + return StatusCode::UNAUTHORIZED; + } + + let event_type = headers + .get("x-github-event") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + let delivery_id = headers + .get("x-github-delivery") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + + let (repo, action) = parse_event_metadata(&body); + + debug!( + event = %event_type, + delivery = %delivery_id, + repo = %repo, + action = %action, + "Webhook received" + ); + + StatusCode::OK +} + +fn parse_event_metadata(body: &[u8]) -> (String, String) { + let parsed: serde_json::Value = serde_json::from_slice(body).unwrap_or_default(); + let repo = parsed + .get("repository") + .and_then(|r| r.get("full_name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + let action = parsed + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or("none") + .to_string(); + (repo, action) +} + +/// A running webhook listener that can be shut down. +pub struct WebhookListener { + port: u16, + shutdown_tx: tokio::sync::oneshot::Sender<()>, +} + +impl WebhookListener { + pub fn port(&self) -> u16 { + self.port + } + + pub fn shutdown(self) { + let _ = self.shutdown_tx.send(()); + info!("Webhook listener stopped"); + } +} + +/// Spawn the webhook HTTP listener on a random port (127.0.0.1 only). +pub async fn spawn_webhook_listener(secret: Vec) -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + + let state = WebhookState { secret }; + let router = Router::new() + .route("/webhooks/github", post(webhook_handler)) + .with_state(state); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .ok(); + }); + + info!(port = port, "Webhook listener started"); + + Ok(WebhookListener { port, shutdown_tx }) +} + +/// Manage the full webhook lifecycle: listener + tailscale funnel + GitHub API. +pub struct WebhookManager { + listener: WebhookListener, + funnel_port: u16, +} + +impl WebhookManager { + /// Start the webhook system: spawn listener, enable Tailscale funnel, + /// and update the GitHub App webhook URL. + pub async fn start( + secret: Vec, + app_id: &str, + private_key_pem: &str, + ) -> anyhow::Result { + let listener = spawn_webhook_listener(secret).await?; + let port = listener.port(); + + // Enable Tailscale funnel + let funnel_url = match enable_tailscale_funnel(port).await { + Ok(url) => url, + Err(err) => { + error!(error = %err, "Failed to enable Tailscale funnel"); + listener.shutdown(); + return Err(err); + } + }; + + info!(url = %funnel_url, "Tailscale funnel enabled"); + + // Update GitHub App webhook URL + let webhook_url = format!("{funnel_url}/webhooks/github"); + if let Err(err) = update_github_app_webhook(app_id, private_key_pem, &webhook_url).await { + error!(error = %err, "Failed to update GitHub App webhook URL"); + disable_tailscale_funnel(port).await; + listener.shutdown(); + return Err(err); + } + + info!(url = %webhook_url, "GitHub App webhook URL updated"); + + Ok(Self { + listener, + funnel_port: port, + }) + } + + /// Shut down: disable funnel, stop listener. + pub async fn shutdown(self) { + disable_tailscale_funnel(self.funnel_port).await; + self.listener.shutdown(); + } +} + +async fn enable_tailscale_funnel(port: u16) -> anyhow::Result { + let output = tokio::process::Command::new("tailscale") + .args(["funnel", &port.to_string()]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("tailscale funnel failed: {stderr}"); + } + + // Get the funnel URL from `tailscale funnel status` + let status_output = tokio::process::Command::new("tailscale") + .args(["funnel", "status"]) + .output() + .await?; + + let stdout = String::from_utf8_lossy(&status_output.stdout); + // Parse the HTTPS URL from status output — first line typically contains it + let url = stdout + .lines() + .find_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with("https://") { + // Strip trailing path/colon info + Some(trimmed.trim_end_matches('/').to_string()) + } else { + None + } + }) + .ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))?; + + Ok(url) +} + +async fn disable_tailscale_funnel(port: u16) { + match tokio::process::Command::new("tailscale") + .args(["funnel", "off", &port.to_string()]) + .output() + .await + { + Ok(output) if output.status.success() => { + info!("Tailscale funnel disabled"); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!(error = %stderr, "Failed to disable Tailscale funnel"); + } + Err(err) => { + warn!(error = %err, "Failed to disable Tailscale funnel"); + } + } +} + +async fn update_github_app_webhook( + app_id: &str, + private_key_pem: &str, + webhook_url: &str, +) -> anyhow::Result<()> { + let jwt = arc_workflows::github_app::sign_app_jwt(app_id, private_key_pem) + .map_err(|e| anyhow::anyhow!(e))?; + + let client = reqwest::Client::new(); + let body = serde_json::json!({ + "url": webhook_url, + "content_type": "json", + }); + + let resp = client + .patch("https://api.github.com/app/hook/config") + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .json(&body) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + anyhow::bail!("GitHub API returned {status}: {text}"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + // ----------------------------------------------------------------------- + // verify_signature + // ----------------------------------------------------------------------- + + fn compute_signature(secret: &[u8], body: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(secret).unwrap(); + mac.update(body); + let result = mac.finalize(); + format!("sha256={}", hex::encode(result.into_bytes())) + } + + #[test] + fn valid_signature() { + let secret = b"test-secret"; + let body = b"hello world"; + let sig = compute_signature(secret, body); + assert!(verify_signature(secret, body, &sig)); + } + + #[test] + fn wrong_signature() { + let secret = b"test-secret"; + let body = b"hello world"; + let sig = compute_signature(b"wrong-secret", body); + assert!(!verify_signature(secret, body, &sig)); + } + + #[test] + fn missing_sha256_prefix() { + let secret = b"test-secret"; + let body = b"hello world"; + let mut sig = compute_signature(secret, body); + sig = sig.replace("sha256=", ""); + assert!(!verify_signature(secret, body, &sig)); + } + + #[test] + fn empty_body_valid_signature() { + let secret = b"test-secret"; + let body = b""; + let sig = compute_signature(secret, body); + assert!(verify_signature(secret, body, &sig)); + } + + // ----------------------------------------------------------------------- + // webhook_handler + // ----------------------------------------------------------------------- + + fn build_test_router(secret: &[u8]) -> Router { + let state = WebhookState { + secret: secret.to_vec(), + }; + Router::new() + .route("/webhooks/github", post(webhook_handler)) + .with_state(state) + } + + #[tokio::test] + async fn rejects_missing_signature() { + let app = build_test_router(b"secret"); + let req = Request::builder() + .method("POST") + .uri("/webhooks/github") + .body(Body::from("{}")) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn rejects_bad_signature() { + let app = build_test_router(b"secret"); + let body = b"{}"; + let bad_sig = compute_signature(b"wrong", body); + + let req = Request::builder() + .method("POST") + .uri("/webhooks/github") + .header("x-hub-signature-256", bad_sig) + .body(Body::from(body.to_vec())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn accepts_valid_webhook() { + let secret = b"my-secret"; + let app = build_test_router(secret); + let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; + let sig = compute_signature(secret, body); + + let req = Request::builder() + .method("POST") + .uri("/webhooks/github") + .header("x-hub-signature-256", sig) + .header("x-github-event", "pull_request") + .header("x-github-delivery", "abc-123") + .body(Body::from(body.to_vec())) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + // ----------------------------------------------------------------------- + // spawn_webhook_listener + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn spawn_listener_serves_route() { + let secret = b"integration-secret"; + let listener = spawn_webhook_listener(secret.to_vec()).await.unwrap(); + let port = listener.port(); + + // Valid request should return 200 + let body = b"{}"; + let sig = compute_signature(secret, body); + + let client = reqwest::Client::new(); + let resp = client + .post(format!("http://127.0.0.1:{port}/webhooks/github")) + .header("x-hub-signature-256", sig) + .body(body.to_vec()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + + // Missing signature should return 401 + let resp = client + .post(format!("http://127.0.0.1:{port}/webhooks/github")) + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 401); + + listener.shutdown(); + } +} diff --git a/crates/arc-api/src/lib.rs b/crates/arc-api/src/lib.rs index 268160b60..978da93e0 100644 --- a/crates/arc-api/src/lib.rs +++ b/crates/arc-api/src/lib.rs @@ -1,5 +1,6 @@ mod demo; pub mod error; +pub mod github_webhooks; pub mod jwt_auth; pub mod serve; pub mod server; diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index 698ffd4c4..c50dce395 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -5,7 +5,7 @@ use std::time::Duration; use arc_llm::provider::Provider; use arc_util::terminal::Styles; use tokio::net::TcpListener; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use clap::Args; @@ -167,6 +167,41 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: eprintln!("{}", styles.dim.apply_to("(dry-run mode)")); } + // Optionally start webhook listener + let webhook_manager = { + let cfg = shared_config.read().expect("config lock poisoned"); + match (&cfg.git.webhooks, &cfg.git.app_id) { + (Some(_webhook_config), Some(app_id)) => { + let secret = std::env::var("GITHUB_APP_WEBHOOK_SECRET").ok(); + let private_key_pem = read_github_private_key(); + match (secret, private_key_pem) { + (Some(secret), Some(pem)) => { + let app_id = app_id.clone(); + drop(cfg); + match crate::github_webhooks::WebhookManager::start( + secret.into_bytes(), + &app_id, + &pem, + ) + .await + { + Ok(manager) => Some(manager), + Err(err) => { + error!(error = %err, "Failed to start webhook listener"); + None + } + } + } + _ => { + warn!("Webhook config present but GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_PRIVATE_KEY not set; skipping webhook listener"); + None + } + } + } + _ => None, + } + }; + // Spawn config polling task let config_for_poll = Arc::clone(&shared_config); let config_path_for_poll = config_path.clone(); @@ -214,6 +249,11 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: axum::serve(listener, router).await?; } + // Clean up webhook listener on shutdown + if let Some(manager) = webhook_manager { + manager.shutdown().await; + } + Ok(()) } @@ -264,6 +304,18 @@ fn resolve_model_provider( (model, provider_enum) } +/// Read the GitHub App private key from the environment, decoding base64 if needed. +fn read_github_private_key() -> Option { + let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?; + if raw.starts_with("-----") { + Some(raw) + } else { + let pem_bytes = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &raw).ok()?; + String::from_utf8(pem_bytes).ok() + } +} + /// Derive client certificate verification mode from the resolved auth strategies. fn client_auth_from_mode(auth_mode: &AuthMode) -> ClientAuth { let strategies = match auth_mode { diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index 297eb17e9..65cab07b9 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -71,6 +71,17 @@ pub struct GitAuthorConfig { pub email: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookStrategy { + TailscaleFunnel, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub struct WebhookConfig { + pub strategy: WebhookStrategy, +} + #[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] pub struct GitConfig { #[serde(default)] @@ -80,6 +91,7 @@ pub struct GitConfig { pub slug: Option, #[serde(default)] pub author: GitAuthorConfig, + pub webhooks: Option, } #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] @@ -510,6 +522,32 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**"] assert!(config.run_defaults.checkpoint.exclude_globs.is_empty()); } + #[test] + fn parse_git_webhooks_config() { + let toml = r#" +[git] +provider = "github" +app_id = "2993730" + +[git.webhooks] +strategy = "tailscale_funnel" +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + let webhooks = config.git.webhooks.unwrap(); + assert_eq!(webhooks.strategy, WebhookStrategy::TailscaleFunnel); + } + + #[test] + fn parse_git_webhooks_missing_is_none() { + let toml = r#" +[git] +provider = "github" +app_id = "123" +"#; + let config: ServerConfig = toml::from_str(toml).unwrap(); + assert!(config.git.webhooks.is_none()); + } + #[test] fn parse_log_config() { let toml = "[log]\nlevel = \"trace\"";