Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ 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 DBHUB_HOST=127.0.0.1 and front DBHub with a reverse
# proxy (nginx/Caddy) or a firewall. DBHub does not authenticate HTTP
# clients. The variable is prefixed to avoid collisions with the generic
# HOST env var that some shells and CI systems set automatically.
# DBHUB_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="DBHUB_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` | `DBHUB_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
90 changes: 90 additions & 0 deletions src/__tests__/http-bind-host.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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}`,
DBHUB_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();
// Without clearing this on normal exit, the pending timer keeps
// the Vitest process alive until the 5s tail elapses.
const killTimeout = setTimeout(() => {
if (serverProcess && !serverProcess.killed) serverProcess.kill('SIGKILL');
resolve();
}, 5000);
serverProcess.on('exit', () => {
clearTimeout(killTimeout);
resolve();
});
});
}
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}`);
});
});
198 changes: 197 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,200 @@ describe('Environment Configuration Tests', () => {
});
});
});

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

beforeEach(() => {
delete process.env.HOST;
delete process.env.DBHUB_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 DBHUB_HOST from the environment variable', () => {
process.env.DBHUB_HOST = '127.0.0.1';

const result = resolveHost();

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

it('ignores the generic HOST env var to avoid shell/CI collisions', () => {
process.env.HOST = 'my-laptop.local';

const result = resolveHost();

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

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 DBHUB_HOST environment variable', () => {
process.env.DBHUB_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 DBHUB_HOST env var as unset and falls back to default', () => {
process.env.DBHUB_HOST = '';

const result = resolveHost();

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

it('treats whitespace-only DBHUB_HOST env var as unset and falls back to default', () => {
// Without trimming, Node's listen() would be handed " " verbatim and
// fail with an obscure bind error. Consistent with the `--host` flag
// validation, treat blank-after-trim as "not set" rather than silently
// misconfigured.
process.env.DBHUB_HOST = ' ';

const result = resolveHost();

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

it('trims surrounding whitespace from DBHUB_HOST env var', () => {
process.env.DBHUB_HOST = ' 127.0.0.1 ';

const result = resolveHost();

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

it('accepts IPv6 addresses verbatim', () => {
process.env.DBHUB_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();
});

it('exits when --host is followed by another flag', () => {
process.argv = ['node', 'script.js', '--host', '--port=8080'];
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();
});

it('passes through an explicit --host=true without erroring (node listen will reject it)', () => {
process.argv = ['node', 'script.js', '--host=true'];

const result = resolveHost();

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

it('exits when --host= is provided with an empty 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();
});

it('exits when --host= is followed by another flag', () => {
process.argv = ['node', 'script.js', '--host=', '--port=8080'];
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();
});

it('exits when --host= is present even if a non-flag token follows (empty value, no concatenation)', () => {
// `--host= 127.0.0.1` is not the same as `--host=127.0.0.1`: the token
// is literally the empty string. parseCommandLineArgs has already been
// observed to bind the positional that follows to --host, silently
// accepting what the user almost certainly did not intend.
process.argv = ['node', 'script.js', '--host=', '127.0.0.1'];
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();
});

it('exits when a later bare --host appears after an earlier valid --host', () => {
// With an early break in the argv scan, only the first --host is
// inspected — a later duplicate bare --host sneaks through even though
// it has no value and the user's intent is ambiguous.
process.argv = ['node', 'script.js', '--host', '127.0.0.1', '--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();
});
});
});
Loading
Loading