fix(desktop): restore saved outbound proxy settings on app start (#949)

The standalone server hydrates its proxy state in index.ts right after
initDb, but the desktop embedder (desktop/src/server-host.ts) builds the
app without index.ts and never did — so a proxy URL saved through
PUT /api/settings/proxy sat in the settings table while the process
started with an empty proxy, and every restart made the Outbound proxy
fields appear empty until re-saved.

- lib/proxy.ts: new restoreProxySettings() — the single hydration step
  (proxy_url / proxy_enabled / proxy_bypass), env-var precedence intact
- index.ts: calls it instead of the three inline apply* calls
- server-host.ts: calls it after initDb, before createApp
- new proxy-restore.test.ts: pins DB → process-state hydration, the
  disabled flag, empty-DB defaults, and PROXY_URL env precedence

Checked: server vitest (proxy + proxy-restore: 87 pass; one pre-existing
compression perf failure on clean main, unrelated), desktop vitest,
esbuild bundle of server-host.ts.
This commit is contained in:
tashdroid
2026-08-21 16:32:40 +00:00
parent 6c4233b684
commit a78e2c4bc3
4 changed files with 120 additions and 5 deletions
+6
View File
@@ -11,6 +11,7 @@ import crypto from 'node:crypto';
import type { Server } from 'node:http';
import { createApp } from '../../server/src/app.js';
import { initDb, getDb, getUnifiedApiKey } from '../../server/src/db/index.js';
import { restoreProxySettings } from '../../server/src/lib/proxy.js';
import { startHealthChecker } from '../../server/src/services/health.js';
import { startCatalogSync } from '../../server/src/services/catalog-sync.js';
import { userCount, createUser, createSession } from '../../server/src/services/auth.js';
@@ -40,6 +41,11 @@ export async function startServer(opts: StartOptions): Promise<ServerHandle> {
// 0.0.0.0, and a remote viewer must still enter the password.
process.env.FREEAPI_DESKTOP = '1';
initDb(opts.dbPath);
// #949: the standalone server hydrates its proxy state in index.ts after
// initDb; this embedder builds the app without index.ts, so without this
// the URL saved in the settings table is ignored on every restart and the
// outbound proxy fields appear empty until re-saved.
restoreProxySettings();
const app = createApp();
const { server, port } = await listenWithScan(app, opts.host, opts.preferredPort);
// Background timers need a Scheduler since the abstraction landed (4cbb571);
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { initDb, getDb, setSetting } from '../../db/index.js';
import {
restoreProxySettings,
getProxyUrl,
isProxyEnabled,
getProxyBypassPlatforms,
applyProxyUrl,
applyProxyEnabled,
applyProxyBypass,
} from '../../lib/proxy.js';
// #949: the desktop embedder builds the app without server/src/index.ts, so
// the proxy state it starts with is whatever the module defaults are — an
// empty URL. The URL the user saved through PUT /api/settings/proxy sits in
// the settings table, ignored until the next re-save. restoreProxySettings()
// is the single hydration step both entry points now call after initDb; this
// test pins that the DB value actually reaches the process state.
const PROXY_ENV_VARS = ['PROXY_URL', 'ALL_PROXY', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'];
function clearProxyEnv(): void {
for (const name of PROXY_ENV_VARS) {
delete process.env[name];
delete process.env[name.toLowerCase()];
}
}
let closed = false;
beforeEach(() => {
clearProxyEnv();
// Reset to the module defaults so each case starts from "fresh process".
applyProxyUrl('');
applyProxyEnabled(true);
applyProxyBypass('');
});
afterAll(() => {
if (!closed) {
getDb().close();
closed = true;
}
});
describe('restoreProxySettings (desktop embedder hydration, #949)', () => {
it('loads a saved proxy URL, enabled flag and bypass list from the settings table', () => {
process.env.ENCRYPTION_KEY = '0'.repeat(64);
initDb(':memory:');
setSetting('proxy_url', 'socks5h://127.0.0.1:9050');
setSetting('proxy_enabled', '1');
setSetting('proxy_bypass', 'groq,openrouter');
restoreProxySettings();
expect(getProxyUrl()).toBe('socks5h://127.0.0.1:9050');
expect(isProxyEnabled()).toBe(true);
expect(getProxyBypassPlatforms()).toEqual(['groq', 'openrouter']);
});
it('respects a disabled proxy saved across a restart', () => {
process.env.ENCRYPTION_KEY = '0'.repeat(64);
initDb(':memory:');
setSetting('proxy_url', 'http://127.0.0.1:3128');
setSetting('proxy_enabled', '0');
restoreProxySettings();
expect(getProxyUrl()).toBe('http://127.0.0.1:3128');
expect(isProxyEnabled()).toBe(false);
});
it('keeps the defaults when nothing was ever saved', () => {
process.env.ENCRYPTION_KEY = '0'.repeat(64);
initDb(':memory:');
restoreProxySettings();
expect(getProxyUrl()).toBe('');
expect(isProxyEnabled()).toBe(true);
expect(getProxyBypassPlatforms()).toEqual([]);
});
it('lets the PROXY_URL env var outrank the saved value, as before', () => {
process.env.ENCRYPTION_KEY = '0'.repeat(64);
initDb(':memory:');
setSetting('proxy_url', 'http://saved:3128');
process.env.PROXY_URL = 'http://env:8080';
restoreProxySettings();
expect(getProxyUrl()).toBe('http://env:8080');
});
});
+3 -5
View File
@@ -1,8 +1,8 @@
import './env.js';
import { createApp } from './app.js';
import { initDb, getDb, getSetting } from './db/index.js';
import { initDb, getDb } from './db/index.js';
import { startHealthChecker, checkAllKeys } from './services/health.js';
import { applyProxyUrl, applyProxyEnabled, applyProxyBypass, flushProxyCache } from './lib/proxy.js';
import { restoreProxySettings, flushProxyCache } from './lib/proxy.js';
import { startWakeDetect } from './lib/wake-detect.js';
import { startCatalogSync } from './services/catalog-sync.js';
import { startCooldownProbe } from './services/cooldown-probe.js';
@@ -64,9 +64,7 @@ async function main() {
// Load the persisted proxy settings from the DB (env var wins if set).
// Must happen after initDb so the settings table is ready.
applyProxyUrl(getSetting('proxy_url') ?? '');
applyProxyEnabled(getSetting('proxy_enabled') !== '0'); // default: enabled
applyProxyBypass(getSetting('proxy_bypass') ?? '');
restoreProxySettings();
const app = createApp(config);
+17
View File
@@ -1,6 +1,7 @@
import http from 'http';
import https from 'https';
import { AsyncLocalStorage } from 'node:async_hooks';
import { getSetting } from '../db/index.js';
import { assertProviderUrlAllowed } from './url-guard.js';
// #590 (per-key proxy): the SAME provider may be reached through different
@@ -190,6 +191,22 @@ export function applyProxyUrl(dbValue: string): void {
_initialized = true;
}
/**
* Hydrate the process-wide proxy state from the settings table.
*
* The standalone server does this in index.ts after initDb; the desktop
* embedder (desktop/src/server-host.ts) builds the app without index.ts and
* must call this itself — otherwise the URL saved by PUT /api/settings/proxy
* sits in the DB but the process starts with an empty proxy and every
* outbound request goes direct until the user re-saves the setting (#949).
* Safe to call more than once; it is idempotent.
*/
export function restoreProxySettings(): void {
applyProxyUrl(getSetting('proxy_url') ?? '');
applyProxyEnabled(getSetting('proxy_enabled') !== '0'); // default: enabled
applyProxyBypass(getSetting('proxy_bypass') ?? '');
}
export function getProxyUrl(): string {
return _proxyUrl;
}