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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-08 12:37:56 -04:00
parent 46d170468e
commit d838fda004
8 changed files with 544 additions and 2 deletions

4
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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 }

View file

@ -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,

View file

@ -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<Sha256>;
/// Verify a GitHub webhook HMAC-SHA256 signature.
///
/// `signature_header` is the value of the `X-Hub-Signature-256` header,
/// expected in the form `sha256=<hex-digest>`.
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<u8>,
}
async fn webhook_handler(
State(state): State<WebhookState>,
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<u8>) -> anyhow::Result<WebhookListener> {
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<u8>,
app_id: &str,
private_key_pem: &str,
) -> anyhow::Result<Self> {
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<String> {
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();
}
}

View file

@ -1,5 +1,6 @@
mod demo;
pub mod error;
pub mod github_webhooks;
pub mod jwt_auth;
pub mod serve;
pub mod server;

View file

@ -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<String> {
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 {

View file

@ -71,6 +71,17 @@ pub struct GitAuthorConfig {
pub email: Option<String>,
}
#[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<String>,
#[serde(default)]
pub author: GitAuthorConfig,
pub webhooks: Option<WebhookConfig>,
}
#[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\"";