From c8971fe5fb8bf29c7758dd373289f76749c59d0e Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 30 Jun 2026 20:43:18 -0400 Subject: [PATCH] Support TLS for ACP serve (#10088) Co-authored-by: Douwe M Osinga Co-authored-by: Lifei Zhou --- Cargo.lock | 10 +- crates/goose-cli/Cargo.toml | 5 + crates/goose-cli/src/cli.rs | 79 ++++++- crates/goose-server/Cargo.toml | 6 - crates/goose-server/src/commands/agent.rs | 4 +- crates/goose-server/src/tls.rs | 214 +----------------- crates/goose/Cargo.toml | 15 ++ .../goose/src/acp/server/custom_dispatch.rs | 23 +- crates/goose/src/acp/server/dispatch.rs | 6 + crates/goose/src/acp/transport/mod.rs | 2 + crates/goose/src/acp/transport/tls.rs | 179 +++++++++++++++ ui/desktop/src/acp/chatSessionController.ts | 1 + .../settings/app/ExternalBackendSection.tsx | 28 ++- ui/desktop/src/i18n/messages/de.json | 3 + ui/desktop/src/i18n/messages/en.json | 3 + ui/desktop/src/i18n/messages/es.json | 3 + ui/desktop/src/i18n/messages/fr.json | 3 + ui/desktop/src/i18n/messages/hi.json | 3 + ui/desktop/src/i18n/messages/id.json | 3 + ui/desktop/src/i18n/messages/it.json | 3 + ui/desktop/src/i18n/messages/ja.json | 3 + ui/desktop/src/i18n/messages/ko.json | 3 + ui/desktop/src/i18n/messages/ms.json | 3 + ui/desktop/src/i18n/messages/pt.json | 3 + ui/desktop/src/i18n/messages/ru.json | 3 + ui/desktop/src/i18n/messages/tr.json | 3 + ui/desktop/src/i18n/messages/vi.json | 3 + ui/desktop/src/i18n/messages/zh-CN.json | 3 + ui/desktop/src/i18n/messages/zh-TW.json | 3 + ui/desktop/src/main.ts | 47 +++- 30 files changed, 423 insertions(+), 244 deletions(-) create mode 100644 crates/goose/src/acp/transport/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 9317be3f2..bce322a50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4875,10 +4875,12 @@ dependencies = [ "async-stream", "async-trait", "aws-config", + "aws-lc-rs", "aws-sdk-bedrockruntime", "aws-sdk-sagemakerruntime", "aws-smithy-types", "axum", + "axum-server", "base64 0.22.1", "blake3", "byteorder", @@ -4931,6 +4933,7 @@ dependencies = [ "nostr-sdk", "oauth2", "once_cell", + "openssl", "opentelemetry 0.32.0", "opentelemetry-appender-tracing", "opentelemetry-otlp 0.32.0", @@ -4938,10 +4941,12 @@ dependencies = [ "opentelemetry_sdk 0.32.1", "pastey", "pctx_code_mode", + "pem", "process-wrap", "pulldown-cmark", "rand 0.10.1", "rayon", + "rcgen", "regex", "reqwest 0.13.4", "rmcp", @@ -5017,6 +5022,7 @@ dependencies = [ "anyhow", "async-trait", "axum", + "axum-server", "base64 0.22.1", "bat", "bzip2", @@ -5158,7 +5164,6 @@ name = "goose-server" version = "1.40.0" dependencies = [ "anyhow", - "aws-lc-rs", "axum", "axum-server", "base64 0.22.1", @@ -5172,10 +5177,7 @@ dependencies = [ "goose-providers", "hex", "http 1.4.2", - "openssl", - "pem", "rand 0.10.1", - "rcgen", "reqwest 0.13.4", "rmcp", "rustls", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index e50ee6d8a..7c510d559 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -64,6 +64,7 @@ comfy-table = { version = "7", default-features = false } sha2 = { workspace = true } sigstore-verify = { version = "0.9", default-features = false, optional = true } axum.workspace = true +axum-server = { version = "0.8", default-features = false, optional = true } clap_complete_nushell = { version = "4", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] @@ -99,14 +100,18 @@ portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel", "tui"] # disables the update command disable-update = [] rustls-tls = [ + "dep:axum-server", "reqwest/rustls", + "axum-server/tls-rustls", "sigstore-verify?/rustls", "goose/rustls-tls", "goose-mcp/rustls-tls", "goose-providers/rustls-tls", ] native-tls = [ + "dep:axum-server", "reqwest/native-tls", + "axum-server/tls-openssl", "sigstore-verify?/native-tls", "goose/native-tls", "goose-mcp/native-tls", diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 0ebf44a14..ce581370a 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -831,6 +831,15 @@ enum Command { #[arg(long, default_value = "3284")] port: u16, + #[arg(long, help = "Serve ACP over TLS")] + tls: bool, + + #[arg(long = "tls-cert-path", value_name = "PATH")] + tls_cert_path: Option, + + #[arg(long = "tls-key-path", value_name = "PATH")] + tls_key_path: Option, + #[arg( long = "with-builtin", value_name = "NAME", @@ -1333,7 +1342,14 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { Ok(()) } -async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> Result<()> { +async fn handle_serve_command( + host: String, + port: u16, + tls: bool, + tls_cert_path: Option, + tls_key_path: Option, + builtins: Vec, +) -> Result<()> { use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; use goose::acp::transport::create_router; use goose::config::paths::Paths; @@ -1380,15 +1396,55 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> let secret_key = env_secret.unwrap_or_else(generate_serve_secret_key); let router = create_router(server, secret_key, require_token); - let addr: SocketAddr = format!("{}:{}", host, port).parse()?; - info!("Starting ACP server on {}", addr); + let config = Config::global(); + let tls_cert_path = + tls_cert_path.or_else(|| config.get_param::("GOOSE_TLS_CERT_PATH").ok()); + let tls_key_path = + tls_key_path.or_else(|| config.get_param::("GOOSE_TLS_KEY_PATH").ok()); + let tls = tls + || config.get_param::("GOOSE_TLS").unwrap_or(false) + || tls_cert_path.is_some() + || tls_key_path.is_some(); - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .await?; + let addr: SocketAddr = format!("{}:{}", host, port).parse()?; + if tls { + #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] + { + let tls_setup = goose::acp::transport::tls::setup_tls( + tls_cert_path.as_deref(), + tls_key_path.as_deref(), + ) + .await?; + info!("Starting ACP server on https://{}", addr); + + #[cfg(feature = "rustls-tls")] + axum_server::bind_rustls(addr, tls_setup.config) + .serve(router.into_make_service_with_connect_info::()) + .await?; + + #[cfg(feature = "native-tls")] + axum_server::bind_openssl(addr, tls_setup.config) + .serve(router.into_make_service_with_connect_info::()) + .await?; + } + + #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] + { + let _ = (tls_cert_path, tls_key_path); + anyhow::bail!( + "TLS was requested but no TLS backend is enabled. \ + Enable the `rustls-tls` or `native-tls` feature." + ); + } + } else { + info!("Starting ACP server on http://{}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await?; + } Ok(()) } @@ -2099,8 +2155,11 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Serve { host, port, + tls, + tls_cert_path, + tls_key_path, builtins, - }) => handle_serve_command(host, port, builtins).await, + }) => handle_serve_command(host, port, tls, tls_cert_path, tls_key_path, builtins).await, Some(Command::Session { command: Some(cmd), .. }) => handle_session_subcommand(cmd).await, diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index a05618a33..50062cbe7 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -37,7 +37,6 @@ rustls-tls = [ "reqwest/rustls", "axum-server/tls-rustls", "dep:rustls", - "dep:aws-lc-rs", "goose/rustls-tls", "goose-mcp/rustls-tls", "goose-providers/rustls-tls", @@ -45,7 +44,6 @@ rustls-tls = [ native-tls = [ "reqwest/native-tls", "axum-server/tls-openssl", - "dep:openssl", "goose/native-tls", "goose-mcp/native-tls", "goose-providers/native-tls", @@ -82,11 +80,7 @@ rand = { workspace = true } hex = { version = "0.4.3", default-features = false, features = ["std"] } rustls = { workspace = true, optional = true } uuid = { workspace = true } -rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] } axum-server = { version = "0.8", default-features = false } -aws-lc-rs = { version = "1.17", default-features = false, optional = true } -openssl = { version = "0.10.66", default-features = false, optional = true } -pem = { version = "3.0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] winreg = { version = "0.56", default-features = false } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index 1a4d6f0fa..0845fef61 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -8,8 +8,6 @@ use goose::acp::transport::create_acp_router; use goose::agents::GoosePlatform; use goose::config::paths::Paths; use goose_server::auth::{check_acp_token, check_token}; -#[cfg(any(feature = "rustls-tls", feature = "native-tls"))] -use goose_server::tls::setup_tls; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -90,7 +88,7 @@ pub async fn run() -> Result<()> { #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] { boot_marker("tls setup start"); - let tls_setup = setup_tls( + let tls_setup = goose::acp::transport::tls::setup_tls( settings.tls_cert_path.as_deref(), settings.tls_key_path.as_deref(), ) diff --git a/crates/goose-server/src/tls.rs b/crates/goose-server/src/tls.rs index 273052ec6..7f60840bd 100644 --- a/crates/goose-server/src/tls.rs +++ b/crates/goose-server/src/tls.rs @@ -1,210 +1,4 @@ -//! TLS configuration for the goose server. -//! -//! Two TLS backends are supported for the HTTPS listener via `axum-server`: -//! -//! - **`rustls-tls`** (enabled by default) – uses `axum-server/tls-rustls` with -//! the `aws-lc-rs` crypto provider. -//! - **`native-tls`** – uses `axum-server/tls-openssl`, which links against the -//! platform's OpenSSL (or a compatible fork such as LibreSSL / BoringSSL). -//! On Linux this *is* the platform-native TLS stack; on macOS/Windows the -//! `native-tls` crate used by the HTTP *client* delegates to Security.framework -//! / SChannel respectively, but `axum-server` does not offer those backends so -//! the server listener always uses OpenSSL when this feature is active. - -use anyhow::{bail, Result}; -use goose::config::paths::Paths; -use rcgen::{CertificateParams, DnType, KeyPair, SanType}; -use std::path::Path; - -#[cfg(feature = "rustls-tls")] -pub type TlsConfig = axum_server::tls_rustls::RustlsConfig; - -#[cfg(feature = "native-tls")] -pub type TlsConfig = axum_server::tls_openssl::OpenSSLConfig; - -pub struct TlsSetup { - pub config: TlsConfig, - pub fingerprint: String, -} - -fn generate_self_signed_cert() -> Result<(rcgen::Certificate, KeyPair)> { - 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)?; - Ok((cert, key_pair)) -} - -fn sha256_fingerprint(der: &[u8]) -> String { - #[cfg(feature = "rustls-tls")] - { - let sha256 = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, der); - sha256 - .as_ref() - .iter() - .map(|b| format!("{b:02X}")) - .collect::>() - .join(":") - } - - #[cfg(feature = "native-tls")] - { - use openssl::hash::MessageDigest; - let digest = - openssl::hash::hash(MessageDigest::sha256(), der).expect("SHA-256 hash failed"); - digest - .iter() - .map(|b| format!("{b:02X}")) - .collect::>() - .join(":") - } -} - -/// Load TLS configuration from user-provided PEM certificate and key files. -/// -/// The SHA-256 fingerprint of the leaf certificate is computed and printed to -/// stdout so the parent process (e.g. Electron) can pin it, just like the -/// self-signed path. -pub async fn from_pem_files(cert_path: &Path, key_path: &Path) -> Result { - let cert_pem = std::fs::read(cert_path)?; - let key_pem = std::fs::read(key_path)?; - - // Parse the first PEM block to extract the DER-encoded certificate for fingerprinting. - let der = pem::parse(&cert_pem)?.into_contents(); - let fingerprint = sha256_fingerprint(&der); - println!("GOOSED_CERT_FINGERPRINT={fingerprint}"); - - #[cfg(feature = "rustls-tls")] - let config = { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem.clone()).await? - }; - - #[cfg(feature = "native-tls")] - let config = axum_server::tls_openssl::OpenSSLConfig::from_pem(&cert_pem, &key_pem)?; - - Ok(TlsSetup { - config, - fingerprint, - }) -} - -/// Set up TLS, using user-provided PEM files if both paths are given, -/// otherwise generating a self-signed certificate. -pub async fn setup_tls(cert_path: Option<&str>, key_path: Option<&str>) -> Result { - match (cert_path, key_path) { - (Some(cert), Some(key)) => from_pem_files(Path::new(cert), Path::new(key)).await, - (None, None) => self_signed_config().await, - _ => bail!("Both GOOSE_TLS_CERT_PATH and GOOSE_TLS_KEY_PATH must be set, or neither"), - } -} - -fn tls_cache_dir() -> std::path::PathBuf { - Paths::config_dir().join("tls") -} - -fn write_private_key(path: &std::path::Path, contents: &[u8]) { - #[cfg(unix)] - { - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - - let result = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path); - if let Ok(mut file) = result { - let _ = file.write_all(contents); - } - } - - #[cfg(not(unix))] - { - let _ = std::fs::write(path, contents); - } -} - -async fn load_cached_tls() -> Option { - let dir = tls_cache_dir(); - let cert_pem = std::fs::read(dir.join("server.pem")).ok()?; - let key_pem = std::fs::read(dir.join("server.key")).ok()?; - - let der = pem::parse(&cert_pem).ok()?.into_contents(); - let fingerprint = sha256_fingerprint(&der); - - #[cfg(feature = "rustls-tls")] - let config = axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem) - .await - .ok()?; - #[cfg(feature = "native-tls")] - let config = axum_server::tls_openssl::OpenSSLConfig::from_pem(&cert_pem, &key_pem).ok()?; - - Some(TlsSetup { - config, - fingerprint, - }) -} - -/// All errors are silently ignored — this is a best-effort optimisation and -/// must never prevent the server from starting. -fn save_tls_to_cache(cert_pem: &str, key_pem: &str) { - let dir = tls_cache_dir(); - if std::fs::create_dir_all(&dir).is_err() { - return; - } - let _ = std::fs::write(dir.join("server.pem"), cert_pem); - write_private_key(&dir.join("server.key"), key_pem.as_bytes()); -} - -/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and -/// return a [`TlsSetup`] containing the server 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 { - #[cfg(feature = "rustls-tls")] - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - - // Fast path: reuse a previously cached certificate if one exists. - if let Some(cached) = load_cached_tls().await { - println!("GOOSED_CERT_FINGERPRINT={}", cached.fingerprint); - return Ok(cached); - } - - let (cert, key_pair) = generate_self_signed_cert()?; - - let fingerprint = sha256_fingerprint(cert.der()); - println!("GOOSED_CERT_FINGERPRINT={fingerprint}"); - - let cert_pem = cert.pem(); - let key_pem = key_pair.serialize_pem(); - - // Persist for future restarts before moving the strings into the config. - save_tls_to_cache(&cert_pem, &key_pem); - - #[cfg(feature = "rustls-tls")] - let config = axum_server::tls_rustls::RustlsConfig::from_pem( - cert_pem.into_bytes(), - key_pem.into_bytes(), - ) - .await?; - - #[cfg(feature = "native-tls")] - let config = - axum_server::tls_openssl::OpenSSLConfig::from_pem(cert_pem.as_bytes(), key_pem.as_bytes())?; - - Ok(TlsSetup { - config, - fingerprint, - }) -} +#[cfg(any(feature = "rustls-tls", feature = "native-tls"))] +pub use goose::acp::transport::tls::{ + from_pem_files, self_signed_config, setup_tls, TlsConfig, TlsSetup, +}; diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 15ac4a062..0f0fdd25f 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -44,6 +44,11 @@ vulkan = ["local-inference", "llama-cpp-2/vulkan"] mlx = ["local-inference", "dep:mlx-rs", "dep:mlx-lm", "dep:mlx-lm-utils"] rustls-tls = [ "dep:rustls", + "dep:rcgen", + "dep:pem", + "dep:axum-server", + "dep:aws-lc-rs", + "axum-server/tls-rustls", "reqwest/rustls", "rmcp/reqwest", "smithy-transport-reqwest?/rustls", @@ -55,6 +60,11 @@ rustls-tls = [ "goose-providers/rustls-tls", ] native-tls = [ + "dep:rcgen", + "dep:pem", + "dep:axum-server", + "dep:openssl", + "axum-server/tls-openssl", "reqwest/native-tls", "rmcp/reqwest-native-tls", "smithy-transport-reqwest?/native-tls", @@ -203,6 +213,11 @@ process-wrap = { version = "9", default-features = false, features = ["std"] } nostr = { version = "0.44", default-features = false, features = ["nip44", "std"], optional = true } nostr-sdk = { version = "0.44", default-features = false, features = ["nip44"], optional = true } rustls = { workspace = true, optional = true } +rcgen = { version = "0.14", default-features = false, features = ["aws_lc_rs", "crypto", "pem"], optional = true } +axum-server = { version = "0.8", default-features = false, optional = true } +aws-lc-rs = { version = "1.17", default-features = false, optional = true } +openssl = { version = "0.10.66", default-features = false, optional = true } +pem = { version = "3.0.2", default-features = false, features = ["std"], optional = true } hf-hub = { version = "1.0.0-rc.1", default-features = false, optional = true } mlx-rs = { version = "0.25.3", default-features = false, features = ["accelerate", "metal", "safetensors"], optional = true } mlx-lm = { git = "https://github.com/jh-block/mlx-lm", optional = true } diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 4cf02d1b7..0e78d2b5f 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -8,14 +8,25 @@ impl GooseAcpAgent { method: &str, params: serde_json::Value, ) -> Result { - if ::matches_method(method) { - let req = recipe::deserialize_save_recipe_request(params)?; - let result = self.on_save_recipe(req).await?; - return serde_json::to_value(&result) - .map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string())); + let result = async { + if ::matches_method(method) + { + let req = recipe::deserialize_save_recipe_request(params)?; + let result = self.on_save_recipe(req).await?; + return serde_json::to_value(&result).map_err(|e| { + agent_client_protocol::Error::internal_error().data(e.to_string()) + }); + } + + self.handle_custom_request(method, params).await + } + .await; + + if let Err(error) = &result { + tracing::error!(method, error = ?error, "ACP custom request failed"); } - self.handle_custom_request(method, params).await + result } #[custom_method(AddSessionExtensionRequest)] diff --git a/crates/goose/src/acp/server/dispatch.rs b/crates/goose/src/acp/server/dispatch.rs index 11698568d..1c30ceae9 100644 --- a/crates/goose/src/acp/server/dispatch.rs +++ b/crates/goose/src/acp/server/dispatch.rs @@ -56,11 +56,17 @@ impl HandleDispatchFrom for GooseAcpHandler { let agent = agent.clone(); let cx_clone = cx.clone(); cx.spawn(async move { + let session_id = req.session_id.0.to_string(); match agent.on_load_session(&cx_clone, req).await { Ok(response) => { responder.respond(response)?; } Err(e) => { + tracing::error!( + session_id = %session_id, + error = ?e, + "ACP load_session failed" + ); responder.respond_with_error(e)?; } } diff --git a/crates/goose/src/acp/transport/mod.rs b/crates/goose/src/acp/transport/mod.rs index 3587fc6c4..39d5d6eb9 100644 --- a/crates/goose/src/acp/transport/mod.rs +++ b/crates/goose/src/acp/transport/mod.rs @@ -1,4 +1,6 @@ pub mod auth; +#[cfg(any(feature = "rustls-tls", feature = "native-tls"))] +pub mod tls; use std::sync::Arc; diff --git a/crates/goose/src/acp/transport/tls.rs b/crates/goose/src/acp/transport/tls.rs new file mode 100644 index 000000000..fa93d502c --- /dev/null +++ b/crates/goose/src/acp/transport/tls.rs @@ -0,0 +1,179 @@ +use crate::config::paths::Paths; +use anyhow::{bail, Result}; +use rcgen::{CertificateParams, DnType, KeyPair, SanType}; +use std::path::Path; + +#[cfg(feature = "rustls-tls")] +pub type TlsConfig = axum_server::tls_rustls::RustlsConfig; + +#[cfg(feature = "native-tls")] +pub type TlsConfig = axum_server::tls_openssl::OpenSSLConfig; + +pub struct TlsSetup { + pub config: TlsConfig, + pub fingerprint: String, +} + +fn generate_self_signed_cert() -> Result<(rcgen::Certificate, KeyPair)> { + 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)?; + Ok((cert, key_pair)) +} + +fn sha256_fingerprint(der: &[u8]) -> String { + #[cfg(feature = "rustls-tls")] + { + let sha256 = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, der); + sha256 + .as_ref() + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") + } + + #[cfg(feature = "native-tls")] + { + use openssl::hash::MessageDigest; + let digest = + openssl::hash::hash(MessageDigest::sha256(), der).expect("SHA-256 hash failed"); + digest + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") + } +} + +pub async fn from_pem_files(cert_path: &Path, key_path: &Path) -> Result { + let cert_pem = std::fs::read(cert_path)?; + let key_pem = std::fs::read(key_path)?; + + let der = pem::parse(&cert_pem)?.into_contents(); + let fingerprint = sha256_fingerprint(&der); + println!("GOOSED_CERT_FINGERPRINT={fingerprint}"); + + #[cfg(feature = "rustls-tls")] + let config = { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem.clone()).await? + }; + + #[cfg(feature = "native-tls")] + let config = axum_server::tls_openssl::OpenSSLConfig::from_pem(&cert_pem, &key_pem)?; + + Ok(TlsSetup { + config, + fingerprint, + }) +} + +pub async fn setup_tls(cert_path: Option<&str>, key_path: Option<&str>) -> Result { + match (cert_path, key_path) { + (Some(cert), Some(key)) => from_pem_files(Path::new(cert), Path::new(key)).await, + (None, None) => self_signed_config().await, + _ => bail!("Both GOOSE_TLS_CERT_PATH and GOOSE_TLS_KEY_PATH must be set, or neither"), + } +} + +fn tls_cache_dir() -> std::path::PathBuf { + Paths::config_dir().join("tls") +} + +fn write_private_key(path: &std::path::Path, contents: &[u8]) { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let result = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path); + if let Ok(mut file) = result { + let _ = file.write_all(contents); + } + } + + #[cfg(not(unix))] + { + let _ = std::fs::write(path, contents); + } +} + +async fn load_cached_tls() -> Option { + let dir = tls_cache_dir(); + let cert_pem = std::fs::read(dir.join("server.pem")).ok()?; + let key_pem = std::fs::read(dir.join("server.key")).ok()?; + + let der = pem::parse(&cert_pem).ok()?.into_contents(); + let fingerprint = sha256_fingerprint(&der); + + #[cfg(feature = "rustls-tls")] + let config = axum_server::tls_rustls::RustlsConfig::from_pem(cert_pem, key_pem) + .await + .ok()?; + #[cfg(feature = "native-tls")] + let config = axum_server::tls_openssl::OpenSSLConfig::from_pem(&cert_pem, &key_pem).ok()?; + + Some(TlsSetup { + config, + fingerprint, + }) +} + +fn try_save_tls_to_cache(cert_pem: &str, key_pem: &str) { + let dir = tls_cache_dir(); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let _ = std::fs::write(dir.join("server.pem"), cert_pem); + write_private_key(&dir.join("server.key"), key_pem.as_bytes()); +} + +pub async fn self_signed_config() -> Result { + #[cfg(feature = "rustls-tls")] + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + if let Some(cached) = load_cached_tls().await { + println!("GOOSED_CERT_FINGERPRINT={}", cached.fingerprint); + return Ok(cached); + } + + let (cert, key_pair) = generate_self_signed_cert()?; + + let fingerprint = sha256_fingerprint(cert.der()); + println!("GOOSED_CERT_FINGERPRINT={fingerprint}"); + + let cert_pem = cert.pem(); + let key_pem = key_pair.serialize_pem(); + + try_save_tls_to_cache(&cert_pem, &key_pem); + + #[cfg(feature = "rustls-tls")] + let config = axum_server::tls_rustls::RustlsConfig::from_pem( + cert_pem.into_bytes(), + key_pem.into_bytes(), + ) + .await?; + + #[cfg(feature = "native-tls")] + let config = + axum_server::tls_openssl::OpenSSLConfig::from_pem(cert_pem.as_bytes(), key_pem.as_bytes())?; + + Ok(TlsSetup { + config, + fingerprint, + }) +} diff --git a/ui/desktop/src/acp/chatSessionController.ts b/ui/desktop/src/acp/chatSessionController.ts index 36cc4ffa6..849d6a9bb 100644 --- a/ui/desktop/src/acp/chatSessionController.ts +++ b/ui/desktop/src/acp/chatSessionController.ts @@ -145,6 +145,7 @@ async function loadSession(sessionId: string, options: AcpLoadSessionOptions = { acpChatSessionActions.finishSessionLoad(sessionId, sessionInfoToSession(sessionInfo, meta)); options.onSessionLoaded?.(); } catch (error) { + console.error('Failed to load ACP session:', error); acpChatSessionActions.failSessionLoad(sessionId, errorMessage(error)); } } diff --git a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx index 5f7fcd8e1..ae81cbd38 100644 --- a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx +++ b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx @@ -62,6 +62,10 @@ const i18n = defineMessages({ id: 'externalBackendSection.urlProtocolError', defaultMessage: 'URL must use http or https protocol', }, + fingerprintRequiresHttps: { + id: 'externalBackendSection.fingerprintRequiresHttps', + defaultMessage: 'Certificate fingerprint requires an https URL', + }, urlFormatError: { id: 'externalBackendSection.urlFormatError', defaultMessage: 'Invalid URL format', @@ -82,7 +86,10 @@ export default function ExternalBackendSection() { loadSettings(); }, []); - const validateUrl = (value: string): boolean => { + const validateUrl = ( + value: string, + certFingerprint = config.certFingerprint + ): boolean => { if (!value) { setUrlError(null); return true; @@ -93,6 +100,10 @@ export default function ExternalBackendSection() { setUrlError(intl.formatMessage(i18n.urlProtocolError)); return false; } + if (certFingerprint?.trim() && parsed.protocol !== 'https:') { + setUrlError(intl.formatMessage(i18n.fingerprintRequiresHttps)); + return false; + } setUrlError(null); return true; } catch { @@ -132,6 +143,17 @@ export default function ExternalBackendSection() { } }; + const handleCertFingerprintChange = (value: string) => { + updateField('certFingerprint', value); + validateUrl(config.url, value); + }; + + const handleCertFingerprintBlur = async () => { + if (validateUrl(config.url)) { + await saveConfig(config); + } + }; + return (
@@ -210,8 +232,8 @@ export default function ExternalBackendSection() { type="text" placeholder={intl.formatMessage(i18n.certFingerprintPlaceholder)} value={config.certFingerprint || ''} - onChange={(e) => updateField('certFingerprint', e.target.value)} - onBlur={() => saveConfig(config)} + onChange={(e) => handleCertFingerprintChange(e.target.value)} + onBlur={handleCertFingerprintBlur} disabled={isSaving} className="font-mono text-xs" /> diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index 255e59ce3..52640c837 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Standardmäßig startet goose einen Server für Sie. Verwenden Sie dies, um eine Verbindung zu einem externen goose-Server herzustellen" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Änderungen werden erst nach einem Neustart von Goose wirksam. Neue Chat-Fenster verbinden sich mit dem externen Server." }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index b9ca4e94c..af7e0e216 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Changes require restarting Goose to take effect. New chat windows will connect to the external server." }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 45bf5d93f..25e7a307f 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Por defecto goose lanza un servidor por ti; usa esto para conectarte a un servidor goose externo" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Los cambios requieren reiniciar Goose para que surtan efecto. Las nuevas ventanas de chat se conectarán al servidor externo." }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 7bfadaf10..d73f192eb 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Par défaut, goose lance un serveur pour vous ; utilisez ceci pour vous connecter à un serveur goose externe" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Les modifications nécessitent le redémarrage de Goose pour prendre effet. Les nouvelles fenêtres de discussion se connecteront au serveur externe." }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index b5ea468f0..61db2c6d6 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "डिफ़ॉल्ट रूप से goose आपके लिए एक सर्वर लॉन्च करता है, बाहरी goose सर्वर से कनेक्ट करने के लिए इसका उपयोग करें" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "परिवर्तनों को प्रभावी होने के लिए Goose को पुनः आरंभ करने की आवश्यकता है। नई चैट विंडो बाहरी सर्वर से कनेक्ट होंगी." }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 16cf9ac8b..37090e485 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Secara bawaan goose menjalankan server untuk Anda, gunakan ini untuk terhubung ke server goose eksternal" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Perubahan memerlukan mulai ulang Goose agar berlaku. Jendela obrolan baru akan terhubung ke server eksternal." }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index c588dec53..c25c659f0 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Per impostazione predefinita goose avvia un server per te; usa questa opzione per connetterti a un server goose esterno" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Le modifiche richiedono il riavvio di Goose per avere effetto. Le nuove finestre di chat si connetteranno al server esterno." }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index e3fa1321e..79e3e4f8c 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "デフォルトでは goose がサーバーを起動します。外部の goose サーバーに接続する場合に使用してください。" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "変更を有効にするには Goose の再起動が必要です。新しいチャットウィンドウは外部サーバーに接続します。" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index eb4359e77..45c8220be 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "기본적으로 goose가 서버를 자동으로 실행합니다. 외부 goose 서버에 연결하려면 이 옵션을 사용하세요." }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "변경 사항을 적용하려면 goose를 다시 시작해야 합니다. 새 채팅 창은 외부 서버에 연결됩니다." }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index f7ec43d52..ca44ad01e 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Secara lalai goose melancarkan pelayan untuk anda, gunakan ini untuk menyambung ke pelayan goose luaran" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Perubahan memerlukan memulakan semula Goose untuk berkuat kuasa. Tetingkap sembang baharu akan menyambung ke pelayan luaran." }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index ac78928a7..a1e711052 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Por padrão, o goose inicia um servidor para você; use isto para conectar-se a um servidor goose externo" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "As alterações exigem reiniciar o Goose para entrar em vigor. As novas janelas de chat se conectarão ao servidor externo." }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 70eab467f..fba615191 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "По умолчанию goose запускает сервер за вас; используйте это для подключения к внешнему серверу goose" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Чтобы изменения вступили в силу, требуется перезапуск Goose. Новые окна чата будут подключаться к внешнему серверу." }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index a6b2afa1c..398b902dc 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Varsayılan olarak goose sizin için bir sunucu başlatır; harici bir goose sunucusuna bağlanmak için bunu kullanın" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Değişikliklerin etkili olması için Goose'nin yeniden başlatılması gerekir. Yeni sohbet pencereleri harici sunucuya bağlanacaktır." }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 183284c23..cc28a0b44 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "Theo mặc định goose khởi chạy một máy chủ cho bạn, hãy dùng tùy chọn này để kết nối tới một máy chủ goose bên ngoài" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "Các thay đổi cần khởi động lại Goose để có hiệu lực. Các cửa sổ trò chuyện mới sẽ kết nối tới máy chủ bên ngoài." }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index ff133f7c4..e45d274c9 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "默认情况下 goose 会为你启动一个服务器,此选项用于连接到外部的 goose 服务器" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "更改需要重启 Goose 才能生效。新的聊天窗口将连接到外部服务器。" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index 6dd7d53f4..f8f1acb73 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -1169,6 +1169,9 @@ "externalBackendSection.description": { "defaultMessage": "goose 預設會為您啟動伺服器,使用此選項以連線至外部 goose 伺服器" }, + "externalBackendSection.fingerprintRequiresHttps": { + "defaultMessage": "Certificate fingerprint requires an https URL" + }, "externalBackendSection.restartNote": { "defaultMessage": "變更需要重新啟動 Goose 才會生效。新的聊天視窗將連線至外部伺服器。" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 18b107f1d..95d326c55 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -599,10 +599,11 @@ async function handleProtocolUrl(url: string, parsedUrl: URL) { existingWindows.length > 0 ? existingWindows[0] : await createChat(app, { dir: openDir || undefined }); + if (!targetWindow) return; await processProtocolUrl(url, parsedUrl, targetWindow); } else { const existingWindows = BrowserWindow.getAllWindows(); - let targetWindow: BrowserWindow; + let targetWindow: BrowserWindow | undefined; if (existingWindows.length > 0) { targetWindow = existingWindows[0]; if (targetWindow.isMinimized()) { @@ -613,6 +614,8 @@ async function handleProtocolUrl(url: string, parsedUrl: URL) { targetWindow = await createChat(app, { dir: openDir || undefined }); } + if (!targetWindow) return; + if (targetWindow.webContents.isLoadingMainFrame()) { queuePendingDeepLink(targetWindow.id, url); } else { @@ -709,6 +712,7 @@ app.on('open-url', async (_event, url) => { } else { openUrlHandledLaunch = true; const newWindow = await createChat(app, { dir: openDir || undefined }); + if (!newWindow) return; queuePendingDeepLink(newWindow.id, url); } } @@ -944,7 +948,10 @@ interface CreateChatOptions { recipeParameters?: Record; } -const createChat = async (app: App, options: CreateChatOptions = {}) => { +const createChat = async ( + app: App, + options: CreateChatOptions = {} +): Promise => { const { initialMessage, initialMessageNoAutoSubmit, @@ -957,6 +964,42 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { recipeParameters, } = options; const settings = getSettings(); + + if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) { + const url = settings.externalGoosed.url; + const usesHttps = (() => { + try { + return new URL(url).protocol === 'https:'; + } catch { + return false; + } + })(); + + if (!usesHttps) { + const response = dialog.showMessageBoxSync({ + type: 'error', + title: 'External Backend Misconfigured', + message: 'Certificate fingerprint requires an HTTPS external backend URL.', + detail: 'Use an https:// URL or remove the configured certificate fingerprint.', + buttons: ['Disable External Backend & Retry', 'Quit'], + defaultId: 0, + cancelId: 1, + }); + + if (response === 0) { + updateSettings((s) => { + if (s.externalGoosed) { + s.externalGoosed.enabled = false; + } + }); + return createChat(app, options); + } + + app.quit(); + return; + } + } + const serverSecret = getServerSecret(settings); // Update the cached trusted-external-hostname so the TLS handlers allow