mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 10:15:40 +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:
1 parent
5160e1d454
commit
881b061dfe
3 files changed
+100
-20
No files matched your search
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user