feat: self-signed HTTPS for goosed server (#7126)
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user