use fabro_static::EnvVars; use hmac::{Hmac, Mac}; use sha2::Sha256; use tokio::process::Command; use tracing::{info, warn}; type HmacSha256 = Hmac; /// Name of the server secret holding the GitHub App webhook HMAC key. pub(crate) const WEBHOOK_SECRET_ENV: &str = EnvVars::GITHUB_APP_WEBHOOK_SECRET; /// Route path where Fabro receives GitHub App webhook deliveries. pub(crate) const WEBHOOK_ROUTE: &str = "/api/v1/webhooks/github"; /// 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(crate) fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool { let Some(hex_digest) = signature_header.strip_prefix("sha256=") else { return false; }; let Ok(expected) = hex::decode(hex_digest) else { return false; }; let Ok(mut mac) = HmacSha256::new_from_slice(secret) else { return false; }; mac.update(body); mac.verify_slice(&expected).is_ok() } pub(crate) 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) } /// Manages `tailscale funnel` lifecycle for the main Fabro server port. pub struct TailscaleFunnelManager { port: u16, } impl TailscaleFunnelManager { pub async fn start( main_server_port: u16, app_id: &str, private_key_pem: &str, ) -> anyhow::Result { let funnel_url = enable_tailscale_funnel(main_server_port).await?; info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled"); let webhook_url = format!("{funnel_url}{WEBHOOK_ROUTE}"); match fabro_github::update_app_webhook_config(app_id, private_key_pem, &webhook_url).await { Ok(()) => { info!(url = %webhook_url, "GitHub App webhook URL updated"); } Err(err) => { warn!( error = %err, url = %webhook_url, "Failed to update GitHub App webhook URL" ); } } Ok(Self { port: main_server_port, }) } pub async fn shutdown(self) { disable_tailscale_funnel(self.port).await; } } async fn enable_tailscale_funnel(port: u16) -> anyhow::Result { let output = 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}"); } let status_output = Command::new("tailscale") .args(["funnel", "status"]) .output() .await?; if !status_output.status.success() { let stderr = String::from_utf8_lossy(&status_output.stderr); anyhow::bail!("tailscale funnel status failed: {stderr}"); } let stdout = String::from_utf8_lossy(&status_output.stdout); parse_tailscale_funnel_url(&stdout) .ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}")) } fn parse_tailscale_funnel_url(status_output: &str) -> Option { status_output.lines().find_map(|line| { line.split_whitespace() .find(|part| part.starts_with("https://")) .map(|url| url.trim_end_matches('/').to_string()) }) } async fn disable_tailscale_funnel(port: u16) { match Command::new("tailscale") .args(["funnel", "off", &port.to_string()]) .output() .await { Ok(output) if output.status.success() => { info!(port, "Tailscale funnel disabled"); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); warn!(port, error = %stderr, "Failed to disable Tailscale funnel"); } Err(err) => { warn!(port, error = %err, "Failed to disable Tailscale funnel"); } } } #[cfg(test)] pub(crate) fn compute_signature(secret: &[u8], body: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); mac.update(body); format!("sha256={}", hex::encode(mac.finalize().into_bytes())) } #[cfg(test)] mod tests { use super::*; #[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)); } #[test] fn parse_event_metadata_defaults_missing_fields() { assert_eq!( parse_event_metadata(br#"{"repository":{}}"#), ("unknown".to_string(), "none".to_string(),) ); } #[test] fn parse_tailscale_funnel_url_extracts_https_origin() { let status = "https://fabro.example.ts.net proxy http://127.0.0.1:32276"; assert_eq!( parse_tailscale_funnel_url(status), Some("https://fabro.example.ts.net".to_string()) ); } }