Skip to content

Commit e80f778

Browse files
tianzhouclaude
andauthored
refactor(connectors): split PEM and EXPLAIN code out of connector index files (#467)
Pure code moves, no behaviour change. postgres: the sslrootcert/sslcert/sslkey handling (PEM reading, encrypted key detection, and the pg.Client subclass that re-reads PEMs on every connection) moves from index.ts to ssl-pems.ts, alongside the existing failed-to-read-certificate.ts and type-parsers.ts helpers. sqlserver: the EXPLAIN emulation (SHOWPLAN_XML / STATISTICS XML on a dedicated single-connection pool, option prefix parsing, rollback wrapper and plan extraction) moves to explain.ts, taking the connector's config as a parameter instead of reading this.config. bindParameters and assertNoReadOnlyEscapes, shared by executeReadOnly and the explain paths, move to request-helpers.ts so both files import them from one place. Claude-Session: https://claude.ai/code/session_01T79tgGfqWNqUeTwhRc65MJ Co-authored-by: Claude <[email protected]>
1 parent 8b5d45a commit e80f778

5 files changed

Lines changed: 609 additions & 566 deletions

File tree

‎src/connectors/postgres/index.ts‎

Lines changed: 11 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import fs from "fs";
2-
import os from "os";
3-
import path from "path";
41
import pg from "pg";
52
const { Pool } = pg;
63
import {
@@ -25,150 +22,24 @@ import { quoteIdentifier } from "../../utils/identifier-quoter.js";
2522
import { splitSQLStatements } from "../../utils/sql-parser.js";
2623
import { FailedToReadCertificate } from "./failed-to-read-certificate.js";
2724
import { postgresTypeParsers } from "./type-parsers.js";
25+
import {
26+
CLIENT_CERT_SSL_MODES,
27+
SUPPORTED_SSL_MODES,
28+
SSL_ROOT_CERT_LABEL,
29+
SSL_CLIENT_CERT_LABEL,
30+
SSL_CLIENT_KEY_LABEL,
31+
readPemFile,
32+
isEncryptedPemKey,
33+
encryptedKeyMessage,
34+
rotatingPemClient,
35+
} from "./ssl-pems.js";
2836
import { closeQuietly } from "../../utils/resource-cleanup.js";
2937
import {
3038
CANCEL_QUERY_TIMEOUT_MS,
3139
clientQueryTimeoutMs,
3240
isClientSideTimeout,
3341
} from "../../utils/query-timeout.js";
3442

35-
/** SSL modes in which a client certificate is presented to the server. */
36-
const CLIENT_CERT_SSL_MODES = ["require", "verify-ca", "verify-full"];
37-
38-
/** SSL modes this parser maps to a node-postgres `ssl` setting. */
39-
const SUPPORTED_SSL_MODES = ["disable", ...CLIENT_CERT_SSL_MODES];
40-
41-
/** Expand a leading `~/` in a PEM path referenced by an SSL DSN parameter. */
42-
function resolvePemPath(filePath: string): string {
43-
return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath;
44-
}
45-
46-
function failedToRead(label: string, resolved: string, err: unknown): FailedToReadCertificate {
47-
return new FailedToReadCertificate(
48-
`Failed to read ${label} at '${resolved}': ${err instanceof Error ? err.message : String(err)}`
49-
);
50-
}
51-
52-
/**
53-
* Read a PEM file referenced by an SSL DSN parameter, expanding a leading `~/`.
54-
* Wraps any read failure in FailedToReadCertificate so callers can tell a
55-
* misconfigured cert path apart from a malformed DSN.
56-
*/
57-
async function readPemFile(filePath: string, label: string): Promise<string> {
58-
const resolved = resolvePemPath(filePath);
59-
try {
60-
return await fs.promises.readFile(resolved, "utf-8");
61-
} catch (err) {
62-
throw failedToRead(label, resolved, err);
63-
}
64-
}
65-
66-
/** Synchronous variant of readPemFile for the per-connection reload. */
67-
function readPemFileSync(filePath: string, label: string): string {
68-
const resolved = resolvePemPath(filePath);
69-
try {
70-
return fs.readFileSync(resolved, "utf-8");
71-
} catch (err) {
72-
throw failedToRead(label, resolved, err);
73-
}
74-
}
75-
76-
/**
77-
* Node's TLS layer needs a passphrase to open an encrypted PEM key and fails
78-
* with an opaque decoder error otherwise. Detect the common PEM markers and
79-
* fail with a clear message instead (sslpassword is not supported yet).
80-
*/
81-
function isEncryptedPemKey(pem: string): boolean {
82-
return pem.includes("ENCRYPTED PRIVATE KEY") || pem.includes("Proc-Type: 4,ENCRYPTED");
83-
}
84-
85-
function encryptedKeyMessage(keyPath: string): string {
86-
return (
87-
`SSL client key at '${keyPath}' is encrypted; encrypted private keys are not supported. ` +
88-
`Decrypt it first, e.g. 'openssl pkey -in client.key -out client-plain.key'`
89-
);
90-
}
91-
92-
const SSL_ROOT_CERT_LABEL = "SSL root certificate";
93-
const SSL_CLIENT_CERT_LABEL = "SSL client certificate";
94-
const SSL_CLIENT_KEY_LABEL = "SSL client key";
95-
96-
/** Paths of the PEM files behind a pool's `ssl` setting. */
97-
interface PemPaths {
98-
sslrootcert?: string;
99-
sslcert?: string;
100-
sslkey?: string;
101-
}
102-
103-
/** PEM contents keyed by the node-postgres `ssl` property they populate. */
104-
interface PemContents {
105-
ca?: string;
106-
cert?: string;
107-
key?: string;
108-
}
109-
110-
/**
111-
* Read every configured PEM file. The client key is rejected when encrypted.
112-
* Throws FailedToReadCertificate naming the file on any problem, so nothing is
113-
* returned unless the whole set is readable. The files are read back to back
114-
* rather than as an atomic snapshot: a rotation that renames cert and key
115-
* separately can, in the microseconds between the two reads, yield one file
116-
* from each generation. libpq has the same window. TLS then rejects that one
117-
* connection attempt with a key mismatch error and the next attempt re-reads.
118-
*/
119-
function loadPems(paths: PemPaths): PemContents {
120-
const pems: PemContents = {};
121-
if (paths.sslrootcert !== undefined) {
122-
pems.ca = readPemFileSync(paths.sslrootcert, SSL_ROOT_CERT_LABEL);
123-
}
124-
if (paths.sslcert !== undefined && paths.sslkey !== undefined) {
125-
const key = readPemFileSync(paths.sslkey, SSL_CLIENT_KEY_LABEL);
126-
if (isEncryptedPemKey(key)) {
127-
throw new FailedToReadCertificate(encryptedKeyMessage(paths.sslkey));
128-
}
129-
pems.key = key;
130-
pems.cert = readPemFileSync(paths.sslcert, SSL_CLIENT_CERT_LABEL);
131-
}
132-
return pems;
133-
}
134-
135-
/**
136-
* A pg.Client that re-reads the configured PEM files every time it connects, so
137-
* a certificate rotated on disk reaches new pool connections without a restart.
138-
* libpq likewise opens sslrootcert/sslcert/sslkey per connection, and like
139-
* libpq the connection fails, with the file named, when a file cannot be read.
140-
*
141-
* pg-pool constructs one of these per physical connection and passes it the
142-
* pool config, so `this.ssl` is the very object the pool config holds and the
143-
* one the Connection copies into the TLS options once the socket is open.
144-
* Writing the fresh contents onto it before connecting is enough; reading all
145-
* files up front, before any socket is opened, means a failure surfaces as an
146-
* ordinary connection error from `pool.connect()` rather than from inside the
147-
* TLS handshake.
148-
*/
149-
function rotatingPemClient(paths: PemPaths): typeof pg.Client {
150-
return class RotatingPemClient extends pg.Client {
151-
connect(): Promise<void>;
152-
connect(callback: (err: Error) => void): void;
153-
connect(callback?: (err: Error) => void): Promise<void> | void {
154-
let pems: PemContents;
155-
try {
156-
pems = loadPems(paths);
157-
} catch (err) {
158-
const error = err instanceof Error ? err : new Error(String(err));
159-
if (callback) {
160-
callback(error);
161-
return;
162-
}
163-
return Promise.reject(error);
164-
}
165-
// ssl is always an object when PEM paths are configured (see parse()).
166-
Object.assign(this.ssl as unknown as object, pems);
167-
return callback ? super.connect(callback) : super.connect();
168-
}
169-
};
170-
}
171-
17243
/**
17344
* PostgreSQL DSN Parser
17445
* Handles DSN strings like: postgres://user:password@localhost:5432/dbname?sslmode=disable
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import fs from "fs";
2+
import os from "os";
3+
import path from "path";
4+
import pg from "pg";
5+
import { FailedToReadCertificate } from "./failed-to-read-certificate.js";
6+
7+
/**
8+
* PEM handling behind the PostgreSQL `sslrootcert` / `sslcert` / `sslkey` DSN
9+
* parameters: reading the files (once at parse time, and again on every new
10+
* pool connection so a rotated certificate is picked up without a restart),
11+
* rejecting encrypted client keys, and the pg.Client subclass that does the
12+
* per-connection reload.
13+
*/
14+
15+
/** SSL modes in which a client certificate is presented to the server. */
16+
export const CLIENT_CERT_SSL_MODES = ["require", "verify-ca", "verify-full"];
17+
18+
/** SSL modes the DSN parser maps to a node-postgres `ssl` setting. */
19+
export const SUPPORTED_SSL_MODES = ["disable", ...CLIENT_CERT_SSL_MODES];
20+
21+
export const SSL_ROOT_CERT_LABEL = "SSL root certificate";
22+
export const SSL_CLIENT_CERT_LABEL = "SSL client certificate";
23+
export const SSL_CLIENT_KEY_LABEL = "SSL client key";
24+
25+
/** Paths of the PEM files behind a pool's `ssl` setting. */
26+
export interface PemPaths {
27+
sslrootcert?: string;
28+
sslcert?: string;
29+
sslkey?: string;
30+
}
31+
32+
/** PEM contents keyed by the node-postgres `ssl` property they populate. */
33+
export interface PemContents {
34+
ca?: string;
35+
cert?: string;
36+
key?: string;
37+
}
38+
39+
/** Expand a leading `~/` in a PEM path referenced by an SSL DSN parameter. */
40+
function resolvePemPath(filePath: string): string {
41+
return filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath;
42+
}
43+
44+
function failedToRead(label: string, resolved: string, err: unknown): FailedToReadCertificate {
45+
return new FailedToReadCertificate(
46+
`Failed to read ${label} at '${resolved}': ${err instanceof Error ? err.message : String(err)}`
47+
);
48+
}
49+
50+
/**
51+
* Read a PEM file referenced by an SSL DSN parameter, expanding a leading `~/`.
52+
* Wraps any read failure in FailedToReadCertificate so callers can tell a
53+
* misconfigured cert path apart from a malformed DSN.
54+
*/
55+
export async function readPemFile(filePath: string, label: string): Promise<string> {
56+
const resolved = resolvePemPath(filePath);
57+
try {
58+
return await fs.promises.readFile(resolved, "utf-8");
59+
} catch (err) {
60+
throw failedToRead(label, resolved, err);
61+
}
62+
}
63+
64+
/** Synchronous variant of readPemFile for the per-connection reload. */
65+
function readPemFileSync(filePath: string, label: string): string {
66+
const resolved = resolvePemPath(filePath);
67+
try {
68+
return fs.readFileSync(resolved, "utf-8");
69+
} catch (err) {
70+
throw failedToRead(label, resolved, err);
71+
}
72+
}
73+
74+
/**
75+
* Node's TLS layer needs a passphrase to open an encrypted PEM key and fails
76+
* with an opaque decoder error otherwise. Detect the common PEM markers and
77+
* fail with a clear message instead (sslpassword is not supported yet).
78+
*/
79+
export function isEncryptedPemKey(pem: string): boolean {
80+
return pem.includes("ENCRYPTED PRIVATE KEY") || pem.includes("Proc-Type: 4,ENCRYPTED");
81+
}
82+
83+
export function encryptedKeyMessage(keyPath: string): string {
84+
return (
85+
`SSL client key at '${keyPath}' is encrypted; encrypted private keys are not supported. ` +
86+
`Decrypt it first, e.g. 'openssl pkey -in client.key -out client-plain.key'`
87+
);
88+
}
89+
90+
/**
91+
* Read every configured PEM file. The client key is rejected when encrypted.
92+
* Throws FailedToReadCertificate naming the file on any problem, so nothing is
93+
* returned unless the whole set is readable. The files are read back to back
94+
* rather than as an atomic snapshot: a rotation that renames cert and key
95+
* separately can, in the microseconds between the two reads, yield one file
96+
* from each generation. libpq has the same window. TLS then rejects that one
97+
* connection attempt with a key mismatch error and the next attempt re-reads.
98+
*/
99+
function loadPems(paths: PemPaths): PemContents {
100+
const pems: PemContents = {};
101+
if (paths.sslrootcert !== undefined) {
102+
pems.ca = readPemFileSync(paths.sslrootcert, SSL_ROOT_CERT_LABEL);
103+
}
104+
if (paths.sslcert !== undefined && paths.sslkey !== undefined) {
105+
const key = readPemFileSync(paths.sslkey, SSL_CLIENT_KEY_LABEL);
106+
if (isEncryptedPemKey(key)) {
107+
throw new FailedToReadCertificate(encryptedKeyMessage(paths.sslkey));
108+
}
109+
pems.key = key;
110+
pems.cert = readPemFileSync(paths.sslcert, SSL_CLIENT_CERT_LABEL);
111+
}
112+
return pems;
113+
}
114+
115+
/**
116+
* A pg.Client that re-reads the configured PEM files every time it connects, so
117+
* a certificate rotated on disk reaches new pool connections without a restart.
118+
* libpq likewise opens sslrootcert/sslcert/sslkey per connection, and like
119+
* libpq the connection fails, with the file named, when a file cannot be read.
120+
*
121+
* pg-pool constructs one of these per physical connection and passes it the
122+
* pool config, so `this.ssl` is the very object the pool config holds and the
123+
* one the Connection copies into the TLS options once the socket is open.
124+
* Writing the fresh contents onto it before connecting is enough; reading all
125+
* files up front, before any socket is opened, means a failure surfaces as an
126+
* ordinary connection error from `pool.connect()` rather than from inside the
127+
* TLS handshake.
128+
*/
129+
export function rotatingPemClient(paths: PemPaths): typeof pg.Client {
130+
return class RotatingPemClient extends pg.Client {
131+
connect(): Promise<void>;
132+
connect(callback: (err: Error) => void): void;
133+
connect(callback?: (err: Error) => void): Promise<void> | void {
134+
let pems: PemContents;
135+
try {
136+
pems = loadPems(paths);
137+
} catch (err) {
138+
const error = err instanceof Error ? err : new Error(String(err));
139+
if (callback) {
140+
callback(error);
141+
return;
142+
}
143+
return Promise.reject(error);
144+
}
145+
// ssl is always an object when PEM paths are configured (see parse()).
146+
Object.assign(this.ssl as unknown as object, pems);
147+
return callback ? super.connect(callback) : super.connect();
148+
}
149+
};
150+
}

0 commit comments

Comments
 (0)