From 7a8aa16d7d34e181c9714db3166cbb872f544d11 Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:43:30 +0200 Subject: [PATCH 01/11] Add support for verify-ca --- dbhub.toml.example | 19 +- src/config/__tests__/toml-loader.test.ts | 201 ++++++++++++++++++++ src/config/toml-loader.ts | 38 +++- src/connectors/__tests__/dsn-parser.test.ts | 98 +++++++++- src/connectors/postgres/index.ts | 60 +++--- src/types/config.ts | 3 +- 6 files changed, 388 insertions(+), 31 deletions(-) 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..fb618e07 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..28e4febb 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,12 @@ export function buildDSNFromSource(source: SourceConfig): string { queryParams.push(`sslmode=${source.sslmode}`); } + // Add sslrootcert when provided (path already validated and expanded during config validation) + if (source.sslrootcert) { + 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..8a740ce8 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 } 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 for sslmode=verify-ca without sslrootcert', async () => { + const config = await parser.parse('postgres://user:pass@localhost:5432/db?sslmode=verify-ca'); + expect(config.ssl).toEqual({ rejectUnauthorized: true }); + }); + + it('should set rejectUnauthorized = true for sslmode=verify-full without sslrootcert', async () => { + const config = await parser.parse('postgres://user:pass@localhost:5432/db?sslmode=verify-full'); + expect(config.ssl).toEqual({ rejectUnauthorized: true }); + }); + + 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); + expect(config.ssl).toEqual({ + rejectUnauthorized: true, + ca: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n', + }); + }); + + 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 homeDir = os.homedir(); + const homeCertDir = path.join(homeDir, '.dbhub-test-ssl'); + const homeCertPath = path.join(homeCertDir, 'ca.pem'); + + fs.mkdirSync(homeCertDir, { recursive: true }); + fs.writeFileSync(homeCertPath, 'test-ca-content'); + + try { + const dsn = `postgres://user:pass@localhost:5432/db?sslmode=verify-ca&sslrootcert=${encodeURIComponent('~/.dbhub-test-ssl/ca.pem')}`; + const config = await parser.parse(dsn); + expect(config.ssl).toEqual({ + rejectUnauthorized: true, + ca: 'test-ca-content', + }); + } finally { + fs.rmSync(homeCertDir, { recursive: true, force: true }); + } + }); + + 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/index.ts b/src/connectors/postgres/index.ts index 6ba0f6da..7e66c900 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -1,3 +1,5 @@ +import fs from "fs"; +import { homedir } from "os"; import pg from "pg"; const { Pool } = pg; import { @@ -18,19 +20,10 @@ import { SQLRowLimiter } from "../../utils/sql-row-limiter.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { splitSQLStatements } from "../../utils/sql-parser.js"; -/** - * PostgreSQL DSN Parser - * Handles DSN strings like: postgres://user:password@localhost:5432/dbname?sslmode=disable - * Supported SSL modes: - * - sslmode=disable: No SSL - * - sslmode=require: SSL connection without certificate verification - * - Any other value: SSL with certificate verification - */ class PostgresDSNParser implements DSNParser { async parse(dsn: string, config?: ConnectorConfig): Promise { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; const queryTimeoutSeconds = config?.queryTimeoutSeconds; - // Basic validation if (!this.isValidDSN(dsn)) { const obfuscatedDSN = obfuscateDSNPassword(dsn); const expectedFormat = this.getSampleDSN(); @@ -40,46 +33,63 @@ class PostgresDSNParser implements DSNParser { } try { - // Use the SafeURL helper instead of the built-in URL - // This will handle special characters in passwords, etc. const url = new SafeURL(dsn); const poolConfig: pg.PoolConfig = { host: url.hostname, port: url.port ? parseInt(url.port) : 5432, - database: url.pathname ? url.pathname.substring(1) : '', // Remove leading '/' if exists + database: url.pathname ? url.pathname.substring(1) : '', user: url.username, password: url.password, }; - // Handle query parameters (like sslmode, etc.) + let sslmode: string | undefined; + let sslrootcert: string | undefined; + 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 }); - // Apply connection timeout if specified + 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 }; + if (sslrootcert) { + const certPath = sslrootcert.startsWith("~/") + ? sslrootcert.replace("~", homedir()) + : sslrootcert; + try { + sslConfig.ca = fs.readFileSync(certPath, "utf-8"); + } catch (err) { + throw new Error( + `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; + } + if (connectionTimeoutSeconds !== undefined) { - // pg library expects timeout in milliseconds poolConfig.connectionTimeoutMillis = connectionTimeoutSeconds * 1000; } - // Apply query timeout if specified (client-side timeout) if (queryTimeoutSeconds !== undefined) { - // pg library expects query_timeout in milliseconds poolConfig.query_timeout = queryTimeoutSeconds * 1000; } return poolConfig; } catch (error) { + if (error instanceof Error && error.message.startsWith("Failed to read SSL root certificate")) { + throw error; + } throw new Error( `Failed to parse PostgreSQL DSN: ${error instanceof Error ? error.message : String(error)}` ); diff --git a/src/types/config.ts b/src/types/config.ts index 205843e9..94a211f3 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"; + sslrootcert?: string; // SQL Server authentication options authentication?: "ntlm" | "azure-active-directory-access-token"; domain?: string; // Required for NTLM authentication From 2e2d0720bce80d7ae3ea3ba373b6ec81e4225c91 Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:53:17 +0200 Subject: [PATCH 02/11] wip --- src/connectors/postgres/index.ts | 13 +++++++++++++ src/types/config.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index 7e66c900..6d5cc4ce 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -20,6 +20,19 @@ import { SQLRowLimiter } from "../../utils/sql-row-limiter.js"; import { quoteIdentifier } from "../../utils/identifier-quoter.js"; import { splitSQLStatements } from "../../utils/sql-parser.js"; +/** + * PostgreSQL DSN Parser + * Handles DSN strings like: postgres://user:password@localhost:5432/dbname?sslmode=disable + * Supported SSL modes: + * - sslmode=disable: No SSL + * - sslmode=require: SSL connection without certificate verification + * - sslmode=verify-ca: SSL with CA certificate verification (rejectUnauthorized: true) + * - sslmode=verify-full: SSL with CA and hostname verification (rejectUnauthorized: true) + * - 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 { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; diff --git a/src/types/config.ts b/src/types/config.ts index 94a211f3..ea30e43d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -36,8 +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" | "verify-ca" | "verify-full"; - sslrootcert?: string; + 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 From d642c43609c5c4aa78b8022b9e04aee018379370 Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:54:01 +0200 Subject: [PATCH 03/11] wip --- src/connectors/postgres/index.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index 6d5cc4ce..1a113210 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -51,7 +51,7 @@ class PostgresDSNParser implements DSNParser { const poolConfig: pg.PoolConfig = { host: url.hostname, port: url.port ? parseInt(url.port) : 5432, - database: url.pathname ? url.pathname.substring(1) : '', + database: url.pathname ? url.pathname.substring(1) : '', // Remove leading '/' if exists user: url.username, password: url.password, }; @@ -220,8 +220,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 `, @@ -247,8 +247,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 ) `, @@ -274,18 +274,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 @@ -294,11 +294,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] @@ -469,7 +469,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, @@ -478,8 +478,8 @@ export class PostgresConnector implements Connector { routine_definition as definition, ( SELECT string_agg( - parameter_name || ' ' || - parameter_mode || ' ' || + parameter_name || ' ' || + parameter_mode || ' ' || data_type, ', ' ) From 8a6822b1104662077415f894803815526b7806bb Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:54:22 +0200 Subject: [PATCH 04/11] wip --- src/connectors/postgres/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index 1a113210..887d342f 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -59,6 +59,7 @@ class PostgresDSNParser implements DSNParser { let sslmode: string | undefined; let sslrootcert: string | undefined; + // Handle query parameters (like sslmode, etc.) url.forEachSearchParam((value, key) => { if (key === "sslmode") { sslmode = value; From 693035e501aded95d3e26590759fc06d3d6a77bc Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:54:36 +0200 Subject: [PATCH 05/11] wip --- src/connectors/postgres/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index 887d342f..36476343 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -66,6 +66,7 @@ class PostgresDSNParser implements DSNParser { } else if (key === "sslrootcert") { sslrootcert = value; } + // Add other parameters as needed }); if (sslmode === "disable") { From 27d6d1c935618c21d33b0baf5c7d5ba83c7f8669 Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:54:45 +0200 Subject: [PATCH 06/11] wip --- src/connectors/postgres/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index 36476343..ca74ed85 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -59,14 +59,14 @@ class PostgresDSNParser implements DSNParser { let sslmode: string | undefined; let sslrootcert: string | undefined; - // Handle query parameters (like sslmode, etc.) + // Handle query parameters (like sslmode, etc.) url.forEachSearchParam((value, key) => { if (key === "sslmode") { sslmode = value; } else if (key === "sslrootcert") { sslrootcert = value; } - // Add other parameters as needed + // Add other parameters as needed }); if (sslmode === "disable") { From 4719de598038f804b9874244697438c2452c987d Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 08:58:40 +0200 Subject: [PATCH 07/11] wip --- src/connectors/__tests__/dsn-parser.test.ts | 28 ++++++++++++--------- src/connectors/postgres/index.ts | 21 +++++++++++++--- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/connectors/__tests__/dsn-parser.test.ts b/src/connectors/__tests__/dsn-parser.test.ts index 8a740ce8..c3d3f919 100644 --- a/src/connectors/__tests__/dsn-parser.test.ts +++ b/src/connectors/__tests__/dsn-parser.test.ts @@ -33,23 +33,28 @@ describe('DSN Parser - PostgreSQL SSL Modes', () => { expect(config.ssl).toEqual({ rejectUnauthorized: false }); }); - it('should set rejectUnauthorized = true for sslmode=verify-ca without sslrootcert', async () => { + 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'); - expect(config.ssl).toEqual({ rejectUnauthorized: true }); + 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 for sslmode=verify-full without sslrootcert', async () => { + 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'); - expect(config.ssl).toEqual({ rejectUnauthorized: true }); + 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); - expect(config.ssl).toEqual({ - rejectUnauthorized: true, - ca: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n', - }); + 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 () => { @@ -72,10 +77,9 @@ describe('DSN Parser - PostgreSQL SSL Modes', () => { try { const dsn = `postgres://user:pass@localhost:5432/db?sslmode=verify-ca&sslrootcert=${encodeURIComponent('~/.dbhub-test-ssl/ca.pem')}`; const config = await parser.parse(dsn); - expect(config.ssl).toEqual({ - rejectUnauthorized: true, - ca: 'test-ca-content', - }); + const ssl = config.ssl as Record; + expect(ssl.rejectUnauthorized).toBe(true); + expect(ssl.ca).toBe('test-ca-content'); } finally { fs.rmSync(homeCertDir, { recursive: true, force: true }); } diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index ca74ed85..b46a1a7d 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -26,8 +26,8 @@ import { splitSQLStatements } from "../../utils/sql-parser.js"; * Supported SSL modes: * - sslmode=disable: No SSL * - sslmode=require: SSL connection without certificate verification - * - sslmode=verify-ca: SSL with CA certificate verification (rejectUnauthorized: true) - * - sslmode=verify-full: SSL with CA and hostname verification (rejectUnauthorized: true) + * - 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: @@ -37,6 +37,7 @@ class PostgresDSNParser implements DSNParser { async parse(dsn: string, config?: ConnectorConfig): Promise { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; const queryTimeoutSeconds = config?.queryTimeoutSeconds; + // Basic validation if (!this.isValidDSN(dsn)) { const obfuscatedDSN = obfuscateDSNPassword(dsn); const expectedFormat = this.getSampleDSN(); @@ -46,12 +47,14 @@ class PostgresDSNParser implements DSNParser { } try { + // Use the SafeURL helper instead of the built-in URL + // This will handle special characters in passwords, etc. const url = new SafeURL(dsn); const poolConfig: pg.PoolConfig = { host: url.hostname, port: url.port ? parseInt(url.port) : 5432, - database: url.pathname ? url.pathname.substring(1) : '', // Remove leading '/' if exists + database: url.pathname ? url.pathname.substring(1) : '', // Remove leading '/' if exists user: url.username, password: url.password, }; @@ -59,7 +62,7 @@ class PostgresDSNParser implements DSNParser { let sslmode: string | undefined; let sslrootcert: string | undefined; - // Handle query parameters (like sslmode, etc.) + // Handle query parameters (like sslmode, sslrootcert, etc.) url.forEachSearchParam((value, key) => { if (key === "sslmode") { sslmode = value; @@ -75,6 +78,12 @@ class PostgresDSNParser implements DSNParser { 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("~/") ? sslrootcert.replace("~", homedir()) @@ -92,11 +101,15 @@ class PostgresDSNParser implements DSNParser { poolConfig.ssl = true; } + // Apply connection timeout if specified if (connectionTimeoutSeconds !== undefined) { + // pg library expects timeout in milliseconds poolConfig.connectionTimeoutMillis = connectionTimeoutSeconds * 1000; } + // Apply query timeout if specified (client-side timeout) if (queryTimeoutSeconds !== undefined) { + // pg library expects query_timeout in milliseconds poolConfig.query_timeout = queryTimeoutSeconds * 1000; } From 093b0d1a6305ace7a4dd42120f09b010d1df0d63 Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 18:54:52 +0200 Subject: [PATCH 08/11] wip --- src/config/__tests__/toml-loader.test.ts | 6 +++--- src/config/toml-loader.ts | 7 +++++-- src/connectors/__tests__/dsn-parser.test.ts | 16 ++++++---------- src/connectors/postgres/index.ts | 20 +++++++------------- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts index fb618e07..ff10fe7f 100644 --- a/src/config/__tests__/toml-loader.test.ts +++ b/src/config/__tests__/toml-loader.test.ts @@ -609,7 +609,7 @@ database = "testdb" user = "user" password = "pass" sslmode = "require" -sslrootcert = "${certPath}" +sslrootcert = '${certPath}' `; fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); @@ -628,7 +628,7 @@ host = "localhost" database = "testdb" user = "user" password = "pass" -sslrootcert = "${certPath}" +sslrootcert = '${certPath}' `; fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); @@ -648,7 +648,7 @@ database = "testdb" user = "user" password = "pass" sslmode = "verify-ca" -sslrootcert = "${certPath}" +sslrootcert = '${certPath}' `; fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index 28e4febb..199df0e4 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -624,8 +624,11 @@ export function buildDSNFromSource(source: SourceConfig): string { queryParams.push(`sslmode=${source.sslmode}`); } - // Add sslrootcert when provided (path already validated and expanded during config validation) - if (source.sslrootcert) { + if ( + source.sslrootcert && + source.type === "postgres" && + (source.sslmode === "verify-ca" || source.sslmode === "verify-full") + ) { const expandedCertPath = expandHomeDir(source.sslrootcert); queryParams.push(`sslrootcert=${encodeURIComponent(expandedCertPath)}`); } diff --git a/src/connectors/__tests__/dsn-parser.test.ts b/src/connectors/__tests__/dsn-parser.test.ts index c3d3f919..569a42dc 100644 --- a/src/connectors/__tests__/dsn-parser.test.ts +++ b/src/connectors/__tests__/dsn-parser.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -67,27 +67,23 @@ describe('DSN Parser - PostgreSQL SSL Modes', () => { }); it('should expand ~ in sslrootcert path', async () => { - const homeDir = os.homedir(); - const homeCertDir = path.join(homeDir, '.dbhub-test-ssl'); - const homeCertPath = path.join(homeCertDir, 'ca.pem'); - - fs.mkdirSync(homeCertDir, { recursive: true }); - fs.writeFileSync(homeCertPath, 'test-ca-content'); + 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('~/.dbhub-test-ssl/ca.pem')}`; + 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 { - fs.rmSync(homeCertDir, { recursive: true, force: true }); + 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'"); + await expect(parser.parse(dsn)).rejects.toThrow("Failed to parse PostgreSQL DSN"); }); it('should ignore sslrootcert when sslmode=require', async () => { diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index b46a1a7d..d384f656 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -1,5 +1,6 @@ import fs from "fs"; -import { homedir } from "os"; +import os from "os"; +import path from "path"; import pg from "pg"; const { Pool } = pg; import { @@ -86,15 +87,9 @@ class PostgresDSNParser implements DSNParser { } if (sslrootcert) { const certPath = sslrootcert.startsWith("~/") - ? sslrootcert.replace("~", homedir()) + ? path.join(os.homedir(), sslrootcert.slice(2)) : sslrootcert; - try { - sslConfig.ca = fs.readFileSync(certPath, "utf-8"); - } catch (err) { - throw new Error( - `Failed to read SSL root certificate at '${certPath}': ${err instanceof Error ? err.message : String(err)}` - ); - } + sslConfig.ca = await fs.promises.readFile(certPath, "utf-8"); } poolConfig.ssl = sslConfig; } else if (sslmode !== undefined) { @@ -115,11 +110,10 @@ class PostgresDSNParser implements DSNParser { return poolConfig; } catch (error) { - if (error instanceof Error && error.message.startsWith("Failed to read SSL root certificate")) { - 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 } ); } } From d735ea391a17911a08a423c401428276752fb865 Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 19:07:43 +0200 Subject: [PATCH 09/11] wip --- src/connectors/__tests__/dsn-parser.test.ts | 2 +- src/connectors/postgres/index.ts | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/connectors/__tests__/dsn-parser.test.ts b/src/connectors/__tests__/dsn-parser.test.ts index 569a42dc..4e843531 100644 --- a/src/connectors/__tests__/dsn-parser.test.ts +++ b/src/connectors/__tests__/dsn-parser.test.ts @@ -83,7 +83,7 @@ describe('DSN Parser - PostgreSQL SSL Modes', () => { 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 parse PostgreSQL DSN"); + 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 () => { diff --git a/src/connectors/postgres/index.ts b/src/connectors/postgres/index.ts index d384f656..07116ff8 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -34,6 +34,13 @@ import { splitSQLStatements } from "../../utils/sql-parser.js"; * Optional parameter for verify-ca/verify-full: * - sslrootcert=/path/to/ca.pem: Path to CA certificate bundle (supports ~/ expansion) */ +class FailedToReadCertificate extends Error { + constructor(message: string) { + super(message); + this.name = "FailedToReadCertificate"; + } +} + class PostgresDSNParser implements DSNParser { async parse(dsn: string, config?: ConnectorConfig): Promise { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; @@ -89,7 +96,13 @@ class PostgresDSNParser implements DSNParser { const certPath = sslrootcert.startsWith("~/") ? path.join(os.homedir(), sslrootcert.slice(2)) : sslrootcert; - sslConfig.ca = await fs.promises.readFile(certPath, "utf-8"); + 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) { @@ -110,6 +123,9 @@ 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: ${originalError.message}`, From cbfd0934e0941e936607e0742894d2ce5da801ca Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 19:12:03 +0200 Subject: [PATCH 10/11] wip --- .../postgres/failed-to-read-certificate.ts | 14 ++++++++++++++ src/connectors/postgres/index.ts | 8 +------- 2 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 src/connectors/postgres/failed-to-read-certificate.ts 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..986584e4 --- /dev/null +++ b/src/connectors/postgres/failed-to-read-certificate.ts @@ -0,0 +1,14 @@ +/** + * Thrown when a CA certificate file specified via `sslrootcert` cannot be read + * (e.g. the file does not exist or is not accessible). + * + * Using a dedicated error class allows the outer DSN-parsing catch block to + * re-throw it without wrapping, so callers receive a clear, actionable message + * about the missing certificate rather than a generic "Failed to parse DSN" error. + */ +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 07116ff8..b24bbab1 100644 --- a/src/connectors/postgres/index.ts +++ b/src/connectors/postgres/index.ts @@ -20,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 @@ -34,13 +35,6 @@ import { splitSQLStatements } from "../../utils/sql-parser.js"; * Optional parameter for verify-ca/verify-full: * - sslrootcert=/path/to/ca.pem: Path to CA certificate bundle (supports ~/ expansion) */ -class FailedToReadCertificate extends Error { - constructor(message: string) { - super(message); - this.name = "FailedToReadCertificate"; - } -} - class PostgresDSNParser implements DSNParser { async parse(dsn: string, config?: ConnectorConfig): Promise { const connectionTimeoutSeconds = config?.connectionTimeoutSeconds; From 4f2ccf64217b3a1b4ed56a9d8af596e5246e6a4c Mon Sep 17 00:00:00 2001 From: Elrendio Date: Wed, 1 Apr 2026 19:12:19 +0200 Subject: [PATCH 11/11] wip --- src/connectors/postgres/failed-to-read-certificate.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/connectors/postgres/failed-to-read-certificate.ts b/src/connectors/postgres/failed-to-read-certificate.ts index 986584e4..3ec8f395 100644 --- a/src/connectors/postgres/failed-to-read-certificate.ts +++ b/src/connectors/postgres/failed-to-read-certificate.ts @@ -1,10 +1,6 @@ /** * Thrown when a CA certificate file specified via `sslrootcert` cannot be read * (e.g. the file does not exist or is not accessible). - * - * Using a dedicated error class allows the outer DSN-parsing catch block to - * re-throw it without wrapping, so callers receive a clear, actionable message - * about the missing certificate rather than a generic "Failed to parse DSN" error. */ export class FailedToReadCertificate extends Error { constructor(message: string) {