Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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}`,
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();
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}`);
});
});
143 changes: 142 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,145 @@ 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('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();
});
});
});
58 changes: 58 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,64 @@ 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 } {
// Detect a missing --host value directly in argv. parseCommandLineArgs()
// collapses bare flags and explicit empty values into the same sentinel
// string "true", which is indistinguishable from an explicit --host=true.
// We inspect argv here so we can reject only the genuinely value-less cases:
// --host (followed by nothing or another --flag)
// --host= (empty after equals, alone or followed by another --flag)
// An explicit --host=true passes through and fails later at listen().
const rawArgs = process.argv.slice(2);
for (let i = 0; i < rawArgs.length; i++) {
const token = rawArgs[i];

if (token === "--host") {
const next = rawArgs[i + 1];
if (!next || next.startsWith("--")) {
console.error("ERROR: --host requires a value (e.g., --host=127.0.0.1).");
process.exit(1);
}
break;
}

if (token === "--host=") {
const next = rawArgs[i + 1];
if (!next || next.startsWith("--")) {
console.error("ERROR: --host requires a value (e.g., --host=127.0.0.1).");
process.exit(1);
}
break;

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 --host= (empty value) check only exits if the next argv token is missing/another flag. If a user accidentally writes --host= 127.0.0.1 (space after =), this scan will not exit, parseCommandLineArgs() will still set host="true", and the process will attempt to bind to hostname true. --host= should be rejected unconditionally (regardless of following tokens), or parseCommandLineArgs() should treat empty --key= as an empty string so you can reliably detect and error on it.

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.

Addressed in 0d35b8d. Note: your trace of --host= 127.0.0.1 binding to "true" is actually inverted — parseCommandLineArgs strips the empty = and binds the following positional, so it was silently resolving to 127.0.0.1. Either way, the behavior was ambiguous and wrong. The fix rejects --host= unconditionally (no next-token check) so the user gets a clear error. Test added for the --host= <positional> case.

}
Comment on lines +357 to +373

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()’s argv scan breaks after the first --host/--host= occurrence. If the user provides multiple --host flags (e.g. --host 127.0.0.1 --host), the later bare/empty occurrence will still be parsed by parseCommandLineArgs() as host="true" and will win, but the pre-scan won’t catch it. Consider scanning the full argv and rejecting any bare/empty --host occurrence (don’t break on the first match).

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.

Addressed in 0d35b8d — dropped the early break from the argv scan, so a later bare --host after an earlier valid occurrence is now rejected. Added a unit test covering --host 127.0.0.1 --host.

}

const args = parseCommandLineArgs();

// 1. Command line argument has highest priority
if (args.host) {
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() 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)
// Using DBHUB_HOST rather than generic HOST to avoid collisions — HOST is
// set by default in csh/tcsh, some CI systems, and Docker base images
// (often to the machine hostname), which would silently redirect binds.
if (process.env.DBHUB_HOST) {
return { host: process.env.DBHUB_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.

resolveHost() treats any truthy DBHUB_HOST as a value without trimming/validating it. A whitespace-only value (e.g. DBHUB_HOST=" ") will be accepted and later cause a confusing bind failure. Consider trimming DBHUB_HOST and either (a) treating empty-after-trim as unset (fall back to default) or (b) exiting with a clear error similar to the --host validation.

Suggested change
// 2. Environment variable (empty string is treated as unset)
// Using DBHUB_HOST rather than generic HOST to avoid collisions — HOST is
// set by default in csh/tcsh, some CI systems, and Docker base images
// (often to the machine hostname), which would silently redirect binds.
if (process.env.DBHUB_HOST) {
return { host: process.env.DBHUB_HOST, source: "environment variable" };
// 2. Environment variable (empty string or whitespace-only value is treated as unset)
// Using DBHUB_HOST rather than generic HOST to avoid collisions — HOST is
// set by default in csh/tcsh, some CI systems, and Docker base images
// (often to the machine hostname), which would silently redirect binds.
const envHost = process.env.DBHUB_HOST?.trim();
if (envHost) {
return { host: envHost, source: "environment variable" };

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. DBHUB_HOST is now trimmed before use, and an empty/whitespace-only value falls back to the default (0.0.0.0), matching the --host flag's validation. Tests added for both DBHUB_HOST=" " (falls back to default) and DBHUB_HOST=" 127.0.0.1 " (surrounding whitespace stripped).

}

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.

This PR’s metadata/title mentions a HOST env var, but the implementation and docs use DBHUB_HOST (and explicitly ignore HOST). Please align the PR description/title (and any release notes) with the actual env var name to avoid operator confusion.

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.

PR title and body updated to reference DBHUB_HOST consistently (summary, changes, and test plan).


// 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
Loading
Loading