fix(config): bind dashboard to loopback by default

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 10:59:49 -04:00
committed by GitHub
parent 837f7f46e3
commit d61469edcb
8 changed files with 66 additions and 39 deletions
+2 -2
View File
@@ -6,9 +6,9 @@ CLI commands for managing CCS dashboard authentication.
## Overview
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, including when the runtime's default bind is network-accessible or when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`.
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, such as when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`.
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
Authentication is **disabled by default** for backward compatibility, while `ccs config` binds to `localhost` by default. Use the CLI to configure and enable auth before exposing the dashboard to other devices.
CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine.
+4 -2
View File
@@ -1,5 +1,6 @@
import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor';
import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime';
import { DEFAULT_DASHBOARD_HOST } from './config-dashboard-host';
const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const;
@@ -22,6 +23,7 @@ function formatUnexpectedArgsError(tokens: string[]): string {
export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult {
const options: ConfigCommandOptions = {
host: DEFAULT_DASHBOARD_HOST,
hostProvided: false,
dev: false,
};
@@ -121,7 +123,7 @@ export function showConfigCommandHelp(): void {
console.log('');
console.log('Options:');
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
console.log(' --host, -H HOST Bind dashboard server host (default: system default)');
console.log(' --host, -H HOST Bind dashboard server host (default: localhost)');
console.log(' --dev Development mode with Vite HMR');
console.log(' --help, -h Show this help message');
console.log('');
@@ -129,7 +131,7 @@ export function showConfigCommandHelp(): void {
console.log(' ccs config Auto-detect available port');
console.log(' ccs config --port 3000 Use specific port');
console.log(' ccs config --host 0.0.0.0 Force all-interface binding for remote devices');
console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine');
console.log(' ccs config --host localhost Restrict dashboard to this machine');
console.log(' ccs config --dev Development mode with hot reload');
console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config channels Show Official Channels status');
+1 -1
View File
@@ -176,7 +176,7 @@ export async function handleConfigCommand(
port,
dev: options.dev,
};
if (options.hostProvided && options.host) {
if (options.host) {
serverOptions.host = normalizeDashboardHost(options.host);
}
+2
View File
@@ -1,5 +1,7 @@
import * as os from 'os';
export const DEFAULT_DASHBOARD_HOST = 'localhost';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
+44 -19
View File
@@ -8,6 +8,7 @@
import express from 'express';
import http from 'http';
import type { AddressInfo } from 'net';
import path from 'path';
import { WebSocketServer } from 'ws';
import { setupWebSocket } from './websocket';
@@ -23,6 +24,7 @@ import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
import { shutdownUsageAggregator } from './usage/aggregator';
import { createLogger } from '../services/logging';
import { DEFAULT_DASHBOARD_HOST, isLoopbackHost } from '../commands/config-dashboard-host';
export interface ServerOptions {
port: number;
@@ -37,6 +39,10 @@ export interface ServerInstance {
cleanup: () => void;
}
function getListenHost(options: ServerOptions): string {
return options.host || DEFAULT_DASHBOARD_HOST;
}
const logger = createLogger('web-server');
/**
@@ -183,11 +189,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening
return new Promise<ServerInstance>((resolve, reject) => {
const listenHost = getListenHost(options);
const onError = (error: NodeJS.ErrnoException) => {
logger.error('server.listen_failed', 'Dashboard server failed to start', {
code: error.code || 'unknown',
message: error.message,
host: options.host || null,
host: listenHost,
port: options.port,
});
cleanup();
@@ -198,8 +205,18 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const onListening = () => {
server.off('error', onError);
try {
assertSafeDashboardBind(options, server.address());
} catch (error) {
cleanup();
server.close(() => {
reject(error instanceof Error ? error : new Error(String(error)));
});
return;
}
logger.info('server.listening', 'Dashboard server listening', {
host: options.host || '0.0.0.0',
host: listenHost,
port: options.port,
dev: Boolean(options.dev),
});
@@ -209,12 +226,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
};
try {
if (options.host) {
server.listen(options.port, options.host, onListening);
return;
}
server.listen(options.port, onListening);
server.listen(options.port, listenHost, onListening);
} catch (error) {
server.off('error', onError);
cleanup();
@@ -247,26 +259,39 @@ function rejectWebSocketUpgrade(
socket.destroy();
}
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
if (error.code === 'EADDRINUSE' && options.host) {
return `Unable to bind ${options.host}:${options.port}; the address may be unavailable or the port may already be in use`;
function assertSafeDashboardBind(
options: ServerOptions,
address: string | AddressInfo | null
): void {
const listenHost = getListenHost(options);
if (!isLoopbackHost(listenHost) || typeof address === 'string' || !address) {
return;
}
if (isLoopbackHost(address.address)) {
return;
}
throw new Error(
`Dashboard host ${listenHost} resolved to non-loopback address ${address.address}; pass --host explicitly to allow network exposure.`
);
}
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
const listenHost = getListenHost(options);
if (error.code === 'EADDRINUSE') {
return `Port ${options.port} is already in use`;
return `Unable to bind ${listenHost}:${options.port}; the address may be unavailable or the port may already be in use`;
}
if (error.code === 'EADDRNOTAVAIL' && options.host) {
return `Cannot bind to ${options.host}:${options.port} on this machine`;
if (error.code === 'EADDRNOTAVAIL') {
return `Cannot bind to ${listenHost}:${options.port} on this machine`;
}
if (error.code === 'EACCES') {
return `Permission denied while binding to port ${options.port}`;
}
if (options.host) {
return `Cannot bind to ${options.host}:${options.port}: ${error.message}`;
}
return error.message;
return `Cannot bind to ${listenHost}:${options.port}: ${error.message}`;
}
@@ -3,12 +3,12 @@ import { describe, expect, it } from 'bun:test';
import { parseConfigCommandArgs } from '../../../src/commands/config-command-options';
describe('config command options parser', () => {
it('defaults to system bind behavior without explicit host', () => {
it('defaults to a localhost-only bind without explicit host', () => {
const result = parseConfigCommandArgs([]);
expect(result.help).toBe(false);
expect(result.error).toBeUndefined();
expect(result.options.host).toBeUndefined();
expect(result.options.host).toBe('localhost');
expect(result.options.hostProvided).toBe(false);
});
+9 -12
View File
@@ -10,7 +10,7 @@ let logLines: string[] = [];
let errorLines: string[] = [];
let dashboardAuthEnabled = false;
let startServerError: Error | null = null;
let mockServerBindHost = '::';
let mockServerBindHost = '::1';
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
let originalProcessExit: typeof process.exit;
@@ -79,7 +79,7 @@ beforeEach(() => {
errorLines = [];
dashboardAuthEnabled = false;
startServerError = null;
mockServerBindHost = '::';
mockServerBindHost = '::1';
originalConsoleLog = console.log;
originalConsoleError = console.error;
@@ -140,19 +140,16 @@ describe('config command dashboard startup', () => {
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
});
it('keeps the default startup path free of an explicit host override', async () => {
it('binds the default startup path to localhost only', async () => {
await handleConfigCommand([], createTestDeps());
expect(startServerCalls).toHaveLength(1);
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false });
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false, host: 'localhost' });
const rendered = logLines.join('\n');
expect(rendered).toContain('Dashboard: http://localhost:3000');
expect(rendered).toContain('Bind host: ::');
expect(rendered).toContain(
'Dashboard may be reachable from other devices that can connect to this machine.'
);
expect(rendered).toContain('Protect it before sharing: ccs config auth setup');
expect(rendered).toContain('Dashboard: http://[::1]:3000');
expect(rendered).not.toContain('Dashboard may be reachable from other devices');
expect(rendered).not.toContain('Protect it before sharing: ccs config auth setup');
expect(errorLines).toHaveLength(0);
});
@@ -205,8 +202,8 @@ describe('config command dashboard startup', () => {
expect(startServerCalls).toHaveLength(1);
const rendered = logLines.join('\n');
expect(rendered).toContain('Dashboard: http://localhost:3000');
expect(rendered).toContain('Bind host: ::');
expect(rendered).toContain('Dashboard: http://[::1]:3000');
expect(rendered).not.toContain('Bind host:');
expect(errorLines).toHaveLength(0);
} finally {
networkInterfacesSpy.mockRestore();
@@ -47,12 +47,13 @@ afterEach(async () => {
});
describe('startServer host binding', () => {
it('binds with system-default host when no host is provided', async () => {
it('binds to localhost by default when no host is provided', async () => {
const instance = await startServer({ port: 0 });
instances.push(instance);
const address = instance.server.address() as AddressInfo;
expect(address.port).toBeGreaterThan(0);
expect(['127.0.0.1', '::1']).toContain(address.address);
});
it('binds to an explicit loopback host', async () => {