diff --git a/docker/README.md b/docker/README.md index eecd14ea..414d4e02 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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: "" +``` + +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://: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://: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: diff --git a/src/commands/docker/up-subcommand.ts b/src/commands/docker/up-subcommand.ts index 0ee8bc60..fcbb4242 100644 --- a/src/commands/docker/up-subcommand.ts +++ b/src/commands/docker/up-subcommand.ts @@ -37,6 +37,13 @@ export async function handleUp(args: string[]): Promise { 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)), { diff --git a/src/web-server/routes/auth-routes.ts b/src/web-server/routes/auth-routes.ts index 3a7efb3b..70b2564f 100644 --- a/src/web-server/routes/auth-routes.ts +++ b/src/web-server/routes/auth-routes.ts @@ -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, }); diff --git a/tests/unit/commands/docker-up-subcommand.test.ts b/tests/unit/commands/docker-up-subcommand.test.ts index 7816cbc3..ba290b6c 100644 --- a/tests/unit/commands/docker-up-subcommand.test.ts +++ b/tests/unit/commands/docker-up-subcommand.test.ts @@ -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 { diff --git a/tests/unit/web-server/auth-check-remote-access.test.ts b/tests/unit/web-server/auth-check-remote-access.test.ts new file mode 100644 index 00000000..6d00d053 --- /dev/null +++ b/tests/unit/web-server/auth-check-remote-access.test.ts @@ -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); + }); +}); diff --git a/ui/src/components/layout/hero-section.tsx b/ui/src/components/layout/hero-section.tsx index 409900ff..40ea83d9 100644 --- a/ui/src/components/layout/hero-section.tsx +++ b/ui/src/components/layout/hero-section.tsx @@ -5,16 +5,18 @@ interface HeroSectionProps { version?: string; } -export function HeroSection({ version = '5.0.0' }: HeroSectionProps) { +export function HeroSection({ version }: HeroSectionProps) { return (

CCS Config

- - v{version} - + {version && ( + + v{version} + + )}

Claude Code Switch Dashboard

diff --git a/ui/src/contexts/auth-context.tsx b/ui/src/contexts/auth-context.tsx index 6be8c976..d0e39ee7 100644 --- a/ui/src/contexts/auth-context.tsx +++ b/ui/src/contexts/auth-context.tsx @@ -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)); }, []);