diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 0ce3714c6..bd444ed98 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -211,7 +211,7 @@ Customize the git author identity used for checkpoint commits. When not set, def ### `[server.integrations.github]` section -Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow, browser OAuth, and webhooks. +Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow and browser OAuth; webhook delivery is configured separately under `[server.integrations.github.webhooks]`. ```toml title="settings.toml" [server.integrations.github] @@ -227,11 +227,20 @@ app_id = "123456" client_id = "Iv1.abc123" slug = "fabro-app" +[server.api] +url = "https://fabro-api.example.com" + [server.integrations.github.webhooks] -strategy = "tailscale_funnel" +strategy = "server_url" ``` -When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable. +Fabro always serves the GitHub webhook handler at `POST /api/v1/webhooks/github` when `GITHUB_APP_WEBHOOK_SECRET` is configured. The `strategy` field controls how Fabro exposes that route and whether it mutates the GitHub App webhook URL on startup: + +- `strategy = "server_url"`: recommended for production or any deployment with a stable public API URL. Fabro sets the GitHub App webhook URL to `/api/v1/webhooks/github` on startup. Requires `server.api.url` and `GITHUB_APP_WEBHOOK_SECRET`. +- `strategy = "tailscale_funnel"`: opt-in for Tailscale-hosted machines without a stable public URL. Fabro runs `tailscale funnel `, exposes the main server on that Funnel URL, and best-effort updates the GitHub App webhook URL to `/api/v1/webhooks/github`. Requires a TCP listener and `GITHUB_APP_WEBHOOK_SECRET`. +- `strategy` unset: Fabro still accepts signed webhook deliveries on `/api/v1/webhooks/github` when the secret is present, but it does not run `tailscale funnel` and does not update the GitHub App webhook URL. + +Incoming webhooks are authenticated only by GitHub's `X-Hub-Signature-256` HMAC signature, not by Fabro's bearer/session auth. ### `[run.checkpoint]` section diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 1b9cb3a1f..e3edd6bea 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -9,6 +9,8 @@ tags: description: API discovery and health - name: Install description: First-run browser install workflow + - name: Integrations + description: External provider callbacks and integration endpoints - name: Runs description: Run management operations - name: Human-in-the-Loop @@ -374,6 +376,26 @@ paths: schema: type: object + /api/v1/webhooks/github: + post: + operationId: receiveGithubWebhook + tags: [Integrations] + summary: Receive GitHub Webhook + description: Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: string + format: binary + responses: + "200": + description: Webhook accepted + "401": + description: Missing or invalid webhook signature + /api/v1/user: get: operationId: getUser diff --git a/docs/changelog/2026-04-18.mdx b/docs/changelog/2026-04-18.mdx index caf21e39a..f9acfedf2 100644 --- a/docs/changelog/2026-04-18.mdx +++ b/docs/changelog/2026-04-18.mdx @@ -19,6 +19,14 @@ The published Docker image switched to [Docker Hardened Images](https://www.dock A new `docker-compose.prod.yaml` stands up a Caddy 2 sidecar that handles auto-HTTPS on ports 80/443 and proxies to the Fabro service. Set `FABRO_DOMAIN` to your domain and Caddy provisions and renews the certificate; certs persist in a named volume. The base `docker-compose.yaml` has moved to the repo root. +## GitHub webhook exposure is now explicit + +GitHub App webhook exposure no longer auto-enables just because `[server.integrations.github.webhooks]` exists. The webhook handler now lives on the main API router at `POST /api/v1/webhooks/github`, and operators must choose an explicit strategy if they want Fabro to mutate any external state on startup. + +- Breaking: if you previously relied on Fabro silently starting `tailscale funnel` and rewriting the GitHub App webhook URL, add `strategy = "tailscale_funnel"` under `[server.integrations.github.webhooks]` to restore that behavior. +- New: `strategy = "server_url"` tells Fabro to use `server.api.url` and set the GitHub App webhook URL to `/api/v1/webhooks/github` on startup. This is the recommended choice for stable production deployments behind HTTPS. +- Unset `strategy`: Fabro still verifies and serves signed GitHub webhooks when `GITHUB_APP_WEBHOOK_SECRET` is present, but it will not run `tailscale funnel` and it will not rewrite the GitHub App webhook URL. + ## More diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index 201a29d72..cb9297732 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -6,7 +6,7 @@ description: "Integrate Fabro with GitHub for repository access and OAuth login" Fabro supports two GitHub integration strategies: - `token` — the default for local and individual use. Fabro captures `gh auth token` during `fabro install`, stores it as `GITHUB_TOKEN`, and uses that token directly for repo access, pull requests, and sandbox `GITHUB_TOKEN` injection. -- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, and enables browser OAuth and webhooks. +- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, enables browser OAuth, and supports webhook delivery when you configure a [webhook strategy](#webhook-delivery-strategies). `token` changes GitHub integration auth only. It does not provide browser sign-in, so the embedded web UI is disabled when `strategy = "token"`. @@ -19,11 +19,11 @@ Fabro supports two GitHub integration strategies: | Sandbox `GITHUB_TOKEN` | Direct token | Scoped installation token | | Browser sign-in | No | Yes | | Web UI routes | Disabled | Enabled | -| Webhooks | No | Yes | +| Webhooks | No | Strategy-dependent | ## GitHub App mode -The rest of this page describes the `app` strategy, which is required for browser auth and webhooks. +The rest of this page describes the `app` strategy, which is required for browser auth and for any webhook delivery strategy. | Feature | How it's used | |---|---| @@ -125,6 +125,30 @@ Fabro stores the GitHub App secrets in `/server.env` under these keys: The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`). +### Webhook delivery strategies + +Fabro receives GitHub webhooks on `POST /api/v1/webhooks/github` whenever `GITHUB_APP_WEBHOOK_SECRET` is configured. The webhook `strategy` controls how that route becomes reachable from GitHub: + +```toml title="settings.toml" +[server.integrations.github] +strategy = "app" +app_id = "123456" +client_id = "Iv1.abc123def" +slug = "fabro-a3f2" + +[server.api] +url = "https://fabro-api.example.com" + +[server.integrations.github.webhooks] +strategy = "server_url" +``` + +- `server_url`: recommended for production. Fabro assumes `server.api.url` is already publicly reachable and best-effort updates the GitHub App webhook URL to `/api/v1/webhooks/github` each time `fabro server start` runs. +- `tailscale_funnel`: opt-in for machines reachable through Tailscale but not through a stable public URL. Fabro runs `tailscale funnel` against the main server port and best-effort updates the GitHub App webhook URL to the resulting Funnel origin. +- Unset `strategy`: Fabro still serves the webhook route if the secret is present, but it does not expose the route for you and does not mutate the GitHub App webhook URL. + +`tailscale_funnel` has host-wide side effects: it changes Tailscale Funnel state on the server and rewrites the GitHub App webhook URL on startup. Use `server_url` when you already have HTTPS and a stable hostname. + ### Reconfigure GitHub after install To switch GitHub strategies or re-register the app without re-running the full install wizard, use `fabro install github`: diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index befb71ea7..4084f8f9a 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -1,15 +1,16 @@ use fabro_types::settings::InterpString; use fabro_types::settings::server::{ - DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksLayer, - IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, ObjectStoreProvider, - ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, ServerApiSettings, - ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthLayer, - ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, ServerIntegrationsSettings, - ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings, - ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, ServerListenSettings, - ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, ServerSlateDbLayer, - ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, ServerWebLayer, - ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, + DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, + IntegrationWebhooksLayer, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, + ObjectStoreProvider, ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, + ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, + ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, + ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, + ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, + ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, + ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, + ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, + WebhookStrategy, }; use fabro_util::Home; @@ -25,6 +26,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se let integrations = resolve_integrations(layer.integrations.as_ref(), errors); validate_ip_allowlist_for_listen(&listen, &ip_allowlist, errors); validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors); + validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors); ServerSettings { listen, @@ -294,6 +296,40 @@ fn validate_github_webhook_ip_allowlist_for_listen( } } +fn validate_github_webhook_strategy( + integrations: &ServerIntegrationsSettings, + api_layer: Option<&ServerApiLayer>, + errors: &mut Vec, +) { + let github = &integrations.github; + let strategy = github + .webhooks + .as_ref() + .and_then(|webhooks| webhooks.strategy); + + if strategy.is_some() + && github.strategy == GithubIntegrationStrategy::App + && github.app_id.is_none() + { + errors.push(ResolveError::Invalid { + path: "server.integrations.github.app_id".to_string(), + reason: "must be set when server.integrations.github.webhooks.strategy is configured" + .to_string(), + }); + } + + if matches!(strategy, Some(WebhookStrategy::ServerUrl)) + && api_layer.and_then(|api| api.url.as_ref()).is_none() + { + errors.push(ResolveError::Invalid { + path: "server.api.url".to_string(), + reason: + "must be set when server.integrations.github.webhooks.strategy = \"server_url\"" + .to_string(), + }); + } +} + fn resolve_artifacts( layer: Option<&ServerArtifactsLayer>, storage_root: &InterpString, diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index b5d55abac..72a670574 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -298,6 +298,56 @@ trusted_proxy_count = 3 assert_eq!(webhook_allowlist.trusted_proxy_count, Some(3)); } +#[test] +fn rejects_server_url_webhook_strategy_without_server_api_url() { + let file = parse( + r#" +_version = 1 + +[server.integrations.github] +strategy = "app" + +[server.integrations.github.webhooks] +strategy = "server_url" +"#, + ); + + let errors = fabro_config::resolve_server_from_file(&file) + .expect_err("server_url webhook strategy should require server.api.url"); + let rendered = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + assert!(rendered.contains("server.api.url")); +} + +#[test] +fn rejects_configured_webhook_strategy_without_github_app_id() { + let file = parse( + r#" +_version = 1 + +[server.integrations.github] +strategy = "app" + +[server.integrations.github.webhooks] +strategy = "tailscale_funnel" +"#, + ); + + let errors = fabro_config::resolve_server_from_file(&file) + .expect_err("configured webhook strategy should require server.integrations.github.app_id"); + let rendered = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + assert!(rendered.contains("server.integrations.github.app_id")); +} + #[test] fn rejects_invalid_ip_allowlist_entry() { let file = parse( diff --git a/lib/crates/fabro-server/src/github_webhooks.rs b/lib/crates/fabro-server/src/github_webhooks.rs index 97c7fb471..640944b02 100644 --- a/lib/crates/fabro-server/src/github_webhooks.rs +++ b/lib/crates/fabro-server/src/github_webhooks.rs @@ -1,19 +1,7 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use axum::body::Bytes; -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; -use axum::routing::post; -use axum::{Router, middleware}; use hmac::{Hmac, Mac}; use sha2::Sha256; -use tokio::net::TcpListener; use tokio::process::Command; -use tokio::sync::oneshot; -use tracing::{debug, error, info, warn}; - -use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; +use tracing::{info, warn}; type HmacSha256 = Hmac; @@ -21,7 +9,7 @@ type HmacSha256 = Hmac; /// /// `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 { +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; }; @@ -37,60 +25,7 @@ pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> b 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 delivery_id = headers - .get("x-github-delivery") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown"); - - let Some(signature) = headers - .get("x-hub-signature-256") - .and_then(|v| v.to_str().ok()) - else { - warn!(delivery = %delivery_id, "Webhook signature verification failed"); - return StatusCode::UNAUTHORIZED; - }; - - if !verify_signature(&state.secret, &body, signature) { - 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"); - - if tracing::enabled!(tracing::Level::DEBUG) { - let (repo, action) = parse_event_metadata(&body); - debug!( - event = %event_type, - delivery = %delivery_id, - repo = %repo, - action = %action, - "Webhook received" - ); - } else { - info!( - event = %event_type, - delivery = %delivery_id, - "Webhook received" - ); - } - - StatusCode::OK -} - -fn parse_event_metadata(body: &[u8]) -> (String, String) { +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") @@ -106,106 +41,41 @@ fn parse_event_metadata(body: &[u8]) -> (String, String) { (repo, action) } -/// A running webhook listener that can be shut down. -pub struct WebhookListener { - port: u16, - shutdown_tx: oneshot::Sender<()>, +/// Manages `tailscale funnel` lifecycle for the main Fabro server port. +pub struct TailscaleFunnelManager { + port: u16, } -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, - ip_allowlist: Arc, -) -> 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) - .layer(middleware::from_fn_with_state( - ip_allowlist, - ip_allowlist_middleware, - )); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - - tokio::spawn(async move { - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .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, -} - -impl WebhookManager { - /// Start the webhook system: spawn listener, enable Tailscale funnel, - /// and update the GitHub App webhook URL. +impl TailscaleFunnelManager { pub async fn start( - secret: Vec, + main_server_port: u16, app_id: &str, private_key_pem: &str, - ip_allowlist: Arc, ) -> anyhow::Result { - let listener = spawn_webhook_listener(secret, ip_allowlist).await?; - let port = listener.port(); + let funnel_url = enable_tailscale_funnel(main_server_port).await?; + info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled"); - // 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); + let webhook_url = format!("{funnel_url}/api/v1/webhooks/github"); + match update_github_app_webhook(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" + ); } - }; - - 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 }) + Ok(Self { + port: main_server_port, + }) } - /// Shut down: disable funnel, stop listener. pub async fn shutdown(self) { - disable_tailscale_funnel(self.listener.port()).await; - self.listener.shutdown(); + disable_tailscale_funnel(self.port).await; } } @@ -220,28 +90,26 @@ async fn enable_tailscale_funnel(port: u16) -> anyhow::Result { anyhow::bail!("tailscale funnel failed: {stderr}"); } - // Get the funnel URL from `tailscale funnel status` 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 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}"))?; + parse_tailscale_funnel_url(&stdout) + .ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}")) +} - Ok(url) +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) { @@ -251,19 +119,19 @@ async fn disable_tailscale_funnel(port: u16) { .await { Ok(output) if output.status.success() => { - info!("Tailscale funnel disabled"); + info!(port, "Tailscale funnel disabled"); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); - warn!(error = %stderr, "Failed to disable Tailscale funnel"); + warn!(port, error = %stderr, "Failed to disable Tailscale funnel"); } Err(err) => { - warn!(error = %err, "Failed to disable Tailscale funnel"); + warn!(port, error = %err, "Failed to disable Tailscale funnel"); } } } -async fn update_github_app_webhook( +pub(crate) async fn update_github_app_webhook( app_id: &str, private_key_pem: &str, webhook_url: &str, @@ -297,27 +165,7 @@ async fn update_github_app_webhook( #[cfg(test)] mod tests { - use axum::body::Body; - use axum::http::Request; - use tower::ServiceExt; - use super::*; - use crate::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; - - fn test_http_client() -> fabro_http::HttpClient { - fabro_http::test_http_client().unwrap() - } - - fn empty_allowlist_config() -> Arc { - Arc::new(IpAllowlistConfig { - allowlist: IpAllowlist::default(), - trusted_proxy_count: 0, - }) - } - - // ----------------------------------------------------------------------- - // verify_signature - // ----------------------------------------------------------------------- fn compute_signature(secret: &[u8], body: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); @@ -359,138 +207,21 @@ mod tests { 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) - .layer(middleware::from_fn_with_state( - empty_allowlist_config(), - ip_allowlist_middleware, - )) + #[test] + fn parse_event_metadata_defaults_missing_fields() { + assert_eq!( + parse_event_metadata(br#"{"repository":{}}"#), + ("unknown".to_string(), "none".to_string(),) + ); } - #[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(); + #[test] + fn parse_tailscale_funnel_url_extracts_https_origin() { + let status = "https://fabro.example.ts.net proxy http://127.0.0.1:32276"; - 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(), empty_allowlist_config()) - .await - .unwrap(); - let port = listener.port(); - - // Valid request should return 200 - let body = b"{}"; - let sig = compute_signature(secret, body); - - let client = test_http_client(); - 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(); - } - - #[tokio::test] - async fn spawn_listener_blocks_non_allowlisted_ip() { - let secret = b"integration-secret"; - let listener = spawn_webhook_listener( - secret.to_vec(), - Arc::new(IpAllowlistConfig { - allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), - trusted_proxy_count: 0, - }), - ) - .await - .unwrap(); - let port = listener.port(); - - let body = b"{}"; - let sig = compute_signature(secret, body); - - let client = test_http_client(); - 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(), 403); - - listener.shutdown(); + assert_eq!( + parse_tailscale_funnel_url(status), + Some("https://fabro.example.ts.net".to_string()) + ); } } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index c962c17da..234c50cbd 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -9,7 +9,9 @@ use fabro_config::merge::combine_files; use fabro_config::user::load_settings_config; use fabro_config::{Storage, resolve_server_from_file}; use fabro_sandbox::SandboxProvider; -use fabro_types::settings::server::{GithubIntegrationStrategy, ServerLayer, ServerListenLayer}; +use fabro_types::settings::server::{ + GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy, +}; use fabro_types::settings::{ InterpString, ObjectStoreSettings, ServerListenSettings, ServerSettings as ResolvedServerSettings, SettingsLayer, @@ -25,7 +27,7 @@ use tokio::time::interval; use tracing::{error, info, warn}; use crate::bind::{self, Bind, BindRequest}; -use crate::github_webhooks::WebhookManager; +use crate::github_webhooks::{TailscaleFunnelManager, update_github_app_webhook}; use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config}; use crate::jwt_auth::resolve_auth_mode_with_lookup; use crate::server::{ @@ -163,6 +165,20 @@ async fn resolve_github_webhook_ip_allowlist( Ok(Arc::new(config)) } +async fn resolve_startup_github_webhook_ip_allowlist( + resolved_server_settings: &ResolvedServerSettings, + github_meta_resolver: &GitHubMetaResolver, + webhook_secret_present: bool, +) -> anyhow::Result>> { + if !webhook_secret_present { + return Ok(None); + } + + resolve_github_webhook_ip_allowlist(resolved_server_settings, github_meta_resolver) + .await + .map(Some) +} + fn use_in_memory_store() -> bool { !matches!( std::env::var(TEST_IN_MEMORY_STORE_ENV).ok().as_deref(), @@ -335,6 +351,7 @@ where let vault_path = storage.secrets_path(); let server_env_path = storage.server_state().env_path(); let server_secrets = ServerSecrets::load(server_env_path.clone())?; + let webhook_secret_present = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET").is_some(); // Shared config for live reloading let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir); @@ -400,67 +417,143 @@ where .await .context("resolving server IP allowlist")?, ); + let github_webhook_ip_allowlist = resolve_startup_github_webhook_ip_allowlist( + &resolved_server_settings, + &github_meta_resolver, + webhook_secret_present, + ) + .await?; let router = build_router_with_options( Arc::clone(&state), auth_mode, Arc::clone(&default_ip_allowlist), - RouterOptions { web_enabled }, + RouterOptions { + web_enabled, + github_webhook_ip_allowlist, + }, ); + let bound_listener = bind_listener(&bind_request).await?; + let bind_addr = bound_listener.bind.clone(); - // Optionally start webhook listener - let webhook_manager = match resolved_server_settings.integrations.github.strategy { - GithubIntegrationStrategy::Token => None, - GithubIntegrationStrategy::App => { + let webhook_manager = match resolved_server_settings + .integrations + .github + .webhooks + .as_ref() + .and_then(|webhooks| webhooks.strategy) + { + None => None, + Some(_) + if resolved_server_settings.integrations.github.strategy + != GithubIntegrationStrategy::App => + { + warn!( + "GitHub webhook strategy is configured but GitHub integration auth is not set to app; skipping webhook startup" + ); + None + } + Some(strategy) => { let webhook_app_id = resolved_server_settings .integrations .github - .webhooks + .app_id .as_ref() - .and(resolved_server_settings.integrations.github.app_id.as_ref()) .map(resolve_interp) .transpose()?; match webhook_app_id { Some(app_id) => { - let secret = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET"); let github_app = state .github_credentials(&resolved_server_settings.integrations.github) .unwrap_or_else(|err| { warn!( error = %err, - "Webhook config present but GitHub credentials are invalid; skipping webhook listener" + "Webhook strategy is configured but GitHub credentials are invalid; skipping webhook startup" ); None }); - if let (Some(secret), Some(fabro_github::GitHubCredentials::App(github_app))) = - (secret, github_app) - { - let webhook_ip_allowlist = resolve_github_webhook_ip_allowlist( - &resolved_server_settings, - &github_meta_resolver, - ) - .await?; - match WebhookManager::start( - secret.into_bytes(), - &app_id, - &github_app.private_key_pem, - webhook_ip_allowlist, - ) - .await - { - Ok(manager) => Some(manager), - Err(err) => { - error!(error = %err, "Failed to start webhook listener"); - None + if webhook_secret_present { + if let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app { + match strategy { + WebhookStrategy::TailscaleFunnel => { + match bound_tcp_port(&bind_addr) { + Some(port) => match TailscaleFunnelManager::start( + port, + &app_id, + &github_app.private_key_pem, + ) + .await + { + Ok(manager) => Some(manager), + Err(err) => { + error!( + error = %err, + "Failed to start Tailscale funnel for GitHub webhooks" + ); + None + } + }, + None => { + warn!( + "GitHub webhook strategy tailscale_funnel requires a TCP server listen address; skipping webhook startup" + ); + None + } + } + } + WebhookStrategy::ServerUrl => { + let server_api_url = resolved_server_settings + .api + .url + .as_ref() + .map(resolve_interp) + .transpose()? + .expect( + "server.api.url should be validated when server_url strategy is configured", + ); + let webhook_url = format!( + "{}/api/v1/webhooks/github", + server_api_url.trim_end_matches('/') + ); + if let Err(err) = update_github_app_webhook( + &app_id, + &github_app.private_key_pem, + &webhook_url, + ) + .await + { + warn!( + error = %err, + url = %webhook_url, + "Failed to update GitHub App webhook URL" + ); + } else { + info!( + url = %webhook_url, + "GitHub App webhook URL updated" + ); + } + None + } } + } else { + warn!( + "Webhook strategy is configured but GITHUB_APP_PRIVATE_KEY is not available; skipping webhook startup" + ); + None } } else { warn!( - "Webhook config present but GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_PRIVATE_KEY not set; skipping webhook listener" + "Webhook strategy is configured but GITHUB_APP_WEBHOOK_SECRET is not set; skipping webhook startup" ); None } } - None => None, + None => { + warn!( + "Webhook strategy is configured but server.integrations.github.app_id is not set; skipping webhook startup" + ); + None + } } } }; @@ -517,8 +610,6 @@ where } }); - let bound_listener = bind_listener(&bind_request).await?; - let bind_addr = bound_listener.bind.clone(); if bound_listener.used_random_port_fallback { if let BindRequest::TcpHost(host) = bind_request { warn!( @@ -600,6 +691,13 @@ enum BoundListener { Tcp(TcpListener), } +fn bound_tcp_port(bind: &Bind) -> Option { + match bind { + Bind::Tcp(address) => Some(address.port()), + Bind::Unix(_) => None, + } +} + async fn bind_listener(requested: &BindRequest) -> anyhow::Result { match requested { BindRequest::Unix(path) => { @@ -748,7 +846,8 @@ mod tests { GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings, bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_slatedb_store, resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist, - resolve_server_settings, router_web_enabled, server_bind_title, server_title, + resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled, + server_bind_title, server_title, }; use crate::bind::{Bind, BindRequest}; @@ -1080,4 +1179,41 @@ entries = ["github_meta_hooks"] assert!(error.to_string().contains("GitHub webhook IP allowlist")); } + + #[tokio::test] + async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() { + let settings = resolve_server_settings(&parse_settings( + r#" +_version = 1 + +[server.listen] +type = "tcp" +address = "127.0.0.1:0" + +[server.integrations.github] +strategy = "app" +app_id = "123" + +[server.integrations.github.webhooks.ip_allowlist] +entries = ["github_meta_hooks"] +"#, + )) + .expect("settings should resolve"); + + let cache_dir = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let resolver = GitHubMetaResolver::new( + fabro_http::test_http_client().unwrap(), + format!("http://127.0.0.1:{port}/meta"), + cache_dir.path().join("github-meta.json"), + ); + + let allowlist = resolve_startup_github_webhook_ip_allowlist(&settings, &resolver, false) + .await + .expect("inactive webhook route should skip GitHub meta resolution"); + + assert!(allowlist.is_none()); + } } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index bc93b6bdc..704f338ff 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -111,6 +111,7 @@ use ulid::Ulid; use crate::bind::Bind; use crate::error::ApiError; +use crate::github_webhooks::{parse_event_metadata, verify_signature}; use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; use crate::jwt_auth::{ AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts, @@ -922,14 +923,18 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { ) } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct RouterOptions { - pub web_enabled: bool, + pub web_enabled: bool, + pub github_webhook_ip_allowlist: Option>, } impl Default for RouterOptions { fn default() -> Self { - Self { web_enabled: true } + Self { + web_enabled: true, + github_webhook_ip_allowlist: None, + } } } @@ -945,8 +950,15 @@ pub fn build_router_with_options( options: RouterOptions, ) -> Router { start_optional_slack_service(&state); + let web_enabled = options.web_enabled; + let webhook_ip_allowlist = options.github_webhook_ip_allowlist; let middleware_state = Arc::clone(&state); - let api_common = if options.web_enabled { + let webhook_state = Arc::clone(&state); + let default_webhook_ip_allowlist = Arc::clone(&ip_allowlist_config); + let webhook_secret_present = webhook_state + .server_secret("GITHUB_APP_WEBHOOK_SECRET") + .is_some(); + let api_common = if web_enabled { Router::new() .route("/openapi.json", get(openapi_spec)) .merge(web_auth::api_routes()) @@ -960,7 +972,7 @@ pub fn build_router_with_options( .with_state(state.clone()); let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes())); - if options.web_enabled { + if web_enabled { real_router = real_router.nest("/auth", web_auth::routes()); } let real_router = real_router @@ -971,7 +983,7 @@ pub fn build_router_with_options( let demo = demo_router.clone(); let real = real_router.clone(); async move { - if options.web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") { + if web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") { demo.oneshot(req).await } else { real.oneshot(req).await @@ -1000,7 +1012,7 @@ pub fn build_router_with_options( }, ); - let mut router = Router::new() + let mut app_router = Router::new() .route("/health", get(health)) .fallback_service(service_fn(move |req: axum_extract::Request| { let dispatch = dispatch.clone(); @@ -1008,14 +1020,12 @@ pub fn build_router_with_options( let path = req.uri().path().to_string(); let dispatch_path = path.starts_with("/api/") || path == "/health" - || (options.web_enabled && path.starts_with("/auth/")); + || (web_enabled && path.starts_with("/auth/")); if dispatch_path { dispatch.oneshot(req).await - } else if options.web_enabled && removed_web_route(&path) { + } else if web_enabled && removed_web_route(&path) { Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response()) - } else if options.web_enabled - && matches!(req.method(), &Method::GET | &Method::HEAD) - { + } else if web_enabled && matches!(req.method(), &Method::GET | &Method::HEAD) { let headers = req.headers().clone(); Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers).await) } else { @@ -1024,23 +1034,40 @@ pub fn build_router_with_options( } })); - if options.web_enabled { - router = router.layer(middleware::from_fn_with_state( + if web_enabled { + app_router = app_router.layer(middleware::from_fn_with_state( middleware_state, cookie_and_demo_middleware, )); } - router = router.layer(middleware::from_fn_with_state( + app_router = app_router.layer(middleware::from_fn_with_state( ip_allowlist_config, ip_allowlist_middleware, )); + let mut router = app_router; + if webhook_secret_present { + let webhook_ip_allowlist = webhook_ip_allowlist.unwrap_or(default_webhook_ip_allowlist); + router = github_webhook_routes(webhook_ip_allowlist) + .with_state(webhook_state) + .merge(router); + } + router .layer(middleware::from_fn(security_headers::layer)) .layer(trace_layer) } +fn github_webhook_routes(ip_allowlist_config: Arc) -> Router> { + Router::new() + .route("/api/v1/webhooks/github", post(github_webhook)) + .layer(middleware::from_fn_with_state( + ip_allowlist_config, + ip_allowlist_middleware, + )) +} + fn demo_routes() -> Router> { Router::new() .route("/runs", get(demo::list_runs).post(demo::create_run_stub)) @@ -1211,6 +1238,62 @@ async fn not_implemented() -> Response { ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response() } +async fn github_webhook( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let delivery_id = headers + .get("x-github-delivery") + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown"); + + let Some(secret) = state.server_secret("GITHUB_APP_WEBHOOK_SECRET") else { + warn!( + delivery = %delivery_id, + "GitHub webhook route reached without a configured webhook secret" + ); + return StatusCode::NOT_FOUND; + }; + + let Some(signature) = headers + .get("x-hub-signature-256") + .and_then(|value| value.to_str().ok()) + else { + warn!(delivery = %delivery_id, "Webhook signature verification failed"); + return StatusCode::UNAUTHORIZED; + }; + + if !verify_signature(secret.as_bytes(), &body, signature) { + warn!(delivery = %delivery_id, "Webhook signature verification failed"); + return StatusCode::UNAUTHORIZED; + } + + let event_type = headers + .get("x-github-event") + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown"); + + if tracing::enabled!(tracing::Level::DEBUG) { + let (repo, action) = parse_event_metadata(&body); + debug!( + event = %event_type, + delivery = %delivery_id, + repo = %repo, + action = %action, + "Webhook received" + ); + } else { + info!( + event = %event_type, + delivery = %delivery_id, + "Webhook received" + ); + } + + StatusCode::OK +} + async fn health() -> Response { Json(serde_json::json!({ "status": "ok", @@ -7307,7 +7390,9 @@ mod tests { use fabro_model::Provider; use fabro_types::settings::ServerAuthMethod; use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures}; + use hmac::{Hmac, Mac}; use serde_json::json; + use sha2::Sha256; use tower::ServiceExt; use super::*; @@ -7319,6 +7404,13 @@ mod tests { exit [shape=Msquare] start -> exit }"#; + const TEST_WEBHOOK_SECRET: &str = "webhook-secret"; + const TEST_DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + const WRONG_DEV_TOKEN: &str = + "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + + type TestHmacSha256 = Hmac; fn test_app_with() -> Router { let state = create_app_state(); @@ -7339,6 +7431,28 @@ mod tests { format!("/api/v1{path}") } + fn webhook_signature(secret: &str, body: &[u8]) -> String { + let mut mac = TestHmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) + } + + fn webhook_test_app(auth_mode: AuthMode) -> Router { + let secret = TEST_WEBHOOK_SECRET.to_string(); + let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| { + (name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone()) + }); + build_router_with_options( + state, + auth_mode, + Arc::new(IpAllowlistConfig::default()), + RouterOptions { + web_enabled: false, + ..RouterOptions::default() + }, + ) + } + #[tokio::test] async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() { let state = create_app_state(); @@ -7403,6 +7517,79 @@ type = "http" )]); } + #[tokio::test] + async fn github_webhook_rejects_missing_or_invalid_signatures() { + let app = webhook_test_app(AuthMode::Disabled); + let body = br#"{"action":"opened"}"#; + + let missing_signature = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_signature.status(), StatusCode::UNAUTHORIZED); + + let invalid_signature = app + .oneshot( + Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .header( + "x-hub-signature-256", + webhook_signature("wrong-secret", body), + ) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_signature.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn github_webhook_accepts_valid_signatures_regardless_of_auth_mode() { + let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; + let signature = webhook_signature(TEST_WEBHOOK_SECRET, body); + + for auth_mode in [ + AuthMode::Disabled, + AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::DevToken], + dev_token: Some(TEST_DEV_TOKEN.to_string()), + }), + ] { + let app = webhook_test_app(auth_mode); + + for authorization in [ + None, + Some(format!("Bearer {WRONG_DEV_TOKEN}")), + Some(format!("Bearer {TEST_DEV_TOKEN}")), + ] { + let mut request = Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .header("x-hub-signature-256", signature.clone()) + .header("x-github-event", "pull_request") + .body(Body::from(body.to_vec())) + .unwrap(); + if let Some(value) = authorization.as_deref() { + request + .headers_mut() + .insert(header::AUTHORIZATION, value.parse().unwrap()); + } + + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + } + } + #[tokio::test] async fn create_secret_stores_valid_credential_entries() { let state = create_app_state(); diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs index b694c8d72..346bbd5ac 100644 --- a/lib/crates/fabro-server/tests/it/api/docs.rs +++ b/lib/crates/fabro-server/tests/it/api/docs.rs @@ -58,3 +58,22 @@ fn changelog_marks_removed_mutual_tls_as_historical() { "historical changelog should clarify that inbound mutual TLS is no longer supported" ); } + +#[test] +fn github_docs_describe_webhooks_as_strategy_dependent() { + let github = read_doc("docs/integrations/github.mdx"); + assert!( + !github.contains("enables browser OAuth and webhooks"), + "GitHub integration docs should not imply app auth alone enables webhook delivery" + ); + assert!( + !github.contains("| Webhooks | No | Yes |"), + "GitHub strategy matrix should describe webhook delivery as strategy-dependent" + ); + + let server_configuration = read_doc("docs/administration/server-configuration.mdx"); + assert!( + !server_configuration.contains("enables the GitHub App flow, browser OAuth, and webhooks"), + "server configuration docs should not imply app auth alone enables webhook delivery" + ); +} diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index f628d0343..c5ca35b42 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -310,7 +310,10 @@ enabled = false create_app_state_with_options(settings, 5), AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), - RouterOptions { web_enabled: false }, + RouterOptions { + web_enabled: false, + ..RouterOptions::default() + }, ); for (method, path, body) in [ @@ -369,7 +372,10 @@ enabled = false create_app_state_with_options(settings, 5), AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), - RouterOptions { web_enabled: false }, + RouterOptions { + web_enabled: false, + ..RouterOptions::default() + }, ); let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index b9280eef7..a40aedaba 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -536,4 +536,5 @@ pub enum GithubIntegrationStrategy { #[serde(rename_all = "snake_case")] pub enum WebhookStrategy { TailscaleFunnel, + ServerUrl, } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 42b42198a..2f458c063 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -5,6 +5,7 @@ api/discovery-api.ts api/human-in-the-loop-api.ts api/insights-api.ts api/install-api.ts +api/integrations-api.ts api/models-api.ts api/repos-api.ts api/run-internals-api.ts diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index b12f77e9b..b38b6c603 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -20,6 +20,7 @@ export * from './api/discovery-api'; export * from './api/human-in-the-loop-api'; export * from './api/insights-api'; export * from './api/install-api'; +export * from './api/integrations-api'; export * from './api/models-api'; export * from './api/repos-api'; export * from './api/run-internals-api'; diff --git a/lib/packages/fabro-api-client/src/api/integrations-api.ts b/lib/packages/fabro-api-client/src/api/integrations-api.ts new file mode 100644 index 000000000..a0e68aa35 --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/integrations-api.ts @@ -0,0 +1,122 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +/** + * IntegrationsApi - axios parameter creator + */ +export const IntegrationsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + receiveGithubWebhook: async (body: File, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('receiveGithubWebhook', 'body', body) + const localVarPath = `/api/v1/webhooks/github`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * IntegrationsApi - functional programming interface + */ +export const IntegrationsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = IntegrationsApiAxiosParamCreator(configuration) + return { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.receiveGithubWebhook(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['IntegrationsApi.receiveGithubWebhook']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * IntegrationsApi - factory interface + */ +export const IntegrationsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = IntegrationsApiFp(configuration) + return { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.receiveGithubWebhook(body, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * IntegrationsApi - object-oriented interface + */ +export class IntegrationsApi extends BaseAPI { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig) { + return IntegrationsApiFp(this.configuration).receiveGithubWebhook(body, options).then((request) => request(this.axios, this.basePath)); + } +} +