From 881b061dfec497d7120b15d2045cf8e1a8f04bcc Mon Sep 17 00:00:00 2001 From: User Date: Mon, 30 Mar 2026 06:25:14 -0700 Subject: [PATCH 1/3] fix(docker): proxy CLIProxy management panel through dashboard to avoid cross-origin errors In Docker, the browser cannot directly reach the container-internal CLIProxy port (8317). This adds a reverse proxy at /cliproxy-local/* that forwards requests through the dashboard Express server to 127.0.0.1:8317 internally. The control panel embed now uses same-origin API endpoints for health checks and the proxy path for the management iframe. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web-server/index.ts | 4 ++ src/web-server/routes/cliproxy-local-proxy.ts | 49 ++++++++++++++ .../cliproxy/control-panel-embed.tsx | 67 +++++++++++++------ 3 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 src/web-server/routes/cliproxy-local-proxy.ts diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 6ec39e00..6015c004 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -79,6 +79,10 @@ export async function startServer(options: ServerOptions): Promise { + // Strip the mount prefix — req.url already has it removed by Express + const targetPath = req.url || '/'; + + const options: http.RequestOptions = { + hostname: '127.0.0.1', + port: CLIPROXY_DEFAULT_PORT, + path: targetPath, + method: req.method, + headers: { + ...req.headers, + host: `127.0.0.1:${CLIPROXY_DEFAULT_PORT}`, + }, + }; + + const proxyReq = http.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + + proxyReq.on('error', () => { + if (!res.headersSent) { + res.status(502).json({ error: 'CLIProxy is not reachable' }); + } + }); + + req.pipe(proxyReq, { end: true }); +}); + +export default router; diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index d256c1f0..8755ab44 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -78,11 +78,12 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }; } - // Local mode - use effective management secret from auth tokens API + // Local mode - proxy through dashboard server to avoid cross-origin/port issues + // (e.g., in Docker the browser cannot reach the internal CLIProxy port directly) const effectiveSecret = authTokens?.managementSecret?.value || 'ccs'; return { - managementUrl: `http://localhost:${port}/management.html`, - checkUrl: `http://localhost:${port}/`, + managementUrl: '/cliproxy-local/management.html', + checkUrl: '/cliproxy-local/', authToken: effectiveSecret, isRemote: false, displayHost: `localhost:${port}`, @@ -98,19 +99,36 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const checkConnection = async () => { try { - const response = await fetch(checkUrl, { - signal: controller.signal, - }); - if (response.ok) { - setIsConnected(true); - setError(null); + if (isRemote) { + // Remote mode: use the test endpoint via same-origin API to avoid CORS + const remote = cliproxyConfig?.remote; + const result = await api.cliproxyServer.test({ + host: remote?.host ?? '', + port: remote?.port, + protocol: remote?.protocol ?? 'http', + authToken: remote?.auth_token, + }); + if (result?.reachable) { + setIsConnected(true); + setError(null); + } else { + setIsConnected(false); + setError( + result?.error + ? `Remote CLIProxy at ${displayHost}: ${result.error}` + : `Remote CLIProxy at ${displayHost} returned an error` + ); + } } else { - setIsConnected(false); - setError( - isRemote - ? `Remote CLIProxy at ${displayHost} returned an error` - : 'CLIProxy returned an error' - ); + // Local mode: use same-origin API to check proxy status (avoids CORS) + const status = await api.cliproxy.proxyStatus(); + if (status.running) { + setIsConnected(true); + setError(null); + } else { + setIsConnected(false); + setError('CLIProxy is not running'); + } } } catch (e) { // Ignore abort errors (component unmounting) @@ -131,7 +149,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Cleanup: abort fetch on unmount return () => controller.abort(); - }, [checkUrl, isRemote, displayHost]); + }, [isRemote, displayHost, cliproxyConfig]); const postAutoLoginCredentials = useCallback(() => { // Auto-login can only run when iframe has loaded and authToken is available. @@ -140,12 +158,21 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } try { - // Derive apiBase from checkUrl (remove trailing slash) - const apiBase = checkUrl.replace(/\/$/, ''); + // Derive apiBase and targetOrigin from checkUrl + // Local mode: checkUrl is a relative path (/cliproxy-local/) → same origin + // Remote mode: checkUrl is an absolute URL (http://host:port/) + const isRelative = checkUrl.startsWith('/'); + const apiBase = isRelative + ? `${window.location.origin}/cliproxy-local` + : checkUrl.replace(/\/$/, ''); + const targetOrigin = isRelative ? window.location.origin : apiBase; // Security: Validate iframe src matches target origin before sending credentials const iframeSrc = iframeRef.current.src; - if (!iframeSrc.startsWith(apiBase)) { + const resolvedSrc = isRelative + ? new URL(iframeSrc, window.location.origin).href + : iframeSrc; + if (!resolvedSrc.startsWith(targetOrigin)) { console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); return; } @@ -157,7 +184,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel apiBase, managementKey: authToken, }, - apiBase + targetOrigin ); } catch (e) { // Cross-origin restriction - expected if not same origin From 6471cc55d72deeebcbca70d54983b90ae32c999a Mon Sep 17 00:00:00 2001 From: User Date: Mon, 30 Mar 2026 16:33:32 -0700 Subject: [PATCH 2/3] fix(docker): harden cliproxy local proxy with auth guard, dynamic port, and body handling Move reverse proxy under /api/cliproxy-local so it sits behind auth middleware. Enforce localhost-only access when dashboard auth is disabled. Resolve the target port from unified config instead of hardcoding 8317. Re-serialize parsed JSON bodies before forwarding so writes still work behind express.json(). Clean up proxy connections on client abort. On the frontend, point the iframe and health check at the new same-origin API path, probe the proxy directly, and tighten iframe origin/path validation before sending the auto-login secret. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/web-server/index.ts | 8 +- src/web-server/routes/cliproxy-local-proxy.ts | 147 ++++++++++++++---- .../web-server/cliproxy-local-proxy.test.ts | 121 ++++++++++++++ .../cliproxy/control-panel-embed.tsx | 69 ++++---- 4 files changed, 279 insertions(+), 66 deletions(-) create mode 100644 tests/unit/web-server/cliproxy-local-proxy.test.ts diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 6015c004..9aa1ae0d 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -63,6 +63,10 @@ export async function startServer(options: ServerOptions): Promise http://127.0.0.1:{port}/* */ -import { Router, Request, Response } from 'express'; import http from 'http'; -import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { Request, Response, Router } from 'express'; +import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; -const router = Router(); +export interface CliproxyLocalProxyDeps { + enforceAccess?: (req: Request, res: Response) => boolean; + request?: typeof http.request; + resolveTargetPort?: () => number; +} -router.all('/*', (req: Request, res: Response) => { - // Strip the mount prefix — req.url already has it removed by Express - const targetPath = req.url || '/'; +function resolveLocalCliproxyPort(): number { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); +} - const options: http.RequestOptions = { - hostname: '127.0.0.1', - port: CLIPROXY_DEFAULT_PORT, - path: targetPath, - method: req.method, - headers: { - ...req.headers, - host: `127.0.0.1:${CLIPROXY_DEFAULT_PORT}`, - }, +function isJsonContentType(contentType: string | string[] | undefined): boolean { + const values = Array.isArray(contentType) ? contentType : [contentType]; + return values.some((value) => value?.toLowerCase().includes('application/json') === true); +} + +function buildProxyBody(req: Request): Buffer | undefined { + if (!isJsonContentType(req.headers['content-type']) || req.body === undefined) { + return undefined; + } + + const contentLengthHeader = req.headers['content-length']; + const contentLength = Array.isArray(contentLengthHeader) + ? contentLengthHeader[0] + : contentLengthHeader; + const hasTransferEncoding = req.headers['transfer-encoding'] !== undefined; + const parsedContentLength = + typeof contentLength === 'string' ? Number.parseInt(contentLength, 10) : NaN; + + if (!hasTransferEncoding && (!Number.isFinite(parsedContentLength) || parsedContentLength <= 0)) { + return undefined; + } + + return Buffer.from(JSON.stringify(req.body)); +} + +function buildProxyHeaders( + headers: http.IncomingHttpHeaders, + port: number, + bodyBuffer?: Buffer +): http.IncomingHttpHeaders { + const proxyHeaders: http.IncomingHttpHeaders = { + ...headers, + host: `127.0.0.1:${port}`, }; - const proxyReq = http.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); - proxyRes.pipe(res, { end: true }); - }); + delete proxyHeaders.connection; - proxyReq.on('error', () => { - if (!res.headersSent) { - res.status(502).json({ error: 'CLIProxy is not reachable' }); + if (bodyBuffer) { + delete proxyHeaders['transfer-encoding']; + proxyHeaders['content-length'] = String(bodyBuffer.length); + } + + return proxyHeaders; +} + +export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {}): Router { + const router = Router(); + const enforceAccess = + deps.enforceAccess ?? + ((req: Request, res: Response) => + requireLocalAccessWhenAuthDisabled( + req, + res, + 'CLIProxy local proxy requires localhost access when dashboard auth is disabled.' + )); + const createRequest = deps.request ?? http.request; + const resolveTargetPort = deps.resolveTargetPort ?? resolveLocalCliproxyPort; + + router.use((req: Request, res: Response, next) => { + if (enforceAccess(req, res)) { + next(); } }); - req.pipe(proxyReq, { end: true }); -}); + router.all('/*', (req: Request, res: Response) => { + const targetPort = resolveTargetPort(); + const targetPath = req.url || '/'; + const bodyBuffer = buildProxyBody(req); -export default router; + const proxyReq = createRequest( + { + hostname: '127.0.0.1', + port: targetPort, + path: targetPath, + method: req.method, + headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer), + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + } + ); + + proxyReq.on('error', () => { + if (!res.headersSent) { + res.status(502).json({ error: 'CLIProxy is not reachable' }); + } + }); + + req.on('aborted', () => proxyReq.destroy()); + res.on('close', () => { + if (!res.writableEnded) { + proxyReq.destroy(); + } + }); + + if (bodyBuffer) { + proxyReq.end(bodyBuffer); + return; + } + + req.pipe(proxyReq, { end: true }); + }); + + return router; +} + +export default createCliproxyLocalProxyRouter(); diff --git a/tests/unit/web-server/cliproxy-local-proxy.test.ts b/tests/unit/web-server/cliproxy-local-proxy.test.ts new file mode 100644 index 00000000..57f39755 --- /dev/null +++ b/tests/unit/web-server/cliproxy-local-proxy.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import http from 'http'; +import type { AddressInfo } from 'net'; + +import { + createCliproxyLocalProxyRouter, + type CliproxyLocalProxyDeps, +} from '../../../src/web-server/routes/cliproxy-local-proxy'; + +const servers: http.Server[] = []; + +async function listen(server: http.Server): Promise { + servers.push(server); + + return await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +async function createBackendServer( + handler: http.RequestListener +): Promise<{ port: number; server: http.Server }> { + const server = http.createServer(handler); + const port = await listen(server); + return { port, server }; +} + +async function createProxyServer(options: { + enforceAccess?: CliproxyLocalProxyDeps['enforceAccess']; + resolveTargetPort: () => number; +}): Promise<{ baseUrl: string; server: http.Server }> { + const app = express(); + app.use(express.json()); + app.use( + '/api/cliproxy-local', + createCliproxyLocalProxyRouter({ + enforceAccess: options.enforceAccess, + resolveTargetPort: options.resolveTargetPort, + }) + ); + + const server = http.createServer(app); + const port = await listen(server); + return { baseUrl: `http://127.0.0.1:${port}`, server }; +} + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + if (!server) { + continue; + } + + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +describe('cliproxy local proxy route', () => { + it('blocks requests when local-access enforcement fails', async () => { + let backendHit = false; + const backend = await createBackendServer((_req, res) => { + backendHit = true; + res.writeHead(200).end('ok'); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: (_req, res) => { + res.status(403).json({ error: 'blocked' }); + return false; + }, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'blocked' }); + expect(backendHit).toBe(false); + }); + + it('forwards JSON request bodies after express.json has parsed them', async () => { + const backend = await createBackendServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + body: JSON.parse(body), + method: req.method, + path: req.url, + }) + ); + }); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/v0/management/test`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, ids: ['a', 'b'] }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + body: { enabled: true, ids: ['a', 'b'] }, + method: 'PATCH', + path: '/v0/management/test', + }); + }); +}); diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 8755ab44..67abc74e 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -57,6 +57,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Calculate URLs and settings based on remote or local mode const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => { const remote = cliproxyConfig?.remote; + const localPort = cliproxyConfig?.local?.port ?? port; if (remote?.enabled && remote?.host) { const protocol = remote.protocol || 'http'; @@ -82,11 +83,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // (e.g., in Docker the browser cannot reach the internal CLIProxy port directly) const effectiveSecret = authTokens?.managementSecret?.value || 'ccs'; return { - managementUrl: '/cliproxy-local/management.html', - checkUrl: '/cliproxy-local/', + managementUrl: withApiBase('/cliproxy-local/management.html'), + checkUrl: withApiBase('/cliproxy-local/'), authToken: effectiveSecret, isRemote: false, - displayHost: `localhost:${port}`, + displayHost: `localhost:${localPort}`, }; }, [cliproxyConfig, authTokens, port]); @@ -96,6 +97,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel // Check if CLIProxy is running useEffect(() => { const controller = new AbortController(); + let cancelled = false; + + const updateConnectionState = (connected: boolean, nextError: string | null) => { + if (cancelled) return; + setIsConnected(connected); + setError(nextError); + }; const checkConnection = async () => { try { @@ -109,33 +117,30 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel authToken: remote?.auth_token, }); if (result?.reachable) { - setIsConnected(true); - setError(null); + updateConnectionState(true, null); } else { - setIsConnected(false); - setError( + updateConnectionState( + false, result?.error ? `Remote CLIProxy at ${displayHost}: ${result.error}` : `Remote CLIProxy at ${displayHost} returned an error` ); } } else { - // Local mode: use same-origin API to check proxy status (avoids CORS) - const status = await api.cliproxy.proxyStatus(); - if (status.running) { - setIsConnected(true); - setError(null); + // Local mode: probe the proxied control panel root directly. + const response = await fetch(checkUrl, { signal: controller.signal }); + if (response.ok) { + updateConnectionState(true, null); } else { - setIsConnected(false); - setError('CLIProxy is not running'); + updateConnectionState(false, 'CLIProxy returned an error'); } } } catch (e) { // Ignore abort errors (component unmounting) if (e instanceof Error && e.name === 'AbortError') return; - setIsConnected(false); - setError( + updateConnectionState( + false, isRemote ? `Remote CLIProxy at ${displayHost} is not reachable` : 'CLIProxy is not running' @@ -148,8 +153,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel checkConnection().finally(() => clearTimeout(timeoutId)); // Cleanup: abort fetch on unmount - return () => controller.abort(); - }, [isRemote, displayHost, cliproxyConfig]); + return () => { + cancelled = true; + controller.abort(); + }; + }, [checkUrl, isRemote, displayHost, cliproxyConfig]); const postAutoLoginCredentials = useCallback(() => { // Auto-login can only run when iframe has loaded and authToken is available. @@ -158,21 +166,20 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } try { - // Derive apiBase and targetOrigin from checkUrl - // Local mode: checkUrl is a relative path (/cliproxy-local/) → same origin - // Remote mode: checkUrl is an absolute URL (http://host:port/) - const isRelative = checkUrl.startsWith('/'); - const apiBase = isRelative - ? `${window.location.origin}/cliproxy-local` + // Derive apiBase and targetOrigin from checkUrl. + // Local mode uses the same-origin dashboard proxy; remote mode stays absolute. + const apiBase = checkUrl.startsWith('/') + ? new URL(checkUrl.replace(/\/$/, ''), window.location.origin).href : checkUrl.replace(/\/$/, ''); - const targetOrigin = isRelative ? window.location.origin : apiBase; + const apiBaseUrl = new URL(`${apiBase}/`); + const targetOrigin = apiBaseUrl.origin; - // Security: Validate iframe src matches target origin before sending credentials - const iframeSrc = iframeRef.current.src; - const resolvedSrc = isRelative - ? new URL(iframeSrc, window.location.origin).href - : iframeSrc; - if (!resolvedSrc.startsWith(targetOrigin)) { + // Security: Validate iframe src matches the expected origin/path before sending credentials. + const iframeUrl = new URL(iframeRef.current.src, window.location.origin); + if ( + iframeUrl.origin !== apiBaseUrl.origin || + !iframeUrl.pathname.startsWith(apiBaseUrl.pathname) + ) { console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); return; } From 27409b789b29a1d008f621f55aab7eed9861d6d4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 11:39:34 -0400 Subject: [PATCH 3/3] fix(docker): harden proxy with timeout, Bun compat, and test coverage - Add 30s timeout on proxy requests to prevent indefinite hangs - Wrap resolveLocalCliproxyPort in try/catch with default port fallback - Simplify buildProxyBody: remove fragile content-length check that caused Bun to fall through to req.pipe() on consumed streams - Replace proxyRes.pipe(res) with manual streaming for Bun compatibility (pipe hangs after writeHead in Bun runtime) - Replace deprecated req.on('aborted') with res.on('close') cleanup (req.on('close') fires with req.destroyed=true in Bun after body consumption, prematurely destroying the proxy connection) - Explicitly end proxy request for bodyless methods (GET/HEAD/OPTIONS) instead of piping an already-consumed express stream - Add server.closeAllConnections() in test cleanup to prevent hangs - Add GET passthrough and 502 unreachable test cases --- src/web-server/routes/cliproxy-local-proxy.ts | 51 ++++++++++++------- .../web-server/cliproxy-local-proxy.test.ts | 31 +++++++++++ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index fa745d11..a8359186 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -19,9 +19,16 @@ export interface CliproxyLocalProxyDeps { resolveTargetPort?: () => number; } +/** Proxy request timeout in milliseconds (30 seconds) */ +const PROXY_TIMEOUT_MS = 30_000; + function resolveLocalCliproxyPort(): number { - const config = loadOrCreateUnifiedConfig(); - return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); + try { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); + } catch { + return CLIPROXY_DEFAULT_PORT; + } } function isJsonContentType(contentType: string | string[] | undefined): boolean { @@ -30,22 +37,17 @@ function isJsonContentType(contentType: string | string[] | undefined): boolean } function buildProxyBody(req: Request): Buffer | undefined { - if (!isJsonContentType(req.headers['content-type']) || req.body === undefined) { - return undefined; - } - - const contentLengthHeader = req.headers['content-length']; - const contentLength = Array.isArray(contentLengthHeader) - ? contentLengthHeader[0] - : contentLengthHeader; - const hasTransferEncoding = req.headers['transfer-encoding'] !== undefined; - const parsedContentLength = - typeof contentLength === 'string' ? Number.parseInt(contentLength, 10) : NaN; - - if (!hasTransferEncoding && (!Number.isFinite(parsedContentLength) || parsedContentLength <= 0)) { + // If express.json() parsed the body (content-type is JSON and req.body is populated), + // re-serialize it since the original request stream was consumed by the middleware. + if ( + !isJsonContentType(req.headers['content-type']) || + req.body === undefined || + req.body === null + ) { return undefined; } + // express.json() sets req.body to the parsed value — re-serialize for the proxy target return Buffer.from(JSON.stringify(req.body)); } @@ -100,20 +102,27 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} path: targetPath, method: req.method, headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer), + timeout: PROXY_TIMEOUT_MS, }, (proxyRes) => { res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); - proxyRes.pipe(res, { end: true }); + // Manual streaming instead of pipe() for Bun runtime compatibility + proxyRes.on('data', (chunk: Buffer) => res.write(chunk)); + proxyRes.on('end', () => res.end()); } ); + proxyReq.on('timeout', () => proxyReq.destroy()); + proxyReq.on('error', () => { if (!res.headersSent) { res.status(502).json({ error: 'CLIProxy is not reachable' }); } }); - req.on('aborted', () => proxyReq.destroy()); + // Clean up proxy connection when client disconnects. + // Only use res.on('close') — req.on('close') fires with req.destroyed=true + // in Bun after body consumption, which would prematurely kill the proxy. res.on('close', () => { if (!res.writableEnded) { proxyReq.destroy(); @@ -125,6 +134,14 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} return; } + // For methods without a body (GET, HEAD, etc.) or when express.json() + // has already consumed the stream, end the request immediately. + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS'; + if (!hasBody) { + proxyReq.end(); + return; + } + req.pipe(proxyReq, { end: true }); }); diff --git a/tests/unit/web-server/cliproxy-local-proxy.test.ts b/tests/unit/web-server/cliproxy-local-proxy.test.ts index 57f39755..d665731b 100644 --- a/tests/unit/web-server/cliproxy-local-proxy.test.ts +++ b/tests/unit/web-server/cliproxy-local-proxy.test.ts @@ -56,6 +56,8 @@ afterEach(async () => { continue; } + // Force-close keep-alive connections so server.close() doesn't hang + server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } }); @@ -118,4 +120,33 @@ describe('cliproxy local proxy route', () => { path: '/v0/management/test', }); }); + + it('forwards GET requests and returns backend response', async () => { + const backend = await createBackendServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('management panel'); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('management panel'); + }); + + it('returns 502 when CLIProxy is not reachable', async () => { + // Use a port with nothing listening + const proxy = await createProxyServer({ + resolveTargetPort: () => 19999, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/`); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'CLIProxy is not reachable' }); + }); });