neonctl 2.22.0 → 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -16
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/checkout.js +249 -0
- package/commands/connection_string.js +15 -2
- package/commands/data_api.js +286 -0
- package/commands/functions.js +277 -0
- package/commands/index.js +12 -0
- package/commands/link.js +667 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +62 -0
- package/commands/set_context.js +7 -2
- package/context.js +86 -14
- package/functions_api.js +44 -0
- package/index.js +3 -0
- package/package.json +60 -51
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/enrichers.js +18 -1
- package/utils/esbuild.js +147 -0
- package/utils/middlewares.js +1 -1
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/connection_string.test.js +0 -196
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
package/psql/wire/tls.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS negotiation for the psql wire layer (WP-02).
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. Drive the PG-flavoured SSL handshake. Postgres negotiates TLS
|
|
7
|
+
* *before* the protocol startup: client sends an `SSLRequest`
|
|
8
|
+
* (8-byte fixed message), server replies with a single byte —
|
|
9
|
+
* 'S' to accept, 'N' to refuse. On 'S' we wrap the existing socket
|
|
10
|
+
* with `tls.connect({ socket })`; on 'N' we either bail (require/
|
|
11
|
+
* verify-*) or fall through plaintext (prefer/allow/disable).
|
|
12
|
+
*
|
|
13
|
+
* 2. Extract `tls-server-end-point` channel-binding material from the
|
|
14
|
+
* negotiated TLS session for SCRAM-SHA-256-PLUS. Per RFC 5929 §4 the
|
|
15
|
+
* data is the hash of the peer certificate computed with the cert's
|
|
16
|
+
* own signature hash, unless that hash is MD5 or SHA-1 — in which
|
|
17
|
+
* case the binding uses SHA-256. libpq's policy (`fe-secure-openssl.c`
|
|
18
|
+
* `PgChannelBinding`) is "always SHA-256 of the DER cert", which is
|
|
19
|
+
* also what the PG server expects. We follow libpq.
|
|
20
|
+
*
|
|
21
|
+
* Notes:
|
|
22
|
+
* - We deliberately do NOT validate the server cert here; that's the
|
|
23
|
+
* caller's responsibility (pass `tlsOpts.rejectUnauthorized` etc.). The
|
|
24
|
+
* ssl-mode → tls-options mapping lives in `connection.ts`.
|
|
25
|
+
* - `verify-ca` and `verify-full` differ only in hostname checking, which
|
|
26
|
+
* Node's `tls.connect` performs automatically when `checkServerIdentity`
|
|
27
|
+
* is the default and `servername` is set. The connection layer wires
|
|
28
|
+
* `servername` to the configured host before calling us.
|
|
29
|
+
*/
|
|
30
|
+
import * as tls from 'node:tls';
|
|
31
|
+
import { createHash, createPrivateKey } from 'node:crypto';
|
|
32
|
+
import { promises as fs, appendFileSync } from 'node:fs';
|
|
33
|
+
import * as path from 'node:path';
|
|
34
|
+
import { SSLRequest } from './protocol.js';
|
|
35
|
+
/**
|
|
36
|
+
* Hash of the peer cert for `tls-server-end-point`. libpq always uses SHA-256
|
|
37
|
+
* of the DER-encoded certificate; we match that.
|
|
38
|
+
*
|
|
39
|
+
* Exposed for tests so we can stub the peer cert.
|
|
40
|
+
*/
|
|
41
|
+
export function computeChannelBindingData(cert) {
|
|
42
|
+
// `cert.raw` is the DER-encoded certificate. Strict typing in @types/node
|
|
43
|
+
// marks it as `Buffer | undefined` on some versions, hence the guard.
|
|
44
|
+
const raw = cert.raw;
|
|
45
|
+
if (!raw || raw.length === 0) {
|
|
46
|
+
throw new Error('TLS channel binding: peer certificate has no DER bytes');
|
|
47
|
+
}
|
|
48
|
+
return createHash('sha256').update(raw).digest();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Send SSLRequest, read the 1-byte server response, and either upgrade the
|
|
52
|
+
* socket to TLS or stay plain (depending on `sslMode`).
|
|
53
|
+
*
|
|
54
|
+
* `tlsOpts` is passed through to `tls.connect` — the connection layer fills
|
|
55
|
+
* in `host`, `servername`, `ca`, `rejectUnauthorized`, etc. before calling.
|
|
56
|
+
*
|
|
57
|
+
* `fileOpts` carries libpq-style PEM file paths (`sslcert`, `sslkey`,
|
|
58
|
+
* `sslrootcert`, `sslcrl`). Each present path is read from disk before the
|
|
59
|
+
* TLS handshake and threaded into the corresponding tls.connect option
|
|
60
|
+
* (`cert` / `key` / `ca` / `crl`). Read failures bubble out as
|
|
61
|
+
* `could not read ssl<…>: <message>` so the caller sees the libpq diagnostic
|
|
62
|
+
* shape rather than a bare ENOENT.
|
|
63
|
+
*
|
|
64
|
+
* `fileOpts.sslkeylogfile`, when set, is pre-checked for writability here and
|
|
65
|
+
* wired to a `'keylog'` listener on the upgraded socket so TLS session keys
|
|
66
|
+
* are appended for offline decryption.
|
|
67
|
+
*
|
|
68
|
+
* `negotiation` selects how TLS is started (libpq `sslnegotiation`):
|
|
69
|
+
* - `'postgres'` (default): send `SSLRequest` and await the 'S'/'N' reply.
|
|
70
|
+
* - `'direct'`: skip `SSLRequest` and start the TLS handshake immediately
|
|
71
|
+
* on the raw socket (PG 17+). The caller must have set
|
|
72
|
+
* `tlsOpts.ALPNProtocols` to `['postgresql']`; there is no plaintext
|
|
73
|
+
* fallback, so a server that does not speak TLS surfaces the handshake
|
|
74
|
+
* failure rather than a quiet downgrade.
|
|
75
|
+
*/
|
|
76
|
+
export async function negotiateTls(socket, sslMode, tlsOpts = {}, fileOpts = {}, negotiation = 'postgres') {
|
|
77
|
+
if (sslMode === 'disable') {
|
|
78
|
+
return { kind: 'plain', socket };
|
|
79
|
+
}
|
|
80
|
+
// Direct SSL (libpq `sslnegotiation=direct`, PG 17+): skip the `SSLRequest`
|
|
81
|
+
// probe and start the TLS handshake straight away. The parse layer has
|
|
82
|
+
// already rejected weak sslmodes, so this path is only reached with an
|
|
83
|
+
// encrypted mode — never falling back to plaintext.
|
|
84
|
+
if (negotiation === 'direct') {
|
|
85
|
+
const mergedOpts = await loadTlsFileOptions(tlsOpts, fileOpts, sslMode);
|
|
86
|
+
return upgradeToTls(socket, mergedOpts, fileOpts.sslkeylogfile, fileOpts.sslkey,
|
|
87
|
+
/* requireAlpn */ true);
|
|
88
|
+
}
|
|
89
|
+
const reply = await sendSslRequest(socket);
|
|
90
|
+
if (reply === 'S') {
|
|
91
|
+
const mergedOpts = await loadTlsFileOptions(tlsOpts, fileOpts, sslMode);
|
|
92
|
+
return upgradeToTls(socket, mergedOpts, fileOpts.sslkeylogfile, fileOpts.sslkey);
|
|
93
|
+
}
|
|
94
|
+
// reply === 'N': server refused TLS.
|
|
95
|
+
if (sslMode === 'require' ||
|
|
96
|
+
sslMode === 'verify-ca' ||
|
|
97
|
+
sslMode === 'verify-full') {
|
|
98
|
+
throw new Error(`SSL connection required (sslmode=${sslMode}) but server refused (replied 'N')`);
|
|
99
|
+
}
|
|
100
|
+
// 'allow' / 'prefer': fall back to plain text.
|
|
101
|
+
return { kind: 'plain', socket };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Read each non-empty file path in `fileOpts` and merge the bytes onto a
|
|
105
|
+
* shallow copy of `tlsOpts`. Each ENOENT / EACCES / permission error is
|
|
106
|
+
* surfaced as `could not read ssl<file>: <reason>` so users immediately
|
|
107
|
+
* know which option pointed at a bad path.
|
|
108
|
+
*
|
|
109
|
+
* Behaviour-defining details:
|
|
110
|
+
*
|
|
111
|
+
* - `sslrootcert` is only read when `sslMode` is `verify-ca` /
|
|
112
|
+
* `verify-full`. Lower modes (require / prefer / allow) accept the
|
|
113
|
+
* server cert without consulting the trust anchor, matching libpq's
|
|
114
|
+
* policy of never opening the file in those modes. Tests can pass
|
|
115
|
+
* `'verify-ca'` as the sentinel to force the eager read for any
|
|
116
|
+
* non-disable mode (the default if omitted).
|
|
117
|
+
* - `sslpassword` is plumbed into `tls.connect` as `passphrase`; if
|
|
118
|
+
* unset OpenSSL leaves an encrypted key un-decryptable and the
|
|
119
|
+
* handshake errors with a "bad decrypt" diagnostic.
|
|
120
|
+
*
|
|
121
|
+
* Exported for tests (`tls.test.ts` swaps in a mocked `tls.connect`).
|
|
122
|
+
*/
|
|
123
|
+
export async function loadTlsFileOptions(tlsOpts, fileOpts, sslMode) {
|
|
124
|
+
const merged = { ...tlsOpts };
|
|
125
|
+
// libpq only opens the trust-anchor file in modes that actually
|
|
126
|
+
// validate the chain. Mirror that so a stale / placeholder
|
|
127
|
+
// `sslrootcert=` doesn't blow up sslmode=require connections.
|
|
128
|
+
const needsRootCert = sslMode === undefined ||
|
|
129
|
+
sslMode === 'verify-ca' ||
|
|
130
|
+
sslMode === 'verify-full';
|
|
131
|
+
if (needsRootCert &&
|
|
132
|
+
fileOpts.sslrootcert !== undefined &&
|
|
133
|
+
fileOpts.sslrootcert !== '') {
|
|
134
|
+
if (fileOpts.sslrootcert === 'system') {
|
|
135
|
+
// `sslrootcert=system`: use the OS / OpenSSL trust store instead of a
|
|
136
|
+
// file. libpq's OpenSSL build honours OpenSSL's $SSL_CERT_FILE (a single
|
|
137
|
+
// bundle) and $SSL_CERT_DIR (a directory of hashed CA files); we read
|
|
138
|
+
// both. With neither set, leaving `ca` unset makes Node fall back to its
|
|
139
|
+
// built-in root store. `rejectUnauthorized` (set by the connection layer
|
|
140
|
+
// for verify-* modes) is left intact.
|
|
141
|
+
const systemCas = [];
|
|
142
|
+
const sslCertFile = process.env.SSL_CERT_FILE;
|
|
143
|
+
if (sslCertFile !== undefined && sslCertFile !== '') {
|
|
144
|
+
systemCas.push(await readPem('sslrootcert', sslCertFile, 'CERTIFICATE'));
|
|
145
|
+
}
|
|
146
|
+
const sslCertDir = process.env.SSL_CERT_DIR;
|
|
147
|
+
if (sslCertDir !== undefined && sslCertDir !== '') {
|
|
148
|
+
systemCas.push(...(await readCaDir(sslCertDir)));
|
|
149
|
+
}
|
|
150
|
+
if (systemCas.length === 1) {
|
|
151
|
+
merged.ca = systemCas[0];
|
|
152
|
+
}
|
|
153
|
+
else if (systemCas.length > 1) {
|
|
154
|
+
merged.ca = systemCas;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
merged.ca = await readPem('sslrootcert', fileOpts.sslrootcert, 'CERTIFICATE');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// libpq `sslcertmode` gates whether the client cert/key are sent.
|
|
162
|
+
// - `disable`: skip loading them entirely, even when configured.
|
|
163
|
+
// - `require`: a cert MUST be configured (we only honour an explicit
|
|
164
|
+
// `sslcert`, not libpq's default `~/.postgresql/postgresql.crt`).
|
|
165
|
+
// - `allow` / unset: current behaviour (send when present).
|
|
166
|
+
const certMode = fileOpts.sslcertmode ?? 'allow';
|
|
167
|
+
const clientCert = fileOpts.sslcert !== undefined && fileOpts.sslcert !== ''
|
|
168
|
+
? fileOpts.sslcert
|
|
169
|
+
: undefined;
|
|
170
|
+
if (certMode === 'require' && clientCert === undefined) {
|
|
171
|
+
throw new Error(`sslcertmode value "require" requires a client certificate`);
|
|
172
|
+
}
|
|
173
|
+
if (certMode !== 'disable') {
|
|
174
|
+
if (clientCert !== undefined) {
|
|
175
|
+
merged.cert = await readPem('sslcert', clientCert, 'CERTIFICATE');
|
|
176
|
+
}
|
|
177
|
+
if (fileOpts.sslkey !== undefined && fileOpts.sslkey !== '') {
|
|
178
|
+
await assertKeyPermissions(fileOpts.sslkey);
|
|
179
|
+
merged.key = await readPem('sslkey', fileOpts.sslkey, 'PRIVATE KEY');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// CRLs come from a single file (`sslcrl`) and/or every file in a directory
|
|
183
|
+
// (`sslcrldir`). Node's `crl` option accepts an array of PEM buffers, so we
|
|
184
|
+
// collect each source and only set `crl` when at least one was read.
|
|
185
|
+
const crls = [];
|
|
186
|
+
if (fileOpts.sslcrl !== undefined && fileOpts.sslcrl !== '') {
|
|
187
|
+
crls.push(await readPem('sslcrl', fileOpts.sslcrl));
|
|
188
|
+
}
|
|
189
|
+
if (fileOpts.sslcrldir !== undefined && fileOpts.sslcrldir !== '') {
|
|
190
|
+
crls.push(...(await readCrlDir(fileOpts.sslcrldir)));
|
|
191
|
+
}
|
|
192
|
+
if (crls.length === 1) {
|
|
193
|
+
merged.crl = crls[0];
|
|
194
|
+
}
|
|
195
|
+
else if (crls.length > 1) {
|
|
196
|
+
merged.crl = crls;
|
|
197
|
+
}
|
|
198
|
+
// sslpassword is plumbed through verbatim — OpenSSL applies it when it
|
|
199
|
+
// sees an encrypted key. Empty string is "no passphrase" (libpq's
|
|
200
|
+
// convention) so we skip it.
|
|
201
|
+
if (fileOpts.sslpassword !== undefined && fileOpts.sslpassword !== '') {
|
|
202
|
+
merged.passphrase = fileOpts.sslpassword;
|
|
203
|
+
}
|
|
204
|
+
// sslkeylogfile is not a tls.connect option; the keylog listener is wired
|
|
205
|
+
// in `upgradeToTls`. We pre-check it here (the home of file diagnostics)
|
|
206
|
+
// by opening it for append, so an unwritable path fails fast at connect
|
|
207
|
+
// time with `could not open sslkeylogfile "<path>": <reason>` rather than
|
|
208
|
+
// silently dropping keys mid-handshake.
|
|
209
|
+
if (fileOpts.sslkeylogfile !== undefined && fileOpts.sslkeylogfile !== '') {
|
|
210
|
+
await assertKeyLogFileWritable(fileOpts.sslkeylogfile);
|
|
211
|
+
}
|
|
212
|
+
return merged;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Pre-flight the `sslkeylogfile` target by opening it for append (creating
|
|
216
|
+
* it if absent) and immediately closing the handle. Surfaces libpq-style
|
|
217
|
+
* `could not open sslkeylogfile "<path>": <reason>` on any failure (e.g. an
|
|
218
|
+
* unwritable directory) before the handshake starts.
|
|
219
|
+
*/
|
|
220
|
+
async function assertKeyLogFileWritable(filePath) {
|
|
221
|
+
try {
|
|
222
|
+
const handle = await fs.open(filePath, 'a');
|
|
223
|
+
await handle.close();
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
227
|
+
throw new Error(`could not open sslkeylogfile "${filePath}": ${reason}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/** True when the bytes already carry a `-----BEGIN ...-----` PEM header. */
|
|
231
|
+
function isPemArmored(bytes) {
|
|
232
|
+
// A PEM file is ASCII text; scan a bounded prefix (skipping leading
|
|
233
|
+
// whitespace libpq tolerates) for the armor marker. DER is binary and will
|
|
234
|
+
// not contain this token at the front.
|
|
235
|
+
const head = bytes.subarray(0, 64).toString('latin1');
|
|
236
|
+
return head.includes('-----BEGIN');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Wrap raw DER bytes in the requested PEM armor: base64 the DER, split into
|
|
240
|
+
* 64-char lines (the PEM convention), and bracket with the BEGIN/END markers.
|
|
241
|
+
*/
|
|
242
|
+
function derToPem(der, armor) {
|
|
243
|
+
const b64 = der.toString('base64');
|
|
244
|
+
const lines = b64.match(/.{1,64}/g) ?? [];
|
|
245
|
+
const body = lines.join('\n');
|
|
246
|
+
const pem = `-----BEGIN ${armor}-----\n${body}\n-----END ${armor}-----\n`;
|
|
247
|
+
return Buffer.from(pem, 'ascii');
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Convert a DER-encoded private key to canonical PKCS#8 PEM, the way libpq
|
|
251
|
+
* relies on OpenSSL to sniff `sslkey` format. A DER key may be PKCS#8
|
|
252
|
+
* (`PrivateKeyInfo`), PKCS#1 (bare RSA `RSAPrivateKey`), or SEC1 (bare EC
|
|
253
|
+
* `ECPrivateKey`) — and the encoding `openssl pkey -outform der` produces for
|
|
254
|
+
* RSA differs across OpenSSL versions (3.0.x emits a form that blind PKCS#8
|
|
255
|
+
* armor cannot load: `DECODER routines::unsupported`). Rather than guess the
|
|
256
|
+
* armor, let `crypto.createPrivateKey` decode each candidate type and
|
|
257
|
+
* re-export a single canonical PKCS#8 PEM that `tls.connect` always accepts.
|
|
258
|
+
*/
|
|
259
|
+
function derPrivateKeyToPem(der) {
|
|
260
|
+
let lastErr;
|
|
261
|
+
for (const type of ['pkcs8', 'pkcs1', 'sec1']) {
|
|
262
|
+
try {
|
|
263
|
+
const key = createPrivateKey({ key: der, format: 'der', type });
|
|
264
|
+
const pem = key.export({ format: 'pem', type: 'pkcs8' });
|
|
265
|
+
return typeof pem === 'string' ? Buffer.from(pem, 'ascii') : pem;
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
lastErr = err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const reason = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
272
|
+
throw new Error(`sslkey is DER but could not be decoded as PKCS#8, PKCS#1, or SEC1: ${reason}`);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Read a libpq SSL file, returning PEM bytes ready for `tls.connect`. If the
|
|
276
|
+
* file is already PEM-armored it's returned verbatim; otherwise it's treated
|
|
277
|
+
* as DER and converted in-memory using {@link derToPem} with `derArmor`
|
|
278
|
+
* (matching libpq's PEM-or-DER auto-detection). When `derArmor` is omitted the
|
|
279
|
+
* file is returned as-is even if not PEM (used for CRLs, where DER conversion
|
|
280
|
+
* is out of scope).
|
|
281
|
+
*/
|
|
282
|
+
async function readPem(label, filePath, derArmor) {
|
|
283
|
+
let bytes;
|
|
284
|
+
try {
|
|
285
|
+
bytes = await fs.readFile(filePath);
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
289
|
+
throw new Error(`could not read ${label} "${filePath}": ${reason}`);
|
|
290
|
+
}
|
|
291
|
+
if (derArmor !== undefined && !isPemArmored(bytes)) {
|
|
292
|
+
// Private keys need format-aware decoding (PKCS#8 / PKCS#1 / SEC1);
|
|
293
|
+
// certs are a single ASN.1 shape and wrap directly.
|
|
294
|
+
return derArmor === 'PRIVATE KEY'
|
|
295
|
+
? derPrivateKeyToPem(bytes)
|
|
296
|
+
: derToPem(bytes, derArmor);
|
|
297
|
+
}
|
|
298
|
+
return bytes;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* libpq-style permission guard for the client private key (`sslkey`). libpq
|
|
302
|
+
* (`fe-secure-openssl.c`) `stat()`s the key file and refuses to load it when
|
|
303
|
+
* it is a regular file with any group or world access bits set, unless it is
|
|
304
|
+
* root-owned with at most `u=rw,g=r` (0640). Mirroring that keeps an
|
|
305
|
+
* accidentally world-readable key from being used silently.
|
|
306
|
+
*
|
|
307
|
+
* The check is a no-op on Windows, where the POSIX mode bits are not
|
|
308
|
+
* meaningful (matching libpq, which `#ifndef WIN32`-guards the same check),
|
|
309
|
+
* and when the key path is a directory / special file (only regular files
|
|
310
|
+
* carry a private key here).
|
|
311
|
+
*/
|
|
312
|
+
async function assertKeyPermissions(keyPath) {
|
|
313
|
+
if (process.platform === 'win32')
|
|
314
|
+
return;
|
|
315
|
+
let stat;
|
|
316
|
+
try {
|
|
317
|
+
stat = await fs.stat(keyPath);
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
321
|
+
throw new Error(`could not read sslkey "${keyPath}": ${reason}`);
|
|
322
|
+
}
|
|
323
|
+
if (!stat.isFile())
|
|
324
|
+
return;
|
|
325
|
+
// Low 9 mode bits: rwx for user/group/other.
|
|
326
|
+
const mode = stat.mode & 0o777;
|
|
327
|
+
const groupOrWorld = mode & 0o077;
|
|
328
|
+
if (groupOrWorld === 0)
|
|
329
|
+
return;
|
|
330
|
+
// Root-owned keys are allowed to be u=rw,g=r (0640) or less, matching
|
|
331
|
+
// libpq's relaxed allowance for system-managed keys: no bits outside the
|
|
332
|
+
// 0640 mask may be set.
|
|
333
|
+
if (stat.uid === 0 && (mode & ~0o640) === 0) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
throw new Error(`private key file "${keyPath}" has group or world access`);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Read every regular file in an `sslcrldir` directory and return their PEM
|
|
340
|
+
* bytes. Subdirectories are skipped (libpq's c_rehash-style directory only
|
|
341
|
+
* holds hashed CRL files). A failure to list the directory or read any file
|
|
342
|
+
* surfaces with the `sslcrldir` label so the caller sees which option was
|
|
343
|
+
* misconfigured.
|
|
344
|
+
*/
|
|
345
|
+
async function readCrlDir(dirPath) {
|
|
346
|
+
let entries;
|
|
347
|
+
try {
|
|
348
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
352
|
+
throw new Error(`could not read sslcrldir "${dirPath}": ${reason}`);
|
|
353
|
+
}
|
|
354
|
+
const out = [];
|
|
355
|
+
for (const entry of entries) {
|
|
356
|
+
if (!entry.isFile())
|
|
357
|
+
continue;
|
|
358
|
+
out.push(await readPem('sslcrldir', path.join(dirPath, entry.name)));
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Read every regular file in an OpenSSL `$SSL_CERT_DIR` (the hashed-dir
|
|
364
|
+
* convention honoured by `sslrootcert=system`) and return their PEM bytes.
|
|
365
|
+
* Mirrors {@link readCrlDir}: subdirectories are skipped and a DER-format
|
|
366
|
+
* file is auto-converted to PEM. A failure to list the directory or read any
|
|
367
|
+
* file surfaces as `could not read SSL_CERT_DIR "<path>": <reason>`.
|
|
368
|
+
*
|
|
369
|
+
* Exported for tests.
|
|
370
|
+
*/
|
|
371
|
+
export async function readCaDir(dirPath) {
|
|
372
|
+
let entries;
|
|
373
|
+
try {
|
|
374
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
378
|
+
throw new Error(`could not read SSL_CERT_DIR "${dirPath}": ${reason}`);
|
|
379
|
+
}
|
|
380
|
+
const out = [];
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
if (!entry.isFile())
|
|
383
|
+
continue;
|
|
384
|
+
out.push(await readPem('SSL_CERT_DIR', path.join(dirPath, entry.name), 'CERTIFICATE'));
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Send SSLRequest and pull off the 1-byte server response. The byte is
|
|
390
|
+
* outside the regular framed protocol (it has no length / type header), so
|
|
391
|
+
* we can't reuse MessageParser; instead we peel off one byte and push any
|
|
392
|
+
* remainder back onto the socket via `unshift`.
|
|
393
|
+
*
|
|
394
|
+
* Exported for tests.
|
|
395
|
+
*/
|
|
396
|
+
export function sendSslRequest(socket) {
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
const onError = (err) => {
|
|
399
|
+
cleanup();
|
|
400
|
+
reject(err);
|
|
401
|
+
};
|
|
402
|
+
const onData = (chunk) => {
|
|
403
|
+
if (chunk.length === 0)
|
|
404
|
+
return;
|
|
405
|
+
const first = String.fromCharCode(chunk[0]);
|
|
406
|
+
if (first !== 'S' && first !== 'N') {
|
|
407
|
+
cleanup();
|
|
408
|
+
reject(new Error(`Unexpected SSLRequest response byte 0x${chunk[0].toString(16)}`));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// Any extra bytes belong to subsequent messages; push them back.
|
|
412
|
+
if (chunk.length > 1) {
|
|
413
|
+
socket.unshift(chunk.subarray(1));
|
|
414
|
+
}
|
|
415
|
+
cleanup();
|
|
416
|
+
resolve(first);
|
|
417
|
+
};
|
|
418
|
+
const cleanup = () => {
|
|
419
|
+
socket.removeListener('data', onData);
|
|
420
|
+
socket.removeListener('error', onError);
|
|
421
|
+
};
|
|
422
|
+
socket.on('data', onData);
|
|
423
|
+
socket.on('error', onError);
|
|
424
|
+
socket.write(SSLRequest());
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Translate a Node/OpenSSL TLS handshake error into libpq-style wording so
|
|
429
|
+
* our diagnostics match upstream psql/libpq exactly (the cases asserted by
|
|
430
|
+
* upstream `001_ssltests.pl`). Unrecognised errors pass through unchanged.
|
|
431
|
+
* The original error is preserved on `cause` for callers that introspect.
|
|
432
|
+
*
|
|
433
|
+
* - Chain-verification failures (`ERR_TLS_CERT_ALTNAME_INVALID` excluded)
|
|
434
|
+
* → `certificate verify failed` (libpq's `SSL error: certificate verify
|
|
435
|
+
* failed`).
|
|
436
|
+
* - Hostname mismatch (`ERR_TLS_CERT_ALTNAME_INVALID`, Node's
|
|
437
|
+
* "Hostname/IP does not match certificate's altnames") →
|
|
438
|
+
* `server certificate for "<host>" does not match host name "<host>"`.
|
|
439
|
+
* - Encrypted-key decrypt failures (`ERR_OSSL_BAD_DECRYPT`, or an OpenSSL
|
|
440
|
+
* message containing `bad decrypt`) → libpq's
|
|
441
|
+
* `could not load private key file "<path>": <openssl text>` shape. The
|
|
442
|
+
* raw OpenSSL text (which carries the `bad decrypt` token upstream's
|
|
443
|
+
* `001_ssltests.pl` matches on) is preserved in the message tail. When the
|
|
444
|
+
* key path is unknown the path segment is omitted but the `bad decrypt`
|
|
445
|
+
* token is still surfaced.
|
|
446
|
+
*
|
|
447
|
+
* `keyPath`, when supplied, is the configured `sslkey` path — used only to
|
|
448
|
+
* fill libpq's `private key file "<path>"` phrasing on a decrypt failure.
|
|
449
|
+
*
|
|
450
|
+
* Exported for unit tests.
|
|
451
|
+
*/
|
|
452
|
+
export function mapTlsHandshakeError(err, servername, keyPath) {
|
|
453
|
+
const code = err.code;
|
|
454
|
+
const msg = err.message;
|
|
455
|
+
// Encrypted client-key decrypt failure (wrong / missing `sslpassword`).
|
|
456
|
+
// OpenSSL throws this synchronously out of `tls.connect`; reshape it to
|
|
457
|
+
// libpq's `could not load private key file "<path>": ... bad decrypt`.
|
|
458
|
+
if (code === 'ERR_OSSL_BAD_DECRYPT' || /bad decrypt/i.test(msg)) {
|
|
459
|
+
const where = keyPath !== undefined && keyPath !== ''
|
|
460
|
+
? `private key file "${keyPath}"`
|
|
461
|
+
: 'private key file';
|
|
462
|
+
const mapped = new Error(`could not load ${where}: ${msg}`);
|
|
463
|
+
mapped.cause = err;
|
|
464
|
+
return mapped;
|
|
465
|
+
}
|
|
466
|
+
if (code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
|
|
467
|
+
const host = servername ?? '';
|
|
468
|
+
const mapped = new Error(`server certificate for "${host}" does not match host name "${host}"`);
|
|
469
|
+
mapped.cause = err;
|
|
470
|
+
return mapped;
|
|
471
|
+
}
|
|
472
|
+
// OpenSSL chain-verification failures surface with a `code` like
|
|
473
|
+
// `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `DEPTH_ZERO_SELF_SIGNED_CERT`,
|
|
474
|
+
// `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED`, etc. libpq collapses
|
|
475
|
+
// them all to `certificate verify failed`.
|
|
476
|
+
const isVerifyFailure = code !== undefined &&
|
|
477
|
+
code !== 'ERR_TLS_CERT_ALTNAME_INVALID' &&
|
|
478
|
+
/CERT|SIGNATURE|SELF_SIGNED|UNABLE_TO|CHAIN|EXPIRED|NOT_YET_VALID|INVALID_CA/.test(code);
|
|
479
|
+
if (isVerifyFailure || /certificate verify failed/i.test(msg)) {
|
|
480
|
+
const mapped = new Error('certificate verify failed');
|
|
481
|
+
mapped.cause = err;
|
|
482
|
+
return mapped;
|
|
483
|
+
}
|
|
484
|
+
return err;
|
|
485
|
+
}
|
|
486
|
+
/** Pull the SNI `servername` from the TLS options for error messages. */
|
|
487
|
+
function getServername(tlsOpts) {
|
|
488
|
+
return typeof tlsOpts.servername === 'string'
|
|
489
|
+
? tlsOpts.servername
|
|
490
|
+
: undefined;
|
|
491
|
+
}
|
|
492
|
+
function upgradeToTls(socket, tlsOpts, sslkeylogfile, keyPath,
|
|
493
|
+
// Direct SSL (sslnegotiation=direct, PG17+) REQUIRES the server to select
|
|
494
|
+
// ALPN `postgresql` — a protocol-confusion defense, since there is no
|
|
495
|
+
// SSLRequest probe to confirm a postgres endpoint. libpq aborts when the
|
|
496
|
+
// negotiated ALPN isn't `postgresql`; we mirror that (review item #9).
|
|
497
|
+
requireAlpn = false) {
|
|
498
|
+
return new Promise((resolve, reject) => {
|
|
499
|
+
let tlsSocket;
|
|
500
|
+
const cleanup = () => {
|
|
501
|
+
tlsSocket?.removeListener('error', onError);
|
|
502
|
+
};
|
|
503
|
+
const onError = (err) => {
|
|
504
|
+
cleanup();
|
|
505
|
+
reject(mapTlsHandshakeError(err, getServername(tlsOpts), keyPath));
|
|
506
|
+
};
|
|
507
|
+
// OpenSSL surfaces an un-decryptable client key (wrong / missing
|
|
508
|
+
// `sslpassword`) by throwing synchronously out of `tls.connect` rather
|
|
509
|
+
// than emitting `'error'`. Catch it here so it flows through the same
|
|
510
|
+
// libpq-wording mapper as asynchronous handshake failures.
|
|
511
|
+
try {
|
|
512
|
+
tlsSocket = tls.connect({
|
|
513
|
+
...tlsOpts,
|
|
514
|
+
socket,
|
|
515
|
+
}, () => {
|
|
516
|
+
cleanup();
|
|
517
|
+
// `tlsSocket` is always assigned by the time this async handshake
|
|
518
|
+
// callback fires (tls.connect returns it synchronously above); the
|
|
519
|
+
// guard simply narrows the `| undefined` for the type checker.
|
|
520
|
+
const established = tlsSocket;
|
|
521
|
+
if (established === undefined)
|
|
522
|
+
return;
|
|
523
|
+
// Enforce the mandatory `postgresql` ALPN for direct SSL. Without
|
|
524
|
+
// it a TLS terminator / HTTPS proxy with a valid host cert would be
|
|
525
|
+
// accepted and the startup packet sent in the blind (review #9).
|
|
526
|
+
if (requireAlpn && established.alpnProtocol !== 'postgresql') {
|
|
527
|
+
cleanup();
|
|
528
|
+
established.destroy();
|
|
529
|
+
reject(new Error('direct SSL connection requires ALPN, but the server did ' +
|
|
530
|
+
'not negotiate the "postgresql" protocol'));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
let channelBindingData = null;
|
|
534
|
+
try {
|
|
535
|
+
// Prefer the modern X509Certificate API (Node 15.6+): it returns a
|
|
536
|
+
// proper `X509Certificate` instance whose `.raw` is the
|
|
537
|
+
// DER-encoded cert. Falls back to the legacy
|
|
538
|
+
// `getPeerCertificate(true)` for compatibility.
|
|
539
|
+
const x509 = established.getPeerX509Certificate?.();
|
|
540
|
+
if (x509?.raw && x509.raw.length > 0) {
|
|
541
|
+
channelBindingData = createHash('sha256')
|
|
542
|
+
.update(x509.raw)
|
|
543
|
+
.digest();
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
// `detailed = true` gets us the full peer cert chain. Some
|
|
547
|
+
// Node/OpenSSL combinations leave `.raw` undefined on the legacy
|
|
548
|
+
// API when `rejectUnauthorized: false`; in that case we have to
|
|
549
|
+
// accept that channel binding is unavailable.
|
|
550
|
+
const peerCert = established.getPeerCertificate(true);
|
|
551
|
+
if (peerCert?.raw && peerCert.raw.length > 0) {
|
|
552
|
+
channelBindingData = computeChannelBindingData(peerCert);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
// Best-effort: a missing peer cert => no channel binding. SASL
|
|
558
|
+
// path will fall back to SCRAM-SHA-256 (non-PLUS).
|
|
559
|
+
channelBindingData = null;
|
|
560
|
+
}
|
|
561
|
+
resolve({ kind: 'tls', socket: established, channelBindingData });
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
reject(mapTlsHandshakeError(err instanceof Error ? err : new Error(String(err)), getServername(tlsOpts), keyPath));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
tlsSocket.on('error', onError);
|
|
569
|
+
// libpq `sslkeylogfile`: append each emitted key-log line so the
|
|
570
|
+
// handshake can be decrypted offline. The path was pre-checked for
|
|
571
|
+
// writability in loadTlsFileOptions.
|
|
572
|
+
if (sslkeylogfile !== undefined && sslkeylogfile !== '') {
|
|
573
|
+
attachKeyLogListener(tlsSocket, sslkeylogfile);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Wire a TLSSocket's `'keylog'` event to append each emitted key-log line to
|
|
579
|
+
* `filePath`. Node emits one already-newline-terminated Buffer per event. A
|
|
580
|
+
* write that fails after the pre-check (e.g. the directory was removed
|
|
581
|
+
* mid-session) is re-emitted as a socket `'error'` rather than crashing the
|
|
582
|
+
* process.
|
|
583
|
+
*
|
|
584
|
+
* Accepts a minimal event-emitter shape so it can be unit-tested against a
|
|
585
|
+
* fake socket without a real TLS handshake. Exported for tests.
|
|
586
|
+
*/
|
|
587
|
+
export function attachKeyLogListener(socket, filePath) {
|
|
588
|
+
socket.on('keylog', (line) => {
|
|
589
|
+
try {
|
|
590
|
+
appendFileSync(filePath, line);
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
socket.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
package/test_utils/fixtures.js
CHANGED
package/utils/enrichers.js
CHANGED
|
@@ -40,9 +40,26 @@ export const branchIdFromProps = async (props) => {
|
|
|
40
40
|
props.branchId = await getBranchIdFromProps(props);
|
|
41
41
|
return props.branchId;
|
|
42
42
|
};
|
|
43
|
+
export const resolveSingleDatabase = async (props) => {
|
|
44
|
+
const { data } = await props.apiClient.listProjectBranchDatabases(props.projectId, props.branchId);
|
|
45
|
+
const databases = data.databases;
|
|
46
|
+
if (props.database !== undefined) {
|
|
47
|
+
if (!databases.find((d) => d.name === props.database)) {
|
|
48
|
+
throw new Error(`Database not found: ${props.database}. Available databases on branch ${props.branchId}: ${databases.map((d) => d.name).join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
return props.database;
|
|
51
|
+
}
|
|
52
|
+
if (databases.length === 0) {
|
|
53
|
+
throw new Error(`No databases found for the branch: ${props.branchId}`);
|
|
54
|
+
}
|
|
55
|
+
if (databases.length === 1) {
|
|
56
|
+
return databases[0].name;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`Multiple databases found for the branch, please provide one with the --database option: ${databases.map((d) => d.name).join(', ')}`);
|
|
59
|
+
};
|
|
43
60
|
export const fillSingleProject = async (props) => {
|
|
44
61
|
if (props.projectId) {
|
|
45
|
-
return props;
|
|
62
|
+
return { ...props, projectId: props.projectId };
|
|
46
63
|
}
|
|
47
64
|
// If no orgId is provided, try to auto-fill it if there's only one org
|
|
48
65
|
let orgId = props.orgId;
|