diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b9d78f..67bcdc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: npm install --no-save jest-junit node --experimental-vm-modules node_modules/.bin/jest \ --testPathPatterns='tests/unit|plugins' \ - --testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test' \ + --testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test|requestProxyApi\.test' \ --forceExit env: CI: true @@ -92,7 +92,7 @@ jobs: run: | xvfb-run --auto-servernum \ node --experimental-vm-modules node_modules/.bin/jest \ - --testPathPatterns='tests/unit/(security|tabRecycling|cookies)\.test' \ + --testPathPatterns='tests/unit/(security|tabRecycling|cookies|requestProxyApi)\.test' \ --runInBand --forceExit env: CI: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8417e33..fd9c702 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,7 +35,7 @@ jobs: npm install --no-save jest-junit node --experimental-vm-modules node_modules/.bin/jest \ --testPathPatterns='tests/unit|plugins' \ - --testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test' \ + --testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test|requestProxyApi\.test' \ --forceExit env: CI: true @@ -68,7 +68,7 @@ jobs: run: | xvfb-run --auto-servernum \ node --experimental-vm-modules node_modules/.bin/jest \ - --testPathPatterns='tests/unit/(security|tabRecycling|cookies)\.test' \ + --testPathPatterns='tests/unit/(security|tabRecycling|cookies|requestProxyApi)\.test' \ --runInBand --forceExit env: CI: true diff --git a/AGENTS.md b/AGENTS.md index 82f2693..d0623bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -303,13 +303,13 @@ export function register(app, ctx) { | Property | Type | Description | |----------|------|-------------| -| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, lastAccess }` | +| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, pageLeases, lastAccess, proxySessionId, requestProxy, tracePath }` | | `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) | | `log` | `function` | `log(level, msg, fields)` -- structured JSON logging | | `events` | `EventEmitter` | Plugin event bus (29 events -- see below) | | `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback | | `ensureBrowser` | `async function` | Launch browser if not running, return browser instance | -| `getSession` | `async function` | `getSession(userId)` -- get or create a session | +| `getSession` | `async function` | `getSession(userId, { trace, requestProxy, inheritedRequestProxy })` -- get or create a session; request proxies are immutable per user session | | `destroySession` | `async function` | `destroySession(userId, { reason })` -- tear down and await a session close | | `withUserLimit` | `async function` | `withUserLimit(userId, fn)` -- run `fn` within per-user concurrency limit | | `safePageClose` | `async function` | `safePageClose(page)` -- close a page with timeout guard | @@ -322,6 +322,8 @@ export function register(app, ctx) { | `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) | | `metricsRegistry` | `function` | `metricsRegistry()` -- raw prom-client Registry or null | +`session.requestProxy` is either a normalized Playwright proxy object or `null`. It may contain credentials. Automatic teardown can retain the same normalized object in the bounded, expiring recovery cache after the session closes. Treat both copies as sensitive: compare them with the request-proxy helpers, but do not add them to logs or HTTP responses. `POST /tabs` may expose only the boolean `proxied` state. The existing `session:creating` hook exposes `contextOptions` to trusted in-process plugins, including proxy credentials. `inheritedRequestProxy` is reserved for trusted internal recovery paths; HTTP handlers must pass untrusted input as `requestProxy` so validation and conflict handling run. + ### Events (29) 28 emitted by core, 1 (`session:storage:export`) emitted by plugins. diff --git a/README.md b/README.md index 6b5fc1b..4717556 100644 --- a/README.md +++ b/README.md @@ -376,12 +376,42 @@ docker run -p 9377:9377 \ camofox-browser ``` -When a proxy is configured: +When a global proxy is configured: - All traffic routes through the proxy - Camoufox's GeoIP automatically sets `locale`, `timezone`, and `geolocation` to match the proxy's exit IP - Browser fingerprint (language, timezone, coordinates) is consistent with the proxy location - Without a proxy, defaults to `en-US`, `America/Los_Angeles`, San Francisco coordinates +**Request-level proxy (per user session):** + +When the server has no global `PROXY_*` configuration, `POST /tabs` can select a proxy for the new user's BrowserContext: + +```bash +curl -X POST http://localhost:9377/tabs \ + -H 'Content-Type: application/json' \ + -d '{ + "userId": "research-user", + "sessionKey": "job-1", + "proxy": { + "server": "http://gw.example.com:10000", + "username": "myuser", + "password": "mypass" + } + }' +``` + +The request proxy is a session-level choice, not a tab-level switch: + +- The first session created for a `userId` fixes its proxy. All tab groups for that user share it. +- Repeating the same proxy is allowed. Supplying a different proxy returns `409 proxy_conflict` with `recovery: delete_session`. +- Omitting `proxy` reuses the live session's choice. Dead-context, browser-disconnect/restart, navigation-timeout, new-page, idle-expiry, memory-pressure, and tab-reaper recovery preserve it for at least one minute beyond the longer configured session/tab idle period. +- `POST /tabs` returns `proxied: true` when the user's BrowserContext uses a request-level proxy, and `false` otherwise. It never returns proxy credentials. +- `DELETE /sessions/:userId` intentionally clears both the session and any pending proxy-recovery state. +- Cookie import does not accept a proxy. It reuses the current or recently recovered proxy, otherwise it creates an unproxied session. +- Request-level proxies and global `PROXY_*` configuration are mutually exclusive. Mixed mode returns `409 proxy_mode_conflict`. +- Accepted schemes are `http`, `https`, `socks4`, and `socks5`. Put credentials in `username` and `password`, not in `server`. Request-body credentials are literal strings; unlike global `PROXY_USERNAME` / `PROXY_PASSWORD` values, they are not percent-decoded. +- Unlike global proxy mode, a request-level proxy does not infer GeoIP settings from the exit IP. It uses the deterministic no-global-proxy fallback: `en-US`, `America/Los_Angeles`, and San Francisco coordinates. + ### Telemetry Browser automation fails in ways that are hard to predict -- Cloudflare challenges, site redesigns breaking selectors, redirect loops, dialog storms, renderer crashes. The scope is wide and the failure modes are diverse. Without telemetry, the only signal is "it didn't work." diff --git a/lib/browser-errors.js b/lib/browser-errors.js index a2c9b0a..715dc6b 100644 --- a/lib/browser-errors.js +++ b/lib/browser-errors.js @@ -129,6 +129,7 @@ export function browserErrorCode(err) { } export function browserErrorRecovery(err) { + if (err?.recovery) return err.recovery; const code = browserErrorCode(err); if (code === 'page_crashed' || code === 'tab_destroyed' || code === 'tab_unresponsive') return 'create_new_tab'; if (code === 'stale_refs' || code === 'element_not_actionable' || code === 'ambiguous_selector' || code === 'navigation_race') return 'snapshot_then_retry'; @@ -137,5 +138,6 @@ export function browserErrorRecovery(err) { } export function isRetryableBrowserError(err) { + if (err?.retryable !== undefined) return Boolean(err.retryable); return Boolean(browserErrorRecovery(err)); } diff --git a/lib/new-page-recovery.js b/lib/new-page-recovery.js index 4a0fd1a..f6a94c0 100644 --- a/lib/new-page-recovery.js +++ b/lib/new-page-recovery.js @@ -13,6 +13,7 @@ export async function createPageWithSessionRecovery({ getSession, log, }) { + const inheritedRequestProxy = session.requestProxy || null; let lease = acquirePageLease(session); try { const page = setLeasedPage(lease, await withTimeout(session.context.newPage(), timeoutMs, 'new page')); @@ -32,7 +33,11 @@ export async function createPageWithSessionRecovery({ await destroySession(userId, { reason: 'new_page_unresponsive' }); } - session = await getSession(userId, { trace }); + const recoveryOptions = { trace }; + if (inheritedRequestProxy) { + recoveryOptions.inheritedRequestProxy = inheritedRequestProxy; + } + session = await getSession(userId, recoveryOptions); lease = acquirePageLease(session); try { const page = setLeasedPage(lease, await withTimeout(session.context.newPage(), timeoutMs, 'new page retry')); diff --git a/lib/request-proxy.js b/lib/request-proxy.js new file mode 100644 index 0000000..1ca0fd9 --- /dev/null +++ b/lib/request-proxy.js @@ -0,0 +1,203 @@ +const ALLOWED_PROXY_SCHEMES = new Set(['http:', 'https:', 'socks4:', 'socks5:']); +const MAX_PROXY_SERVER_LENGTH = 2048; +const MAX_PROXY_CREDENTIAL_LENGTH = 512; +const DEFAULT_RECOVERY_TTL_MS = 5 * 60 * 1000; +const DEFAULT_MAX_RECOVERY_ENTRIES = 256; +const ALLOWED_PROXY_FIELDS = new Set(['server', 'username', 'password']); +export const REQUEST_PROXY_RECOVERY_REASONS = Object.freeze([ + 'route_dead_context', + 'dead_context', + 'browser_disconnected', + 'navigation_timeout', + 'new_page_unresponsive', + 'pressure_cleanup_empty_session', + 'session_timeout', + 'memory_pressure', + 'tab_reaper_empty_session', +]); +const AUTOMATIC_RECOVERY_REASONS = new Set(REQUEST_PROXY_RECOVERY_REASONS); + +function proxyError(message, statusCode = 400, { code = null, recovery = null, retryable } = {}) { + return Object.assign(new Error(message), { + statusCode, + ...(code ? { code } : {}), + ...(recovery ? { recovery } : {}), + ...(retryable !== undefined ? { retryable } : {}), + }); +} + +function copyProxy(proxy) { + if (!proxy) return null; + return { + server: proxy.server, + ...(proxy.username !== undefined ? { username: proxy.username } : {}), + ...(proxy.password !== undefined ? { password: proxy.password } : {}), + }; +} + +export function redactProxy(proxy) { + if (!proxy) return null; + return { + server: proxy.server, + username: proxy.username ? '' : undefined, + password: proxy.password ? '' : undefined, + }; +} + +export function shouldPreserveRequestProxy(reason) { + return AUTOMATIC_RECOVERY_REASONS.has(reason) || reason?.startsWith('browser_restart:') === true; +} + +function validateOptionalCredential(value, field) { + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw proxyError(`proxy.${field} must be a string`); + } + if (value.length > MAX_PROXY_CREDENTIAL_LENGTH) { + throw proxyError(`proxy.${field} is too long`); + } + return value; +} + +export function normalizeRequestProxy(proxy) { + if (proxy === undefined) return null; + if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) { + throw proxyError('proxy must be an object'); + } + const unsupportedField = Object.keys(proxy).find(field => !ALLOWED_PROXY_FIELDS.has(field)); + if (unsupportedField) { + throw proxyError('proxy contains unsupported fields'); + } + if (typeof proxy.server !== 'string' || !proxy.server.trim()) { + throw proxyError('proxy.server is required'); + } + + const server = proxy.server.trim(); + if (server.length > MAX_PROXY_SERVER_LENGTH) { + throw proxyError('proxy.server is too long'); + } + + let parsed; + try { + parsed = new URL(server); + } catch { + throw proxyError('proxy.server must be a valid URL'); + } + if (!ALLOWED_PROXY_SCHEMES.has(parsed.protocol)) { + throw proxyError('proxy.server scheme must be http, https, socks4, or socks5'); + } + if (!parsed.hostname) { + throw proxyError('proxy.server must include a hostname'); + } + if (parsed.username || parsed.password) { + throw proxyError('proxy credentials must be provided as proxy.username/proxy.password, not embedded in proxy.server'); + } + + const username = validateOptionalCredential(proxy.username, 'username'); + const password = validateOptionalCredential(proxy.password, 'password'); + return { + server, + ...(username !== undefined ? { username } : {}), + ...(password !== undefined ? { password } : {}), + }; +} + +export function requestProxiesEqual(a, b) { + const left = a || null; + const right = b || null; + if (!left || !right) return left === right; + return left.server === right.server && + (left.username || '') === (right.username || '') && + (left.password || '') === (right.password || ''); +} + +export function requestProxyConflict(message = 'existing session uses a different proxy') { + return proxyError(message, 409, { + code: 'proxy_conflict', + recovery: 'delete_session', + retryable: false, + }); +} + +export function assertRequestProxyCompatible(expectedProxy, actualProxy) { + if (!requestProxiesEqual(expectedProxy, actualProxy)) throw requestProxyConflict(); + return actualProxy || null; +} + +export function resolveRequestProxy({ requestedProxy, existingSession, globalProxyActive }) { + if (requestedProxy === undefined) return null; + const proxy = normalizeRequestProxy(requestedProxy); + + if (globalProxyActive) { + throw proxyError( + 'request-level proxy cannot be used while global proxy configuration is active', + 409, + { code: 'proxy_mode_conflict', recovery: 'remove_request_proxy', retryable: false }, + ); + } + + if (existingSession) { + if (requestProxiesEqual(proxy, existingSession.requestProxy || null)) { + return proxy; + } + throw requestProxyConflict('proxy can only be set when creating a new user session; existing session uses a different proxy'); + } + + return proxy; +} + +export function createRequestProxyRecoveryCache({ + ttlMs = DEFAULT_RECOVERY_TTL_MS, + maxEntries = DEFAULT_MAX_RECOVERY_ENTRIES, + now = Date.now, +} = {}) { + const entries = new Map(); + const boundedTtlMs = Math.max(1, Number(ttlMs) || DEFAULT_RECOVERY_TTL_MS); + const boundedMaxEntries = Math.max(1, Math.floor(Number(maxEntries) || DEFAULT_MAX_RECOVERY_ENTRIES)); + + function pruneExpired(at = now()) { + for (const [userId, entry] of entries) { + if (entry.expiresAt <= at) entries.delete(userId); + } + } + + return { + remember(userId, proxy) { + const key = String(userId); + if (!proxy) { + entries.delete(key); + return; + } + const at = now(); + pruneExpired(at); + entries.delete(key); + while (entries.size >= boundedMaxEntries) { + const oldestKey = entries.keys().next().value; + entries.delete(oldestKey); + } + entries.set(key, { + proxy: copyProxy(proxy), + expiresAt: at + boundedTtlMs, + }); + }, + + get(userId) { + const at = now(); + pruneExpired(at); + return copyProxy(entries.get(String(userId))?.proxy || null); + }, + + delete(userId) { + return entries.delete(String(userId)); + }, + + clear() { + entries.clear(); + }, + + get size() { + pruneExpired(now()); + return entries.size; + }, + }; +} diff --git a/openapi.json b/openapi.json index bc31368..d630d29 100644 --- a/openapi.json +++ b/openapi.json @@ -406,7 +406,7 @@ "Tabs" ], "summary": "Create a new tab", - "description": "Creates a tab in the given session. Optionally navigates to an initial URL.", + "description": "Creates a tab in the given session. Optionally navigates to an initial URL.\n\nA `proxy` is applied when the user BrowserContext is first created and is immutable\nfor that `userId`. Supplying the same proxy again is allowed; supplying a different\nproxy returns `409 proxy_conflict`. Omitting `proxy` reuses the existing session choice.\n\nAutomatic recovery, browser disconnects/restarts, idle expiry, memory-pressure eviction,\nand tab reaping preserve the request proxy for at least one minute beyond the longer\nconfigured session/tab idle period. Explicit `DELETE /sessions/{userId}` clears that\nrecovery state. Cookie import does not accept a proxy; it reuses the current or recently\nrecovered proxy, otherwise it creates an unproxied session. Request proxies cannot be\ncombined with global `PROXY_*` configuration. They use the deterministic no-global-proxy\nlocale, timezone, and geolocation instead of inferring those values from the proxy exit IP.\n", "requestBody": { "required": true, "content": { @@ -437,6 +437,34 @@ "trace": { "type": "boolean", "description": "Enable Playwright tracing for this session (screenshots, DOM snapshots, network). Must be set on first tab creation; cannot be added to an existing session." + }, + "proxy": { + "type": "object", + "additionalProperties": false, + "required": [ + "server" + ], + "description": "Optional Playwright proxy for the user BrowserContext. Do not put credentials in the server URL.", + "properties": { + "server": { + "type": "string", + "maxLength": 2048, + "description": "Proxy server URL using http, https, socks4, or socks5. Embedded credentials are rejected.", + "example": "http://gw.example.com:10000" + }, + "username": { + "type": "string", + "maxLength": 512, + "writeOnly": true, + "description": "Optional literal proxy username. Request-body credentials are not percent-decoded." + }, + "password": { + "type": "string", + "maxLength": 512, + "writeOnly": true, + "description": "Optional literal proxy password. Request-body credentials are not percent-decoded." + } + } } } } @@ -456,6 +484,10 @@ }, "url": { "type": "string" + }, + "proxied": { + "type": "boolean", + "description": "True when this tab's user BrowserContext uses a request-level proxy." } } } @@ -463,7 +495,7 @@ } }, "400": { - "description": "Missing required fields.", + "description": "Missing required fields or invalid proxy configuration.", "content": { "application/json": { "schema": { @@ -473,7 +505,7 @@ } }, "409": { - "description": "Cannot enable tracing on an existing session.", + "description": "Cannot enable tracing on an existing session, or the request proxy conflicts with an existing/global proxy session.", "content": { "application/json": { "schema": { diff --git a/server.js b/server.js index 06443a7..8cd6d51 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,13 @@ import os from 'os'; import { expandMacro } from './lib/macros.js'; import { loadConfig } from './lib/config.js'; import { normalizePlaywrightProxy, createProxyPool, buildProxyUrl } from './lib/proxy.js'; +import { + assertRequestProxyCompatible, + createRequestProxyRecoveryCache, + redactProxy, + resolveRequestProxy, + shouldPreserveRequestProxy, +} from './lib/request-proxy.js'; import { createFlyHelpers } from './lib/fly.js'; import { createPluginEvents, loadPlugins } from './lib/plugins.js'; import { requireAuth, accessKeyMiddleware, timingSafeCompare as _timingSafeCompare, isLoopbackAddress as _isLoopbackAddress } from './lib/auth.js'; @@ -292,7 +299,7 @@ function sendError(res, err, extraFields = {}) { if (code) body.code = code; if (recovery) body.recovery = recovery; if (err instanceof StaleRefsError) body.ref = err.ref; - if (status >= 500 && !err.statusCode && !recovery) { + if (status >= 500 && !err.statusCode) { const req = res.req; const userId = req?.query?.userId || req?.body?.userId; sentryCaptureException(err, { @@ -480,7 +487,7 @@ let browser = null; let _lastBrowserPid = null; // Track PID independently for force-kill after close let _browserClosePromise = null; // Shared promise for concurrent close serialization let _lastBrowserRestartAt = 0; // Timestamp of last browser relaunch (for stale tab detection) -// userId -> { context, tabGroups: Map>, lastAccess } +// userId -> { context, tabGroups, pageLeases, lastAccess, proxySessionId, requestProxy, tracePath } // TabState = { page, refs: Map, visitedUrls: Set, downloads: Array, toolCalls: number } // Note: sessionKey was previously called listItemId - both are accepted for backward compatibility const sessions = new Map(); @@ -1165,6 +1172,19 @@ function normalizeUserId(userId) { } const sessionCreations = new Map(); +const recoverableRequestProxies = createRequestProxyRecoveryCache({ + ttlMs: Math.max(SESSION_TIMEOUT_MS, TAB_INACTIVITY_MS) + 60_000, + maxEntries: Math.max(64, MAX_SESSIONS * 2), +}); + +function updateRecoverableRequestProxy(userId, session, reason) { + const key = normalizeUserId(userId); + if (shouldPreserveRequestProxy(reason)) { + if (session?.requestProxy) recoverableRequestProxies.remember(key, session.requestProxy); + return; + } + recoverableRequestProxies.delete(key); +} function clearSessionLocks(session) { if (!session?.tabGroups) return; @@ -1188,6 +1208,7 @@ async function closeSession(userId, session, { if (!session) return; const key = normalizeUserId(userId); + updateRecoverableRequestProxy(key, session, reason); // Drain locks BEFORE closing context — queued operations get clean "Tab destroyed" // (410) instead of messy "Target page closed" (500) errors. @@ -1210,22 +1231,30 @@ async function closeSession(userId, session, { } await session.context.close().catch(() => {}); - sessions.delete(key); + if (sessions.get(key) === session) sessions.delete(key); await pluginEvents.emitAsync('session:destroyed', { userId: key, reason }); refreshActiveTabsGauge(); } async function closeAllSessions(reason, { clearDownloads = true, clearLocks = true } = {}) { + if (!shouldPreserveRequestProxy(reason)) recoverableRequestProxies.clear(); const openSessions = Array.from(sessions.entries()); for (const [userId, session] of openSessions) { await closeSession(userId, session, { reason, clearDownloads, clearLocks }); } } -async function getSession(userId, { trace = false } = {}) { +async function getSession(userId, { + trace = false, + requestProxy = undefined, + inheritedRequestProxy = undefined, +} = {}) { const key = normalizeUserId(userId); + const hasRequestProxy = requestProxy !== undefined; let session = sessions.get(key); + let normalizedRequestProxy = null; + let recoveryProxy = inheritedRequestProxy || null; // Check if existing session's context is still alive if (session) { @@ -1237,12 +1266,32 @@ async function getSession(userId, { trace = false } = {}) { // Lightweight probe: pages() is synchronous-ish and throws if context is dead session.context.pages(); } catch (err) { + recoveryProxy = session.requestProxy || recoveryProxy; log('warn', 'session context dead, recreating', { userId: key, error: err.message }); await closeSession(key, session, { reason: 'dead_context', clearDownloads: true, clearLocks: true }); session = null; } } } + + if (session && recoveryProxy) { + assertRequestProxyCompatible(recoveryProxy, session.requestProxy || null); + normalizedRequestProxy = session.requestProxy || null; + } + + if (!session) { + recoveryProxy = recoveryProxy || recoverableRequestProxies.get(key); + } + + if (hasRequestProxy) { + normalizedRequestProxy = resolveRequestProxy({ + requestedProxy: requestProxy, + existingSession: session || (recoveryProxy ? { requestProxy: recoveryProxy } : null), + globalProxyActive: !!proxyPool, + }); + } else if (!session && recoveryProxy) { + normalizedRequestProxy = recoveryProxy; + } if (!session) { session = await coalesceInflight(sessionCreations, key, async () => { @@ -1276,13 +1325,21 @@ async function getSession(userId, { trace = false } = {}) { }; // When geoip is active (proxy configured), camoufox auto-configures // locale/timezone/geolocation from the proxy IP. Without proxy, use defaults. - if (!CONFIG.proxy.host) { + if (normalizedRequestProxy || !CONFIG.proxy.host) { contextOptions.locale = 'en-US'; contextOptions.timezoneId = 'America/Los_Angeles'; contextOptions.geolocation = { latitude: 37.7749, longitude: -122.4194 }; } let sessionProxy = null; - if (proxyPool?.canRotateSessions) { + if (normalizedRequestProxy) { + // Request-body credentials are already literal strings. Global proxy credentials + // keep using normalizePlaywrightProxy because env values may be percent-encoded. + contextOptions.proxy = normalizedRequestProxy; + log('info', 'request proxy assigned', { + userId: key, + proxy: redactProxy(normalizedRequestProxy), + }); + } else if (proxyPool?.canRotateSessions) { sessionProxy = proxyPool.getNext(`ctx-${key}-${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`); contextOptions.proxy = normalizePlaywrightProxy(sessionProxy); log('info', 'session proxy assigned', { userId: key, sessionId: sessionProxy.sessionId }); @@ -1307,18 +1364,31 @@ async function getSession(userId, { trace = false } = {}) { } } - const created = { context, tabGroups: new Map(), pageLeases: new Set(), lastAccess: Date.now(), proxySessionId: sessionProxy?.sessionId || null, tracePath }; + const created = { + context, + tabGroups: new Map(), + pageLeases: new Set(), + lastAccess: Date.now(), + proxySessionId: sessionProxy?.sessionId || null, + requestProxy: normalizedRequestProxy, + tracePath, + }; sessions.set(key, created); + recoverableRequestProxies.delete(key); await pluginEvents.emitAsync('session:created', { userId: key, context }); log('info', 'session created', { userId: key, proxyMode: proxyPool?.mode || null, - proxyServer: sessionProxy?.server || browserLaunchProxy?.server || null, + proxyServer: normalizedRequestProxy?.server || sessionProxy?.server || browserLaunchProxy?.server || null, proxySession: sessionProxy?.sessionId || browserLaunchProxy?.sessionId || null, }); return created; }); } + if (hasRequestProxy || recoveryProxy) { + assertRequestProxyCompatible(normalizedRequestProxy, session.requestProxy || null); + } + recoverableRequestProxies.delete(key); session.lastAccess = Date.now(); return session; } @@ -1415,7 +1485,7 @@ function handleRouteError(err, req, res, extraFields = {}) { return res.status(410).json({ error: 'Page crashed. Open a new tab.', code: 'page_crashed', retryable: true, recovery: 'create_new_tab', ...extraFields }); } if (userId && isDeadContextError(err)) { - destroySession(userId).catch(() => {}); + destroySession(userId, { reason: 'route_dead_context' }).catch(() => {}); } // Proxy errors mean the session is dead -- rotate at context level. // Destroy the user's session so the next request gets a fresh context with a new proxy. @@ -1424,7 +1494,7 @@ function handleRouteError(err, req, res, extraFields = {}) { action, userId, error: err.message, }); browserRestartsTotal.labels('proxy_error').inc(); - destroySession(userId).catch(() => {}); + destroySession(userId, { reason: 'proxy_error' }).catch(() => {}); } // Navigation-related timeouts can poison the proxy session (e.g., Cloudflare holding // the connection open for 30s). The browser context shares a single proxy session, so @@ -1436,7 +1506,7 @@ function handleRouteError(err, req, res, extraFields = {}) { action, userId, error: err.message, }); browserRestartsTotal.labels('navigation_timeout').inc(); - destroySession(userId).catch(() => {}); + destroySession(userId, { reason: 'navigation_timeout' }).catch(() => {}); } // Track consecutive timeouts per tab and auto-destroy stuck tabs // (for non-navigation timeouts like type, scroll that don't poison the proxy) @@ -1572,7 +1642,10 @@ async function recycleOldestTab(session, reqId, userId) { async function destroySession(userId, { reason = 'destroy_session' } = {}) { const key = normalizeUserId(userId); const session = sessions.get(key); - if (!session) return false; + if (!session) { + if (!shouldPreserveRequestProxy(reason)) recoverableRequestProxies.delete(key); + return false; + } log('warn', 'destroying session', { userId: key, reason }); sessions.delete(key); await closeSession(key, session, { reason, clearDownloads: true, clearLocks: true }); @@ -2672,7 +2745,20 @@ app.post('/pressure/cleanup', async (req, res) => { * post: * tags: [Tabs] * summary: Create a new tab - * description: Creates a tab in the given session. Optionally navigates to an initial URL. + * description: | + * Creates a tab in the given session. Optionally navigates to an initial URL. + * + * A `proxy` is applied when the user BrowserContext is first created and is immutable + * for that `userId`. Supplying the same proxy again is allowed; supplying a different + * proxy returns `409 proxy_conflict`. Omitting `proxy` reuses the existing session choice. + * + * Automatic recovery, browser disconnects/restarts, idle expiry, memory-pressure eviction, + * and tab reaping preserve the request proxy for at least one minute beyond the longer + * configured session/tab idle period. Explicit `DELETE /sessions/{userId}` clears that + * recovery state. Cookie import does not accept a proxy; it reuses the current or recently + * recovered proxy, otherwise it creates an unproxied session. Request proxies cannot be + * combined with global `PROXY_*` configuration. They use the deterministic no-global-proxy + * locale, timezone, and geolocation instead of inferring those values from the proxy exit IP. * requestBody: * required: true * content: @@ -2696,6 +2782,27 @@ app.post('/pressure/cleanup', async (req, res) => { * trace: * type: boolean * description: Enable Playwright tracing for this session (screenshots, DOM snapshots, network). Must be set on first tab creation; cannot be added to an existing session. + * proxy: + * type: object + * additionalProperties: false + * required: [server] + * description: Optional Playwright proxy for the user BrowserContext. Do not put credentials in the server URL. + * properties: + * server: + * type: string + * maxLength: 2048 + * description: Proxy server URL using http, https, socks4, or socks5. Embedded credentials are rejected. + * example: http://gw.example.com:10000 + * username: + * type: string + * maxLength: 512 + * writeOnly: true + * description: Optional literal proxy username. Request-body credentials are not percent-decoded. + * password: + * type: string + * maxLength: 512 + * writeOnly: true + * description: Optional literal proxy password. Request-body credentials are not percent-decoded. * responses: * 200: * description: Tab created. @@ -2708,20 +2815,23 @@ app.post('/pressure/cleanup', async (req, res) => { * type: string * url: * type: string + * proxied: + * type: boolean + * description: True when this tab's user BrowserContext uses a request-level proxy. * 400: - * description: Missing required fields. + * description: Missing required fields or invalid proxy configuration. * content: * application/json: * schema: * $ref: '#/components/schemas/Error' - * 429: - * description: Tab limit reached. + * 409: + * description: Cannot enable tracing on an existing session, or the request proxy conflicts with an existing/global proxy session. * content: * application/json: * schema: * $ref: '#/components/schemas/Error' - * 409: - * description: Cannot enable tracing on an existing session. + * 429: + * description: Tab limit reached. * content: * application/json: * schema: @@ -2729,7 +2839,7 @@ app.post('/pressure/cleanup', async (req, res) => { */ app.post('/tabs', async (req, res) => { try { - const { userId, sessionKey, listItemId, url, trace } = req.body; + const { userId, sessionKey, listItemId, url, trace, proxy } = req.body; // Accept both sessionKey (preferred) and listItemId (legacy) for backward compatibility const resolvedSessionKey = sessionKey || listItemId; if (!userId || !resolvedSessionKey) { @@ -2760,7 +2870,7 @@ app.post('/tabs', async (req, res) => { { statusCode: 409 }, ); } - let session = await getSession(userId, { trace: !!trace }); + let session = await getSession(userId, { trace: !!trace, requestProxy: proxy }); let totalTabs = 0; for (const group of session.tabGroups.values()) totalTabs += group.size; @@ -2804,7 +2914,7 @@ app.post('/tabs', async (req, res) => { if (oldSession) { await closeSession(key, oldSession, { reason: 'proxy_retry_rotate', clearDownloads: true, clearLocks: true }); } - session = await getSession(userId, { trace: !!trace }); + session = await getSession(userId, { trace: !!trace, requestProxy: proxy }); const retryGroup = getTabGroup(session, resolvedSessionKey); const { page: retryPage, lease: retryLease } = await createLeasedPage(session); tabState = createTabState(retryPage); @@ -2824,7 +2934,7 @@ app.post('/tabs', async (req, res) => { pluginEvents.emit('tab:created', { userId, tabId, page, url: page.url() }); log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() }); - return { tabId, url: page.url() }; + return { tabId, url: page.url(), proxied: Boolean(session.requestProxy) }; })(), requestTimeoutMs(), 'tab create'); res.json(result); @@ -5385,6 +5495,7 @@ app.delete('/sessions/:userId/traces/:filename', authMiddleware(), async (req, r app.delete('/sessions/:userId', async (req, res) => { try { const userId = normalizeUserId(req.params.userId); + recoverableRequestProxies.delete(userId); const session = sessions.get(userId); if (session) { await closeSession(userId, session, { reason: 'api_delete_session', clearDownloads: true, clearLocks: true }); diff --git a/tests/helpers/client.js b/tests/helpers/client.js index ce6dea2..a0b7471 100644 --- a/tests/helpers/client.js +++ b/tests/helpers/client.js @@ -60,9 +60,10 @@ class BrowserClient { } // Tab management - async createTab(url = null, { retries = 2 } = {}) { + async createTab(url = null, { retries = 2, proxy = undefined } = {}) { const body = { userId: this.userId, sessionKey: this.sessionKey }; if (url) body.url = url; + if (proxy !== undefined) body.proxy = proxy; for (let attempt = 0; attempt <= retries; attempt++) { try { diff --git a/tests/helpers/fakeProxy.js b/tests/helpers/fakeProxy.js new file mode 100644 index 0000000..8b2619b --- /dev/null +++ b/tests/helpers/fakeProxy.js @@ -0,0 +1,118 @@ +import http from 'node:http'; +import net from 'node:net'; + +const TEST_HOSTNAME = 'proxy-target.invalid'; + +function mappedHostname(hostname) { + return hostname === TEST_HOSTNAME ? '127.0.0.1' : hostname; +} + +export async function startFakeProxy(options = 0) { + const config = options && typeof options === 'object' ? options : {}; + const preferredPort = typeof options === 'number' ? options : (config.preferredPort || 0); + const expectedAuthorization = config.username !== undefined + ? `Basic ${Buffer.from(`${config.username}:${config.password || ''}`).toString('base64')}` + : null; + const requests = []; + const server = http.createServer((req, res) => { + let target; + try { + target = new URL(req.url); + } catch { + res.writeHead(400); + res.end('invalid proxy request URL'); + return; + } + + requests.push({ + method: req.method, + hostname: target.hostname, + path: `${target.pathname}${target.search}`, + hadProxyAuthorization: Boolean(req.headers['proxy-authorization']), + }); + + if (expectedAuthorization && req.headers['proxy-authorization'] !== expectedAuthorization) { + res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="test-proxy"' }); + res.end('proxy authentication required'); + return; + } + + const headers = { ...req.headers, host: target.host }; + delete headers['proxy-authorization']; + delete headers['proxy-connection']; + + const upstream = http.request({ + hostname: mappedHostname(target.hostname), + port: target.port || 80, + method: req.method, + path: `${target.pathname}${target.search}`, + headers, + }, upstreamResponse => { + res.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers); + upstreamResponse.pipe(res); + }); + + upstream.on('error', err => { + if (!res.headersSent) res.writeHead(502); + res.end(`proxy upstream error: ${err.code || 'unknown'}`); + }); + req.pipe(upstream); + }); + + server.on('connect', (req, clientSocket, head) => { + const separator = req.url.lastIndexOf(':'); + const hostname = separator === -1 ? req.url : req.url.slice(0, separator); + const port = Number(separator === -1 ? 443 : req.url.slice(separator + 1)); + requests.push({ + method: 'CONNECT', + hostname, + path: '', + hadProxyAuthorization: Boolean(req.headers['proxy-authorization']), + }); + + if (expectedAuthorization && req.headers['proxy-authorization'] !== expectedAuthorization) { + clientSocket.end( + 'HTTP/1.1 407 Proxy Authentication Required\r\n' + + 'Proxy-Authenticate: Basic realm="test-proxy"\r\n' + + 'Content-Length: 0\r\n\r\n', + ); + return; + } + + const upstreamSocket = net.connect(port, mappedHostname(hostname), () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head.length) upstreamSocket.write(head); + upstreamSocket.pipe(clientSocket); + clientSocket.pipe(upstreamSocket); + }); + upstreamSocket.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstreamSocket.destroy()); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(preferredPort, '127.0.0.1', resolve); + }); + + const port = server.address().port; + return { + port, + server: `http://127.0.0.1:${port}`, + requests, + clearRequests() { + requests.length = 0; + }, + async stop() { + await new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()); + }); + }, + }; +} + +export function proxyTargetUrl(testSiteUrl, path = '/') { + const url = new URL(testSiteUrl); + url.hostname = TEST_HOSTNAME; + url.pathname = path; + return url.toString(); +} diff --git a/tests/helpers/startServer.js b/tests/helpers/startServer.js index 9c67be0..592b73e 100644 --- a/tests/helpers/startServer.js +++ b/tests/helpers/startServer.js @@ -81,9 +81,14 @@ function getServerPort() { return serverPort; } +function getServerProcessPid() { + return serverProcess?.pid || null; +} + export { startServer, stopServer, getServerUrl, - getServerPort + getServerPort, + getServerProcessPid, }; diff --git a/tests/unit/browserErrors.test.js b/tests/unit/browserErrors.test.js index be04080..041c7f8 100644 --- a/tests/unit/browserErrors.test.js +++ b/tests/unit/browserErrors.test.js @@ -98,6 +98,19 @@ describe('browser error normalization', () => { expect(isRetryableBrowserError(err)).toBe(false); }); + test('preserves explicit recovery metadata without marking conflicts retryable', () => { + const err = Object.assign(new Error('existing session uses a different proxy'), { + statusCode: 409, + code: 'proxy_conflict', + recovery: 'delete_session', + retryable: false, + }); + expect(browserErrorStatus(err)).toBe(409); + expect(browserErrorCode(err)).toBe('proxy_conflict'); + expect(browserErrorRecovery(err)).toBe('delete_session'); + expect(isRetryableBrowserError(err)).toBe(false); + }); + test('launch and user concurrency timeouts normalize to 503 retry', () => { const launch = new Error('Browser launch timeout (60s)'); const concurrency = new Error('User concurrency limit reached, try again'); diff --git a/tests/unit/newPageRecovery.test.js b/tests/unit/newPageRecovery.test.js index 382566a..4b6d6e1 100644 --- a/tests/unit/newPageRecovery.test.js +++ b/tests/unit/newPageRecovery.test.js @@ -58,6 +58,34 @@ describe('createPageWithSessionRecovery', () => { expect(destroySession).not.toHaveBeenCalled(); }); + test('inherits the request proxy through replacement-session creation', async () => { + const timeoutError = Object.assign(new Error('new page timed out'), { code: 'timeout' }); + const requestProxy = { + server: 'http://gw.example.com:10000', + username: 'user', + password: 'pass', + }; + const oldSession = { + requestProxy, + context: { newPage: jest.fn().mockRejectedValue(timeoutError) }, + }; + const page = { id: 'fresh-page' }; + const replacement = { context: { newPage: jest.fn().mockResolvedValue(page) } }; + const getSession = jest.fn(async () => replacement); + + await createPageWithSessionRecovery(recoveryOptions({ + session: oldSession, + currentSession: () => oldSession, + destroySession: async () => {}, + getSession, + })); + + expect(getSession).toHaveBeenCalledWith('user-1', { + trace: false, + inheritedRequestProxy: requestProxy, + }); + }); + test('retries only once', async () => { const timeoutError = Object.assign(new Error('new page timed out'), { code: 'timeout' }); const oldSession = { context: { newPage: jest.fn().mockRejectedValue(timeoutError) } }; @@ -72,6 +100,8 @@ describe('createPageWithSessionRecovery', () => { expect(oldSession.context.newPage).toHaveBeenCalledTimes(1); expect(replacement.context.newPage).toHaveBeenCalledTimes(1); + expect(oldSession.pageLeases.size).toBe(0); + expect(replacement.pageLeases.size).toBe(0); }); test('does not recover unrelated failures', async () => { @@ -87,5 +117,6 @@ describe('createPageWithSessionRecovery', () => { }))).rejects.toBe(error); expect(destroySession).not.toHaveBeenCalled(); + expect(session.pageLeases.size).toBe(0); }); }); diff --git a/tests/unit/openapi.test.js b/tests/unit/openapi.test.js index b12828f..dee385b 100644 --- a/tests/unit/openapi.test.js +++ b/tests/unit/openapi.test.js @@ -137,6 +137,19 @@ describe('OpenAPI spec', () => { expect(createTab.requestBody.content['application/json']).toBeDefined(); }); + test('POST /tabs documents request-proxy validation and conflicts', () => { + const createTab = spec.paths['/tabs']?.post; + const schema = createTab.requestBody.content['application/json'].schema; + expect(schema.properties.proxy).toMatchObject({ type: 'object', required: ['server'] }); + expect(schema.properties.proxy.properties.server.description).toContain('http, https, socks4, or socks5'); + expect(schema.properties.proxy.properties.password.writeOnly).toBe(true); + const responseSchema = createTab.responses['200'].content['application/json'].schema; + expect(responseSchema.properties.proxied).toMatchObject({ type: 'boolean' }); + expect(createTab.responses['409']).toBeDefined(); + expect(createTab.description).toContain('Automatic recovery'); + expect(createTab.description).toContain('global `PROXY_*`'); + }); + test('legacy routes are marked deprecated', () => { const legacyPaths = { '/act': 'post', diff --git a/tests/unit/requestProxy.test.js b/tests/unit/requestProxy.test.js new file mode 100644 index 0000000..c3281d4 --- /dev/null +++ b/tests/unit/requestProxy.test.js @@ -0,0 +1,238 @@ +import { + assertRequestProxyCompatible, + createRequestProxyRecoveryCache, + normalizeRequestProxy, + redactProxy, + REQUEST_PROXY_RECOVERY_REASONS, + requestProxiesEqual, + resolveRequestProxy, + shouldPreserveRequestProxy, +} from '../../lib/request-proxy.js'; + +describe('normalizeRequestProxy', () => { + test.each(['http', 'https', 'socks4', 'socks5'])('accepts the %s scheme', scheme => { + expect(normalizeRequestProxy({ server: ` ${scheme}://gw.example.com:10000 ` })).toEqual({ + server: `${scheme}://gw.example.com:10000`, + }); + }); + + test('accepts optional Playwright credentials', () => { + expect(normalizeRequestProxy({ + server: 'http://gw.example.com:10000', + username: 'user', + password: 'pass', + })).toEqual({ + server: 'http://gw.example.com:10000', + username: 'user', + password: 'pass', + }); + }); + + test('preserves valid percent sequences in literal request credentials', () => { + const proxy = { + server: 'http://gw.example.com:10000', + username: 'request%user', + password: 'p%2Fssword', + }; + expect(normalizeRequestProxy(proxy)).toEqual(proxy); + }); + + test('rejects null instead of treating it as omission', () => { + expect(() => normalizeRequestProxy(null)).toThrow('proxy must be an object'); + }); + + test('rejects embedded credentials to avoid accidental leakage', () => { + expect(() => normalizeRequestProxy({ + server: 'http://user:pass@gw.example.com:10000', + })).toThrow('proxy credentials must be provided'); + }); + + test('rejects unsupported schemes and missing hosts', () => { + expect(() => normalizeRequestProxy({ server: 'ftp://gw.example.com:21' })).toThrow('proxy.server scheme'); + expect(() => normalizeRequestProxy({ server: 'http://' })).toThrow('proxy.server'); + }); + + test('type- and length-limits credentials', () => { + expect(() => normalizeRequestProxy({ + server: 'http://gw.example.com:10000', + username: 123, + })).toThrow('proxy.username must be a string'); + expect(() => normalizeRequestProxy({ + server: 'http://gw.example.com:10000', + username: 'u'.repeat(513), + })).toThrow('proxy.username is too long'); + }); + + test('rejects fields the OpenAPI schema does not support', () => { + expect(() => normalizeRequestProxy({ + server: 'http://gw.example.com:10000', + bypass: '*.internal', + })).toThrow('proxy contains unsupported fields'); + }); +}); + +describe('request proxy session semantics', () => { + const proxyA = { server: 'http://gw.example.com:10000', username: 'u1', password: 'p1' }; + const proxyB = { server: 'http://gw.example.com:10001', username: 'u1', password: 'p1' }; + + test('accepts proxy on first session creation', () => { + expect(resolveRequestProxy({ + requestedProxy: proxyA, + existingSession: null, + globalProxyActive: false, + })).toEqual(proxyA); + }); + + test('allows existing session when the identical proxy is supplied', () => { + expect(resolveRequestProxy({ + requestedProxy: proxyA, + existingSession: { requestProxy: proxyA }, + globalProxyActive: false, + })).toEqual(proxyA); + }); + + test('rejects changing proxy on an existing session with actionable metadata', () => { + expect.assertions(4); + try { + resolveRequestProxy({ + requestedProxy: proxyB, + existingSession: { requestProxy: proxyA }, + globalProxyActive: false, + }); + } catch (err) { + expect(err.statusCode).toBe(409); + expect(err.code).toBe('proxy_conflict'); + expect(err.recovery).toBe('delete_session'); + expect(err.retryable).toBe(false); + } + }); + + test('rejects request proxy when a global proxy pool is active', () => { + expect.assertions(4); + try { + resolveRequestProxy({ + requestedProxy: proxyA, + existingSession: null, + globalProxyActive: true, + }); + } catch (err) { + expect(err.statusCode).toBe(409); + expect(err.code).toBe('proxy_mode_conflict'); + expect(err.recovery).toBe('remove_request_proxy'); + expect(err.retryable).toBe(false); + } + }); + + test('treats an omitted proxy as a no-op', () => { + expect(resolveRequestProxy({ + requestedProxy: undefined, + existingSession: { requestProxy: proxyA }, + globalProxyActive: false, + })).toBeNull(); + }); + + test('compares full proxy config including credentials', () => { + expect(requestProxiesEqual(proxyA, { ...proxyA })).toBe(true); + expect(requestProxiesEqual(proxyA, { ...proxyA, password: 'different' })).toBe(false); + }); + + test('fails closed when a live or coalesced session has a different proxy', () => { + expect(assertRequestProxyCompatible(proxyA, { ...proxyA })).toEqual(proxyA); + expect(() => assertRequestProxyCompatible(proxyA, proxyB)).toThrow('different proxy'); + }); +}); + +describe('request proxy recovery cache', () => { + test.each([ + 'route_dead_context', + 'dead_context', + 'browser_disconnected', + 'browser_restart:health_check', + 'navigation_timeout', + 'new_page_unresponsive', + 'pressure_cleanup_empty_session', + 'session_timeout', + 'memory_pressure', + 'tab_reaper_empty_session', + ])('preserves request proxy for automatic teardown reason %s', reason => { + expect(shouldPreserveRequestProxy(reason)).toBe(true); + }); + + test.each([ + 'api_delete_session', + 'admin_stop', + 'destroy_session', + 'session_closed', + 'storage_reset', + 'shutdown:SIGTERM', + ])('clears request proxy for intentional teardown reason %s', reason => { + expect(shouldPreserveRequestProxy(reason)).toBe(false); + }); + + test('exports the complete automatic recovery inventory', () => { + expect(REQUEST_PROXY_RECOVERY_REASONS).toEqual([ + 'route_dead_context', + 'dead_context', + 'browser_disconnected', + 'navigation_timeout', + 'new_page_unresponsive', + 'pressure_cleanup_empty_session', + 'session_timeout', + 'memory_pressure', + 'tab_reaper_empty_session', + ]); + }); + + const proxy = { server: 'http://gw.example.com:10000', username: 'u', password: 'p' }; + + test('retains normalized proxy until successful recreation clears it', () => { + let now = 1_000; + const cache = createRequestProxyRecoveryCache({ ttlMs: 300_000, maxEntries: 2, now: () => now }); + + cache.remember('user-1', proxy); + expect(cache.get('user-1')).toEqual(proxy); + expect(cache.get('user-1')).toEqual(proxy); + cache.delete('user-1'); + expect(cache.get('user-1')).toBeNull(); + }); + + test('expires entries and evicts the oldest entry when bounded', () => { + let now = 1_000; + const cache = createRequestProxyRecoveryCache({ ttlMs: 100, maxEntries: 2, now: () => now }); + + cache.remember('user-1', proxy); + now += 1; + cache.remember('user-2', { ...proxy, server: 'http://gw.example.com:10001' }); + now += 1; + cache.remember('user-3', { ...proxy, server: 'http://gw.example.com:10002' }); + expect(cache.get('user-1')).toBeNull(); + expect(cache.size).toBe(2); + + now = 1_101; + expect(cache.get('user-2')).toBeNull(); + expect(cache.size).toBe(1); + }); + + test('copies values so callers cannot mutate cached credentials', () => { + const cache = createRequestProxyRecoveryCache(); + const mutable = { ...proxy }; + cache.remember('user-1', mutable); + mutable.password = 'changed'; + + expect(cache.get('user-1')).toEqual(proxy); + }); +}); + +describe('redactProxy', () => { + test('does not expose credentials', () => { + expect(redactProxy({ + server: 'http://gw.example.com:10000', + username: 'secret-user', + password: 'secret-pass', + })).toEqual({ + server: 'http://gw.example.com:10000', + username: '', + password: '', + }); + }); +}); diff --git a/tests/unit/requestProxyApi.test.js b/tests/unit/requestProxyApi.test.js new file mode 100644 index 0000000..a2eff56 --- /dev/null +++ b/tests/unit/requestProxyApi.test.js @@ -0,0 +1,308 @@ +import fs from 'node:fs'; +import { createClient } from '../helpers/client.js'; +import { proxyTargetUrl, startFakeProxy } from '../helpers/fakeProxy.js'; +import { getServerProcessPid, getServerUrl, startServer, stopServer } from '../helpers/startServer.js'; +import { getTestSiteUrl, startTestSite, stopTestSite } from '../helpers/testSite.js'; + +const CLEAN_SERVER_ENV = { + NODE_ENV: 'test', + PRE_WARM_BROWSER: 'false', + PROXY_HOST: '', + PROXY_PORT: '', + PROXY_PORTS: '', + PROXY_STRATEGY: '', + PROXY_PROVIDER: '', + PROXY_USERNAME: '', + PROXY_PASSWORD: '', + PROXY_BACKCONNECT_HOST: '', + PROXY_BACKCONNECT_PORT: '', + CAMOFOX_ACCESS_KEY: '', + CAMOFOX_API_KEY: '', + CAMOFOX_ADMIN_KEY: '', + CAMOFOX_CRASH_REPORT_ENABLED: 'false', + SENTRY_DSN: '', +}; + +async function expectRequestError(promise, { status, code, recovery, retryable }) { + try { + await promise; + throw new Error('request unexpectedly succeeded'); + } catch (err) { + expect(err.status).toBe(status); + if (code) expect(err.data?.code).toBe(code); + if (recovery) expect(err.data?.recovery).toBe(recovery); + if (retryable !== undefined) expect(err.data?.retryable).toBe(retryable); + return err; + } +} + +function processDescendants(pid) { + let children = []; + try { + const raw = fs.readFileSync(`/proc/${pid}/task/${pid}/children`, 'utf8').trim(); + children = raw ? raw.split(/\s+/).map(Number) : []; + } catch { + return []; + } + return children.flatMap(childPid => [childPid, ...processDescendants(childPid)]); +} + +function findBrowserProcessPid(serverPid) { + for (const pid of processDescendants(serverPid)) { + try { + const command = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replaceAll('\0', ' '); + if (/(camoufox|firefox)/i.test(command) && !command.includes('-contentproc')) return pid; + } catch { + // Process exited while the tree was being inspected. + } + } + return null; +} + +async function waitFor(predicate, { attempts = 40, delayMs = 250 } = {}) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (await predicate()) return; + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + throw new Error('timed out waiting for condition'); +} + +describe('request-level proxy API contract', () => { + let baseUrl; + let testSiteUrl; + let proxy; + + beforeAll(async () => { + await startTestSite(); + testSiteUrl = getTestSiteUrl(); + proxy = await startFakeProxy(); + await startServer(0, CLEAN_SERVER_ENV); + baseUrl = getServerUrl(); + }, 120000); + + beforeEach(() => { + proxy.clearRequests(); + }); + + afterAll(async () => { + await stopServer(); + await proxy?.stop(); + await stopTestSite(); + }, 30000); + + test('creates and reuses one proxied user session', async () => { + const client = createClient(baseUrl); + const requestProxy = { server: proxy.server }; + try { + const first = await client.createTab(proxyTargetUrl(testSiteUrl, '/pageA'), { proxy: requestProxy }); + const second = await client.createTab(proxyTargetUrl(testSiteUrl, '/pageB'), { proxy: requestProxy }); + + expect(first.url).toContain('/pageA'); + expect(first.proxied).toBe(true); + expect(second.url).toContain('/pageB'); + expect(second.proxied).toBe(true); + expect(proxy.requests.some(request => request.path === '/pageA')).toBe(true); + expect(proxy.requests.some(request => request.path === '/pageB')).toBe(true); + expect(proxy.requests.every(request => request.hadProxyAuthorization === false)).toBe(true); + } finally { + await client.cleanup(); + } + }, 60000); + + test('preserves literal request credentials when authenticating to the proxy', async () => { + const credentials = { username: 'request%user', password: 'p%2Fssword' }; + const authenticatedProxy = await startFakeProxy(credentials); + const client = createClient(baseUrl); + try { + const created = await client.createTab(proxyTargetUrl(testSiteUrl, '/pageA'), { + proxy: { server: authenticatedProxy.server, ...credentials }, + }); + expect(created.proxied).toBe(true); + expect(authenticatedProxy.requests.some(request => request.hadProxyAuthorization)).toBe(true); + } finally { + await client.cleanup(); + await authenticatedProxy.stop(); + } + }, 60000); + + test('reports direct routing without exposing proxy details', async () => { + const client = createClient(baseUrl); + try { + const created = await client.createTab(`${testSiteUrl}/pageA`); + expect(created.url).toContain('/pageA'); + expect(created.proxied).toBe(false); + expect(created).not.toHaveProperty('proxy'); + } finally { + await client.cleanup(); + } + }, 60000); + + test('rejects a different proxy for an existing user session', async () => { + const client = createClient(baseUrl); + try { + await client.createTab(null, { proxy: { server: proxy.server } }); + await expectRequestError( + client.createTab(null, { + retries: 0, + proxy: { server: proxy.server, username: 'different-user' }, + }), + { status: 409, code: 'proxy_conflict', recovery: 'delete_session', retryable: false }, + ); + } finally { + await client.cleanup(); + } + }, 60000); + + test('rejects malformed, null, and unsupported proxy input', async () => { + const malformed = createClient(baseUrl); + const nullProxy = createClient(baseUrl); + const unsupported = createClient(baseUrl); + try { + await expectRequestError( + malformed.createTab(null, { retries: 0, proxy: { server: 'ftp://invalid.example' } }), + { status: 400 }, + ); + await expectRequestError( + nullProxy.createTab(null, { retries: 0, proxy: null }), + { status: 400 }, + ); + await expectRequestError( + unsupported.createTab(null, { + retries: 0, + proxy: { server: proxy.server, bypass: '*.internal' }, + }), + { status: 400 }, + ); + } finally { + await malformed.cleanup(); + await nullProxy.cleanup(); + await unsupported.cleanup(); + } + }, 60000); + + test('documents the cookie-import-first conflict instead of silently replacing the session', async () => { + const client = createClient(baseUrl); + try { + await client.request('POST', `/sessions/${client.userId}/cookies`, { + cookies: [{ name: 'session', value: 'test', domain: '.example.com', path: '/' }], + }); + await expectRequestError( + client.createTab(null, { retries: 0, proxy: { server: proxy.server } }), + { status: 409, code: 'proxy_conflict', recovery: 'delete_session', retryable: false }, + ); + } finally { + await client.cleanup(); + } + }, 60000); + + test('cookie import reuses a recently recovered request proxy', async () => { + const client = createClient(baseUrl); + const cleanupOptions = { + dryRun: false, + minIdleMs: 0, + minTabsPerSession: 0, + maxTabsToClose: 10, + closeEmptySessions: true, + }; + try { + await client.createTab(proxyTargetUrl(testSiteUrl, '/pageA'), { + proxy: { server: proxy.server }, + }); + await client.request('POST', '/pressure/cleanup', cleanupOptions); + await client.request('POST', '/pressure/cleanup', cleanupOptions); + + await client.request('POST', `/sessions/${client.userId}/cookies`, { + cookies: [{ name: 'session', value: 'recovered', domain: '.example.com', path: '/' }], + }); + const created = await client.createTab(proxyTargetUrl(testSiteUrl, '/pageB')); + expect(created.proxied).toBe(true); + expect(proxy.requests.some(request => request.path === '/pageB')).toBe(true); + } finally { + await client.cleanup(); + } + }, 60000); + + test('reuses the proxy after pressure cleanup and proves POST /tabs releases its page lease', async () => { + const client = createClient(baseUrl); + const cleanupOptions = { + dryRun: false, + minIdleMs: 0, + minTabsPerSession: 0, + maxTabsToClose: 10, + closeEmptySessions: true, + }; + try { + await client.createTab(proxyTargetUrl(testSiteUrl, '/pageA'), { + proxy: { server: proxy.server }, + }); + await client.request('POST', '/pressure/cleanup', cleanupOptions); + await client.request('POST', '/pressure/cleanup', cleanupOptions); + proxy.clearRequests(); + const recovered = await client.createTab(proxyTargetUrl(testSiteUrl, '/pageB')); + expect(recovered.url).toContain('/pageB'); + expect(recovered.proxied).toBe(true); + expect(proxy.requests.some(request => request.path === '/pageB')).toBe(true); + } finally { + await client.cleanup(); + } + }, 60000); + test('reuses the proxy after a real browser disconnect', async () => { + if (process.platform !== 'linux') return; + + const client = createClient(baseUrl); + try { + await client.createTab(proxyTargetUrl(testSiteUrl, '/pageA'), { + proxy: { server: proxy.server }, + }); + const browserPid = findBrowserProcessPid(getServerProcessPid()); + expect(browserPid).not.toBeNull(); + process.kill(browserPid, 'SIGKILL'); + + await waitFor(async () => { + try { + const response = await fetch(`${baseUrl}/health`); + const health = await response.json(); + return health.browserConnected === false || health.browserRunning === false; + } catch { + return false; + } + }); + + const recovered = await client.createTab(proxyTargetUrl(testSiteUrl, '/pageB')); + expect(recovered.proxied).toBe(true); + expect(proxy.requests.some(request => request.path === '/pageB')).toBe(true); + } finally { + await client.cleanup(); + } + }, 60000); +}); + +describe('request-level proxy and global proxy mode', () => { + let baseUrl; + + beforeAll(async () => { + await startServer(0, { + ...CLEAN_SERVER_ENV, + PROXY_HOST: '127.0.0.1', + PROXY_PORT: '65530', + PROXY_PORTS: '65530', + PROXY_STRATEGY: 'round_robin', + }); + baseUrl = getServerUrl(); + }, 60000); + + afterAll(async () => { + await stopServer(); + }, 30000); + + test('rejects mixed request-proxy and global-pool mode before browser creation', async () => { + const client = createClient(baseUrl); + await expectRequestError( + client.createTab(null, { + retries: 0, + proxy: { server: 'http://127.0.0.1:65531' }, + }), + { status: 409, code: 'proxy_mode_conflict', recovery: 'remove_request_proxy', retryable: false }, + ); + }); +}); diff --git a/tests/unit/requestProxyServerContract.test.js b/tests/unit/requestProxyServerContract.test.js new file mode 100644 index 0000000..d6e51a4 --- /dev/null +++ b/tests/unit/requestProxyServerContract.test.js @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { REQUEST_PROXY_RECOVERY_REASONS } from '../../lib/request-proxy.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const serverSource = fs.readFileSync(path.join(root, 'server.js'), 'utf8'); +const recoverySource = `${serverSource}\n${fs.readFileSync(path.join(root, 'lib/new-page-recovery.js'), 'utf8')}`; +const workflows = [ + ['CI', fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8')], + ['publish', fs.readFileSync(path.join(root, '.github/workflows/publish.yml'), 'utf8')], +]; +const packageLock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); + +describe('request proxy server integration contract', () => { + test.each(workflows)('%s workflow uses Jest 30 filters and quarantines the browser proxy tests', (_name, source) => { + expect(packageLock.packages['node_modules/jest'].version).toMatch(/^30\./); + expect(source.match(/--testPathPatterns=/g)).toHaveLength(2); + expect(source).not.toMatch(/--testPathPattern=/); + expect(source).toMatch(/--testPathIgnorePatterns='[^']*requestProxyApi\\\.test/); + expect(source).toMatch(/\(security\|tabRecycling\|cookies\|requestProxyApi\)\\\.test/); + }); + + test('keeps recovery metadata independent from 5xx Sentry reporting', () => { + expect(serverSource).toContain('if (status >= 500 && !err.statusCode) {'); + expect(serverSource).not.toContain('status >= 500 && !err.statusCode && !recovery'); + }); + + test('every automatic recovery reason is emitted by a real teardown path', () => { + for (const reason of REQUEST_PROXY_RECOVERY_REASONS) { + expect(recoverySource).toContain(`'${reason}'`); + } + expect(serverSource).toContain('`browser_restart:${reason}`'); + }); + + test('keeps recovered credentials longer than both idle eviction timers', () => { + expect(serverSource).toContain( + 'ttlMs: Math.max(SESSION_TIMEOUT_MS, TAB_INACTIVITY_MS) + 60_000', + ); + }); + + test('applies deterministic context defaults to request-proxied sessions even if global config is incomplete', () => { + expect(serverSource).toContain('if (normalizedRequestProxy || !CONFIG.proxy.host) {'); + }); + + test('does not let an in-flight close delete a replacement session', () => { + expect(serverSource).toContain('if (sessions.get(key) === session) sessions.delete(key);'); + }); + + test('uses the compatibility guard before returning live and coalesced sessions', () => { + expect(serverSource.match(/assertRequestProxyCompatible\(/g)?.length).toBeGreaterThanOrEqual(2); + }); + + test('reports whether the created tab is actually using a request proxy', () => { + expect(serverSource).toContain('proxied: Boolean(session.requestProxy)'); + }); +});