diff --git a/dbhub.toml.example b/dbhub.toml.example index 723b5708..6d49bdf6 100644 --- a/dbhub.toml.example +++ b/dbhub.toml.example @@ -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" @@ -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) diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts index d1ccf5c2..ff10fe7f 100644 --- a/src/config/__tests__/toml-loader.test.ts +++ b/src/config/__tests__/toml-loader.test.ts @@ -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', () => { @@ -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', diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index b716c59b..199df0e4 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -324,7 +324,6 @@ 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. ` + @@ -332,13 +331,37 @@ 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); + if (!fs.existsSync(expandedPath)) { + throw new Error( + `Configuration file ${configPath}: source '${source.id}' sslrootcert file not found: '${expandedPath}'` + ); + } } // Validate SQL Server authentication options @@ -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); @@ -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("&")}`; diff --git a/src/connectors/__tests__/dsn-parser.test.ts b/src/connectors/__tests__/dsn-parser.test.ts index 4a5f285d..4e843531 100644 --- a/src/connectors/__tests__/dsn-parser.test.ts +++ b/src/connectors/__tests__/dsn-parser.test.ts @@ -1,8 +1,104 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { PostgresConnector } from '../postgres/index.js'; import { MySQLConnector } from '../mysql/index.js'; import { MariaDBConnector } from '../mariadb/index.js'; import { SQLServerConnector } from '../sqlserver/index.js'; +describe('DSN Parser - PostgreSQL SSL Modes', () => { + const connector = new PostgresConnector(); + const parser = connector.dsnParser; + let tempDir: string; + let certPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dbhub-ssl-test-')); + certPath = path.join(tempDir, 'ca-bundle.pem'); + fs.writeFileSync(certPath, '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should set ssl = false for sslmode=disable', async () => { + const config = await parser.parse('postgres://user:pass@localhost:5432/db?sslmode=disable'); + expect(config.ssl).toBe(false); + }); + + it('should set rejectUnauthorized = false for sslmode=require', async () => { + const config = await parser.parse('postgres://user:pass@localhost:5432/db?sslmode=require'); + expect(config.ssl).toEqual({ rejectUnauthorized: false }); + }); + + it('should set rejectUnauthorized = true and skip hostname check for sslmode=verify-ca', async () => { + const config = await parser.parse('postgres://user:pass@localhost:5432/db?sslmode=verify-ca'); + const ssl = config.ssl as Record; + expect(ssl.rejectUnauthorized).toBe(true); + expect(typeof ssl.checkServerIdentity).toBe('function'); + expect((ssl.checkServerIdentity as Function)()).toBeUndefined(); + }); + + it('should set rejectUnauthorized = true and verify hostname for sslmode=verify-full', async () => { + const config = await parser.parse('postgres://user:pass@localhost:5432/db?sslmode=verify-full'); + const ssl = config.ssl as Record; + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.checkServerIdentity).toBeUndefined(); + }); + + it('should read CA cert file for sslmode=verify-ca with sslrootcert', async () => { + const dsn = `postgres://user:pass@localhost:5432/db?sslmode=verify-ca&sslrootcert=${encodeURIComponent(certPath)}`; + const config = await parser.parse(dsn); + const ssl = config.ssl as Record; + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.ca).toBe('-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n'); + expect(typeof ssl.checkServerIdentity).toBe('function'); + }); + + it('should read CA cert file for sslmode=verify-full with sslrootcert', async () => { + const dsn = `postgres://user:pass@localhost:5432/db?sslmode=verify-full&sslrootcert=${encodeURIComponent(certPath)}`; + const config = await parser.parse(dsn); + expect(config.ssl).toEqual({ + rejectUnauthorized: true, + ca: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n', + }); + }); + + it('should expand ~ in sslrootcert path', async () => { + const mockHomedir = vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + fs.writeFileSync(path.join(tempDir, 'ca.pem'), 'test-ca-content'); + + try { + const dsn = `postgres://user:pass@localhost:5432/db?sslmode=verify-ca&sslrootcert=${encodeURIComponent('~/ca.pem')}`; + const config = await parser.parse(dsn); + const ssl = config.ssl as Record; + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.ca).toBe('test-ca-content'); + } finally { + mockHomedir.mockRestore(); + } + }); + + it('should throw when sslrootcert points to nonexistent file', async () => { + const dsn = 'postgres://user:pass@localhost:5432/db?sslmode=verify-ca&sslrootcert=/nonexistent/ca.pem'; + await expect(parser.parse(dsn)).rejects.toThrow("Failed to read SSL root certificate at '/nonexistent/ca.pem'"); + }); + + it('should ignore sslrootcert when sslmode=require', async () => { + const dsn = `postgres://user:pass@localhost:5432/db?sslmode=require&sslrootcert=${encodeURIComponent(certPath)}`; + const config = await parser.parse(dsn); + expect(config.ssl).toEqual({ rejectUnauthorized: false }); + }); + + it('should ignore sslrootcert when sslmode=disable', async () => { + const dsn = `postgres://user:pass@localhost:5432/db?sslmode=disable&sslrootcert=${encodeURIComponent(certPath)}`; + const config = await parser.parse(dsn); + expect(config.ssl).toBe(false); + }); +}); + describe('DSN Parser - AWS IAM Authentication', () => { describe('MySQL', () => { const connector = new MySQLConnector(); diff --git a/src/connectors/postgres/failed-to-read-certificate.ts b/src/connectors/postgres/failed-to-read-certificate.ts new file mode 100644 index 00000000..3ec8f395 --- /dev/null +++ b/src/connectors/postgres/failed-to-read-certificate.ts @@ -0,0 +1,10 @@ +/** + * Thrown when a CA certificate file specified via `sslrootcert` cannot be read + * (e.g. the file does not exist or is not accessible). + */ +export class FailedToReadCertificate extends Error { + constructor(message: string) { + super(message); + this.name = "FailedToReadCertificate"; + } +} diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index 6ba0f6da..b24bbab1 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -1,3 +1,6 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; import pg from "pg"; const { Pool } = pg; import { @@ -17,6 +20,7 @@ import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js"; import { SQLRowLimiter } from "../../utils/sql-row-limiter.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { splitSQLStatements } from "../../utils/sql-parser.js"; +import { FailedToReadCertificate } from "./failed-to-read-certificate.js"; /** * PostgreSQL DSN Parser @@ -24,7 +28,12 @@ import { splitSQLStatements } from "../../utils/sql-parser.js"; * Supported SSL modes: * - sslmode=disable: No SSL * - sslmode=require: SSL connection without certificate verification - * - Any other value: SSL with certificate verification + * - sslmode=verify-ca: SSL with CA certificate verification, no hostname check + * - sslmode=verify-full: SSL with CA certificate and hostname verification + * - Any other value: SSL with default Node.js TLS settings + * + * Optional parameter for verify-ca/verify-full: + * - sslrootcert=/path/to/ca.pem: Path to CA certificate bundle (supports ~/ expansion) */ class PostgresDSNParser implements DSNParser { async parse(dsn: string, config?: ConnectorConfig): Promise { @@ -52,20 +61,48 @@ class PostgresDSNParser implements DSNParser { password: url.password, }; - // Handle query parameters (like sslmode, etc.) + let sslmode: string | undefined; + let sslrootcert: string | undefined; + + // Handle query parameters (like sslmode, sslrootcert, etc.) url.forEachSearchParam((value, key) => { if (key === "sslmode") { - if (value === "disable") { - poolConfig.ssl = false; - } else if (value === "require") { - poolConfig.ssl = { rejectUnauthorized: false }; - } else { - poolConfig.ssl = true; - } + sslmode = value; + } else if (key === "sslrootcert") { + sslrootcert = value; } // Add other parameters as needed }); + if (sslmode === "disable") { + poolConfig.ssl = false; + } else if (sslmode === "require") { + poolConfig.ssl = { rejectUnauthorized: false }; + } else if (sslmode === "verify-ca" || sslmode === "verify-full") { + const sslConfig: pg.ConnectionOptions["ssl"] & object = { rejectUnauthorized: true }; + // verify-ca checks the certificate chain but does not verify the server hostname, + // matching libpq behavior. verify-full (the default with rejectUnauthorized: true) + // verifies both the certificate chain and the hostname. + if (sslmode === "verify-ca") { + sslConfig.checkServerIdentity = () => undefined; + } + if (sslrootcert) { + const certPath = sslrootcert.startsWith("~/") + ? path.join(os.homedir(), sslrootcert.slice(2)) + : sslrootcert; + try { + sslConfig.ca = await fs.promises.readFile(certPath, "utf-8"); + } catch (err) { + throw new FailedToReadCertificate( + `Failed to read SSL root certificate at '${certPath}': ${err instanceof Error ? err.message : String(err)}` + ); + } + } + poolConfig.ssl = sslConfig; + } else if (sslmode !== undefined) { + poolConfig.ssl = true; + } + // Apply connection timeout if specified if (connectionTimeoutSeconds !== undefined) { // pg library expects timeout in milliseconds @@ -80,8 +117,13 @@ class PostgresDSNParser implements DSNParser { return poolConfig; } catch (error) { + if (error instanceof FailedToReadCertificate) { + throw error; + } + const originalError = error instanceof Error ? error : new Error(String(error)); throw new Error( - `Failed to parse PostgreSQL DSN: ${error instanceof Error ? error.message : String(error)}` + `Failed to parse PostgreSQL DSN: ${originalError.message}`, + { cause: originalError } ); } } @@ -197,8 +239,8 @@ export class PostgresConnector implements Connector { const result = await client.query( ` - SELECT table_name - FROM information_schema.tables + SELECT table_name + FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name `, @@ -224,8 +266,8 @@ export class PostgresConnector implements Connector { const result = await client.query( ` SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = $1 + SELECT FROM information_schema.tables + WHERE table_schema = $1 AND table_name = $2 ) `, @@ -251,18 +293,18 @@ export class PostgresConnector implements Connector { // Query to get all indexes for the table const result = await client.query( ` - SELECT + SELECT i.relname as index_name, array_agg(a.attname) as column_names, ix.indisunique as is_unique, ix.indisprimary as is_primary - FROM + FROM pg_class t, pg_class i, pg_index ix, pg_attribute a, pg_namespace ns - WHERE + WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid AND a.attrelid = t.oid @@ -271,11 +313,11 @@ export class PostgresConnector implements Connector { AND t.relname = $1 AND ns.oid = t.relnamespace AND ns.nspname = $2 - GROUP BY - i.relname, + GROUP BY + i.relname, ix.indisunique, ix.indisprimary - ORDER BY + ORDER BY i.relname `, [tableName, schemaToUse] @@ -446,7 +488,7 @@ export class PostgresConnector implements Connector { // Get stored procedure details from PostgreSQL const result = await client.query( ` - SELECT + SELECT routine_name as procedure_name, routine_type, CASE WHEN routine_type = 'PROCEDURE' THEN 'procedure' ELSE 'function' END as procedure_type, @@ -455,8 +497,8 @@ export class PostgresConnector implements Connector { routine_definition as definition, ( SELECT string_agg( - parameter_name || ' ' || - parameter_mode || ' ' || + parameter_name || ' ' || + parameter_mode || ' ' || data_type, ', ' ) diff --git a/src/types/config.ts b/src/types/config.ts index 205843e9..ea30e43d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -36,7 +36,8 @@ export interface ConnectionParams { aws_iam_auth?: boolean; // Enable AWS IAM auth token generation for RDS aws_region?: string; // AWS region required when aws_iam_auth is enabled instanceName?: string; // SQL Server named instance support - sslmode?: "disable" | "require"; // SSL mode for network databases (not applicable to SQLite) + sslmode?: "disable" | "require" | "verify-ca" | "verify-full"; // SSL mode for network databases (not applicable to SQLite, verify-* only applicable for PostgreSQL) + sslrootcert?: string; // CA certificate path (requires verify-ca or verify-full) // SQL Server authentication options authentication?: "ntlm" | "azure-active-directory-access-token"; domain?: string; // Required for NTLM authentication