mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
fix(config): bind dashboard to loopback by default
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user