Support TLS for ACP serve (#10088)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Douwe Osinga 2026-06-30 20:43:18 -04:00 committed by GitHub
parent 2f32070975
commit c8971fe5fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 423 additions and 244 deletions

10
Cargo.lock generated
View file

@ -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",

View file

@ -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",

View file

@ -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<String>,
#[arg(long = "tls-key-path", value_name = "PATH")]
tls_key_path: Option<String>,
#[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<String>) -> Result<()> {
async fn handle_serve_command(
host: String,
port: u16,
tls: bool,
tls_cert_path: Option<String>,
tls_key_path: Option<String>,
builtins: Vec<String>,
) -> 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<String>) ->
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::<String>("GOOSE_TLS_CERT_PATH").ok());
let tls_key_path =
tls_key_path.or_else(|| config.get_param::<String>("GOOSE_TLS_KEY_PATH").ok());
let tls = tls
|| config.get_param::<bool>("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::<SocketAddr>(),
)
.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::<SocketAddr>())
.await?;
#[cfg(feature = "native-tls")]
axum_server::bind_openssl(addr, tls_setup.config)
.serve(router.into_make_service_with_connect_info::<SocketAddr>())
.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::<SocketAddr>(),
)
.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,

View file

@ -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 }

View file

@ -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(),
)

View file

@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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<TlsSetup> {
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<TlsSetup> {
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<TlsSetup> {
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<TlsSetup> {
#[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,
};

View file

@ -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 }

View file

@ -8,14 +8,25 @@ impl GooseAcpAgent {
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, agent_client_protocol::Error> {
if <SaveRecipeRequest as agent_client_protocol::JsonRpcMessage>::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 <SaveRecipeRequest as agent_client_protocol::JsonRpcMessage>::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)]

View file

@ -56,11 +56,17 @@ impl HandleDispatchFrom<Client> 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)?;
}
}

View file

@ -1,4 +1,6 @@
pub mod auth;
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
pub mod tls;
use std::sync::Arc;

View file

@ -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::<Vec<_>>()
.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::<Vec<_>>()
.join(":")
}
}
pub async fn from_pem_files(cert_path: &Path, key_path: &Path) -> Result<TlsSetup> {
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<TlsSetup> {
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<TlsSetup> {
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<TlsSetup> {
#[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,
})
}

View file

@ -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));
}
}

View file

@ -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 (
<section id="external-backend" className="space-y-4 pr-4 mt-1">
<Card className="pb-2">
@ -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"
/>

View file

@ -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."
},

View file

@ -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."
},

View file

@ -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."
},

View file

@ -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."
},

View file

@ -1169,6 +1169,9 @@
"externalBackendSection.description": {
"defaultMessage": "डिफ़ॉल्ट रूप से goose आपके लिए एक सर्वर लॉन्च करता है, बाहरी goose सर्वर से कनेक्ट करने के लिए इसका उपयोग करें"
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
"defaultMessage": "परिवर्तनों को प्रभावी होने के लिए Goose को पुनः आरंभ करने की आवश्यकता है। नई चैट विंडो बाहरी सर्वर से कनेक्ट होंगी."
},

View file

@ -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."
},

View file

@ -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."
},

View file

@ -1169,6 +1169,9 @@
"externalBackendSection.description": {
"defaultMessage": "デフォルトでは goose がサーバーを起動します。外部の goose サーバーに接続する場合に使用してください。"
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
"defaultMessage": "変更を有効にするには Goose の再起動が必要です。新しいチャットウィンドウは外部サーバーに接続します。"
},

View file

@ -1169,6 +1169,9 @@
"externalBackendSection.description": {
"defaultMessage": "기본적으로 goose가 서버를 자동으로 실행합니다. 외부 goose 서버에 연결하려면 이 옵션을 사용하세요."
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
"defaultMessage": "변경 사항을 적용하려면 goose를 다시 시작해야 합니다. 새 채팅 창은 외부 서버에 연결됩니다."
},

View file

@ -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."
},

View file

@ -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."
},

View file

@ -1169,6 +1169,9 @@
"externalBackendSection.description": {
"defaultMessage": "По умолчанию goose запускает сервер за вас; используйте это для подключения к внешнему серверу goose"
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
"defaultMessage": "Чтобы изменения вступили в силу, требуется перезапуск Goose. Новые окна чата будут подключаться к внешнему серверу."
},

View file

@ -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."
},

View file

@ -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."
},

View file

@ -1169,6 +1169,9 @@
"externalBackendSection.description": {
"defaultMessage": "默认情况下 goose 会为你启动一个服务器,此选项用于连接到外部的 goose 服务器"
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
"defaultMessage": "更改需要重启 Goose 才能生效。新的聊天窗口将连接到外部服务器。"
},

View file

@ -1169,6 +1169,9 @@
"externalBackendSection.description": {
"defaultMessage": "goose 預設會為您啟動伺服器,使用此選項以連線至外部 goose 伺服器"
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
"defaultMessage": "變更需要重新啟動 Goose 才會生效。新的聊天視窗將連線至外部伺服器。"
},

View file

@ -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<string, string>;
}
const createChat = async (app: App, options: CreateChatOptions = {}) => {
const createChat = async (
app: App,
options: CreateChatOptions = {}
): Promise<BrowserWindow | undefined> => {
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