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
+ {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 {