diff --git a/Cargo.lock b/Cargo.lock index 4282ebe30e..4310f13626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -339,9 +348,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -837,6 +846,28 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "az" version = "1.3.0" @@ -3725,6 +3756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", + "tokio", ] [[package]] @@ -4375,7 +4407,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" -version = "1.24.0" +version = "1.25.0" dependencies = [ "proc-macro2", "quote", @@ -4490,7 +4522,9 @@ name = "goose-server" version = "1.25.0" dependencies = [ "anyhow", + "aws-lc-rs", "axum 0.8.8", + "axum-server", "base64 0.22.1", "bytes", "chrono", @@ -4506,6 +4540,7 @@ dependencies = [ "http 1.4.0", "once_cell", "rand 0.9.2", + "rcgen", "reqwest 0.13.2", "rmcp 0.16.0", "rustls 0.23.36", @@ -7831,6 +7866,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "realfft" version = "3.5.0" @@ -12561,6 +12609,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index d724852dea..6899474c60 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -47,10 +47,13 @@ rand = "0.9.2" hex = "0.4.3" socket2 = "0.6.1" fs2 = { workspace = true } -rustls = { version = "0.23", features = ["ring"] } +rustls = { version = "0.23", features = ["aws_lc_rs"] } uuid = { workspace = true } once_cell = { workspace = true } dirs = { workspace = true } +rcgen = "0.13" +axum-server = { version = "0.8.0", features = ["tls-rustls"] } +aws-lc-rs = "1.16.0" [target.'cfg(windows)'.dependencies] winreg = { version = "0.55.0" } diff --git a/crates/goose-server/src/auth.rs b/crates/goose-server/src/auth.rs index f902f21167..41790a9351 100644 --- a/crates/goose-server/src/auth.rs +++ b/crates/goose-server/src/auth.rs @@ -13,6 +13,7 @@ pub async fn check_token( if request.uri().path() == "/status" || request.uri().path() == "/mcp-ui-proxy" || request.uri().path() == "/mcp-app-proxy" + || request.uri().path() == "/mcp-app-guest" { return Ok(next.run(request).await); } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index e5bc0fb975..87eec07f4e 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -2,11 +2,12 @@ use crate::configuration; use crate::state; use anyhow::Result; use axum::middleware; +use axum_server::Handle; use goose_server::auth::check_token; +use goose_server::tls::self_signed_config; use tower_http::cors::{Any, CorsLayer}; use tracing::info; -// Graceful shutdown signal #[cfg(unix)] async fn shutdown_signal() { use tokio::signal::unix::{signal, SignalKind}; @@ -53,8 +54,17 @@ pub async fn run() -> Result<()> { )) .layer(cors); - let listener = tokio::net::TcpListener::bind(settings.socket_addr()).await?; - info!("listening on {}", listener.local_addr()?); + let addr = settings.socket_addr(); + let tls_setup = self_signed_config().await?; + + let handle = Handle::new(); + let shutdown_handle = handle.clone(); + tokio::spawn(async move { + shutdown_signal().await; + shutdown_handle.graceful_shutdown(None); + }); + + info!("listening on https://{}", addr); let tunnel_manager = app_state.tunnel_manager.clone(); tokio::spawn(async move { @@ -66,8 +76,9 @@ pub async fn run() -> Result<()> { gateway_manager.check_auto_start().await; }); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) + axum_server::bind_rustls(addr, tls_setup.config) + .handle(handle) + .serve(app.into_make_service()) .await?; if goose::otel::otlp::is_otlp_initialized() { diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs index aa5ae94f41..4403ef7f85 100644 --- a/crates/goose-server/src/lib.rs +++ b/crates/goose-server/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod openapi; pub mod routes; pub mod state; +pub mod tls; pub mod tunnel; // Re-export commonly used items diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index 6fc0a26635..81afbff621 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -848,6 +848,16 @@ async fn update_working_dir( Ok(StatusCode::OK) } +async fn ensure_extensions_loaded(state: &AppState, session_id: &str) { + if let Some(_results) = state.take_extension_loading_task(session_id).await { + tracing::debug!( + "Awaited background extension loading for session {} before serving request", + session_id + ); + state.remove_extension_loading_task(session_id).await; + } +} + #[utoipa::path( post, path = "/agent/read_resource", @@ -866,6 +876,8 @@ async fn read_resource( ) -> Result, StatusCode> { use rmcp::model::ResourceContents; + ensure_extensions_loaded(&state, &payload.session_id).await; + let agent = state .get_agent_for_route(payload.session_id.clone()) .await?; @@ -879,7 +891,16 @@ async fn read_resource( CancellationToken::default(), ) .await - .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + tracing::error!( + "read_resource failed for session={}, uri={}, extension={}: {:?}", + payload.session_id, + payload.uri, + payload.extension_name, + e + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; let content = read_result .contents @@ -936,6 +957,8 @@ async fn call_tool( State(state): State>, Json(payload): Json, ) -> Result, StatusCode> { + ensure_extensions_loaded(&state, &payload.session_id).await; + let agent = state .get_agent_for_route(payload.session_id.clone()) .await?; diff --git a/crates/goose-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs index 7ff045c205..a2c5d1576b 100644 --- a/crates/goose-server/src/routes/mcp_app_proxy.rs +++ b/crates/goose-server/src/routes/mcp_app_proxy.rs @@ -2,10 +2,23 @@ use axum::{ extract::Query, http::{header, StatusCode}, response::{Html, IntoResponse, Response}, - routing::get, - Router, + routing::{get, post}, + Json, Router, }; use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; +use uuid::Uuid; + +const GUEST_HTML_TTL_SECS: u64 = 300; // 5 minutes +const GUEST_HTML_MAX_ENTRIES: usize = 64; + +/// In-memory store for guest HTML content. +/// Maps nonce -> (html_content, csp_string, created_at) +/// Entries are consumed on first read and evicted after TTL. +type GuestHtmlStore = Arc>>; #[derive(Deserialize)] struct ProxyQuery { @@ -18,6 +31,22 @@ struct ProxyQuery { frame_domains: Option, /// Comma-separated list of allowed base URIs (base-uri) base_uri_domains: Option, + /// Comma-separated list of domains for script-src (external scripts like SDKs) + script_domains: Option, +} + +#[derive(Deserialize)] +struct GuestQuery { + secret: String, + nonce: String, +} + +#[derive(Deserialize)] +struct StoreGuestBody { + secret: String, + html: String, + /// CSP string to apply to the guest page + csp: Option, } const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html"); @@ -35,6 +64,7 @@ fn build_outer_csp( resource_domains: &[String], frame_domains: &[String], base_uri_domains: &[String], + script_domains: &[String], ) -> String { let resources = if resource_domains.is_empty() { String::new() @@ -42,16 +72,23 @@ fn build_outer_csp( format!(" {}", resource_domains.join(" ")) }; + let scripts = if script_domains.is_empty() { + String::new() + } else { + format!(" {}", script_domains.join(" ")) + }; + let connections = if connect_domains.is_empty() { String::new() } else { format!(" {}", connect_domains.join(" ")) }; + // frame-src needs 'self' so the proxy can load the guest iframe from /mcp-app-guest let frame_src = if frame_domains.is_empty() { - "frame-src 'none'".to_string() + "frame-src 'self'".to_string() } else { - format!("frame-src {}", frame_domains.join(" ")) + format!("frame-src 'self' {}", frame_domains.join(" ")) }; let base_uris = if base_uri_domains.is_empty() { @@ -62,8 +99,8 @@ fn build_outer_csp( format!( "default-src 'none'; \ - script-src 'self' 'unsafe-inline'{resources}; \ - script-src-elem 'self' 'unsafe-inline'{resources}; \ + script-src 'self' 'unsafe-inline'{resources}{scripts}; \ + script-src-elem 'self' 'unsafe-inline'{resources}{scripts}; \ style-src 'self' 'unsafe-inline'{resources}; \ style-src-elem 'self' 'unsafe-inline'{resources}; \ connect-src 'self'{connections}; \ @@ -88,6 +125,12 @@ fn parse_domains(domains: Option<&String>) -> Vec { .unwrap_or_default() } +#[derive(Clone)] +struct AppState { + secret_key: String, + guest_store: GuestHtmlStore, +} + #[utoipa::path( get, path = "/mcp-app-proxy", @@ -96,7 +139,8 @@ fn parse_domains(domains: Option<&String>) -> Vec { ("connect_domains" = Option, Query, description = "Comma-separated domains for connect-src"), ("resource_domains" = Option, Query, description = "Comma-separated domains for resource loading"), ("frame_domains" = Option, Query, description = "Comma-separated origins for nested iframes (frame-src)"), - ("base_uri_domains" = Option, Query, description = "Comma-separated allowed base URIs (base-uri)") + ("base_uri_domains" = Option, Query, description = "Comma-separated allowed base URIs (base-uri)"), + ("script_domains" = Option, Query, description = "Comma-separated domains for script-src") ), responses( (status = 200, description = "MCP App proxy HTML page", content_type = "text/html"), @@ -104,10 +148,10 @@ fn parse_domains(domains: Option<&String>) -> Vec { ) )] async fn mcp_app_proxy( - axum::extract::State(secret_key): axum::extract::State, + axum::extract::State(state): axum::extract::State, Query(params): Query, ) -> Response { - if params.secret != secret_key { + if params.secret != state.secret_key { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } @@ -116,6 +160,7 @@ async fn mcp_app_proxy( let resource_domains = parse_domains(params.resource_domains.as_ref()); let frame_domains = parse_domains(params.frame_domains.as_ref()); let base_uri_domains = parse_domains(params.base_uri_domains.as_ref()); + let script_domains = parse_domains(params.script_domains.as_ref()); // Build the outer CSP based on declared domains let csp = build_outer_csp( @@ -123,6 +168,7 @@ async fn mcp_app_proxy( &resource_domains, &frame_domains, &base_uri_domains, + &script_domains, ); // Replace the CSP placeholder in the HTML template @@ -141,8 +187,98 @@ async fn mcp_app_proxy( .into_response() } +/// Store guest HTML and return a nonce for retrieval. +/// The proxy page calls this via fetch, then sets the guest iframe src to /mcp-app-guest?nonce=... +async fn store_guest_html( + axum::extract::State(state): axum::extract::State, + Json(body): Json, +) -> Response { + if body.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + let nonce = Uuid::new_v4().to_string(); + let csp = body.csp.unwrap_or_default(); + + { + let mut store = state.guest_store.write().await; + + // Evict expired entries + let cutoff = Instant::now() - std::time::Duration::from_secs(GUEST_HTML_TTL_SECS); + store.retain(|_, (_, _, created)| *created > cutoff); + + // If still at capacity, drop the oldest entry + if store.len() >= GUEST_HTML_MAX_ENTRIES { + if let Some(oldest_key) = store + .iter() + .min_by_key(|(_, (_, _, created))| *created) + .map(|(k, _)| k.clone()) + { + store.remove(&oldest_key); + } + } + + store.insert(nonce.clone(), (body.html, csp, Instant::now())); + } + + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json")], + format!(r#"{{"nonce":"{}"}}"#, nonce), + ) + .into_response() +} + +/// Serve stored guest HTML with a real HTTPS URL. +/// This gives the guest iframe `window.location.protocol === "https:"`, +/// which is required by SDKs like Square Web Payments that check for secure context. +async fn serve_guest_html( + axum::extract::State(state): axum::extract::State, + Query(params): Query, +) -> Response { + if params.secret != state.secret_key { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + // Consume the entry (one-time use) + let entry = { + let mut store = state.guest_store.write().await; + store.remove(¶ms.nonce) + }; + + match entry { + Some((html, csp, _created)) => { + let mut response = Html(html).into_response(); + let headers = response.headers_mut(); + // Use strict-origin so third-party SDKs (e.g. Square Web Payments) + // receive the origin in their requests, which they need for auth. + // no-referrer would cause 401s from SDK servers. + headers.insert( + header::HeaderName::from_static("referrer-policy"), + "strict-origin".parse().unwrap(), + ); + if !csp.is_empty() { + headers.insert(header::CONTENT_SECURITY_POLICY, csp.parse().unwrap()); + } + response + } + None => ( + StatusCode::NOT_FOUND, + "Guest content not found or already consumed", + ) + .into_response(), + } +} + pub fn routes(secret_key: String) -> Router { + let state = AppState { + secret_key, + guest_store: Arc::new(RwLock::new(HashMap::new())), + }; + Router::new() .route("/mcp-app-proxy", get(mcp_app_proxy)) - .with_state(secret_key) + .route("/mcp-app-guest", get(serve_guest_html)) + .route("/mcp-app-guest", post(store_guest_html)) + .with_state(state) } diff --git a/crates/goose-server/src/routes/templates/mcp_app_proxy.html b/crates/goose-server/src/routes/templates/mcp_app_proxy.html index cea3318e8f..8a15e99a52 100644 --- a/crates/goose-server/src/routes/templates/mcp_app_proxy.html +++ b/crates/goose-server/src/routes/templates/mcp_app_proxy.html @@ -34,7 +34,42 @@ let guestIframe = null; - function createGuestIframe(html, permissions) { + /** + * Extract the secret and base URL from the current page URL. + */ + function getProxyParams() { + var params = new URLSearchParams(window.location.search); + return { + secret: params.get('secret') || '', + baseUrl: window.location.origin + }; + } + + /** + * Store guest HTML on the server and get a nonce for retrieval. + * This allows the guest iframe to load from a real HTTPS URL + * instead of srcdoc (which has about: protocol). + */ + async function storeGuestHtml(html) { + var proxyParams = getProxyParams(); + var response = await fetch(proxyParams.baseUrl + '/mcp-app-guest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + secret: proxyParams.secret, + html: html + }) + }); + + if (!response.ok) { + throw new Error('Failed to store guest HTML: ' + response.status); + } + + var data = await response.json(); + return data.nonce; + } + + async function createGuestIframe(html, permissions) { if (guestIframe) { guestIframe.remove(); } @@ -43,7 +78,7 @@ // Sandbox permissions for the Guest UI // allow-scripts: needed for the app to run - // allow-same-origin: needed for localStorage, cookies, etc. + // allow-same-origin: needed for localStorage, cookies, and real URL context // allow-forms: needed if the app has forms guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); @@ -58,9 +93,22 @@ guestIframe.setAttribute('allow', allowList.join('; ')); } - guestIframe.srcdoc = html; guestIframe.style.cssText = 'width:100%; height:100%; border:none;'; + // Store the HTML server-side and load via real URL. + // This gives the guest iframe a real https:// URL instead of about:srcdoc, + // which is required by SDKs (like Square Web Payments) that check + // window.location.protocol for secure context verification. + try { + var nonce = await storeGuestHtml(html); + var proxyParams = getProxyParams(); + guestIframe.src = proxyParams.baseUrl + '/mcp-app-guest?secret=' + encodeURIComponent(proxyParams.secret) + '&nonce=' + encodeURIComponent(nonce); + } catch (e) { + // Fallback to srcdoc if the store endpoint is not available + console.warn('Failed to use /mcp-app-guest endpoint, falling back to srcdoc:', e); + guestIframe.srcdoc = html; + } + document .body .appendChild(guestIframe); diff --git a/crates/goose-server/src/tls.rs b/crates/goose-server/src/tls.rs new file mode 100644 index 0000000000..8a5cf86daf --- /dev/null +++ b/crates/goose-server/src/tls.rs @@ -0,0 +1,52 @@ +use anyhow::Result; +use aws_lc_rs::digest; +use axum_server::tls_rustls::RustlsConfig; +use rcgen::{CertificateParams, DnType, KeyPair, SanType}; + +pub struct TlsSetup { + pub config: RustlsConfig, + pub fingerprint: String, +} + +/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and +/// return a [`TlsSetup`] containing the rustls config and the SHA-256 +/// fingerprint of the generated certificate (colon-separated hex). +/// +/// The fingerprint is printed to stdout so the parent process (e.g. Electron) +/// can pin it and reject connections from any other certificate. +pub async fn self_signed_config() -> Result { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::CommonName, "goosed localhost"); + params.subject_alt_names = vec![ + SanType::IpAddress(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + SanType::DnsName("localhost".try_into()?), + ]; + + let key_pair = KeyPair::generate()?; + let cert = params.self_signed(&key_pair)?; + + let cert_der = cert.der(); + let sha256 = digest::digest(&digest::SHA256, cert_der); + let fingerprint = sha256 + .as_ref() + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":"); + + println!("GOOSED_CERT_FINGERPRINT={fingerprint}"); + + let cert_pem = cert.pem(); + let key_pem = key_pair.serialize_pem(); + + let config = RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()).await?; + + Ok(TlsSetup { + config, + fingerprint, + }) +} diff --git a/crates/goose-server/src/tunnel/lapstone.rs b/crates/goose-server/src/tunnel/lapstone.rs index c824a39afd..94c6d5d8ad 100644 --- a/crates/goose-server/src/tunnel/lapstone.rs +++ b/crates/goose-server/src/tunnel/lapstone.rs @@ -13,6 +13,15 @@ use tokio_tungstenite::{connect_async, tungstenite::Message}; use tracing::{error, info, warn}; use url::Url; +/// Shared state for proxying tunnel requests to the local goosed server. +#[derive(Clone)] +struct ProxyContext { + port: u16, + tunnel_secret: String, + server_secret: String, + http_client: reqwest::Client, +} + /// Constant-time comparison using hash to prevent timing attacks fn secure_compare(a: &str, b: &str) -> bool { use std::collections::hash_map::DefaultHasher; @@ -253,38 +262,42 @@ async fn handle_chunked_response( async fn handle_request( message: TunnelMessage, - port: u16, + ctx: ProxyContext, ws_tx: WebSocketSender, - tunnel_secret: String, - server_secret: String, + scheme: &str, ) -> Result<()> { let request_id = message.request_id.clone(); + let client = &ctx.http_client; - let client = reqwest::Client::new(); - let url = format!("http://127.0.0.1:{}{}", port, message.path); + let url = format!("{}://127.0.0.1:{}{}", scheme, ctx.port, message.path); - let request_builder = - match validate_and_build_request(&client, &url, &message, &tunnel_secret, &server_secret) { - Ok(builder) => builder, - Err(e) => { - error!("✗ Authentication error [{}]: {}", request_id, e); - let error_response = TunnelResponse { - request_id, - status: 401, - headers: None, - body: None, - error: Some(e.to_string()), - chunk_index: None, - total_chunks: None, - is_chunked: false, - is_streaming: false, - is_first_chunk: false, - is_last_chunk: false, - }; - send_response(ws_tx, error_response).await?; - return Ok(()); - } - }; + let request_builder = match validate_and_build_request( + client, + &url, + &message, + &ctx.tunnel_secret, + &ctx.server_secret, + ) { + Ok(builder) => builder, + Err(e) => { + error!("✗ Authentication error [{}]: {}", request_id, e); + let error_response = TunnelResponse { + request_id, + status: 401, + headers: None, + body: None, + error: Some(e.to_string()), + chunk_index: None, + total_chunks: None, + is_chunked: false, + is_streaming: false, + is_first_chunk: false, + is_last_chunk: false, + }; + send_response(ws_tx, error_response).await?; + return Ok(()); + } + }; let response = match request_builder.send().await { Ok(resp) => resp, @@ -399,11 +412,10 @@ async fn handle_websocket_messages( >, >, ws_tx: WebSocketSender, - port: u16, - tunnel_secret: String, - server_secret: String, + ctx: ProxyContext, last_activity: Arc>, active_tasks: Arc>>>, + scheme: String, ) { while let Some(msg) = read.next().await { match msg { @@ -413,17 +425,12 @@ async fn handle_websocket_messages( match serde_json::from_str::(&text) { Ok(tunnel_msg) => { let ws_tx_clone = ws_tx.clone(); - let tunnel_secret_clone = tunnel_secret.clone(); - let server_secret_clone = server_secret.clone(); + let ctx_clone = ctx.clone(); + let scheme_clone = scheme.clone(); let task = tokio::spawn(async move { - if let Err(e) = handle_request( - tunnel_msg, - port, - ws_tx_clone, - tunnel_secret_clone, - server_secret_clone, - ) - .await + if let Err(e) = + handle_request(tunnel_msg, ctx_clone, ws_tx_clone, &scheme_clone) + .await { error!("Error handling request: {}", e); } @@ -475,9 +482,10 @@ async fn run_single_connection( agent_id: String, tunnel_secret: String, server_secret: String, + scheme: String, restart_tx: mpsc::Sender<()>, ) { - let _ = rustls::crypto::ring::default_provider().install_default(); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let worker_url = get_worker_url(); let ws_url = worker_url @@ -514,10 +522,25 @@ async fn run_single_connection( }; info!("✓ Connected as agent: {}", agent_id); - info!("✓ Proxying to: http://127.0.0.1:{}", port); + info!("✓ Proxying to: {}://127.0.0.1:{}", scheme, port); let public_url = format!("{}/tunnel/{}", worker_url, agent_id); info!("✓ Public URL: {}", public_url); + let mut client_builder = reqwest::Client::builder(); + if scheme == "https" { + client_builder = client_builder.danger_accept_invalid_certs(true); + } + let http_client = client_builder + .build() + .expect("failed to build reqwest client"); + + let ctx = ProxyContext { + port, + tunnel_secret, + server_secret, + http_client, + }; + let (write, read) = ws_stream.split(); let ws_tx: WebSocketSender = Arc::new(RwLock::new(Some(write))); let last_activity = Arc::new(RwLock::new(Instant::now())); @@ -545,11 +568,10 @@ async fn run_single_connection( _ = handle_websocket_messages( read, ws_tx.clone(), - port, - tunnel_secret.clone(), - server_secret.clone(), + ctx, last_activity, - active_tasks.clone() + active_tasks.clone(), + scheme, ) => { info!("✗ Connection ended"); } @@ -565,6 +587,7 @@ pub async fn start( tunnel_secret: String, server_secret: String, agent_id: String, + scheme: &str, handle: Arc>>>, restart_tx: mpsc::Sender<()>, ) -> Result { @@ -573,6 +596,7 @@ pub async fn start( let agent_id_clone = agent_id.clone(); let tunnel_secret_clone = tunnel_secret.clone(); let server_secret_clone = server_secret; + let scheme = scheme.to_string(); let task = tokio::spawn(async move { run_single_connection( @@ -580,6 +604,7 @@ pub async fn start( agent_id_clone, tunnel_secret_clone, server_secret_clone, + scheme, restart_tx, ) .await; diff --git a/crates/goose-server/src/tunnel/lapstone_test.rs b/crates/goose-server/src/tunnel/lapstone_test.rs index c3a87d7948..77cb270c5b 100644 --- a/crates/goose-server/src/tunnel/lapstone_test.rs +++ b/crates/goose-server/src/tunnel/lapstone_test.rs @@ -88,6 +88,7 @@ async fn test_tunnel_end_to_end() { tunnel_secret.clone(), server_secret.clone(), agent_id.clone(), + "http", handle.clone(), restart_tx, ) @@ -138,6 +139,7 @@ async fn test_tunnel_post_request() { tunnel_secret.clone(), server_secret.clone(), agent_id.clone(), + "http", handle.clone(), restart_tx, ) diff --git a/crates/goose-server/src/tunnel/mod.rs b/crates/goose-server/src/tunnel/mod.rs index 67089249a8..2cbb29e3e5 100644 --- a/crates/goose-server/src/tunnel/mod.rs +++ b/crates/goose-server/src/tunnel/mod.rs @@ -229,6 +229,7 @@ impl TunnelManager { tunnel_secret, server_secret, agent_id, + "https", self.lapstone_handle.clone(), restart_tx, ) diff --git a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx index 2e65254029..5e56d80abf 100644 --- a/ui/desktop/src/components/settings/app/AppSettingsSection.tsx +++ b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx @@ -335,7 +335,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti {/* Navigation Settings */} - diff --git a/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx b/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx index a5a59733c9..ff94435451 100644 --- a/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx +++ b/ui/desktop/src/components/settings/gateways/GatewaySettingsSection.tsx @@ -2,13 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Button } from '../../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Input } from '../../ui/input'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '../../ui/dialog'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../ui/dialog'; import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react'; import { getApiUrl } from '../../../config'; @@ -138,9 +132,15 @@ export default function GatewaySettingsSection() { - doPost('/gateway/start', { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, 'Failed to start') + doPost( + '/gateway/start', + { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, + 'Failed to start' + ) + } + onRestart={() => + doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start') } - onRestart={() => doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')} onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')} onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')} onGenerateCode={async () => { @@ -238,7 +238,11 @@ function TelegramGatewayCard({ const wrap = (fn: () => Promise) => async () => { setBusy(true); - try { await fn(); } finally { setBusy(false); } + try { + await fn(); + } finally { + setBusy(false); + } }; const handleFirstStart = wrap(async () => { @@ -310,9 +314,11 @@ function TelegramGatewayCard({ > @BotFather - - {' '}on your phone, send /newbot, and follow - the prompts to name your bot. BotFather will reply with an API token — paste it below. + {' '} + on your phone, send{' '} + /newbot, and follow + the prompts to name your bot. BotFather will reply with an API token — paste it + below.

@@ -399,8 +405,8 @@ function PairingCodeModal({

- Send this code to your{' '} - {gatewayType} bot to pair. + Send this code to your {gatewayType} bot + to pair.

diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 85ae04b9c7..adcbc63735 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -168,6 +168,7 @@ export interface GoosedResult { errorLog: string[]; cleanup: () => Promise; client: Client; + certFingerprint: string | null; } const goosedClientForUrlAndSecret = (url: string, secret: string): Client => { @@ -209,12 +210,13 @@ export const startGoosed = async (options: StartGoosedOptions): Promise = { ...process.env, @@ -292,8 +295,34 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { - logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`); + let certFingerprint: string | null = null; + const fingerprintReady = new Promise((resolve) => { + const FINGERPRINT_PREFIX = 'GOOSED_CERT_FINGERPRINT='; + let resolved = false; + + goosedProcess.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${text}`); + + if (!resolved && text.includes(FINGERPRINT_PREFIX)) { + for (const line of text.split('\n')) { + if (line.startsWith(FINGERPRINT_PREFIX)) { + certFingerprint = line.slice(FINGERPRINT_PREFIX.length).trim(); + logger.info(`Pinned cert fingerprint: ${certFingerprint}`); + resolved = true; + resolve(certFingerprint); + break; + } + } + } + }); + + goosedProcess.on('exit', () => { + if (!resolved) { + resolved = true; + resolve(null); + } + }); }); goosedProcess.stderr?.on('data', (data: Buffer) => { @@ -354,6 +383,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise b.toString(16).padStart(2, '0')) + .join(':') + .toUpperCase(); + } + return fp.toUpperCase(); +} + +// Renderer requests: pin to the exact cert goosed generated once known. +// Before the fingerprint is available (during the health-check bootstrap +// window) any localhost cert is accepted so the server can come up. +app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => { + const parsed = new URL(url); + if (!isLocalhost(parsed.hostname)) { + callback(false); + return; + } + if (pinnedCertFingerprint) { + const match = + normalizeFingerprint(certificate.fingerprint) === pinnedCertFingerprint.toUpperCase(); + event.preventDefault(); + callback(match); + } else { + event.preventDefault(); + callback(true); + } +}); + +// Main-process net.fetch: pin to the exact cert goosed generated. +app.whenReady().then(() => { + session.defaultSession.setCertificateVerifyProc((request, callback) => { + if (!isLocalhost(request.hostname)) { + callback(-3); + return; + } + if (!pinnedCertFingerprint) { + callback(0); + return; + } + const match = + normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase(); + callback(match ? 0 : -3); + }); +}); + if (process.env.ENABLE_PLAYWRIGHT) { const debugPort = process.env.PLAYWRIGHT_DEBUG_PORT || '9222'; console.log(`[Main] Enabling Playwright remote debugging on port ${debugPort}`); @@ -449,7 +512,7 @@ let appConfig = { GOOSE_DEFAULT_PROVIDER: defaultProvider, GOOSE_DEFAULT_MODEL: defaultModel, GOOSE_PREDEFINED_MODELS: predefinedModels, - GOOSE_API_HOST: 'http://127.0.0.1', + GOOSE_API_HOST: 'https://localhost', GOOSE_WORKING_DIR: '', // If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true', @@ -498,18 +561,18 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { logger: log, }); + // Pin the certificate fingerprint so the cert handlers above only accept + // the exact cert that *this* goosed instance generated. + if (goosedResult.certFingerprint) { + pinnedCertFingerprint = goosedResult.certFingerprint; + } + app.on('will-quit', async () => { log.info('App quitting, terminating goosed server'); await goosedResult.cleanup(); }); - const { - baseUrl, - workingDir, - process: goosedProcess, - errorLog, - client: goosedClient, - } = goosedResult; + const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult; const mainWindowState = windowStateKeeper({ defaultWidth: 940, @@ -563,6 +626,18 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { .catch((err) => log.info('failed to install react dev tools:', err)); } + // Re-create the client with Electron's net.fetch so requests to the local + // self-signed HTTPS server go through the session's certificate handling. + const goosedClient = createClient( + createConfig({ + baseUrl, + fetch: net.fetch as unknown as typeof globalThis.fetch, + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': serverSecret, + }, + }) + ); goosedClients.set(mainWindow.id, goosedClient); const serverReady = await checkServerStatus(goosedClient, errorLog); @@ -1674,6 +1749,9 @@ async function appMain() { const sources = [ "'self'", 'http://127.0.0.1:*', + 'https://127.0.0.1:*', + 'http://localhost:*', + 'https://localhost:*', 'https://api.github.com', 'https://github.com', 'https://objects.githubusercontent.com', diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 6d6e208299..e2876babaa 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -70,6 +70,11 @@ export async function setupGoosed({ error: (...args) => console.error('[goosed]', ...args), }; + // Accept self-signed TLS certs from the local goosed server. + // In Electron this is handled by setCertificateVerifyProc, but integration + // tests run in plain Node.js where fetch rejects self-signed certs. + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + const additionalEnv: Record = { GOOSE_PATH_ROOT: tempDir, };