Skip to content
Merged
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ TRANSPORT=stdio
# Used for both frontend and MCP endpoint (when transport=http)
PORT=8080

# HTTP bind host (default: 0.0.0.0 — listens on all interfaces)
# For production, set HOST=127.0.0.1 and front DBHub with a reverse proxy
# (nginx/Caddy) or a firewall. DBHub does not authenticate HTTP clients.
# HOST=0.0.0.0

# SSH Tunnel Configuration (optional)
# Use these settings to connect through an SSH bastion host
# SSH_HOST=bastion.example.com
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ npx @bytebase/dbhub@latest --transport http --port 8080 --dsn "postgres://user:p
npx @bytebase/dbhub@latest --transport http --port 8080 --demo
```

**Restrict to loopback (recommended for production):**

```bash
npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --demo
```

> The HTTP transport defaults to `--host 0.0.0.0`, exposing DBHub on every network interface. For production, bind to `127.0.0.1` and front DBHub with a reverse proxy (nginx/Caddy) or firewall — DBHub does not authenticate HTTP clients.

See [Command-Line Options](https://dbhub.ai/config/command-line) for all available parameters.

### Multi-Database Setup
Expand Down
22 changes: 22 additions & 0 deletions docs/config/command-line.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ Command-line flags are passed when starting DBHub. These have the highest priori
```
</ParamField>

### --host

<ParamField path="--host" type="string" env="HOST" default="0.0.0.0">
HTTP bind address. Only used when `--transport=http`. Ignored for stdio transport.

```bash
# Restrict to loopback (recommended for production)
npx @bytebase/dbhub@latest --transport http --host 127.0.0.1 --port 8080 --dsn "..."

# Bind to a specific interface
npx @bytebase/dbhub@latest --transport http --host 10.0.0.5 --port 8080 --dsn "..."

# IPv6 loopback
npx @bytebase/dbhub@latest --transport http --host ::1 --port 8080 --dsn "..."
```

<Warning>
The default `0.0.0.0` exposes DBHub on every network interface. For production, set `--host 127.0.0.1` and place DBHub behind a reverse proxy (nginx/Caddy) or restrict with a firewall — DBHub does not authenticate HTTP clients.
</Warning>
</ParamField>

### --dsn

<ParamField path="--dsn" type="string" env="DSN">
Expand Down Expand Up @@ -274,6 +295,7 @@ npx @bytebase/dbhub@latest --dsn "..." \
|------------|---------------------|------|-------------|
| `--transport` | `TRANSPORT` | string | Transport mode: stdio or http (default: `stdio`) |
| `--port` | `PORT` | number | HTTP server port (http transport only, default: `8080`) |
| `--host` | `HOST` | string | HTTP bind address (http transport only, default: `0.0.0.0`) |
| `--demo` | - | boolean | Use sample employee database |
| `--id` | `ID` | string | Instance identifier for tool names |
| `--config` | - | string | Path to TOML config file (default: `./dbhub.toml`) |
Expand Down
85 changes: 85 additions & 0 deletions src/__tests__/http-bind-host.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';

describe('HTTP bind host integration', () => {
let serverProcess: ChildProcess | null = null;
let testDbPath: string;
const testPort = 3002;
const testHost = '127.0.0.1';
const startupLogs: string[] = [];

beforeAll(async () => {
testDbPath = path.join(os.tmpdir(), `bind_host_test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.db`);
Comment on lines +6 to +15

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration test uses a fixed port (3002). This can make the test flaky on CI/dev machines where that port is already in use. Prefer selecting an available ephemeral port at runtime (e.g., bind a temporary server to port 0 to discover a free port, or use a small helper like get-port) and pass that value via PORT when spawning DBHub.

Suggested change
describe('HTTP bind host integration', () => {
let serverProcess: ChildProcess | null = null;
let testDbPath: string;
const testPort = 3002;
const testHost = '127.0.0.1';
const startupLogs: string[] = [];
beforeAll(async () => {
testDbPath = path.join(os.tmpdir(), `bind_host_test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.db`);
import net from 'net';
function getAvailablePort(host: string): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, host, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Failed to determine an available port')));
return;
}
const { port } = address;
server.close((closeError) => {
if (closeError) {
reject(closeError);
return;
}
resolve(port);
});
});
});
}
describe('HTTP bind host integration', () => {
let serverProcess: ChildProcess | null = null;
let testDbPath: string;
let testPort: number;
const testHost = '127.0.0.1';
const startupLogs: string[] = [];
beforeAll(async () => {
testDbPath = path.join(os.tmpdir(), `bind_host_test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.db`);
testPort = await getAvailablePort(testHost);
testPort = await getAvailablePort(testHost);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining for scope. The existing integration test in this repo (src/__tests__/json-rpc-integration.test.ts) already uses a fixed port (testPort = 3001); I deliberately picked 3002 for the new http-bind-host.integration.test.ts so it follows the same convention and cannot collide with that one. Switching only this test to an ephemeral port would leave two integration tests with inconsistent patterns; the cleanup you describe is a sensible improvement, but it belongs in its own PR that migrates both tests (and any future ones) together rather than being bundled into a --host bind feature.


// Invoke tsx directly via node to avoid pnpm.cmd resolution issues on Windows.
const tsxCli = path.resolve(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
const entry = path.resolve(process.cwd(), 'src', 'index.ts');

serverProcess = spawn(process.execPath, [tsxCli, entry, '--transport=http'], {
env: {
...process.env,
DSN: `sqlite://${testDbPath}`,
HOST: testHost,
PORT: testPort.toString(),
NODE_ENV: 'test',
},
stdio: 'pipe',
});

serverProcess.stdout?.on('data', (data) => {
startupLogs.push(data.toString());
});
serverProcess.stderr?.on('data', (data) => {
startupLogs.push(data.toString());
});

// Wait for /healthz to respond on the configured host
let ready = false;
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 1000));
try {
const res = await fetch(`http://${testHost}:${testPort}/healthz`);
if (res.status === 200) {
ready = true;
break;
}
} catch {
// not ready yet
}
}

if (!ready) {
throw new Error(`Server did not bind to ${testHost}:${testPort} within timeout. Logs:\n${startupLogs.join('')}`);
}
}, 45000);

afterAll(async () => {
if (serverProcess) {
serverProcess.kill('SIGTERM');
await new Promise<void>((resolve) => {
if (!serverProcess) return resolve();
serverProcess.on('exit', () => resolve());
setTimeout(() => {
if (serverProcess && !serverProcess.killed) serverProcess.kill('SIGKILL');
resolve();
}, 5000);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In afterAll, the setTimeout(..., 5000) isn't cleared when the child process exits normally. That leaves a pending timer handle that can keep the Vitest process alive (adding an unnecessary ~5s tail). Consider storing the timeout handle and clearTimeout it in the exit handler (or call .unref() on the timeout) so the suite can finish promptly.

Suggested change
serverProcess.on('exit', () => resolve());
setTimeout(() => {
if (serverProcess && !serverProcess.killed) serverProcess.kill('SIGKILL');
resolve();
}, 5000);
const killTimeout = setTimeout(() => {
if (serverProcess && !serverProcess.killed) serverProcess.kill('SIGKILL');
resolve();
}, 5000);
serverProcess.on('exit', () => {
clearTimeout(killTimeout);
resolve();
});

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 101773f. The 5s SIGKILL timer is now captured in a killTimeout handle and clearTimeout'd inside the child exit handler, so a clean SIGTERM shutdown lets the test finish immediately instead of waiting for the safety timer to elapse.

});
}
if (testDbPath && fs.existsSync(testDbPath)) {
fs.unlinkSync(testDbPath);
}
});

it('responds on the configured host', async () => {
const res = await fetch(`http://${testHost}:${testPort}/healthz`);
expect(res.status).toBe(200);
});

it('logs the actual bound address at startup', () => {
const allLogs = startupLogs.join('');
expect(allLogs).toContain(`${testHost}:${testPort}`);
});
});
84 changes: 83 additions & 1 deletion src/config/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { buildDSNFromEnvParams, resolveDSN, resolveId } from '../env.js';
import { buildDSNFromEnvParams, resolveDSN, resolveHost, resolveId } from '../env.js';

// Mock toml-loader to prevent it from loading dbhub.toml during tests
vi.mock('../toml-loader.js', () => ({
Expand Down Expand Up @@ -391,4 +391,86 @@ describe('Environment Configuration Tests', () => {
});
});
});

describe('resolveHost', () => {
const originalArgv = process.argv;

beforeEach(() => {
delete process.env.HOST;
process.argv = ['node', 'script.js'];
});

afterEach(() => {
process.argv = originalArgv;
});

it('defaults to 0.0.0.0 when nothing is set', () => {
const result = resolveHost();

expect(result).toEqual({ host: '0.0.0.0', source: 'default' });
});

it('reads HOST from the environment variable', () => {
process.env.HOST = '127.0.0.1';

const result = resolveHost();

expect(result).toEqual({ host: '127.0.0.1', source: 'environment variable' });
});

it('reads --host from command line arguments (equals form)', () => {
process.argv = ['node', 'script.js', '--host=10.0.0.5'];

const result = resolveHost();

expect(result).toEqual({ host: '10.0.0.5', source: 'command line argument' });
});

it('reads --host from command line arguments (space form)', () => {
process.argv = ['node', 'script.js', '--host', '192.168.1.10'];

const result = resolveHost();

expect(result).toEqual({ host: '192.168.1.10', source: 'command line argument' });
});

it('prefers --host over HOST environment variable', () => {
process.env.HOST = '0.0.0.0';
process.argv = ['node', 'script.js', '--host=127.0.0.1'];

const result = resolveHost();

expect(result).toEqual({ host: '127.0.0.1', source: 'command line argument' });
});

it('treats empty HOST env var as unset and falls back to default', () => {
process.env.HOST = '';

const result = resolveHost();

expect(result).toEqual({ host: '0.0.0.0', source: 'default' });
});

it('accepts IPv6 addresses verbatim', () => {
process.env.HOST = '::1';

const result = resolveHost();

expect(result).toEqual({ host: '::1', source: 'environment variable' });
});

it('exits when --host is provided without a value', () => {
process.argv = ['node', 'script.js', '--host'];
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit: ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

expect(() => resolveHost()).toThrow('process.exit: 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--host requires a value'));

exitSpy.mockRestore();
errorSpy.mockRestore();
});
});
});
30 changes: 30 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,36 @@ export function resolvePort(): { port: number; source: string } {
return { port: 8080, source: "default" };
}

/**
* Resolve HTTP bind host from command line args or environment variables.
* Returns the host with "0.0.0.0" as the default (listen on all interfaces).
*
* Note: Only applicable when using --transport=http. Default "0.0.0.0" keeps
* backward compatibility; production deployments should set "127.0.0.1" and
* front DBHub with a reverse proxy or firewall.
*/
export function resolveHost(): { host: string; source: string } {
const args = parseCommandLineArgs();

// 1. Command line argument has highest priority.
// parseCommandLineArgs turns bare --host (no value) into "true"; reject it.
if (args.host) {
if (args.host === "true") {
console.error("ERROR: --host requires a value (e.g., --host=127.0.0.1).");
process.exit(1);
}
return { host: args.host, source: "command line argument" };

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveHost() treats args.host === "true" as the “bare --host” sentinel, but that’s indistinguishable from an explicit --host=true value because the manual argv parser also produces the literal string "true" for boolean flags. Consider detecting a missing value at parse time (e.g., for known string-valued flags like host/port/dsn) or explicitly inspecting process.argv to see whether --host had a following value, instead of rejecting the literal value "true".

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 1527f6a. resolveHost() now inspects process.argv directly to detect a truly bare --host (no following value, or followed by another -- flag). An explicit --host=true now passes through and will be rejected later by listen() like any other invalid host, rather than being conflated with the bare-flag case.

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveHost() returns args.host verbatim without trimming/validating. This means CLI values like --host=" " or --host=" 127.0.0.1 " will be accepted and handed to server.listen(), producing confusing bind errors (or unexpected whitespace) even though the env var path explicitly trims/ignores whitespace-only values. Consider trimming the CLI value as well and treating empty/whitespace-only as a friendly error (similar to the --host/--host= checks).

Suggested change
return { host: args.host, source: "command line argument" };
const cliHost = args.host.trim();
if (!cliHost) {
console.error("ERROR: --host requires a value (e.g., --host=127.0.0.1).");
process.exit(1);
}
return { host: cliHost, source: "command line argument" };

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 94be454. The CLI path now mirrors the env var path: args.host is trimmed, a whitespace-only value exits with the same --host requires a value error as the bare/empty forms, and surrounding whitespace on a valid value is stripped. Tests added for both --host=" " (rejected) and --host=" 127.0.0.1 " (trimmed to 127.0.0.1).

}

// 2. Environment variable (empty string is treated as unset)
if (process.env.HOST) {
return { host: process.env.HOST, source: "environment variable" };
}

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the generic env var name HOST risks unexpected behavior changes because many environments/shells set HOST by default (often to the machine hostname). That can unintentionally change the bind address or even cause startup failure (e.g., unresolvable hostname), undermining the stated backward compatibility. Consider switching to a more specific variable name (e.g., DBHUB_HOST / HTTP_BIND_HOST) and optionally supporting HOST as a deprecated alias.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d32c7ab. Renamed the env var to DBHUB_HOST so it cannot be shadowed by the generic HOST that csh/tcsh, some CI systems, and Docker base images set automatically (usually to the machine hostname). No alias is kept since this PR hasn't shipped yet. Updated env.ts, tests (incl. a new test asserting the generic HOST is ignored), the integration test, .env.example, and docs/config/command-line.mdx.


// 3. Default: bind all interfaces
return { host: "0.0.0.0", source: "default" };
}

/**
* Redact sensitive information from a DSN string
* Replaces the password with asterisks
Expand Down
28 changes: 22 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { fileURLToPath } from "url";

import { ConnectorManager } from "./connectors/manager.js";
import { ConnectorRegistry } from "./connectors/interface.js";
import { resolveTransport, resolvePort, resolveSourceConfigs, isDemoMode } from "./config/env.js";
import { resolveTransport, resolvePort, resolveHost, resolveSourceConfigs, isDemoMode } from "./config/env.js";
import { registerTools } from "./tools/index.js";
import { listSources, getSource } from "./api/sources.js";
import { listRequests } from "./api/requests.js";
Expand Down Expand Up @@ -129,8 +129,9 @@ See documentation for more details on configuring database connections.
// Resolve transport type (for MCP server)
const transportData = resolveTransport();

// Resolve port for HTTP server (only needed for http transport)
// Resolve port and host for HTTP server (only needed for http transport)
const port = transportData.type === "http" ? resolvePort().port : null;
const host = transportData.type === "http" ? resolveHost().host : null;

// Print ASCII art banner with version and slogan
// Collect active modes
Expand Down Expand Up @@ -258,17 +259,32 @@ See documentation for more details on configuring database connections.
}

// Start the HTTP server
app.listen(port, '0.0.0.0', () => {
const httpServer = app.listen(port!, host!, () => {
const address = httpServer.address();
const boundHost = typeof address === 'object' && address ? address.address : host!;
const boundPort = typeof address === 'object' && address ? address.port : port!;
const displayHost = boundHost.includes(':') ? `[${boundHost}]` : boundHost;

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

httpServer.on('error', ...) is attached after app.listen(...), which can miss early bind errors and is inconsistent with the project pattern of registering error listeners before calling listen(). For example, src/utils/ssh-tunnel.ts:276-289 registers the error handler first “to catch all errors”. Consider creating the HTTP server explicitly (e.g., via Node’s http.createServer(app)), attach the error listener first, then call server.listen(port, host, ...) so bind failures are reliably handled by your custom message/exit path.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in d32c7ab. Replaced app.listen() with an explicit http.createServer(app) and moved the error listener before .listen(), matching the pattern in utils/ssh-tunnel.ts:276-289. This closes the small race where synchronous bind failures (EADDRINUSE, EACCES on privileged ports) could fire before the listener was registered. Integration test still passes.

// Wildcard binds (0.0.0.0 / ::) are not routable; use localhost for user URLs.
const userHost = (boundHost === '0.0.0.0' || boundHost === '::') ? 'localhost' : displayHost;

Comment on lines +274 to +277

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workbench/MCP URLs and docs now encourage IPv6 literals (e.g., --host ::1), but the existing DNS-rebinding protection middleware parses req.headers.host with .split(':')[0], which breaks for bracketed IPv6 Host headers like [::1]:8080 and will cause browser requests to be rejected (Origin/Host mismatch). To make IPv6 binds actually usable from the browser, update the Host parsing logic to properly handle [v6] (e.g., extract the bracketed hostname or use URL parsing) before comparing to originHost.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 185fa52. Extracted the check into a pure validateOrigin helper that parses both the Origin and Host headers with WHATWG new URL(), which correctly unbrackets IPv6 literals (e.g., [::1]:8080::1). Previously split(':')[0] yielded "[" for bracketed hosts and rejected matching IPv6 origins. Added src/utils/__tests__/dns-rebinding.test.ts covering IPv4, IPv6 bracketed, case-insensitive, mismatch, and malformed cases.

console.error(`HTTP server listening on ${displayHost}:${boundPort}`);

// In development mode, suggest using the Vite dev server for hot reloading
if (process.env.NODE_ENV === 'development') {
console.error('Development mode detected!');
console.error(' Workbench dev server (with HMR): http://localhost:5173');
console.error(' Backend API: http://localhost:8080');
console.error(` Backend API: http://${userHost}:${boundPort}`);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When --host is set to a specific loopback IP (e.g., 127.0.0.1), the printed “Backend API” URL will use that host, but the Vite dev server hint still points at http://localhost:5173. With the existing DNS-rebinding protection middleware (Origin host must match Host header), calling the backend at 127.0.0.1 from a page served at localhost will be rejected. Consider either (a) keeping userHost as localhost for loopback binds too (127.0.0.1 / ::1), or (b) updating the development instructions to tell users to open the Vite dev server using the same hostname as userHost.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 1527f6a. The dev-mode Backend API hint now always prints http://localhost:<port> regardless of the bind host, so cross-origin fetches from the Vite dev server (localhost:5173) satisfy the DNS-rebinding middleware's Origin/Host check even when --host=127.0.0.1 is set.

console.error('');
} else {
console.error(`Workbench at http://localhost:${port}/`);
console.error(`Workbench at http://${userHost}:${boundPort}/`);
}
console.error(`MCP server endpoint at http://localhost:${port}/mcp`);
console.error(`MCP server endpoint at http://${userHost}:${boundPort}/mcp`);
});

httpServer.on('error', (err) => {
const displayHost = host!.includes(':') ? `[${host!}]` : host!;
console.error(`Failed to bind HTTP server to ${displayHost}:${port}: ${err.message}`);
process.exit(1);
});
} else {
// STDIO transport: Pure MCP-over-stdio, no HTTP server
Expand Down
Loading