mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 02:14:32 +00:00
Merge pull request #842 from kaitranntt/kai/docs/841-docker-deployment-guide
fix(docker): add auth guide, fix remote dashboard access, and improve deployment UX
This commit is contained in:
7 files changed
+219
-9
No files matched your search
@@ -45,6 +45,104 @@ The `ccs docker` flow uses the integrated assets in this directory:
|
||||
- `docker/supervisord.conf`
|
||||
- `docker/entrypoint-integrated.sh`
|
||||
|
||||
### Post-Deployment: Enable Dashboard Auth (Required for Remote Access)
|
||||
|
||||
When accessing the dashboard from a different machine (not `localhost`), the API blocks requests with **403 Forbidden** unless authentication is configured. Without auth, the dashboard appears empty (no providers, no version).
|
||||
|
||||
Set up auth inside the running container:
|
||||
|
||||
```bash
|
||||
# Interactive setup (recommended)
|
||||
docker exec -it ccs-cliproxy ccs config auth setup
|
||||
|
||||
# Or via environment variables in docker-compose
|
||||
environment:
|
||||
CCS_DASHBOARD_AUTH_ENABLED: "true"
|
||||
CCS_DASHBOARD_USERNAME: "admin"
|
||||
CCS_DASHBOARD_PASSWORD_HASH: "<bcrypt-hash>"
|
||||
```
|
||||
|
||||
Generate a bcrypt hash:
|
||||
|
||||
```bash
|
||||
docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
|
||||
```
|
||||
|
||||
> **Note:** Do not commit the password hash in `docker-compose.yml`. Use Docker secrets or a `.env` file (not tracked in git) for sensitive values like `CCS_DASHBOARD_PASSWORD_HASH`.
|
||||
|
||||
After configuring auth, restart the dashboard:
|
||||
|
||||
```bash
|
||||
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dashboard
|
||||
```
|
||||
|
||||
If accessing from `localhost` only (e.g., via SSH tunnel), auth is not required:
|
||||
|
||||
```bash
|
||||
ssh -L 3000:localhost:3000 my-server
|
||||
# Then open http://localhost:3000 in browser
|
||||
```
|
||||
|
||||
### Post-Deployment: Migrate Existing Auth Tokens
|
||||
|
||||
If you have existing CLIProxy OAuth tokens from a previous deployment, copy them into the Docker volume:
|
||||
|
||||
```bash
|
||||
# Copy auth files into the running container
|
||||
for f in /path/to/old/auth/*.json; do
|
||||
docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/
|
||||
done
|
||||
|
||||
# Restart CLIProxy to load new tokens
|
||||
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
|
||||
```
|
||||
|
||||
For remote deployments via `ccs docker up --host`:
|
||||
|
||||
```bash
|
||||
# Copy tokens into the running container (no root/sudo needed)
|
||||
scp /path/to/auth/*.json my-server:/tmp/ccs-auth/
|
||||
ssh my-server 'for f in /tmp/ccs-auth/*.json; do docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done'
|
||||
|
||||
# Restart CLIProxy to load new tokens
|
||||
ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy"
|
||||
|
||||
# Clean up temp files
|
||||
ssh my-server "rm -rf /tmp/ccs-auth"
|
||||
```
|
||||
|
||||
> **Tip:** `docker cp` is preferred over writing directly to Docker volume mountpoints, which require root access.
|
||||
|
||||
### Post-Deployment: Verification Checklist
|
||||
|
||||
After `ccs docker up`, verify the deployment:
|
||||
|
||||
```bash
|
||||
# 1. Check container is healthy
|
||||
ccs docker status --host my-server
|
||||
|
||||
# 2. Verify CLIProxy responds
|
||||
curl -fsS http://<host>:8317/
|
||||
|
||||
# 3. Check health API (from inside container -- no auth needed)
|
||||
docker exec ccs-cliproxy curl -fsS http://127.0.0.1:3000/api/health \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{d[\"summary\"][\"passed\"]} passed, {d[\"summary\"][\"errors\"]} errors')"
|
||||
|
||||
# 4. Verify auth tokens loaded (check client count)
|
||||
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
|
||||
|
||||
# 5. Test dashboard API (from remote -- requires auth)
|
||||
curl -fsS -X POST http://<host>:3000/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"your-password"}'
|
||||
```
|
||||
|
||||
Expected healthy output:
|
||||
- Container status: `healthy`
|
||||
- Both supervisor services: `RUNNING`
|
||||
- CLIProxy health: `cliproxy-port: ok, CLIProxy running`
|
||||
- Client count matches number of auth token files
|
||||
|
||||
## Prebuilt Image Quick Start
|
||||
|
||||
This existing image still runs the CCS dashboard and its locally managed CLIProxy inside one
|
||||
@@ -218,6 +316,37 @@ docker logs ccs-dashboard --tail 50
|
||||
docker inspect ccs-dashboard --format='{{.State.Health.Status}}'
|
||||
```
|
||||
|
||||
### Dashboard Shows Empty (No Providers, Wrong Version)
|
||||
|
||||
If the dashboard page loads but shows "0 providers", "Not running", or version "v5.0.0":
|
||||
|
||||
**Cause:** The dashboard API blocks non-localhost requests when auth is disabled (security feature). The page HTML loads from any host, but all API calls return 403.
|
||||
|
||||
**Fix:** Enable dashboard authentication:
|
||||
|
||||
```bash
|
||||
docker exec -it ccs-cliproxy ccs config auth setup
|
||||
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dashboard
|
||||
```
|
||||
|
||||
Then log in at the dashboard URL. See [Post-Deployment: Enable Dashboard Auth](#post-deployment-enable-dashboard-auth-required-for-remote-access) above.
|
||||
|
||||
### CLIProxy Shows 0 Clients After Token Migration
|
||||
|
||||
If CLIProxy logs show "0 clients" after copying auth tokens:
|
||||
|
||||
```bash
|
||||
# CLIProxy needs a restart to detect new auth files
|
||||
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
|
||||
|
||||
# Verify tokens loaded
|
||||
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
|
||||
```
|
||||
|
||||
### ETXTBSY Error on First Boot
|
||||
|
||||
On first container start, you may see `ETXTBSY: text file is busy` in dashboard logs. This is a known race condition where the dashboard tries to update the CLIProxy binary while it's already running. The dashboard recovers automatically on the next attempt. No action needed.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable verbose logging:
|
||||
|
||||
@@ -37,6 +37,13 @@ export async function handleUp(args: string[]): Promise<void> {
|
||||
console.log(ok(`Docker stack is running${parsed.host ? ` on ${parsed.host}` : ' locally'}.`));
|
||||
console.log(info(`Dashboard port: ${port}`));
|
||||
console.log(info(`CLIProxy port: ${proxyPort}`));
|
||||
if (parsed.host) {
|
||||
console.log(
|
||||
info(
|
||||
'Remote access requires dashboard auth. Run inside the container:\n docker exec -it ccs-cliproxy ccs config auth setup'
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
box(fail(error instanceof Error ? error.message : String(error)), {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Router, type Request, type Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { getDashboardAuthConfig } from '../../config/unified-config-loader';
|
||||
import { loginRateLimiter } from '../middleware/auth-middleware';
|
||||
import { isLoopbackRemoteAddress, loginRateLimiter } from '../middleware/auth-middleware';
|
||||
|
||||
/**
|
||||
* Timing-safe string comparison to prevent timing attacks.
|
||||
@@ -94,9 +94,15 @@ router.post('/logout', (req: Request, res: Response) => {
|
||||
*/
|
||||
router.get('/check', (req: Request, res: Response) => {
|
||||
const authConfig = getDashboardAuthConfig();
|
||||
const isLocal = isLoopbackRemoteAddress(req.socket.remoteAddress);
|
||||
|
||||
// When auth is not configured and access is remote, the dashboard API
|
||||
// endpoints return 403. Signal auth-required so the UI can show the
|
||||
// login/setup page instead of a silently broken dashboard.
|
||||
const effectiveAuthRequired = authConfig.enabled || !isLocal;
|
||||
|
||||
res.json({
|
||||
authRequired: authConfig.enabled,
|
||||
authRequired: effectiveAuthRequired,
|
||||
authenticated: req.session?.authenticated ?? false,
|
||||
username: req.session?.username ?? null,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('docker up subcommand', () => {
|
||||
expect(rendered).toContain('Docker stack is running on docker-box.');
|
||||
expect(rendered).toContain('Dashboard port: 4000');
|
||||
expect(rendered).toContain('CLIProxy port: 9317');
|
||||
expect(rendered).toContain('Remote access requires dashboard auth');
|
||||
expect(capture.errorLines).toEqual([]);
|
||||
expect(process.exitCode).toBe(0);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Auth Check Route — Remote Access Detection Tests
|
||||
*
|
||||
* Verifies that /api/auth/check returns effectiveAuthRequired=true
|
||||
* for remote clients when auth is disabled, preventing a silently
|
||||
* broken dashboard.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isLoopbackRemoteAddress } from '../../../src/web-server/middleware/auth-middleware';
|
||||
|
||||
describe('isLoopbackRemoteAddress', () => {
|
||||
it('returns true for IPv4 localhost', () => {
|
||||
expect(isLoopbackRemoteAddress('127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for IPv6 localhost', () => {
|
||||
expect(isLoopbackRemoteAddress('::1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for IPv4-mapped IPv6 localhost', () => {
|
||||
expect(isLoopbackRemoteAddress('::ffff:127.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for other loopback addresses', () => {
|
||||
expect(isLoopbackRemoteAddress('127.0.0.2')).toBe(true);
|
||||
expect(isLoopbackRemoteAddress('::ffff:127.0.0.2')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for LAN addresses', () => {
|
||||
expect(isLoopbackRemoteAddress('192.168.1.100')).toBe(false);
|
||||
expect(isLoopbackRemoteAddress('10.0.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isLoopbackRemoteAddress(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveAuthRequired logic', () => {
|
||||
// Mirrors the logic in auth-routes.ts GET /api/auth/check:
|
||||
// effectiveAuthRequired = authConfig.enabled || !isLocal
|
||||
function computeEffectiveAuthRequired(authEnabled: boolean, remoteAddress: string | undefined) {
|
||||
const isLocal = isLoopbackRemoteAddress(remoteAddress);
|
||||
return authEnabled || !isLocal;
|
||||
}
|
||||
|
||||
it('localhost + auth disabled -> authRequired=false', () => {
|
||||
expect(computeEffectiveAuthRequired(false, '127.0.0.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('remote + auth disabled -> authRequired=true', () => {
|
||||
expect(computeEffectiveAuthRequired(false, '192.168.2.100')).toBe(true);
|
||||
});
|
||||
|
||||
it('remote + auth enabled -> authRequired=true', () => {
|
||||
expect(computeEffectiveAuthRequired(true, '192.168.2.100')).toBe(true);
|
||||
});
|
||||
|
||||
it('localhost + auth enabled -> authRequired=true', () => {
|
||||
expect(computeEffectiveAuthRequired(true, '127.0.0.1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,16 +5,18 @@ interface HeroSectionProps {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export function HeroSection({ version = '5.0.0' }: HeroSectionProps) {
|
||||
export function HeroSection({ version }: HeroSectionProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<CcsLogo size="lg" showText={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">CCS Config</h1>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
{version && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
|
||||
</div>
|
||||
|
||||
@@ -47,9 +47,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUsername(res.username);
|
||||
})
|
||||
.catch(() => {
|
||||
// If check fails, assume no auth required (backward compat)
|
||||
setAuthRequired(false);
|
||||
setIsAuthenticated(true);
|
||||
// If auth check fails (network error, server down, CORS issue),
|
||||
// fail closed: require auth instead of granting access.
|
||||
// Prevents silently broken dashboard when server is unreachable.
|
||||
setAuthRequired(true);
|
||||
setIsAuthenticated(false);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
Reference in new issue
Block a user