mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
@@ -79,6 +79,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
const { usageRoutes } = await import('./usage-routes');
|
||||
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
|
||||
if (options.dev) {
|
||||
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';
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user