From c26efae72ad664f9c8b5a85168704bc7fee7533f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 20:39:59 -0400 Subject: [PATCH 1/8] docs(docker): add post-deployment guide for auth, token migration, and verification Users deploying via ccs docker up hit silent failures when accessing the dashboard remotely: empty providers, wrong version badge (v5.0.0), no CLIProxy detected. Root cause is the dashboard auth middleware blocking non-localhost API access. Added sections: - Dashboard auth setup (required for remote access) - Auth token migration from previous deployments - Post-deployment verification checklist - Troubleshooting: empty dashboard, 0 clients, ETXTBSY race Closes #841 --- docker/README.md | 129 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/docker/README.md b/docker/README.md index eecd14ea..a92fb302 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 " + const bcrypt = require('/usr/local/lib/node_modules/@kaitranntt/ccs/node_modules/bcrypt'); + console.log(bcrypt.hashSync('your-password', 10)); +" +``` + +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 +# Find the new volume mountpoint +docker volume inspect docker_ccs_home --format '{{.Mountpoint}}' +# Example output: /var/lib/docker/volumes/docker_ccs_home/_data + +# Copy auth files from old location to new volume +cp /path/to/old/auth/*.json /var/lib/docker/volumes/docker_ccs_home/_data/cliproxy/auth/ + +# 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 +# Auth files are stored in the named volume, accessible on the host at: +ssh my-server "docker volume inspect docker_ccs_home --format '{{.Mountpoint}}'" + +# Copy tokens from old setup +ssh my-server "cp /old/path/cliproxy/auth/*.json \$(docker volume inspect docker_ccs_home --format '{{.Mountpoint}}')/cliproxy/auth/" + +# Restart CLIProxy +ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy" +``` + +### 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 cat /var/log/ccs/cliproxy.log | grep "client load complete" + +# 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 cat /var/log/ccs/cliproxy.log | grep "client load complete" +``` + +### 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: From efe6953da06263d4fdb03468f0c0f7385523cb17 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 21:20:51 -0400 Subject: [PATCH 2/8] fix(ui): hide version badge when API fails instead of showing v5.0.0 HeroSection defaulted to '5.0.0' when version prop was undefined, causing a misleading version badge when the overview API fails (e.g., 403 from remote access without auth). Now hides the badge entirely until a real version is loaded. --- ui/src/components/layout/hero-section.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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

From e9fceed80716b4e032e4ad0eca65b5fb005f02ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 21:21:16 -0400 Subject: [PATCH 3/8] fix(ui): redirect to login when auth check fails from remote access When the auth check API call fails (e.g., 403 from the localhost security middleware), the catch block assumed no auth was needed and marked the user as authenticated. This caused a silently broken dashboard with no providers and no error feedback. Now treats auth check failure as auth-required, redirecting to the login page where users get a clear prompt to configure credentials. --- ui/src/contexts/auth-context.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/src/contexts/auth-context.tsx b/ui/src/contexts/auth-context.tsx index 6be8c976..f43993a2 100644 --- a/ui/src/contexts/auth-context.tsx +++ b/ui/src/contexts/auth-context.tsx @@ -47,9 +47,10 @@ 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 (e.g., 403 from remote access without auth configured), + // treat as auth required so the login page appears instead of a broken dashboard. + setAuthRequired(true); + setIsAuthenticated(false); }) .finally(() => setLoading(false)); }, []); From 59be7f8682c4be95886728555fd1567ea1faf7cf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 28 Mar 2026 21:21:41 -0400 Subject: [PATCH 4/8] fix(docker): print auth setup reminder after remote deployment Users deploying via ccs docker up --host see "Docker stack is running" but hit a broken dashboard because auth is required for remote API access. Now prints a reminder with the exact command to configure auth. --- src/commands/docker/up-subcommand.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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)), { From 5a09547532754da21faacb5351cc3538a9db05a4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 10:54:44 -0400 Subject: [PATCH 5/8] fix(auth): signal auth-required for remote access in /api/auth/check The auth check endpoint always returned authRequired=false when auth was disabled, even for remote clients. This caused the UI to render the dashboard directly, where all data API calls return 403 silently. Now detects remote access via isLoopbackRemoteAddress and sets effectiveAuthRequired=true, so the UI properly redirects to the login page. Also fixes misleading catch handler comment in auth-context. --- src/web-server/routes/auth-routes.ts | 10 ++++++++-- ui/src/contexts/auth-context.tsx | 5 +++-- 2 files changed, 11 insertions(+), 4 deletions(-) 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/ui/src/contexts/auth-context.tsx b/ui/src/contexts/auth-context.tsx index f43993a2..d0e39ee7 100644 --- a/ui/src/contexts/auth-context.tsx +++ b/ui/src/contexts/auth-context.tsx @@ -47,8 +47,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUsername(res.username); }) .catch(() => { - // If auth check fails (e.g., 403 from remote access without auth configured), - // treat as auth required so the login page appears instead of a broken dashboard. + // 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); }) From cd09b845daed75dde6fd4248fac8450837974513 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 10:55:10 -0400 Subject: [PATCH 6/8] =?UTF-8?q?fix(docker):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20test,=20docs,=20bcrypt=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test assertion for --host auth reminder message - Use Node module resolution for bcrypt in README (not hardcoded path) - Replace cat|grep with direct grep in verification commands --- docker/README.md | 11 ++++++----- tests/unit/commands/docker-up-subcommand.test.ts | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docker/README.md b/docker/README.md index a92fb302..5331e977 100644 --- a/docker/README.md +++ b/docker/README.md @@ -65,10 +65,11 @@ environment: Generate a bcrypt hash: ```bash -docker exec ccs-cliproxy node -e " - const bcrypt = require('/usr/local/lib/node_modules/@kaitranntt/ccs/node_modules/bcrypt'); - console.log(bcrypt.hashSync('your-password', 10)); +docker exec ccs-cliproxy npx -y bcryptjs -e " + const b = require('bcryptjs'); console.log(b.hashSync('your-password', 10)); " +# Or if bcrypt is available in the container's CCS install: +docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))" ``` After configuring auth, restart the dashboard: @@ -129,7 +130,7 @@ 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 cat /var/log/ccs/cliproxy.log | grep "client load complete" +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 \ @@ -340,7 +341,7 @@ If CLIProxy logs show "0 clients" after copying auth tokens: docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy # Verify tokens loaded -docker exec ccs-cliproxy cat /var/log/ccs/cliproxy.log | grep "client load complete" +docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log ``` ### ETXTBSY Error on First Boot 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 { From a23caf00513411132a7d28b12f40a51476a69790 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 11:17:24 -0400 Subject: [PATCH 7/8] test(auth): verify effectiveAuthRequired logic for remote vs local access Cover all 4 combinations: localhost+disabled, remote+disabled, remote+enabled, localhost+enabled. Also tests isLoopbackRemoteAddress helper for IPv4, IPv6, mapped addresses, LAN, and undefined. --- .../auth-check-remote-access.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/unit/web-server/auth-check-remote-access.test.ts 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); + }); +}); From f8c43a374d68b1e8bd1c4fd18b1ab1a903116a60 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 29 Mar 2026 11:17:38 -0400 Subject: [PATCH 8/8] docs(docker): use docker cp for token migration and fix bcrypt command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct volume mountpoint writes (requires root) with docker cp which works without elevated permissions. Fix malformed npx bcrypt command — use require('bcrypt') with Node module resolution. Add security note about not committing password hashes to docker-compose. --- docker/README.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/docker/README.md b/docker/README.md index 5331e977..414d4e02 100644 --- a/docker/README.md +++ b/docker/README.md @@ -65,13 +65,11 @@ environment: Generate a bcrypt hash: ```bash -docker exec ccs-cliproxy npx -y bcryptjs -e " - const b = require('bcryptjs'); console.log(b.hashSync('your-password', 10)); -" -# Or if bcrypt is available in the container's CCS install: 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 @@ -90,12 +88,10 @@ ssh -L 3000:localhost:3000 my-server If you have existing CLIProxy OAuth tokens from a previous deployment, copy them into the Docker volume: ```bash -# Find the new volume mountpoint -docker volume inspect docker_ccs_home --format '{{.Mountpoint}}' -# Example output: /var/lib/docker/volumes/docker_ccs_home/_data - -# Copy auth files from old location to new volume -cp /path/to/old/auth/*.json /var/lib/docker/volumes/docker_ccs_home/_data/cliproxy/auth/ +# 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 @@ -104,16 +100,19 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy For remote deployments via `ccs docker up --host`: ```bash -# Auth files are stored in the named volume, accessible on the host at: -ssh my-server "docker volume inspect docker_ccs_home --format '{{.Mountpoint}}'" +# 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' -# Copy tokens from old setup -ssh my-server "cp /old/path/cliproxy/auth/*.json \$(docker volume inspect docker_ccs_home --format '{{.Mountpoint}}')/cliproxy/auth/" - -# Restart CLIProxy +# 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: