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:
Generated
+1
@@ -4604,6 +4604,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"openssl",
|
"openssl",
|
||||||
|
"pem",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ rcgen = "0.13"
|
|||||||
axum-server = { version = "0.8.0" }
|
axum-server = { version = "0.8.0" }
|
||||||
aws-lc-rs = { version = "1.16.0", optional = true }
|
aws-lc-rs = { version = "1.16.0", optional = true }
|
||||||
openssl = { version = "0.10", optional = true }
|
openssl = { version = "0.10", optional = true }
|
||||||
|
pem = "3.0.6"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = { version = "0.55.0" }
|
winreg = { version = "0.55.0" }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use axum::middleware;
|
|||||||
use axum_server::Handle;
|
use axum_server::Handle;
|
||||||
use goose_server::auth::check_token;
|
use goose_server::auth::check_token;
|
||||||
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
|
#[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 tower_http::cors::{Any, CorsLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
@@ -78,7 +78,11 @@ pub async fn run() -> Result<()> {
|
|||||||
if settings.tls {
|
if settings.tls {
|
||||||
#[cfg(any(feature = "rustls-tls", feature = "native-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 handle = Handle::new();
|
||||||
let shutdown_handle = handle.clone();
|
let shutdown_handle = handle.clone();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub struct Settings {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
#[serde(default = "default_tls")]
|
#[serde(default = "default_tls")]
|
||||||
pub tls: bool,
|
pub tls: bool,
|
||||||
|
pub tls_cert_path: Option<String>,
|
||||||
|
pub tls_key_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
@@ -91,6 +93,8 @@ mod tests {
|
|||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
tls: true,
|
tls: true,
|
||||||
|
tls_cert_path: None,
|
||||||
|
tls_key_path: None,
|
||||||
};
|
};
|
||||||
let addr = server_settings.socket_addr();
|
let addr = server_settings.socket_addr();
|
||||||
assert_eq!(addr.to_string(), "127.0.0.1:3000");
|
assert_eq!(addr.to_string(), "127.0.0.1:3000");
|
||||||
|
|||||||
@@ -11,8 +11,9 @@
|
|||||||
//! / SChannel respectively, but `axum-server` does not offer those backends so
|
//! / SChannel respectively, but `axum-server` does not offer those backends so
|
||||||
//! the server listener always uses OpenSSL when this feature is active.
|
//! 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 rcgen::{CertificateParams, DnType, KeyPair, SanType};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
#[cfg(feature = "rustls-tls")]
|
#[cfg(feature = "rustls-tls")]
|
||||||
pub type TlsConfig = axum_server::tls_rustls::RustlsConfig;
|
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
|
/// Generate a self-signed TLS certificate for localhost (127.0.0.1) and
|
||||||
/// return a [`TlsSetup`] containing the server config and the SHA-256
|
/// return a [`TlsSetup`] containing the server config and the SHA-256
|
||||||
/// fingerprint of the generated certificate (colon-separated hex).
|
/// fingerprint of the generated certificate (colon-separated hex).
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ const i18n = defineMessages({
|
|||||||
id: 'externalBackendSection.secretKeyHelp',
|
id: 'externalBackendSection.secretKeyHelp',
|
||||||
defaultMessage: 'The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)',
|
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: {
|
restartNote: {
|
||||||
id: 'externalBackendSection.restartNote',
|
id: 'externalBackendSection.restartNote',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
@@ -189,6 +201,25 @@ export default function ExternalBackendSection() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||||
<strong>Note:</strong> {intl.formatMessage(i18n.restartNote)}
|
<strong>Note:</strong> {intl.formatMessage(i18n.restartNote)}
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export interface ExternalGoosedConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url?: string;
|
url?: string;
|
||||||
secret?: string;
|
secret?: string;
|
||||||
|
certFingerprint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartGoosedOptions {
|
export interface StartGoosedOptions {
|
||||||
|
|||||||
@@ -1136,6 +1136,15 @@
|
|||||||
"extensionsView.searchPlaceholder": {
|
"extensionsView.searchPlaceholder": {
|
||||||
"defaultMessage": "Search extensions..."
|
"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": {
|
"externalBackendSection.description": {
|
||||||
"defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server"
|
"defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server"
|
||||||
},
|
},
|
||||||
|
|||||||
+43
-9
@@ -117,17 +117,26 @@ async function configureProxy() {
|
|||||||
|
|
||||||
if (started) app.quit();
|
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
|
// Both certificate-error (renderer) and setCertificateVerifyProc (main-process
|
||||||
// net.fetch) pin to the exact cert fingerprint emitted by goosed at startup.
|
// net.fetch) pin to the exact cert fingerprint. For locally-spawned goosed the
|
||||||
// Before the fingerprint is available (during the health-check bootstrap
|
// fingerprint comes from its stdout; for external backends we use Trust-On-First-Use
|
||||||
// window) any localhost cert is accepted so the server can come up.
|
// (TOFU) — the first TLS handshake pins the cert for the lifetime of the process.
|
||||||
let pinnedCertFingerprint: string | null = null;
|
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 {
|
function isLocalhost(hostname: string): boolean {
|
||||||
return hostname === '127.0.0.1' || hostname === 'localhost';
|
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 {
|
function normalizeFingerprint(fp: string): string {
|
||||||
if (fp.startsWith('sha256/')) {
|
if (fp.startsWith('sha256/')) {
|
||||||
const b64 = fp.slice('sha256/'.length);
|
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.
|
// window) any localhost cert is accepted so the server can come up.
|
||||||
app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => {
|
app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (!isLocalhost(parsed.hostname)) {
|
if (!isTrustedHost(parsed.hostname)) {
|
||||||
callback(false);
|
callback(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,6 +164,8 @@ app.on('certificate-error', (event, _webContents, url, _error, certificate, call
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
callback(match);
|
callback(match);
|
||||||
} else {
|
} else {
|
||||||
|
// TOFU: pin the certificate from the first successful handshake.
|
||||||
|
pinnedCertFingerprint = normalizeFingerprint(certificate.fingerprint);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
callback(true);
|
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.
|
// Main-process net.fetch: pin to the exact cert goosed generated.
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
session.defaultSession.setCertificateVerifyProc((request, callback) => {
|
||||||
if (!isLocalhost(request.hostname)) {
|
if (!isTrustedHost(request.hostname)) {
|
||||||
callback(-3);
|
callback(-3);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pinnedCertFingerprint) {
|
if (!pinnedCertFingerprint) {
|
||||||
|
// TOFU: pin the certificate from the first successful handshake.
|
||||||
|
pinnedCertFingerprint = normalizeFingerprint(request.certificate.fingerprint);
|
||||||
callback(0);
|
callback(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const match =
|
const match =
|
||||||
normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase();
|
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 settings = getSettings();
|
||||||
const serverSecret = getServerSecret(settings);
|
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({
|
const goosedResult = await startGoosed({
|
||||||
serverSecret,
|
serverSecret,
|
||||||
dir: dir || os.homedir(),
|
dir: dir || os.homedir(),
|
||||||
@@ -586,8 +619,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
|
|||||||
logger: log,
|
logger: log,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pin the certificate fingerprint so the cert handlers above only accept
|
// For locally-spawned goosed, pin using the fingerprint from stdout.
|
||||||
// the exact cert that *this* goosed instance generated.
|
// For external backends the TOFU path in the cert handlers will pin
|
||||||
|
// the fingerprint on the first successful TLS handshake.
|
||||||
if (goosedResult.certFingerprint) {
|
if (goosedResult.certFingerprint) {
|
||||||
pinnedCertFingerprint = goosedResult.certFingerprint;
|
pinnedCertFingerprint = goosedResult.certFingerprint;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface ExternalGoosedConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
|
certFingerprint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyboardShortcuts {
|
export interface KeyboardShortcuts {
|
||||||
|
|||||||
Reference in New Issue
Block a user