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
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)
#
# 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: '/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
41 changes: 39 additions & 2 deletions src/config/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,21 +324,44 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {

// Validate sslmode if provided
if (source.sslmode !== undefined) {
// SQLite doesn't support SSL (local file-based database)
if (source.type === "sqlite") {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' has sslmode but SQLite does not support SSL. ` +
`Remove the sslmode field for SQLite sources.`
);
}

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`
);
}
Comment on lines +342 to +347
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The verify-* restriction only applies when source.sslmode is set explicitly. If a user supplies dsn with ?sslmode=verify-ca/verify-full (or sslrootcert=...) these query params are not validated here because parseConnectionInfoFromDSN() doesn’t extract them. If the intent is to restrict verify-* to PostgreSQL sources in TOML regardless of whether the DSN or discrete fields are used, consider parsing source.dsn query params (e.g., via SafeURL) during validation (or populating processed.sslmode/processed.sslrootcert from DSN) and enforcing the same rules.

Copilot uses AI. Check for mistakes.
}

// 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);
if (!fs.existsSync(expandedPath)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' sslrootcert file not found: '${expandedPath}'`
);
}
Comment on lines +360 to +364
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

sslrootcert validation only checks existsSync(). This will pass for directories and other non-regular files, and it doesn’t verify readability; the failure will then occur later during connection setup with a less configuration-specific error. Consider validating that the path is a readable regular file (e.g., statSync().isFile() + accessSync(R_OK)) so misconfigurations are caught deterministically at config load time.

Suggested change
if (!fs.existsSync(expandedPath)) {
throw new Error(
`Configuration file ${configPath}: source '${source.id}' sslrootcert file not found: '${expandedPath}'`
);
}
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}'`
);
}

Copilot uses AI. Check for mistakes.
}

// Validate SQL Server authentication options
Expand Down Expand Up @@ -437,6 +460,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 @@ -596,6 +624,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