From 890ad15934be63637d5ad0b454f4d37c790bbee1 Mon Sep 17 00:00:00 2001 From: Gandalf_Le_Dev <46865726+Gandalf-Le-Dev@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:10:37 +0200 Subject: [PATCH] fix(desktop): accept self-signed certs from configured external goosed host (#8400) Signed-off-by: jh-block Co-authored-by: Mathis Rocher Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: jh-block --- Cargo.lock | 1 + crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/commands/agent.rs | 8 ++- crates/goose-server/src/configuration.rs | 4 ++ crates/goose-server/src/tls.rs | 42 ++++++++++++++- .../settings/app/ExternalBackendSection.tsx | 31 +++++++++++ ui/desktop/src/goosed.ts | 1 + ui/desktop/src/i18n/messages/en.json | 9 ++++ ui/desktop/src/main.ts | 52 +++++++++++++++---- ui/desktop/src/utils/settings.ts | 1 + 10 files changed, 138 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44ea77ca..25b9b46e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4604,6 +4604,7 @@ dependencies = [ "hex", "http 1.4.0", "openssl", + "pem", "rand 0.8.5", "rcgen", "reqwest 0.13.2", diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 9621bed6..2045caa6 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -76,6 +76,7 @@ rcgen = "0.13" axum-server = { version = "0.8.0" } aws-lc-rs = { version = "1.16.0", optional = true } openssl = { version = "0.10", optional = true } +pem = "3.0.6" [target.'cfg(windows)'.dependencies] winreg = { version = "0.55.0" } diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs index f06c464b..26bb0915 100644 --- a/crates/goose-server/src/commands/agent.rs +++ b/crates/goose-server/src/commands/agent.rs @@ -5,7 +5,7 @@ use axum::middleware; use axum_server::Handle; use goose_server::auth::check_token; #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] -use goose_server::tls::self_signed_config; +use goose_server::tls::setup_tls; use tower_http::cors::{Any, CorsLayer}; use tracing::info; @@ -78,7 +78,11 @@ pub async fn run() -> Result<()> { if settings.tls { #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] { - let tls_setup = self_signed_config().await?; + let tls_setup = setup_tls( + settings.tls_cert_path.as_deref(), + settings.tls_key_path.as_deref(), + ) + .await?; let handle = Handle::new(); let shutdown_handle = handle.clone(); diff --git a/crates/goose-server/src/configuration.rs b/crates/goose-server/src/configuration.rs index 4f28ec34..c6f45836 100644 --- a/crates/goose-server/src/configuration.rs +++ b/crates/goose-server/src/configuration.rs @@ -11,6 +11,8 @@ pub struct Settings { pub port: u16, #[serde(default = "default_tls")] pub tls: bool, + pub tls_cert_path: Option, + pub tls_key_path: Option, } impl Settings { @@ -91,6 +93,8 @@ mod tests { host: "127.0.0.1".to_string(), port: 3000, tls: true, + tls_cert_path: None, + tls_key_path: None, }; let addr = server_settings.socket_addr(); assert_eq!(addr.to_string(), "127.0.0.1:3000"); diff --git a/crates/goose-server/src/tls.rs b/crates/goose-server/src/tls.rs index 5eb7e3f4..8f1431a2 100644 --- a/crates/goose-server/src/tls.rs +++ b/crates/goose-server/src/tls.rs @@ -11,8 +11,9 @@ //! / SChannel respectively, but `axum-server` does not offer those backends so //! the server listener always uses OpenSSL when this feature is active. -use anyhow::Result; +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; @@ -65,6 +66,45 @@ fn sha256_fingerprint(der: &[u8]) -> String { } } +/// 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"), + } +} + /// 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). diff --git a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx index deeff84b..5f7fcd8e 100644 --- a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx +++ b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx @@ -41,6 +41,18 @@ const i18n = defineMessages({ id: 'externalBackendSection.secretKeyHelp', defaultMessage: 'The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)', }, + certFingerprint: { + id: 'externalBackendSection.certFingerprint', + defaultMessage: 'Certificate Fingerprint (optional)', + }, + certFingerprintPlaceholder: { + id: 'externalBackendSection.certFingerprintPlaceholder', + defaultMessage: 'AA:BB:CC:... or sha256/base64', + }, + certFingerprintHelp: { + id: 'externalBackendSection.certFingerprintHelp', + defaultMessage: 'Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU).', + }, restartNote: { id: 'externalBackendSection.restartNote', defaultMessage: @@ -189,6 +201,25 @@ export default function ExternalBackendSection() {

+
+ + updateField('certFingerprint', e.target.value)} + onBlur={() => saveConfig(config)} + disabled={isSaving} + className="font-mono text-xs" + /> +

+ {intl.formatMessage(i18n.certFingerprintHelp)} +

+
+

Note: {intl.formatMessage(i18n.restartNote)} diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 9f211e81..0dafe833 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -142,6 +142,7 @@ export interface ExternalGoosedConfig { enabled: boolean; url?: string; secret?: string; + certFingerprint?: string; } export interface StartGoosedOptions { diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index a2655c06..36cc72d6 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1136,6 +1136,15 @@ "extensionsView.searchPlaceholder": { "defaultMessage": "Search extensions..." }, + "externalBackendSection.certFingerprint": { + "defaultMessage": "Certificate Fingerprint (optional)" + }, + "externalBackendSection.certFingerprintHelp": { + "defaultMessage": "Pin a specific TLS certificate fingerprint. If omitted, the certificate is trusted on first use (TOFU)." + }, + "externalBackendSection.certFingerprintPlaceholder": { + "defaultMessage": "AA:BB:CC:... or sha256/base64" + }, "externalBackendSection.description": { "defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index cdef3f0a..821ae797 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -117,17 +117,26 @@ async function configureProxy() { if (started) app.quit(); -// Accept self-signed certificates from the local goosed server. +// Certificate trust for goosed servers (local and external). // Both certificate-error (renderer) and setCertificateVerifyProc (main-process -// net.fetch) pin to the exact cert fingerprint emitted by goosed at startup. -// Before the fingerprint is available (during the health-check bootstrap -// window) any localhost cert is accepted so the server can come up. +// net.fetch) pin to the exact cert fingerprint. For locally-spawned goosed the +// fingerprint comes from its stdout; for external backends we use Trust-On-First-Use +// (TOFU) — the first TLS handshake pins the cert for the lifetime of the process. let pinnedCertFingerprint: string | null = null; +// Cached hostname of the configured external goosed server, updated when a +// chat is created so we don't hit the filesystem on every TLS handshake. +let trustedExternalHostname: string | null = null; + function isLocalhost(hostname: string): boolean { return hostname === '127.0.0.1' || hostname === 'localhost'; } +function isTrustedHost(hostname: string): boolean { + if (isLocalhost(hostname)) return true; + return trustedExternalHostname !== null && hostname === trustedExternalHostname; +} + function normalizeFingerprint(fp: string): string { if (fp.startsWith('sha256/')) { const b64 = fp.slice('sha256/'.length); @@ -145,7 +154,7 @@ function normalizeFingerprint(fp: string): string { // 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)) { + if (!isTrustedHost(parsed.hostname)) { callback(false); return; } @@ -155,6 +164,8 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call event.preventDefault(); callback(match); } else { + // TOFU: pin the certificate from the first successful handshake. + pinnedCertFingerprint = normalizeFingerprint(certificate.fingerprint); event.preventDefault(); callback(true); } @@ -163,17 +174,19 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call // Main-process net.fetch: pin to the exact cert goosed generated. app.whenReady().then(() => { session.defaultSession.setCertificateVerifyProc((request, callback) => { - if (!isLocalhost(request.hostname)) { + if (!isTrustedHost(request.hostname)) { callback(-3); return; } if (!pinnedCertFingerprint) { + // TOFU: pin the certificate from the first successful handshake. + pinnedCertFingerprint = normalizeFingerprint(request.certificate.fingerprint); callback(0); return; } const match = normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase(); - callback(match ? 0 : -3); + callback(match ? 0 : -2); }); }); @@ -574,6 +587,26 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { const settings = getSettings(); const serverSecret = getServerSecret(settings); + // Update the cached trusted-external-hostname so the TLS handlers allow + // connections to the configured remote backend. + if (settings.externalGoosed?.enabled && settings.externalGoosed.url) { + try { + trustedExternalHostname = new URL(settings.externalGoosed.url).hostname; + } catch { + trustedExternalHostname = null; + } + } else { + trustedExternalHostname = null; + } + + // If the user provided a cert fingerprint for the external backend, pin it + // directly (skips TOFU). Otherwise reset so the first handshake pins via TOFU. + if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) { + pinnedCertFingerprint = normalizeFingerprint(settings.externalGoosed.certFingerprint); + } else { + pinnedCertFingerprint = null; + } + const goosedResult = await startGoosed({ serverSecret, dir: dir || os.homedir(), @@ -586,8 +619,9 @@ 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. + // For locally-spawned goosed, pin using the fingerprint from stdout. + // For external backends the TOFU path in the cert handlers will pin + // the fingerprint on the first successful TLS handshake. if (goosedResult.certFingerprint) { pinnedCertFingerprint = goosedResult.certFingerprint; } diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index f23cdd59..6e491b9c 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -2,6 +2,7 @@ export interface ExternalGoosedConfig { enabled: boolean; url: string; secret: string; + certFingerprint?: string; } export interface KeyboardShortcuts {