fokemal / server /src /lib /proxy.ts
automindy's picture
Sync to upstream v0.13.7, keep HF Space adaptations
dcedb25
Raw History Blame Contribute Delete
48.5 kB
import http from 'http';
import https from 'https';
import { execFileSync } from 'node:child_process';
import { AsyncLocalStorage } from 'node:async_hooks';
import { getSetting } from '../db/index.js';
import { decrypt, encrypt } from './crypto.js';
import { assertProviderUrlAllowed, isLoopbackOrPrivateHostname } from './url-guard.js';
import type { ProxyMode } from '@freellmapi/shared/types.js';
// #590 (per-key proxy): the SAME provider may be reached through different
// exit IPs per key (geo-ban / risk-control avoidance). Providers are process
// singletons, so the per-key override cannot live on the provider instance β€”
// it rides request-scoped AsyncLocalStorage instead, set by the dispatcher
// around a provider call and read here in proxyFetch.
const perKeyProxyStore = new AsyncLocalStorage<string>();
/** Run `fn` with a per-key proxy override in effect; empty URL = global proxy. */
export function withKeyProxy<T>(proxyUrl: string | undefined, fn: () => T): T {
return perKeyProxyStore.run(proxyUrl ?? '', fn);
}
// undici (ProxyAgent) and socks-proxy-agent are lazy-loaded on first proxy use
// ONLY. Importing undici at module top-level eagerly runs its web/cache init,
// which throws on some Node 20.x builds ("webidl.util.markAsUncloneable is not
// a function"). Since this module is imported by every provider via base.ts, a
// top-level undici import crashed the entire app/test suite even when no proxy
// was configured. Lazy-loading keeps the proxy feature genuinely zero-cost and
// zero-risk for the common no-proxy case.
type Ctor<T> = new (...args: any[]) => T;
let _proxyAgentCtor: Ctor<unknown> | null = null;
let _socksAgentCtor: Ctor<unknown> | null = null;
async function loadHttpProxyAgent(): Promise<Ctor<unknown>> {
if (!_proxyAgentCtor) _proxyAgentCtor = (await import('undici')).ProxyAgent as unknown as Ctor<unknown>;
return _proxyAgentCtor;
}
async function loadSocksAgent(): Promise<Ctor<unknown>> {
if (!_socksAgentCtor) _socksAgentCtor = (await import('socks-proxy-agent')).SocksProxyAgent as unknown as Ctor<unknown>;
return _socksAgentCtor;
}
// SOCKS schemes socks-proxy-agent understands. `socks5h`/`socks4a` are the
// "resolve DNS at the proxy" variants (#630) β€” the ones that matter on
// DNS-poisoned networks, where resolving the upstream hostname locally is
// exactly what fails. They are ordinary SOCKS URLs to the agent; only our
// scheme detection ever needed teaching.
const SOCKS_SCHEMES = ['socks5:', 'socks5h:', 'socks4:', 'socks4a:'] as const;
/** Every proxy scheme the app accepts. Shared with the settings validator. */
export const PROXY_SCHEMES: readonly string[] = ['http:', 'https:', ...SOCKS_SCHEMES];
export const PROXY_MODES: readonly ProxyMode[] = ['forward', 'fetch-relay'];
export const FETCH_RELAY_TARGET_HEADER = 'fetch-relay-target';
export const FETCH_RELAY_AUTH_HEADER = 'fetch-relay-authorization';
// A Fetch Relay carries the provider API key AND the relay token inside the
// request it forwards, so the hop to the relay must be encrypted. Plain http
// is only tolerable when the relay never leaves the machine.
const LOOPBACK_RELAY_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
/** True for a hostname that cannot leave the local machine. `new URL()` keeps
* the brackets on an IPv6 literal, hence both spellings of ::1. */
export function isLoopbackRelayHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
return LOOPBACK_RELAY_HOSTNAMES.has(host) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
}
/** Why a URL cannot serve as a Fetch Relay endpoint, or undefined when it can.
* Shared by the settings validator and the boot-time env guard so the
* dashboard and a headless install agree on what a usable relay looks like. */
export function fetchRelayUrlError(url: string): string | undefined {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return 'Invalid Fetch Relay URL. Use a full URL like https://relay.example.workers.dev';
}
if (parsed.protocol === 'https:') return undefined;
if (parsed.protocol === 'http:' && isLoopbackRelayHostname(parsed.hostname)) return undefined;
return 'Fetch Relay URL must use https, or http only for a loopback relay. The provider API key and the relay token travel inside the relayed request.';
}
/** True when the URL names a SOCKS scheme (so it needs SocksProxyAgent, not undici). */
export function isSocksProxyUrl(url: string): boolean {
const colon = url.indexOf(':');
if (colon < 0) return false;
return (SOCKS_SCHEMES as readonly string[]).includes(url.slice(0, colon + 1).toLowerCase());
}
/** Reduce a proxy/relay URL to a safe connection hint. Relay paths commonly
* act as bearer secrets, while query strings may contain target templates or
* credentials, so neither is safe to print. */
function redactProxyUrl(url: string): string {
try {
const parsed = new URL(url);
const credentials = parsed.username || parsed.password ? '***@' : '';
const path = parsed.pathname && parsed.pathname !== '/' ? '/[redacted]' : '';
return `${parsed.protocol}//${credentials}${parsed.host}${path}`;
} catch {
return '[invalid proxy URL]';
}
}
// Standard proxy env vars, in the order they are consulted. PROXY_URL is the
// app's own knob and outranks the dashboard; the rest are ambient system
// settings (#353) that only apply when nothing is configured in the dashboard.
const ENV_PROXY_FALLBACKS = ['ALL_PROXY', 'HTTPS_PROXY', 'HTTP_PROXY'] as const;
/** Read an env var in either the upper- or lower-case spelling. */
function readEnv(name: string): string {
return (process.env[name] ?? process.env[name.toLowerCase()] ?? '').trim();
}
/**
* Decide which proxy URL wins, and say where it came from.
*
* PROXY_URL β†’ dashboard setting β†’ ALL_PROXY β†’ HTTPS_PROXY β†’ HTTP_PROXY.
*
* PROXY_URL stays on top because it has always documented itself as taking
* precedence (the dashboard hint says so). The standard vars sit *below* the
* dashboard: they're usually exported machine-wide for curl/git, so a proxy a
* user deliberately typed into the UI must not be silently overridden by them.
*/
function resolveProxySource(dbValue: string): { url: string; source: string } {
const { url, source } = pickProxySource(dbValue);
return { url: normalizeProxyUrl(url), source };
}
/**
* Give a scheme-less `host:port` proxy an `http://` scheme. Windows Internet
* Options and macOS `scutil --proxy` both store the proxy that way, and so do
* plenty of hand-written HTTPS_PROXY values; `new URL('127.0.0.1:7890')`
* throws, so without this the proxy was logged as invalid and every request
* silently went direct (#1373).
*/
export function normalizeProxyUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed || /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
return `http://${trimmed}`;
}
function pickProxySource(dbValue: string): { url: string; source: string } {
const explicit = readEnv('PROXY_URL');
if (explicit) return { url: explicit, source: 'PROXY_URL' };
const db = dbValue.trim();
if (db) return { url: db, source: 'dashboard' };
for (const name of ENV_PROXY_FALLBACKS) {
const value = readEnv(name);
if (value) return { url: value, source: name };
}
// #353: last resort β€” read the system-wide proxy settings (macOS System
// Settings / Windows Internet Options / GNOME desktop) so the app works
// without duplicating the OS config. Best-effort: every failure here just
// falls through to the direct (no-proxy) path.
const system = detectSystemProxy();
if (system.url) return system;
return { url: '', source: 'none' };
}
/**
* Read the OS-wide proxy configuration. Synchronous and best-effort: never
* throws, returns '' when the platform is unsupported or the command fails.
*
* - macOS: `scutil --proxy` (System Settings β†’ Network β†’ Proxies)
* - Windows: Internet Options registry (ProxyEnable + ProxyServer)
* - Linux: GNOME gsettings (the most common desktop), manual mode only
*/
export function detectSystemProxy(): { url: string; source: string } {
try {
if (process.platform === 'darwin') {
const out = execFileSync('scutil', ['--proxy'], { encoding: 'utf8', timeout: 2000 });
return parseScutilProxy(out);
}
if (process.platform === 'win32') {
const enable = execFileSync(
'reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', '/v', 'ProxyEnable'],
{ encoding: 'utf8', timeout: 2000 },
);
const server = execFileSync(
'reg', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings', '/v', 'ProxyServer'],
{ encoding: 'utf8', timeout: 2000 },
);
return parseRegProxy(enable, server);
}
if (process.platform === 'linux') {
return parseGsettingsProxy();
}
} catch {
// fall through to no-proxy
}
return { url: '', source: 'none' };
}
/** Parse `scutil --proxy` output: HTTPEnable/HTTPProxy/HTTPPort first, then SOCKS. */
export function parseScutilProxy(out: string): { url: string; source: string } {
const kv = (key: string) => out.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'm'))?.[1]?.trim();
const httpEnabled = kv('HTTPEnable') === '1';
const httpHost = kv('HTTPProxy');
const httpPort = kv('HTTPPort') || '80';
if (httpEnabled && httpHost) {
return { url: `${httpHost}:${httpPort}`, source: 'system(macOS)' };
}
const socksEnabled = kv('SOCKSEnable') === '1';
const socksHost = kv('SOCKSProxy');
const socksPort = kv('SOCKSPort') || '1080';
if (socksEnabled && socksHost) {
return { url: `socks5://${socksHost}:${socksPort}`, source: 'system(macOS)' };
}
return { url: '', source: 'none' };
}
/** Parse Windows registry output; ProxyServer may be "host:port" or "http=…;https=…". */
export function parseRegProxy(enableOut: string, serverOut: string): { url: string; source: string } {
// reg query prints: ` ProxyEnable REG_DWORD 0x1`
if (!/ProxyEnable\s+REG_DWORD\s+0x1/.test(enableOut)) return { url: '', source: 'none' };
const m = serverOut.match(/ProxyServer\s+REG_SZ\s+(.+)$/m);
if (!m) return { url: '', source: 'none' };
const raw = m[1].trim();
if (!raw.includes('=')) return { url: raw, source: 'system(Windows)' };
// Per-scheme form: prefer the http entry, then https, then socks (which
// Internet Options means as SOCKS, so it must not become an HTTP proxy).
const entries = new Map(
raw.split(';').map(p => p.trim()).filter(Boolean).map(p => {
const eq = p.indexOf('=');
return [p.slice(0, eq).trim().toLowerCase(), p.slice(eq + 1).trim()] as const;
}),
);
const http = entries.get('http') || entries.get('https');
if (http) return { url: http, source: 'system(Windows)' };
const socks = entries.get('socks');
if (socks) return { url: `socks5://${socks}`, source: 'system(Windows)' };
return { url: '', source: 'none' };
}
/** GNOME desktop proxy in manual mode (gsettings). */
function parseGsettingsProxy(): { url: string; source: string } {
const mode = execFileSync('gsettings', ['get', 'org.gnome.system.proxy', 'mode'], { encoding: 'utf8', timeout: 2000 }).trim();
if (mode !== "'manual'") return { url: '', source: 'none' };
const host = execFileSync('gsettings', ['get', 'org.gnome.system.proxy.http', 'host'], { encoding: 'utf8', timeout: 2000 }).trim().replace(/^'|'$/g, '');
const port = execFileSync('gsettings', ['get', 'org.gnome.system.proxy.http', 'port'], { encoding: 'utf8', timeout: 2000 }).trim();
if (!host || !port) return { url: '', source: 'none' };
return { url: `${host}:${port}`, source: 'system(GNOME)' };
}
/**
* Parse a NO_PROXY list into match rules. Entries are hosts or suffixes,
* comma-separated: `localhost,.internal.corp,example.com,*`. A bare domain
* also covers its subdomains, matching curl/git behaviour.
*/
function parseNoProxy(value: string): string[] {
return value
.split(',')
.map(s => s.trim().toLowerCase())
.filter(Boolean)
.map(s => (s.startsWith('*.') ? s.slice(1) : s));
}
/** True when NO_PROXY says this hostname must be reached directly. */
function noProxyMatches(hostname: string): boolean {
if (_noProxyRules.length === 0) return false;
// Trailing dot (FQDN form) and IPv6 brackets are noise for matching.
const host = hostname.toLowerCase().replace(/\.$/, '').replace(/^\[|\]$/g, '');
for (const rule of _noProxyRules) {
if (rule === '*') return true;
// A `host:port` qualifier narrows the rule to one port; we match on host,
// so compare the host half. Guarded so bare IPv6 rules aren't mangled.
const bare = /^[^:]+:\d+$/.test(rule) ? rule.slice(0, rule.lastIndexOf(':')) : rule;
if (!bare) continue;
if (bare.startsWith('.')) {
if (host === bare.slice(1) || host.endsWith(bare)) return true;
} else if (host === bare || host.endsWith(`.${bare}`)) {
return true;
}
}
return false;
}
// Module-level proxy URL.
let _proxyUrl = '';
let _proxyUrlSource = 'none';
let _proxyMode: ProxyMode = 'forward';
let _fetchRelayToken = '';
let _proxyEnabled = true;
let _bypassPlatforms = new Set<string>();
let _noProxyRules: string[] = [];
// Escape hatch for the `ssh -D` tunnel case β€” see shouldBypassProxy.
let _proxyLocalDestinations = false;
let _initialized = false;
// Cache.
let cached: {
dispatcher: unknown | undefined;
proxyUrl: string;
isSocks: boolean;
ts: number;
} | null = null;
const CACHE_TTL_MS = 30_000;
// #590: per-key proxy dispatchers, keyed by the key's proxy URL. Independent
// of the global cache so a per-key override never poisons the global one.
//
// A working dispatcher is cached for as long as it stays in the map: the cache
// key IS the whole proxy URL, so unlike the global entry (whose URL can change
// under it) it can never go stale β€” re-building it on a timer would only churn
// connection pools. A FAILED build is cached briefly instead, so a proxy that
// was down doesn't stay written off forever.
//
// The map is bounded: entries are per distinct proxy URL, so at human scale
// this holds a handful, but nothing stops an operator from pointing a hundred
// keys at a hundred rotating exits. Oldest-first eviction keeps a bad day from
// turning into an unbounded pile of agents. An evicted (or expired) dispatcher
// is dropped, not closed β€” closing it would tear down requests still streaming
// through it; the GC collects it once they finish. Same as the global cache.
const perKeyCached = new Map<string, { dispatcher: unknown | undefined; isSocks: boolean; ts: number }>();
const PER_KEY_FAILURE_TTL_MS = 30_000;
const PER_KEY_CACHE_MAX = 32;
function rememberPerKeyDispatcher(proxyUrl: string, entry: { dispatcher: unknown | undefined; isSocks: boolean; ts: number }): void {
// Delete-then-set so re-use moves an entry to the young end of the map and
// eviction takes the genuinely least-recently-used URL.
perKeyCached.delete(proxyUrl);
perKeyCached.set(proxyUrl, entry);
while (perKeyCached.size > PER_KEY_CACHE_MAX) {
const oldest = perKeyCached.keys().next().value;
if (oldest === undefined) break;
perKeyCached.delete(oldest);
}
}
/** Called once at startup (after initDb) and on PUT /api/settings/proxy. */
export function applyProxyUrl(dbValue: string): void {
const { url, source } = resolveProxySource(dbValue);
_proxyUrl = url;
_proxyUrlSource = source;
// A saved relay mode must never reinterpret legacy/ambient proxy variables.
// PROXY_MODE is the only way an environment-sourced URL becomes a relay.
if (source !== 'dashboard' && !readEnv('PROXY_MODE')) _proxyMode = 'forward';
_noProxyRules = parseNoProxy(readEnv('NO_PROXY'));
_proxyLocalDestinations = /^(1|true|yes)$/i.test(readEnv('FREEAPI_PROXY_LOCAL_DESTINATIONS'));
cached = null;
if (_proxyUrl) {
console.log(`[proxy] Configured β†’ ${redactProxyUrl(_proxyUrl)} (source: ${source})`);
if (_noProxyRules.length > 0) {
console.log(`[proxy] NO_PROXY direct for: ${_noProxyRules.join(', ')}`);
}
if (_proxyLocalDestinations) {
console.log('[proxy] FREEAPI_PROXY_LOCAL_DESTINATIONS is set β€” localhost/LAN destinations go through the proxy too.');
}
} else {
console.log('[proxy] Not configured β€” outbound requests go direct.');
}
enforceRelayUrlPolicy();
_initialized = true;
}
/**
* Refuse to run a relay over a URL it cannot speak. A relay hop is an ordinary
* HTTP request, so a socks5:// (or otherwise non-HTTP) endpoint would fail
* every provider call at runtime with an opaque error. Say so once at boot and
* degrade to a forward proxy, which is what such a URL was always good for. A
* plaintext relay to a remote host does work, but leaks the provider key and
* the relay token it carries, so that one only earns a warning.
*/
function enforceRelayUrlPolicy(): void {
if (_proxyMode !== 'fetch-relay' || !_proxyUrl) return;
let protocol = '';
try { protocol = new URL(_proxyUrl).protocol; } catch { /* handled below */ }
if (protocol !== 'http:' && protocol !== 'https:') {
console.warn(`[proxy] fetch-relay mode needs an http(s) relay URL; ${redactProxyUrl(_proxyUrl)} is not one. Falling back to a forward proxy.`);
_proxyMode = 'forward';
return;
}
const error = fetchRelayUrlError(_proxyUrl);
if (error) console.warn(`[proxy] ${error}`);
}
/**
* 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') ?? '');
applyProxyMode(getSetting('proxy_mode') ?? 'forward');
applyFetchRelayToken(decodeFetchRelayToken(getSetting('fetch_relay_token') ?? ''));
applyProxyEnabled(getSetting('proxy_enabled') !== '0'); // default: enabled
applyProxyBypass(getSetting('proxy_bypass') ?? '');
}
export function getProxyUrl(): string {
return _proxyUrl;
}
/** Set how the global proxy URL is used. An explicit PROXY_MODE wins. A legacy
* PROXY_URL (or an ambient standard proxy variable) without PROXY_MODE always
* stays a forward proxy, regardless of a saved dashboard mode. */
export function applyProxyMode(dbValue: string): void {
const envMode = readEnv('PROXY_MODE');
const candidate = envMode || (_proxyUrlSource === 'dashboard' ? dbValue.trim() : 'forward');
_proxyMode = candidate === 'fetch-relay' ? 'fetch-relay' : 'forward';
enforceRelayUrlPolicy();
}
export function getProxyMode(): ProxyMode {
return _proxyMode;
}
/** Set the bearer token used only to authenticate FreeLLMAPI to a Fetch Relay.
* The environment wins so headless deployments never expose or overwrite it
* through the dashboard. This token is separate from the provider's
* Authorization header, which is preserved for the upstream request. */
export function applyFetchRelayToken(dbValue: string): void {
_fetchRelayToken = readEnv('FETCH_RELAY_TOKEN') || dbValue.trim();
}
export function getFetchRelayToken(): string {
return _fetchRelayToken;
}
/** Encrypt the dashboard-saved Relay credential at rest. The environment form
* never enters the database. */
export function encodeFetchRelayToken(value: string): string {
const trimmed = value.trim();
return trimmed ? JSON.stringify(encrypt(trimmed)) : '';
}
function decodeFetchRelayToken(value: string): string {
if (!value) return '';
try {
const parsed = JSON.parse(value) as { encrypted?: string; iv?: string; authTag?: string };
if (!parsed.encrypted || !parsed.iv || !parsed.authTag) return '';
return decrypt(parsed.encrypted, parsed.iv, parsed.authTag);
} catch {
console.warn('[proxy] Saved Fetch Relay token could not be decrypted; configure it again.');
return '';
}
}
/** Toggle the proxy on/off without losing the URL. */
export function applyProxyEnabled(enabled: boolean): void {
_proxyEnabled = enabled;
if (!enabled) console.log('[proxy] Disabled β€” requests go direct.');
}
export function isProxyEnabled(): boolean {
return _proxyEnabled;
}
/** Set which platforms bypass the proxy. Comma-separated string from DB. */
export function applyProxyBypass(platformsCsv: string): void {
_bypassPlatforms = new Set(
platformsCsv
.split(',')
.map(s => s.trim().toLowerCase())
.filter(Boolean),
);
if (_bypassPlatforms.size > 0) {
console.log(`[proxy] Bypass for: ${[..._bypassPlatforms].join(', ')}`);
}
}
export function getProxyBypassPlatforms(): string[] {
return [..._bypassPlatforms];
}
/** The NO_PROXY rules currently in effect (parsed from the env at apply time). */
export function getNoProxyRules(): string[] {
return [..._noProxyRules];
}
/**
* Returns true when a request should NOT use the proxy.
* True when: proxy is disabled globally, the platform is in the bypass list,
* the upstream host is covered by NO_PROXY, or the upstream is a local/LAN
* destination (#951 β€” see below).
*
* A loopback (127.0.0.0/8, ::1, 0.0.0.0, `localhost`) or private/LAN
* (RFC1918, ULA, CGNAT) destination is unreachable through a remote proxy:
* that proxy has no route to your own 127.0.0.1 and, on any network but
* yours, none to 192.168.1.20 either. Routing it there is never useful and,
* for SOCKS, actively harmful: an IP literal must go on the wire as ATYP 0x01
* (an IP) no matter what the `socks5h` suffix promises, so Tor logs "giving
* Tor only an IP address" and may refuse the connection. The
* Ollama/llama.cpp/LM Studio case β€” the app's primary documented local use,
* "on localhost or the LAN" β€” is exactly this.
*
* FREEAPI_PROXY_LOCAL_DESTINATIONS=true opts out, for the one setup where
* proxying a local address IS the point: an `ssh -D` dynamic tunnel, where
* http://127.0.0.1:11434 sent through the SOCKS proxy resolves at the far end
* and reaches the REMOTE host's Ollama.
*/
function shouldBypassProxy(url: string, platform?: string): boolean {
if (!_proxyEnabled) return true;
if (platform && _bypassPlatforms.has(platform.toLowerCase())) return true;
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
// Unparseable URL β€” leave the routing decision to the caller/fetch.
return false;
}
if (_noProxyRules.length > 0 && noProxyMatches(hostname)) return true;
if (!_proxyLocalDestinations && isLoopbackOrPrivateHostname(hostname)) return true;
return false;
}
/**
* Resolve the proxy dispatcher. For SOCKS schemes this returns a
* SocksProxyAgent; for HTTP/HTTPS it returns an undici ProxyAgent.
*/
async function resolveDispatcher(): Promise<{ dispatcher: unknown; isSocks: boolean } | undefined> {
const now = Date.now();
if (cached && (now - cached.ts) < CACHE_TTL_MS) {
return cached.dispatcher ? { dispatcher: cached.dispatcher, isSocks: cached.isSocks } : undefined;
}
if (!_initialized) applyProxyUrl('');
if (!_proxyUrl) {
cached = { dispatcher: undefined, proxyUrl: '', isSocks: false, ts: now };
return undefined;
}
try {
const isSocks = isSocksProxyUrl(_proxyUrl);
if (isSocks) {
const SocksAgent = await loadSocksAgent();
const dispatcher = new SocksAgent(_proxyUrl);
cached = { dispatcher, proxyUrl: _proxyUrl, isSocks: true, ts: now };
return { dispatcher, isSocks: true };
}
const ProxyAgentCtor = await loadHttpProxyAgent();
const dispatcher = new ProxyAgentCtor({ uri: _proxyUrl });
cached = { dispatcher, proxyUrl: _proxyUrl, isSocks: false, ts: now };
return { dispatcher, isSocks: false };
} catch (err: any) {
console.error(`[proxy] Failed to create dispatcher for "${redactProxyUrl(_proxyUrl)}": ${err.message}`);
cached = { dispatcher: undefined, proxyUrl: _proxyUrl, isSocks: false, ts: now };
return undefined;
}
}
// ── SOCKS-compatible fetch via http/https modules ──
/**
* Request kinds recognised in AbortError messages. Mirrors the values
* written to `requests.request_type` so the abort message and the row
* column agree on terminology.
*/
export type ProxyRequestType = 'chat' | 'embedding' | 'image' | 'video' | 'audio' | 'transcription' | 'moderation' | 'unknown';
/**
* Build an AbortError DOMException whose `message` carries a compact triage
* tag in the form `<platform>, <type>, <timeout>s`. No upstream URL, no
* credentials β€” the platform column in `requests` already identifies the
* upstream and the type column identifies the request kind, so the abort
* message just needs to round-trip what's already on the row.
*
* `isRetryableError()` still triggers on the literal substring "aborted".
*
* `elapsedMs` (when known) is appended so timeout vs. client-cancel is
* distinguishable in logs.
*/
function abortError(
platform: string | undefined,
type: ProxyRequestType,
timeoutMs: number | undefined,
elapsedMs?: number,
): DOMException {
const tag = describeAbort(platform, type, timeoutMs);
const timing = typeof elapsedMs === 'number' ? ` after ${elapsedMs}ms` : '';
return new DOMException(`The operation was aborted (${tag})${timing}`, 'AbortError');
}
/**
* Format the `<platform>, <type>, <timeout>s` tag. Exposed for testing and
* for callers that want to log the tag without re-throwing. Falls back
* gracefully when fields are missing: unknown platform β†’ 'unknown',
* unknown type β†’ 'unknown', no timeout β†’ omit the trailing ', <N>s'.
*/
export function describeAbort(
platform: string | undefined,
type: ProxyRequestType,
timeoutMs: number | undefined,
): string {
const p = (platform && platform.trim()) || 'unknown';
const t = type || 'unknown';
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return `${p}, ${t}`;
}
const seconds = Math.max(1, Math.round(timeoutMs / 1000));
return `${p}, ${t}, ${seconds}s`;
}
/**
* undici reports every connection that never opened as a bare "fetch failed"
* and keeps the reason (ENOTFOUND, ECONNREFUSED, a certificate error) in
* `err.cause`, which never reached the attempt trail, the logs or the client.
* A DNS failure, a dead proxy and TLS-intercepting antivirus all looked the
* same (#1373). Append the cause code and the upstream host so the message
* says which it was. "fetch failed" stays as the prefix, which the
* retry classifier matches on.
*/
export function describeFetchFailure(err: unknown, url: string, platform?: string): void {
if (!(err instanceof TypeError) || err.message !== 'fetch failed') return;
let code = '';
const seen = new Set<unknown>();
let cur: any = err.cause;
for (let depth = 0; cur && typeof cur === 'object' && !seen.has(cur) && depth < 5; depth++) {
seen.add(cur);
if (typeof cur.code === 'string' && cur.code) { code = cur.code; break; }
cur = cur.cause;
}
if (!code) return;
let host = '';
try { host = new URL(url).hostname; } catch { /* leave it out */ }
const proxied = _proxyEnabled && !!(perKeyProxyStore.getStore() || _proxyUrl) && !shouldBypassProxy(url, platform);
const parts = [code, host, proxied ? 'via proxy' : ''].filter(Boolean);
err.message = `fetch failed (${parts.join(' ')})`;
}
/**
* Rewrite an AbortError rejection so its `.message` carries the compact
* triage tag `<platform>, <type>, <timeout>s`. Preserves `name: 'AbortError'`
* so `isRetryableError()` (which matches on the substring "aborted") keeps
* classifying it as retryable. If the original error is not an AbortError,
* it's returned unchanged.
*/
function enrichAbort(
err: unknown,
platform: string | undefined,
type: ProxyRequestType,
timeoutMs: number | undefined,
): Error {
if (!err || typeof err !== 'object') return err as Error;
const e = err as Error & { name?: string; cause?: unknown };
const isAbort = e.name === 'AbortError' || /aborted/i.test(e.message ?? '');
if (!isAbort) return e;
const enriched = new DOMException(
`The operation was aborted (${describeAbort(platform, type, timeoutMs)})`,
'AbortError',
);
// Preserve upstream error chain so debug logs still see the original cause.
if (e.cause !== undefined) (enriched as any).cause = e.cause;
return enriched;
}
/**
* DNS `lookup` override for the SOCKS fallback path: hand back the hostname it
* was asked to resolve, unchanged.
*
* socks-proxy-agent resolves the DESTINATION locally for the `socks5://` and
* `socks4://` schemes (`shouldLookup`) and sends the proxy a bare IP; only
* `socks5h://`/`socks4a://` pass the name through. That local resolution is
* what breaks rule-based proxy clients (Clash and friends), which match routing
* rules on the domain and have nothing to match once the name is gone β€” and on
* a DNS-poisoned network it resolves to the poisoned address as well.
*
* `http.request` forwards this to the agent as `opts.lookup`, so echoing the
* hostname makes every SOCKS scheme reach the proxy with the domain intact,
* i.e. behave like its `h`/`a` variant. The agent only forwards the "address"
* as the SOCKS destination host β€” it never inspects the address family, so the
* `4` is a placeholder the callback signature requires.
*/
export function socksHostnameLookup(
hostname: string,
_options: unknown,
callback: (err: null, address: string, family: number) => void,
): void {
callback(null, hostname, 4);
}
function socksFetch(
urlStr: string,
init: RequestInit | undefined,
agent: http.Agent | undefined,
platform: string | undefined,
type: ProxyRequestType,
timeoutMs: number | undefined,
): Promise<Response> {
const url = new URL(urlStr);
const isTls = url.protocol === 'https:';
const transport = isTls ? https : http;
const port = url.port || (isTls ? 443 : 80);
const method = init?.method ?? 'GET';
const headers: Record<string, string> = {};
if (init?.headers) {
for (const [k, v] of Object.entries(init.headers as Record<string, string>)) {
headers[k.toLowerCase()] = v;
}
}
const signal = init?.signal;
const startedAt = Date.now();
// Socket guard for the SOCKS fallback path β€” deliberately NOT `timeoutMs`.
// The two clocks measure different things: http.request's `timeout` is a
// socket INACTIVITY timer that stays armed across the whole streaming body,
// while `timeoutMs` is a header/request deadline the caller disarms the
// moment response headers arrive (providers/base.ts fetchWithTimeout). Mid-
// stream time is owned by the stall watchdog and the first-byte grace
// (#553/#584, default 90s), so pinning the socket timer to a platform's
// 15-60s chat timeout would kill healthy streams during prefill.
//
// So this only ever RAISES the historical 120s floor (#666): a user with
// PROVIDER_TIMEOUT_CUSTOM=600000 no longer dies at 120s, and the +30s grace
// keeps the caller's abort firing first so the tagged AbortError (see
// enrichAbort) survives instead of the bare socket 'timeout'. 0 means "no
// timeout" (provider semantics); undefined or malformed input falls back to
// the 120s guard rather than disabling it.
const socketTimeoutMs = timeoutMs === 0
? 0
: typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0
? Math.max(timeoutMs + 30_000, 120_000)
: 120_000;
// What to reject with when the signal fires. A client-caused abort carries
// its own marked reason (newClientAbortError in lib/error-classify.ts) β€”
// preserve it so the failure isn't misclassified downstream as a provider
// timeout; a plain timer abort keeps the tagged AbortError.
const abortRejection = (): Error => {
const reason = signal?.reason;
return reason instanceof Error && reason.name !== 'AbortError' && reason.name !== 'TimeoutError'
? reason
: abortError(platform, type, timeoutMs, Date.now() - startedAt);
};
return new Promise((resolve, reject) => {
const req = transport.request({
hostname: url.hostname,
port,
path: url.pathname + url.search,
method,
headers: { ...headers, host: url.hostname },
agent,
servername: isTls ? url.hostname : undefined,
rejectUnauthorized: true,
timeout: socketTimeoutMs,
// Keep the destination hostname unresolved so the SOCKS proxy does the
// DNS. `agent` here is always a SocksProxyAgent (every socksFetch caller
// is behind an `isSocks` branch), and the agent is the only consumer of
// this hook β€” the connection to the proxy itself still resolves normally.
lookup: socksHostnameLookup,
}, (res) => {
if (signal?.aborted) {
res.destroy();
reject(abortRejection());
return;
}
const status = res.statusCode ?? 0;
const statusText = res.statusMessage ?? '';
const body = new ReadableStream({
start(controller) {
res.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
res.on('end', () => controller.close());
res.on('error', (err: Error) => controller.error(err));
},
cancel() {
res.destroy();
},
});
const hdrs: Record<string, string> = {};
for (const [k, v] of Object.entries(res.headers)) {
hdrs[k] = v as string;
}
resolve(new Response(body, { status, statusText, headers: hdrs }));
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('timeout'));
});
if (signal) {
if (signal.aborted) {
req.destroy();
reject(abortRejection());
return;
}
signal.addEventListener('abort', () => {
req.destroy();
reject(abortRejection());
}, { once: true });
}
if (init?.body) {
req.write(init.body as string);
}
req.end();
});
}
/**
* Drop-in replacement for `fetch(url, init)` that routes through the
* configured proxy. Pass an optional `platform` string to respect the
* per-platform bypass list.
*
* When no proxy is configured, or proxy is disabled, or the platform is
* in the bypass list, this is a direct pass-through to `fetch()`.
*
* `requestType` and `timeoutMs` are propagated into the AbortError
* message so triage reads `<platform>, <type>, <timeout>s`. Both default
* to `undefined` / `'unknown'` when callers haven't been updated yet β€”
* the abort still fires, it just omits the unknown fields.
*/
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
export async function proxyFetch(
url: string,
init?: RequestInit,
platform?: string,
requestType: ProxyRequestType = 'unknown',
timeoutMs?: number,
): Promise<Response> {
try {
// SSRF guard (#440): 'custom' is the only platform whose target URL is
// user-supplied (base_url on the api_keys row), so it is re-assessed on
// every request β€” a URL saved before the guard existed, edited in the DB,
// or whose DNS now points somewhere blocked still can't reach cloud
// metadata / link-local addresses.
if (platform === 'custom') {
await assertProviderUrlAllowed(url);
// Redirects are never followed for custom providers: fetch()'s default
// 'follow' would re-request the Location target WITHOUT re-running the
// guard above, so a public base_url answering 302 β†’ an internal or
// metadata address would defeat the check. socksFetch (http.request)
// never followed redirects; forcing redirect: 'manual' here makes every
// path behave the same, and the 3xx is converted to an explicit error
// below so the operator sees why instead of a confusing empty body.
init = { ...init, redirect: 'manual' };
}
const response = await dispatchFetch(url, init, platform, requestType, timeoutMs);
if (platform === 'custom' && REDIRECT_STATUSES.has(response.status)) {
const location = response.headers.get('location') ?? 'an unspecified location';
throw new Error(
`Custom provider URL blocked: upstream redirected (${response.status}) to ${location}; ` +
'redirects are not followed for custom providers, point base_url directly at the API',
);
}
return response;
} catch (err) {
describeFetchFailure(err, url, platform);
// Rewrite bare "The operation was aborted" rejections so they carry the
// compact triage tag. Preserves the AbortError name so
// `isRetryableError()` still classifies the failure as retryable.
throw enrichAbort(err, platform, requestType, timeoutMs);
}
}
/** Route the request through the configured proxy (or straight to fetch). */
async function dispatchFetch(
url: string,
init: RequestInit | undefined,
platform: string | undefined,
requestType: ProxyRequestType,
timeoutMs: number | undefined,
): Promise<Response> {
// #590: a per-key proxy override (set via withKeyProxy around the provider
// call) takes precedence over the global proxy for THIS request. Empty
// string (the store default) means "fall back to global".
const perKeyUrl = perKeyProxyStore.getStore() ?? '';
if (perKeyUrl) {
// Every bypass still applies, unchanged: the global on/off switch, the
// per-platform bypass list, NO_PROXY, and local/LAN destinations. A
// per-key override says WHICH proxy to use, not that this request must be
// proxied β€” an operator who turned proxying off, listed the upstream in
// NO_PROXY, or points at a local box still gets a direct connection.
if (!shouldBypassProxy(url, platform)) {
const resolved = await resolvePerKeyDispatcher(perKeyUrl);
if (resolved) {
if (resolved.isSocks) {
return socksFetch(url, init, resolved.dispatcher as http.Agent, platform, requestType, timeoutMs);
}
return fetch(url, { ...init, dispatcher: resolved.dispatcher } as unknown as RequestInit);
}
}
// Per-key proxy failed to build β†’ fall through to the global/direct path.
}
// Bypass check: disabled globally, this platform is exempt, the upstream
// host is listed in NO_PROXY, or it is a local/LAN destination no proxy can
// reach (#951).
if (shouldBypassProxy(url, platform)) {
return fetch(url, init);
}
if (_proxyMode === 'fetch-relay' && _proxyUrl) {
return fetchRelayFetch(_proxyUrl, url, init, _fetchRelayToken);
}
const resolved = await resolveDispatcher();
// No dispatcher (no proxy URL configured, or it failed to build) β†’ direct
if (!resolved) {
return fetch(url, init);
}
// SOCKS proxy β†’ http/https fallback
if (resolved.isSocks) {
return socksFetch(url, init, resolved.dispatcher as http.Agent, platform, requestType, timeoutMs);
}
// HTTP/HTTPS proxy β†’ undici (dispatcher is an undici extension not in TS types)
return fetch(url, { ...init, dispatcher: resolved.dispatcher } as unknown as RequestInit);
}
/** Send an application-layer HTTP request through a user-controlled fetch
* relay. The original body remains a stream/body object and the returned
* Response is passed through untouched, so neither direction is buffered.
* Redirects from the relay are deliberately exposed to the caller: following
* one here could silently turn a relayed request into a direct request. */
async function fetchRelayFetch(
relayUrl: string,
targetUrl: string,
init: RequestInit | undefined,
relayToken: string,
): Promise<Response> {
const headers = new Headers(init?.headers);
// These describe the provider connection and must be recalculated for the
// relay connection. The relay reference implementation removes the target
// header before calling the provider.
headers.delete('host');
headers.delete('content-length');
headers.set(FETCH_RELAY_TARGET_HEADER, targetUrl);
// Always overwrite caller-supplied Relay control headers. They belong to
// this hop and must not let an upstream request choose another target or
// credential. An empty token supports deliberately unauthenticated relays.
if (relayToken) headers.set(FETCH_RELAY_AUTH_HEADER, `Bearer ${relayToken}`);
else headers.delete(FETCH_RELAY_AUTH_HEADER);
return fetch(relayUrl, {
...init,
headers,
redirect: 'manual',
});
}
/** Build (and TTL-cache) a dispatcher for a per-key proxy URL. Returns
* undefined when the URL is empty or the agent fails to build. */
async function resolvePerKeyDispatcher(proxyUrl: string): Promise<{ dispatcher: unknown; isSocks: boolean } | undefined> {
const now = Date.now();
const hit = perKeyCached.get(proxyUrl);
if (hit?.dispatcher) {
rememberPerKeyDispatcher(proxyUrl, hit);
return { dispatcher: hit.dispatcher, isSocks: hit.isSocks };
}
// Negative entry, still inside its cool-off: don't retry the build yet.
if (hit && now - hit.ts < PER_KEY_FAILURE_TTL_MS) return undefined;
try {
const isSocks = isSocksProxyUrl(proxyUrl);
if (isSocks) {
const SocksAgent = await loadSocksAgent();
const dispatcher = new SocksAgent(proxyUrl);
rememberPerKeyDispatcher(proxyUrl, { dispatcher, isSocks: true, ts: now });
return { dispatcher, isSocks: true };
}
const ProxyAgentCtor = await loadHttpProxyAgent();
const dispatcher = new ProxyAgentCtor({ uri: proxyUrl });
rememberPerKeyDispatcher(proxyUrl, { dispatcher, isSocks: false, ts: now });
return { dispatcher, isSocks: false };
} catch (err: any) {
console.error(`[proxy] Failed to create per-key dispatcher for "${redactProxyUrl(proxyUrl)}": ${err.message}`);
rememberPerKeyDispatcher(proxyUrl, { dispatcher: undefined, isSocks: false, ts: now });
return undefined;
}
}
/**
* Returns true when the proxy is configured AND enabled. Used by the dashboard
* to show the "Active" badge. Intentionally does NOT construct a dispatcher (so
* it never triggers the lazy undici import) β€” "configured + enabled" is exactly
* what the badge means.
*/
export function isProxyActive(): boolean {
if (!_initialized) applyProxyUrl('');
return _proxyEnabled && !!_proxyUrl;
}
/** Force-rebuild the outbound connection pools on the next request. Called on
* sleep/wake recovery to drop pooled TCP connections that died while the
* host was suspended (undici keeps them warm and would hand a dead socket
* to the first post-wake request). */
export function flushProxyCache(): void {
// Outbound-proxy dispatcher (only in play when a proxy URL is configured).
cached = null;
// The default no-proxy path is bare fetch() on Node's GLOBAL undici
// dispatcher β€” exactly the pool the headline laptop-lid scenario rides β€” so
// nulling the proxy cache alone left the flush a no-op for most
// deployments. Node keeps that dispatcher in the global symbol registry
// (getGlobalDispatcher/setGlobalDispatcher read and write the same key), so
// swap in a fresh instance of its own constructor: new requests get new
// sockets, in-flight requests keep a reference to the old dispatcher and
// complete undisturbed. Deliberately NOT `import('undici')`: the built-in
// fetch uses Node's bundled copy, and the npm package isn't installed in
// the production image (verified live β€” the import throws there).
try {
const sym = Symbol.for('undici.globalDispatcher.1');
const current = (globalThis as Record<symbol, unknown>)[sym] as { constructor: new () => unknown } | undefined;
// Symbol unset = no fetch has run yet, so there are no pooled sockets to drop.
if (current?.constructor) {
(globalThis as Record<symbol, unknown>)[sym] = new current.constructor();
}
} catch (err: any) {
console.warn(`[proxy] could not replace the global fetch dispatcher on wake: ${err?.message ?? err}`);
}
}
export interface ProxyProbeResult {
ok: boolean;
latencyMs: number;
status?: number;
error?: string;
/** The URL the probe actually called, so the dashboard can say what it
* reached rather than leaving the operator to guess. */
target?: string;
}
/**
* Where the probe goes when the caller names no target and no provider key
* can supply one.
*
* Deliberately NOT an AI vendor. The probe answers "can this proxy reach the
* internet", and pointing it at a third party the install may never use makes
* the test lie in both directions: a gateway that never calls that vendor now
* calls it on every Test, and a network that blocks it reports a working proxy
* as broken. `/cdn-cgi/trace` is a plain-text reachability endpoint with no
* account, no rate limit and no regional AI-vendor blocking.
*/
export const DEFAULT_PROXY_PROBE_TARGET = 'https://www.cloudflare.com/cdn-cgi/trace';
/**
* Test whether a proxy URL can actually route traffic (#863). Backs the
* Settings β†’ Outbound proxy "Test" button so an operator can verify a draft
* value BEFORE saving it.
*
* `proxyUrl` empty β†’ falls back to the saved global proxy URL (getProxyUrl);
* when neither is set the probe runs direct, so the button is still useful
* before any proxy has been configured.
*
* The probe target is supplied by the caller and should be an endpoint this
* install genuinely uses β€” the /models route of a provider the operator holds
* an enabled key for. Any HTTP response, even a 401/403 without a key, proves
* the proxy route works; only a network-level failure (DNS, connect, timeout)
* counts as a proxy failure.
*/
export async function probeProxyUrl(
proxyUrl: string | undefined,
options: { targetUrl?: string; timeoutMs?: number; mode?: ProxyMode; relayToken?: string } = {},
): Promise<ProxyProbeResult> {
const timeoutMs = options.timeoutMs ?? 10_000;
const started = Date.now();
const url = (proxyUrl ?? '').trim() || getProxyUrl();
// The caller passes the endpoint this install actually talks to (see
// routes/settings.ts); the constant is only the no-providers fallback.
const target = (options.targetUrl ?? '').trim() || DEFAULT_PROXY_PROBE_TARGET;
const relayMode = Boolean(url) && (options.mode ?? getProxyMode()) === 'fetch-relay';
try {
let response: Response;
if (!url) {
response = await fetch(target, { signal: AbortSignal.timeout(timeoutMs) });
} else if (relayMode) {
response = await fetchRelayFetch(url, target, {
method: 'GET',
signal: AbortSignal.timeout(timeoutMs),
}, options.relayToken ?? getFetchRelayToken());
} else {
const resolved = await resolvePerKeyDispatcher(url);
if (!resolved) {
return { ok: false, latencyMs: Date.now() - started, target, error: 'Failed to build a proxy agent for the given URL' };
}
if (resolved.isSocks) {
response = await socksFetch(target, { signal: AbortSignal.timeout(timeoutMs) }, resolved.dispatcher as http.Agent, undefined, 'unknown', timeoutMs);
} else {
response = await fetch(target, { ...{ signal: AbortSignal.timeout(timeoutMs) }, dispatcher: resolved.dispatcher } as unknown as RequestInit);
}
}
// A relay answers on the same connection it forwards over, so a 401/403
// here is far more likely to be the relay refusing our token than the
// provider refusing a key we never sent. Reporting that as a pass is what
// makes a misconfigured token look like a working relay, so fail it and
// name the hop. A provider that genuinely answers 401 through a good relay
// is the rare false negative, and the reason still points at the token.
if (relayMode && (response.status === 401 || response.status === 403)) {
return {
ok: false,
latencyMs: Date.now() - started,
status: response.status,
target,
error: `relay rejected the token (${response.status})`,
};
}
// Any other HTTP response proves the proxy route works; the upstream may
// still answer 4xx without a key, which is connectivity, not proxy failure.
return { ok: true, latencyMs: Date.now() - started, status: response.status, target };
} catch (err: any) {
return { ok: false, latencyMs: Date.now() - started, target, error: err?.message ?? String(err) };
}
}