feat: self-signed HTTPS for goosed server (#7126)

This commit is contained in:
Andrew Harvard
2026-02-25 14:38:57 -05:00
committed by GitHub
parent 5b8b2cf132
commit 785818bb87
17 changed files with 577 additions and 97 deletions
@@ -335,7 +335,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
{/* Navigation Settings */}
<NavigationSettingsCard />
<TelemetrySettings isWelcome={false} />
<Card className="rounded-lg">
@@ -2,13 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Button } from '../../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
import { Input } from '../../ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '../../ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../ui/dialog';
import { Loader2, Copy, Check, Square, Trash2, ExternalLink, User } from 'lucide-react';
import { getApiUrl } from '../../../config';
@@ -138,9 +132,15 @@ export default function GatewaySettingsSection() {
<TelegramGatewayCard
status={telegram}
onStart={(config) =>
doPost('/gateway/start', { gateway_type: 'telegram', platform_config: config, max_sessions: 0 }, 'Failed to start')
doPost(
'/gateway/start',
{ gateway_type: 'telegram', platform_config: config, max_sessions: 0 },
'Failed to start'
)
}
onRestart={() =>
doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')
}
onRestart={() => doPost('/gateway/restart', { gateway_type: 'telegram' }, 'Failed to start')}
onStop={() => doPost('/gateway/stop', { gateway_type: 'telegram' }, 'Failed to stop')}
onRemove={() => doPost('/gateway/remove', { gateway_type: 'telegram' }, 'Failed to remove')}
onGenerateCode={async () => {
@@ -238,7 +238,11 @@ function TelegramGatewayCard({
const wrap = (fn: () => Promise<void>) => async () => {
setBusy(true);
try { await fn(); } finally { setBusy(false); }
try {
await fn();
} finally {
setBusy(false);
}
};
const handleFirstStart = wrap(async () => {
@@ -310,9 +314,11 @@ function TelegramGatewayCard({
>
@BotFather
<ExternalLink className="h-3 w-3" />
</a>
{' '}on your phone, send <code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
the prompts to name your bot. BotFather will reply with an API token paste it below.
</a>{' '}
on your phone, send{' '}
<code className="bg-background-muted px-1 py-0.5 rounded">/newbot</code>, and follow
the prompts to name your bot. BotFather will reply with an API token paste it
below.
</p>
</div>
<div className="flex items-center gap-2">
@@ -399,8 +405,8 @@ function PairingCodeModal({
</div>
<p className="text-center text-sm text-text-muted">
Send this code to your{' '}
<span className="capitalize font-medium">{gatewayType}</span> bot to pair.
Send this code to your <span className="capitalize font-medium">{gatewayType}</span> bot
to pair.
</p>
<div className="text-center text-xs text-text-muted">
+36 -4
View File
@@ -168,6 +168,7 @@ export interface GoosedResult {
errorLog: string[];
cleanup: () => Promise<void>;
client: Client;
certFingerprint: string | null;
}
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
@@ -209,12 +210,13 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.info('Not killing external process that is managed externally');
},
client: goosedClientForUrlAndSecret(url, serverSecret),
certFingerprint: null,
};
}
if (process.env.GOOSE_EXTERNAL_BACKEND) {
const port = process.env.GOOSE_PORT || '3000';
const url = `http://127.0.0.1:${port}`;
const url = `https://127.0.0.1:${port}`;
logger.info(`Using external goosed backend from env at ${url}`);
return {
@@ -226,6 +228,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.info('Not killing external process that is managed externally');
},
client: goosedClientForUrlAndSecret(url, serverSecret),
certFingerprint: null,
};
}
@@ -241,7 +244,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
);
const baseUrl = `http://127.0.0.1:${port}`;
const baseUrl = `https://127.0.0.1:${port}`;
const spawnEnv: Record<string, string | undefined> = {
...process.env,
@@ -292,8 +295,34 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
goosedProcess.stdout?.on('data', (data: Buffer) => {
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
let certFingerprint: string | null = null;
const fingerprintReady = new Promise<string | null>((resolve) => {
const FINGERPRINT_PREFIX = 'GOOSED_CERT_FINGERPRINT=';
let resolved = false;
goosedProcess.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${text}`);
if (!resolved && text.includes(FINGERPRINT_PREFIX)) {
for (const line of text.split('\n')) {
if (line.startsWith(FINGERPRINT_PREFIX)) {
certFingerprint = line.slice(FINGERPRINT_PREFIX.length).trim();
logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
resolved = true;
resolve(certFingerprint);
break;
}
}
}
});
goosedProcess.on('exit', () => {
if (!resolved) {
resolved = true;
resolve(null);
}
});
});
goosedProcess.stderr?.on('data', (data: Buffer) => {
@@ -354,6 +383,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.info(`Goosed server successfully started on port ${port}`);
await fingerprintReady;
return {
baseUrl,
workingDir,
@@ -361,5 +392,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
errorLog,
cleanup,
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
certFingerprint,
};
};
+86 -8
View File
@@ -8,6 +8,7 @@ import {
ipcMain,
Menu,
MenuItem,
net,
Notification,
powerSaveBlocker,
screen,
@@ -16,6 +17,7 @@ import {
Tray,
} from 'electron';
import { pathToFileURL, format as formatUrl, URLSearchParams } from 'node:url';
import { Buffer } from 'node:buffer';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import started from 'electron-squirrel-startup';
@@ -25,6 +27,7 @@ import { spawn } from 'child_process';
import 'dotenv/config';
import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed';
import { createClient, createConfig } from './api/client';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
import { ensureWinShims } from './utils/winShims';
@@ -107,6 +110,66 @@ async function configureProxy() {
if (started) app.quit();
// Accept self-signed certificates from the local goosed server.
// 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.
let pinnedCertFingerprint: string | null = null;
function isLocalhost(hostname: string): boolean {
return hostname === '127.0.0.1' || hostname === 'localhost';
}
function normalizeFingerprint(fp: string): string {
if (fp.startsWith('sha256/')) {
const b64 = fp.slice('sha256/'.length);
const buf = Buffer.from(b64, 'base64');
return Array.from(buf)
.map((b) => b.toString(16).padStart(2, '0'))
.join(':')
.toUpperCase();
}
return fp.toUpperCase();
}
// Renderer requests: pin to the exact cert goosed generated once known.
// Before the fingerprint is available (during the health-check bootstrap
// 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)) {
callback(false);
return;
}
if (pinnedCertFingerprint) {
const match =
normalizeFingerprint(certificate.fingerprint) === pinnedCertFingerprint.toUpperCase();
event.preventDefault();
callback(match);
} else {
event.preventDefault();
callback(true);
}
});
// Main-process net.fetch: pin to the exact cert goosed generated.
app.whenReady().then(() => {
session.defaultSession.setCertificateVerifyProc((request, callback) => {
if (!isLocalhost(request.hostname)) {
callback(-3);
return;
}
if (!pinnedCertFingerprint) {
callback(0);
return;
}
const match =
normalizeFingerprint(request.certificate.fingerprint) === pinnedCertFingerprint.toUpperCase();
callback(match ? 0 : -3);
});
});
if (process.env.ENABLE_PLAYWRIGHT) {
const debugPort = process.env.PLAYWRIGHT_DEBUG_PORT || '9222';
console.log(`[Main] Enabling Playwright remote debugging on port ${debugPort}`);
@@ -449,7 +512,7 @@ let appConfig = {
GOOSE_DEFAULT_PROVIDER: defaultProvider,
GOOSE_DEFAULT_MODEL: defaultModel,
GOOSE_PREDEFINED_MODELS: predefinedModels,
GOOSE_API_HOST: 'http://127.0.0.1',
GOOSE_API_HOST: 'https://localhost',
GOOSE_WORKING_DIR: '',
// If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode)
GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true',
@@ -498,18 +561,18 @@ 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.
if (goosedResult.certFingerprint) {
pinnedCertFingerprint = goosedResult.certFingerprint;
}
app.on('will-quit', async () => {
log.info('App quitting, terminating goosed server');
await goosedResult.cleanup();
});
const {
baseUrl,
workingDir,
process: goosedProcess,
errorLog,
client: goosedClient,
} = goosedResult;
const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
const mainWindowState = windowStateKeeper({
defaultWidth: 940,
@@ -563,6 +626,18 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
.catch((err) => log.info('failed to install react dev tools:', err));
}
// Re-create the client with Electron's net.fetch so requests to the local
// self-signed HTTPS server go through the session's certificate handling.
const goosedClient = createClient(
createConfig({
baseUrl,
fetch: net.fetch as unknown as typeof globalThis.fetch,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': serverSecret,
},
})
);
goosedClients.set(mainWindow.id, goosedClient);
const serverReady = await checkServerStatus(goosedClient, errorLog);
@@ -1674,6 +1749,9 @@ async function appMain() {
const sources = [
"'self'",
'http://127.0.0.1:*',
'https://127.0.0.1:*',
'http://localhost:*',
'https://localhost:*',
'https://api.github.com',
'https://github.com',
'https://objects.githubusercontent.com',
+5
View File
@@ -70,6 +70,11 @@ export async function setupGoosed({
error: (...args) => console.error('[goosed]', ...args),
};
// Accept self-signed TLS certs from the local goosed server.
// In Electron this is handled by setCertificateVerifyProc, but integration
// tests run in plain Node.js where fetch rejects self-signed certs.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const additionalEnv: Record<string, string> = {
GOOSE_PATH_ROOT: tempDir,
};