Skip to content
Merged
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
19 changes: 17 additions & 2 deletions dbhub.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# aws_region = "eu-west-1"
# sslmode = "require"

# PostgreSQL with certificate verification (e.g., AWS RDS)
# [[sources]]
# id = "rds_pg_verified"
# type = "postgres"
# host = "mydb.abc123.eu-west-1.rds.amazonaws.com"
# port = 5432
# database = "myapp"
# user = "app_user"
# password = "secure_password"
# sslmode = "verify-ca"
# sslrootcert = "~/.ssl/rds-combined-ca-bundle.pem"

# Production PostgreSQL (behind SSH bastion, lazy connection)
# [[sources]]
# id = "prod_pg"
Expand Down Expand Up @@ -323,8 +335,11 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# ssh_keepalive_count_max (max missed keepalive responses, default: 3)
#
# SSL Mode (for network databases, not SQLite):
# sslmode = "disable" # No SSL
# sslmode = "require" # SSL without certificate verification
# sslmode = "disable" # No SSL
# sslmode = "require" # SSL without certificate verification
# sslmode = "verify-ca" # SSL with CA certificate verification (PostgreSQL only)
# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL only)
# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (requires verify-ca or verify-full)
Comment thread
Elrendio marked this conversation as resolved.
#
# SQL Server Authentication:
# authentication = "ntlm" # Windows/NTLM auth (requires domain)
Expand Down
201 changes: 201 additions & 0 deletions src/config/__tests__/toml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,172 @@ dsn = "postgres://user:pass@localhost:5432/testdb"
expect(result).toBeTruthy();
expect(result?.sources[0].sslmode).toBeUndefined();
});

it('should accept sslmode = "verify-ca" for PostgreSQL', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-ca"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].sslmode).toBe('verify-ca');
});

it('should accept sslmode = "verify-full" for PostgreSQL', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-full"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].sslmode).toBe('verify-full');
});

it('should reject sslmode = "verify-ca" for MySQL', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "mysql"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-ca"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("sslmode 'verify-ca' which is only supported for PostgreSQL");
});

it('should reject sslmode = "verify-full" for MariaDB', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "mariadb"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-full"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("sslmode 'verify-full' which is only supported for PostgreSQL");
});

it('should reject sslmode = "verify-ca" for SQL Server', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "sqlserver"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-ca"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("sslmode 'verify-ca' which is only supported for PostgreSQL");
});

it('should reject sslrootcert when sslmode is "require"', () => {
const certPath = path.join(tempDir, 'ca.pem');
fs.writeFileSync(certPath, 'cert-content');

const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "require"
sslrootcert = '${certPath}'
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("sslrootcert requires sslmode 'verify-ca' or 'verify-full'");
});

it('should reject sslrootcert when sslmode is not set', () => {
const certPath = path.join(tempDir, 'ca.pem');
fs.writeFileSync(certPath, 'cert-content');

const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslrootcert = '${certPath}'
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("sslrootcert requires sslmode 'verify-ca' or 'verify-full'");
});

it('should accept sslrootcert with sslmode = "verify-ca" when file exists', () => {
const certPath = path.join(tempDir, 'ca.pem');
fs.writeFileSync(certPath, 'cert-content');

const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-ca"
sslrootcert = '${certPath}'
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

const result = loadTomlConfig();

expect(result).toBeTruthy();
expect(result?.sources[0].sslmode).toBe('verify-ca');
expect(result?.sources[0].sslrootcert).toBe(certPath);
});

it('should reject sslrootcert when file does not exist', () => {
const tomlContent = `
[[sources]]
id = "test_db"
type = "postgres"
host = "localhost"
database = "testdb"
user = "user"
password = "pass"
sslmode = "verify-ca"
sslrootcert = "/nonexistent/ca.pem"
`;
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);

expect(() => loadTomlConfig()).toThrow("sslrootcert file not found or not accessible: '/nonexistent/ca.pem'");
});
});

describe('SQL Server authentication validation', () => {
Expand Down Expand Up @@ -978,6 +1144,41 @@ dsn = "postgres://user:pass@localhost:5432/testdb"
expect(dsn).toBe('postgres://user:pass@localhost:5432/testdb?sslmode=require');
});

it('should build PostgreSQL DSN with verify-ca and sslrootcert', () => {
const source: SourceConfig = {
id: 'pg_verify',
type: 'postgres',
host: 'rds.amazonaws.com',
port: 5432,
database: 'testdb',
user: 'user',
password: 'pass',
sslmode: 'verify-ca',
sslrootcert: '/path/to/ca-bundle.pem'
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@rds.amazonaws.com:5432/testdb?sslmode=verify-ca&sslrootcert=%2Fpath%2Fto%2Fca-bundle.pem');
});

it('should build PostgreSQL DSN with verify-full without sslrootcert', () => {
const source: SourceConfig = {
id: 'pg_verify_full',
type: 'postgres',
host: 'localhost',
port: 5432,
database: 'testdb',
user: 'user',
password: 'pass',
sslmode: 'verify-full'
};

const dsn = buildDSNFromSource(source);

expect(dsn).toBe('postgres://user:pass@localhost:5432/testdb?sslmode=verify-full');
});

it('should build MySQL DSN with sslmode', () => {
const source: SourceConfig = {
id: 'mysql_ssl',
Expand Down
73 changes: 72 additions & 1 deletion src/config/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import toml from "@iarna/toml";
import type { SourceConfig, TomlConfig, ToolConfig } from "../types/config.js";
import { parseCommandLineArgs } from "./env.js";
import { parseConnectionInfoFromDSN, getDefaultPortForType } from "../utils/dsn-obfuscate.js";
import { SafeURL } from "../utils/safe-url.js";
import { BUILTIN_TOOLS, BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS } from "../tools/builtin-tools.js";

/**
Expand Down Expand Up @@ -332,13 +333,55 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
);
}

const validSslModes = ["disable", "require"];
const validSslModes = ["disable", "require", "verify-ca", "verify-full"];
if (!validSslModes.includes(source.sslmode)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has invalid sslmode '${source.sslmode}'. ` +
`Valid values: ${validSslModes.join(", ")}`
);
}

if (
(source.sslmode === "verify-ca" || source.sslmode === "verify-full") &&
source.type !== "postgres"
) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslmode '${source.sslmode}' which is only supported for PostgreSQL. ` +
`Valid values for ${source.type}: disable, require`
);
}
}

// Validate sslrootcert if provided
if (source.sslrootcert !== undefined) {
if (source.sslmode !== "verify-ca" && source.sslmode !== "verify-full") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslrootcert but sslmode is '${source.sslmode ?? "not set"}'. ` +
`sslrootcert requires sslmode 'verify-ca' or 'verify-full'`
);
}

const expandedPath = expandHomeDir(source.sslrootcert);
let stats: fs.Stats;
try {
stats = fs.statSync(expandedPath);
} catch {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' sslrootcert file not found or not accessible: '${expandedPath}'`
);
}
if (!stats.isFile()) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' sslrootcert path is not a regular file: '${expandedPath}'`
);
}
try {
fs.accessSync(expandedPath, fs.constants.R_OK);
} catch {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' sslrootcert file is not readable: '${expandedPath}'`
);
}
}

// Validate SQL Server authentication options
Expand Down Expand Up @@ -437,6 +480,11 @@ function processSourceConfigs(
processed.ssh_key = expandHomeDir(processed.ssh_key);
}

// Expand ~ in sslrootcert path
if (processed.sslrootcert) {
processed.sslrootcert = expandHomeDir(processed.sslrootcert);
}

// Expand ~ in SQLite database path (if relative)
if (processed.type === "sqlite" && processed.database) {
processed.database = expandHomeDir(processed.database);
Expand Down Expand Up @@ -469,6 +517,20 @@ function processSourceConfigs(
processed.user = connectionInfo.user;
}
}

try {
const url = new SafeURL(processed.dsn);
const dsnSslmode = url.getSearchParam("sslmode");
if (!processed.sslmode && dsnSslmode) {
processed.sslmode = dsnSslmode as SourceConfig["sslmode"];
}
const dsnSslrootcert = url.getSearchParam("sslrootcert");
if (!processed.sslrootcert && dsnSslrootcert) {
processed.sslrootcert = dsnSslrootcert;
}
} catch {
// DSN parsing for query params is best-effort; connector will handle errors
}
}

return processed;
Expand Down Expand Up @@ -596,6 +658,15 @@ export function buildDSNFromSource(source: SourceConfig): string {
queryParams.push(`sslmode=${source.sslmode}`);
}

if (
source.sslrootcert &&
source.type === "postgres" &&
(source.sslmode === "verify-ca" || source.sslmode === "verify-full")
) {
const expandedCertPath = expandHomeDir(source.sslrootcert);
queryParams.push(`sslrootcert=${encodeURIComponent(expandedCertPath)}`);
}

// Append query string if any params exist
if (queryParams.length > 0) {
dsn += `?${queryParams.join("&")}`;
Expand Down
Loading
Loading