Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Copy link

Copilot AI Apr 24, 2026

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}`);
});
});
225 changes: 224 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,227 @@ 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();
});

it('exits when --host value is whitespace-only (quoted)', () => {
// Shells can pass a quoted whitespace value through to argv, e.g.
// --host=" "
// The env var path already rejects this; the CLI path should match
// so the user gets the same friendly error instead of an opaque
// listen() failure.
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('trims surrounding whitespace from --host CLI value', () => {
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' });
});
});
});
Loading
Loading