mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
Merge pull request #866 from kapdon/review/docker-cliproxy-cors
fix(docker): proxy CLIProxy management panel through dashboard to avoid cross-origin errors
This commit is contained in:
@@ -63,6 +63,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
// Auth middleware (protects API routes when enabled)
|
||||
app.use(authMiddleware);
|
||||
|
||||
// CLIProxy local reverse proxy (avoids cross-origin issues in Docker)
|
||||
const cliproxyLocalProxy = (await import('./routes/cliproxy-local-proxy')).default;
|
||||
app.use('/api/cliproxy-local', cliproxyLocalProxy);
|
||||
|
||||
// REST API routes (modularized)
|
||||
const { apiRoutes } = await import('./routes/index');
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* CLIProxy Local Reverse Proxy
|
||||
*
|
||||
* Proxies requests from the dashboard to the local CLIProxy service
|
||||
* running on 127.0.0.1 inside the same host/container.
|
||||
*
|
||||
* Mounted at: /api/cliproxy-local/* -> http://127.0.0.1:{port}/*
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
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';
|
||||
|
||||
export interface CliproxyLocalProxyDeps {
|
||||
enforceAccess?: (req: Request, res: Response) => boolean;
|
||||
request?: typeof http.request;
|
||||
resolveTargetPort?: () => number;
|
||||
}
|
||||
|
||||
/** Proxy request timeout in milliseconds (30 seconds) */
|
||||
const PROXY_TIMEOUT_MS = 30_000;
|
||||
|
||||
function resolveLocalCliproxyPort(): number {
|
||||
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 {
|
||||
const values = Array.isArray(contentType) ? contentType : [contentType];
|
||||
return values.some((value) => value?.toLowerCase().includes('application/json') === true);
|
||||
}
|
||||
|
||||
function buildProxyBody(req: Request): Buffer | undefined {
|
||||
// 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));
|
||||
}
|
||||
|
||||
function buildProxyHeaders(
|
||||
headers: http.IncomingHttpHeaders,
|
||||
port: number,
|
||||
bodyBuffer?: Buffer
|
||||
): http.IncomingHttpHeaders {
|
||||
const proxyHeaders: http.IncomingHttpHeaders = {
|
||||
...headers,
|
||||
host: `127.0.0.1:${port}`,
|
||||
};
|
||||
|
||||
delete proxyHeaders.connection;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
router.all('/*', (req: Request, res: Response) => {
|
||||
const targetPort = resolveTargetPort();
|
||||
const targetPath = req.url || '/';
|
||||
const bodyBuffer = buildProxyBody(req);
|
||||
|
||||
const proxyReq = createRequest(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: targetPort,
|
||||
path: targetPath,
|
||||
method: req.method,
|
||||
headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer),
|
||||
timeout: PROXY_TIMEOUT_MS,
|
||||
},
|
||||
(proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
if (bodyBuffer) {
|
||||
proxyReq.end(bodyBuffer);
|
||||
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 });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createCliproxyLocalProxyRouter();
|
||||
@@ -0,0 +1,152 @@
|
||||
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<number> {
|
||||
servers.push(server);
|
||||
|
||||
return await new Promise<number>((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;
|
||||
}
|
||||
|
||||
// Force-close keep-alive connections so server.close() doesn't hang
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((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',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards GET requests and returns backend response', async () => {
|
||||
const backend = await createBackendServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end('<html>management panel</html>');
|
||||
});
|
||||
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('<html>management panel</html>');
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -78,14 +79,15 @@ 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: withApiBase('/cliproxy-local/management.html'),
|
||||
checkUrl: withApiBase('/cliproxy-local/'),
|
||||
authToken: effectiveSecret,
|
||||
isRemote: false,
|
||||
displayHost: `localhost:${port}`,
|
||||
displayHost: `localhost:${localPort}`,
|
||||
};
|
||||
}, [cliproxyConfig, authTokens, port]);
|
||||
|
||||
@@ -95,29 +97,50 @@ 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 {
|
||||
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) {
|
||||
updateConnectionState(true, null);
|
||||
} else {
|
||||
updateConnectionState(
|
||||
false,
|
||||
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: probe the proxied control panel root directly.
|
||||
const response = await fetch(checkUrl, { signal: controller.signal });
|
||||
if (response.ok) {
|
||||
updateConnectionState(true, null);
|
||||
} else {
|
||||
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'
|
||||
@@ -130,8 +153,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
checkConnection().finally(() => clearTimeout(timeoutId));
|
||||
|
||||
// Cleanup: abort fetch on unmount
|
||||
return () => controller.abort();
|
||||
}, [checkUrl, isRemote, displayHost]);
|
||||
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.
|
||||
@@ -140,12 +166,20 @@ 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 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 apiBaseUrl = new URL(`${apiBase}/`);
|
||||
const targetOrigin = apiBaseUrl.origin;
|
||||
|
||||
// Security: Validate iframe src matches target origin before sending credentials
|
||||
const iframeSrc = iframeRef.current.src;
|
||||
if (!iframeSrc.startsWith(apiBase)) {
|
||||
// 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;
|
||||
}
|
||||
@@ -157,7 +191,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
apiBase,
|
||||
managementKey: authToken,
|
||||
},
|
||||
apiBase
|
||||
targetOrigin
|
||||
);
|
||||
} catch (e) {
|
||||
// Cross-origin restriction - expected if not same origin
|
||||
|
||||
Reference in New Issue
Block a user