mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
fix(config): bind dashboard to loopback by default
This commit is contained in:
1 parent
837f7f46e3
commit
d61469edcb
8 files changed
+66
-39
No files matched your search
@@ -6,9 +6,9 @@ CLI commands for managing CCS dashboard authentication.
|
|||||||
|
|
||||||
## Overview
|
## 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.
|
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 { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor';
|
||||||
import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime';
|
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;
|
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 {
|
export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult {
|
||||||
const options: ConfigCommandOptions = {
|
const options: ConfigCommandOptions = {
|
||||||
|
host: DEFAULT_DASHBOARD_HOST,
|
||||||
hostProvided: false,
|
hostProvided: false,
|
||||||
dev: false,
|
dev: false,
|
||||||
};
|
};
|
||||||
@@ -121,7 +123,7 @@ export function showConfigCommandHelp(): void {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('Options:');
|
console.log('Options:');
|
||||||
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
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(' --dev Development mode with Vite HMR');
|
||||||
console.log(' --help, -h Show this help message');
|
console.log(' --help, -h Show this help message');
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -129,7 +131,7 @@ export function showConfigCommandHelp(): void {
|
|||||||
console.log(' ccs config Auto-detect available port');
|
console.log(' ccs config Auto-detect available port');
|
||||||
console.log(' ccs config --port 3000 Use specific 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 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 --dev Development mode with hot reload');
|
||||||
console.log(' ccs config auth setup Configure dashboard login');
|
console.log(' ccs config auth setup Configure dashboard login');
|
||||||
console.log(' ccs config channels Show Official Channels status');
|
console.log(' ccs config channels Show Official Channels status');
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export async function handleConfigCommand(
|
|||||||
port,
|
port,
|
||||||
dev: options.dev,
|
dev: options.dev,
|
||||||
};
|
};
|
||||||
if (options.hostProvided && options.host) {
|
if (options.host) {
|
||||||
serverOptions.host = normalizeDashboardHost(options.host);
|
serverOptions.host = normalizeDashboardHost(options.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
|
||||||
|
export const DEFAULT_DASHBOARD_HOST = 'localhost';
|
||||||
|
|
||||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
||||||
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
|
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
|
||||||
|
|
||||||
|
|||||||
+44
-19
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import type { AddressInfo } from 'net';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
@@ -23,6 +24,7 @@ import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
|
|||||||
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
|
||||||
import { shutdownUsageAggregator } from './usage/aggregator';
|
import { shutdownUsageAggregator } from './usage/aggregator';
|
||||||
import { createLogger } from '../services/logging';
|
import { createLogger } from '../services/logging';
|
||||||
|
import { DEFAULT_DASHBOARD_HOST, isLoopbackHost } from '../commands/config-dashboard-host';
|
||||||
|
|
||||||
export interface ServerOptions {
|
export interface ServerOptions {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -37,6 +39,10 @@ export interface ServerInstance {
|
|||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getListenHost(options: ServerOptions): string {
|
||||||
|
return options.host || DEFAULT_DASHBOARD_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
const logger = createLogger('web-server');
|
const logger = createLogger('web-server');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,11 +189,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
|
|
||||||
// Start listening
|
// Start listening
|
||||||
return new Promise<ServerInstance>((resolve, reject) => {
|
return new Promise<ServerInstance>((resolve, reject) => {
|
||||||
|
const listenHost = getListenHost(options);
|
||||||
const onError = (error: NodeJS.ErrnoException) => {
|
const onError = (error: NodeJS.ErrnoException) => {
|
||||||
logger.error('server.listen_failed', 'Dashboard server failed to start', {
|
logger.error('server.listen_failed', 'Dashboard server failed to start', {
|
||||||
code: error.code || 'unknown',
|
code: error.code || 'unknown',
|
||||||
message: error.message,
|
message: error.message,
|
||||||
host: options.host || null,
|
host: listenHost,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
});
|
});
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -198,8 +205,18 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
|
|
||||||
const onListening = () => {
|
const onListening = () => {
|
||||||
server.off('error', onError);
|
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', {
|
logger.info('server.listening', 'Dashboard server listening', {
|
||||||
host: options.host || '0.0.0.0',
|
host: listenHost,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
dev: Boolean(options.dev),
|
dev: Boolean(options.dev),
|
||||||
});
|
});
|
||||||
@@ -209,12 +226,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (options.host) {
|
server.listen(options.port, listenHost, onListening);
|
||||||
server.listen(options.port, options.host, onListening);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.listen(options.port, onListening);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
server.off('error', onError);
|
server.off('error', onError);
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -247,26 +259,39 @@ function rejectWebSocketUpgrade(
|
|||||||
socket.destroy();
|
socket.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
|
function assertSafeDashboardBind(
|
||||||
if (error.code === 'EADDRINUSE' && options.host) {
|
options: ServerOptions,
|
||||||
return `Unable to bind ${options.host}:${options.port}; the address may be unavailable or the port may already be in use`;
|
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') {
|
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) {
|
if (error.code === 'EADDRNOTAVAIL') {
|
||||||
return `Cannot bind to ${options.host}:${options.port} on this machine`;
|
return `Cannot bind to ${listenHost}:${options.port} on this machine`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error.code === 'EACCES') {
|
if (error.code === 'EACCES') {
|
||||||
return `Permission denied while binding to port ${options.port}`;
|
return `Permission denied while binding to port ${options.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.host) {
|
return `Cannot bind to ${listenHost}:${options.port}: ${error.message}`;
|
||||||
return `Cannot bind to ${options.host}:${options.port}: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return error.message;
|
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,12 @@ import { describe, expect, it } from 'bun:test';
|
|||||||
import { parseConfigCommandArgs } from '../../../src/commands/config-command-options';
|
import { parseConfigCommandArgs } from '../../../src/commands/config-command-options';
|
||||||
|
|
||||||
describe('config command options parser', () => {
|
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([]);
|
const result = parseConfigCommandArgs([]);
|
||||||
|
|
||||||
expect(result.help).toBe(false);
|
expect(result.help).toBe(false);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
expect(result.options.host).toBeUndefined();
|
expect(result.options.host).toBe('localhost');
|
||||||
expect(result.options.hostProvided).toBe(false);
|
expect(result.options.hostProvided).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ let logLines: string[] = [];
|
|||||||
let errorLines: string[] = [];
|
let errorLines: string[] = [];
|
||||||
let dashboardAuthEnabled = false;
|
let dashboardAuthEnabled = false;
|
||||||
let startServerError: Error | null = null;
|
let startServerError: Error | null = null;
|
||||||
let mockServerBindHost = '::';
|
let mockServerBindHost = '::1';
|
||||||
let originalConsoleLog: typeof console.log;
|
let originalConsoleLog: typeof console.log;
|
||||||
let originalConsoleError: typeof console.error;
|
let originalConsoleError: typeof console.error;
|
||||||
let originalProcessExit: typeof process.exit;
|
let originalProcessExit: typeof process.exit;
|
||||||
@@ -79,7 +79,7 @@ beforeEach(() => {
|
|||||||
errorLines = [];
|
errorLines = [];
|
||||||
dashboardAuthEnabled = false;
|
dashboardAuthEnabled = false;
|
||||||
startServerError = null;
|
startServerError = null;
|
||||||
mockServerBindHost = '::';
|
mockServerBindHost = '::1';
|
||||||
|
|
||||||
originalConsoleLog = console.log;
|
originalConsoleLog = console.log;
|
||||||
originalConsoleError = console.error;
|
originalConsoleError = console.error;
|
||||||
@@ -140,19 +140,16 @@ describe('config command dashboard startup', () => {
|
|||||||
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
|
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());
|
await handleConfigCommand([], createTestDeps());
|
||||||
|
|
||||||
expect(startServerCalls).toHaveLength(1);
|
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');
|
const rendered = logLines.join('\n');
|
||||||
expect(rendered).toContain('Dashboard: http://localhost:3000');
|
expect(rendered).toContain('Dashboard: http://[::1]:3000');
|
||||||
expect(rendered).toContain('Bind host: ::');
|
expect(rendered).not.toContain('Dashboard may be reachable from other devices');
|
||||||
expect(rendered).toContain(
|
expect(rendered).not.toContain('Protect it before sharing: ccs config auth setup');
|
||||||
'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(errorLines).toHaveLength(0);
|
expect(errorLines).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,8 +202,8 @@ describe('config command dashboard startup', () => {
|
|||||||
expect(startServerCalls).toHaveLength(1);
|
expect(startServerCalls).toHaveLength(1);
|
||||||
|
|
||||||
const rendered = logLines.join('\n');
|
const rendered = logLines.join('\n');
|
||||||
expect(rendered).toContain('Dashboard: http://localhost:3000');
|
expect(rendered).toContain('Dashboard: http://[::1]:3000');
|
||||||
expect(rendered).toContain('Bind host: ::');
|
expect(rendered).not.toContain('Bind host:');
|
||||||
expect(errorLines).toHaveLength(0);
|
expect(errorLines).toHaveLength(0);
|
||||||
} finally {
|
} finally {
|
||||||
networkInterfacesSpy.mockRestore();
|
networkInterfacesSpy.mockRestore();
|
||||||
|
|||||||
@@ -47,12 +47,13 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('startServer host binding', () => {
|
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 });
|
const instance = await startServer({ port: 0 });
|
||||||
instances.push(instance);
|
instances.push(instance);
|
||||||
|
|
||||||
const address = instance.server.address() as AddressInfo;
|
const address = instance.server.address() as AddressInfo;
|
||||||
expect(address.port).toBeGreaterThan(0);
|
expect(address.port).toBeGreaterThan(0);
|
||||||
|
expect(['127.0.0.1', '::1']).toContain(address.address);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('binds to an explicit loopback host', async () => {
|
it('binds to an explicit loopback host', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user