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) <noreply@anthropic.com>
This commit is contained in:
UserandClaude Opus 4.6 committed 2026-03-30 06:25:14 -07:00
1 parent 5160e1d454
commit 881b061dfe
3 files changed
+100 -20

No files matched your search

+4
View File
@@ -79,6 +79,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { usageRoutes } = await import('./usage-routes'); const { usageRoutes } = await import('./usage-routes');
app.use('/api/usage', usageRoutes); app.use('/api/usage', usageRoutes);
// CLIProxy local reverse proxy (avoids cross-origin issues in Docker)
const cliproxyLocalProxy = (await import('./routes/cliproxy-local-proxy')).default;
app.use('/cliproxy-local', cliproxyLocalProxy);
// Dev mode: use Vite middleware for HMR // Dev mode: use Vite middleware for HMR
if (options.dev) { if (options.dev) {
const { createServer: createViteServer } = await import('vite'); const { createServer: createViteServer } = await import('vite');
@@ -0,0 +1,49 @@
/**
* CLIProxy Local Reverse Proxy
*
* Proxies requests from the dashboard to the local CLIProxy service
* running on 127.0.0.1:8317 inside the same host/container.
*
* This eliminates cross-origin issues when the dashboard and CLIProxy
* run on different ports (e.g., inside Docker containers where the
* browser cannot reach the internal CLIProxy port directly).
*
* Mounted at: /cliproxy-local/* → 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';
const router = Router();
router.all('/*', (req: Request, res: Response) => {
// 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;
@@ -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'; const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
return { return {
managementUrl: `http://localhost:${port}/management.html`, managementUrl: '/cliproxy-local/management.html',
checkUrl: `http://localhost:${port}/`, checkUrl: '/cliproxy-local/',
authToken: effectiveSecret, authToken: effectiveSecret,
isRemote: false, isRemote: false,
displayHost: `localhost:${port}`, displayHost: `localhost:${port}`,
@@ -98,19 +99,36 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
const checkConnection = async () => { const checkConnection = async () => {
try { try {
const response = await fetch(checkUrl, { if (isRemote) {
signal: controller.signal, // Remote mode: use the test endpoint via same-origin API to avoid CORS
}); const remote = cliproxyConfig?.remote;
if (response.ok) { const result = await api.cliproxyServer.test({
setIsConnected(true); host: remote?.host ?? '',
setError(null); 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 { } else {
setIsConnected(false); // Local mode: use same-origin API to check proxy status (avoids CORS)
setError( const status = await api.cliproxy.proxyStatus();
isRemote if (status.running) {
? `Remote CLIProxy at ${displayHost} returned an error` setIsConnected(true);
: 'CLIProxy returned an error' setError(null);
); } else {
setIsConnected(false);
setError('CLIProxy is not running');
}
} }
} catch (e) { } catch (e) {
// Ignore abort errors (component unmounting) // Ignore abort errors (component unmounting)
@@ -131,7 +149,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
// Cleanup: abort fetch on unmount // Cleanup: abort fetch on unmount
return () => controller.abort(); return () => controller.abort();
}, [checkUrl, isRemote, displayHost]); }, [isRemote, displayHost, cliproxyConfig]);
const postAutoLoginCredentials = useCallback(() => { const postAutoLoginCredentials = useCallback(() => {
// Auto-login can only run when iframe has loaded and authToken is available. // 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 { try {
// Derive apiBase from checkUrl (remove trailing slash) // Derive apiBase and targetOrigin from checkUrl
const apiBase = checkUrl.replace(/\/$/, ''); // 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 // Security: Validate iframe src matches target origin before sending credentials
const iframeSrc = iframeRef.current.src; 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'); console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return; return;
} }
@@ -157,7 +184,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
apiBase, apiBase,
managementKey: authToken, managementKey: authToken,
}, },
apiBase targetOrigin
); );
} catch (e) { } catch (e) {
// Cross-origin restriction - expected if not same origin // Cross-origin restriction - expected if not same origin