fix(desktop): accept self-signed certs from configured external goosed host (#8400)

Signed-off-by: jh-block <jhugo@block.xyz>
Co-authored-by: Mathis Rocher <mathis.rocher@corp.ovh.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: jh-block <jhugo@block.xyz>
This commit is contained in:
Gandalf_Le_Dev
2026-04-15 21:10:37 +02:00
committed by GitHub
parent cff0992aef
commit 890ad15934
10 changed files with 138 additions and 12 deletions
Generated
+1
View File
@@ -4604,6 +4604,7 @@ dependencies = [
"hex",
"http 1.4.0",
"openssl",
"pem",
"rand 0.8.5",
"rcgen",
"reqwest 0.13.2",
+1
View File
@@ -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" }
+6 -2
View File
@@ -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();
+4
View File
@@ -11,6 +11,8 @@ pub struct Settings {
pub port: u16,
#[serde(default = "default_tls")]
pub tls: bool,
pub tls_cert_path: Option<String>,
pub tls_key_path: Option<String>,
}
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");
+41 -1
View File
@@ -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<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"),
}
}
/// 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).
@@ -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() {
</p>
</div>
<div className="space-y-2">
<label htmlFor="external-cert-fingerprint" className="text-text-primary text-xs">
{intl.formatMessage(i18n.certFingerprint)}
</label>
<Input
id="external-cert-fingerprint"
type="text"
placeholder={intl.formatMessage(i18n.certFingerprintPlaceholder)}
value={config.certFingerprint || ''}
onChange={(e) => updateField('certFingerprint', e.target.value)}
onBlur={() => saveConfig(config)}
disabled={isSaving}
className="font-mono text-xs"
/>
<p className="text-xs text-text-secondary">
{intl.formatMessage(i18n.certFingerprintHelp)}
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<p className="text-xs text-amber-800 dark:text-amber-200">
<strong>Note:</strong> {intl.formatMessage(i18n.restartNote)}
+1
View File
@@ -142,6 +142,7 @@ export interface ExternalGoosedConfig {
enabled: boolean;
url?: string;
secret?: string;
certFingerprint?: string;
}
export interface StartGoosedOptions {
+9
View File
@@ -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"
},
+43 -9
View File
@@ -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;
}
+1
View File
@@ -2,6 +2,7 @@ export interface ExternalGoosedConfig {
enabled: boolean;
url: string;
secret: string;
certFingerprint?: string;
}
export interface KeyboardShortcuts {