mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-21 00:21:27 +00:00
202 lines
6 KiB
Rust
202 lines
6 KiB
Rust
use fabro_static::EnvVars;
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
use tokio::process::Command;
|
|
use tracing::{info, warn};
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
/// 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=<hex-digest>`.
|
|
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<Self> {
|
|
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<String> {
|
|
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<String> {
|
|
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())
|
|
);
|
|
}
|
|
}
|