mcp-for-i 0.1.13 → 0.2.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 +17 -0
- package/dist/config/store.js +229 -109
- package/dist/controlplane/auth.js +128 -0
- package/dist/controlplane/clientIntegrationService.js +83 -4
- package/dist/controlplane/commands.js +14 -9
- package/dist/controlplane/connectionService.js +110 -5
- package/dist/controlplane/connectionTest.js +329 -0
- package/dist/controlplane/errors.js +12 -0
- package/dist/controlplane/runtimeService.js +98 -26
- package/dist/controlplane/server.js +122 -11
- package/dist/controlplane/serverShared.js +25 -6
- package/dist/controlplane/serviceManager.js +113 -100
- package/dist/controlplane/ui.js +1742 -920
- package/dist/ibmi/CompileTools.js +29 -1
- package/dist/ibmi/IBMiContent.js +435 -63
- package/dist/ibmi/IbmDocs.js +685 -0
- package/dist/ibmi/LocalLanguageActions.js +129 -3
- package/dist/ibmi/Search.js +158 -52
- package/dist/ibmi/Tools.js +7 -10
- package/dist/ibmi/client.js +220 -31
- package/dist/ibmi/evfevent.js +256 -0
- package/dist/ibmi/knownHosts.js +190 -0
- package/dist/ibmi/libraryList.js +93 -0
- package/dist/ibmi/sourceInspect.js +815 -0
- package/dist/ibmi/sqlJob.js +888 -0
- package/dist/ibmi/sqlParse.js +317 -0
- package/dist/ibmi/tn5250/certTrust.js +59 -0
- package/dist/ibmi/tn5250/client.js +310 -0
- package/dist/ibmi/tn5250/datastream.js +109 -0
- package/dist/ibmi/tn5250/ebcdic.js +74 -0
- package/dist/ibmi/tn5250/index.js +6 -0
- package/dist/ibmi/tn5250/screen.js +827 -0
- package/dist/ibmi/tn5250/telnet.js +186 -0
- package/dist/ibmi/variables.js +18 -7
- package/dist/index.js +43 -22
- package/dist/mcp/annotations.js +155 -0
- package/dist/mcp/audit.js +490 -133
- package/dist/mcp/confirm.js +48 -0
- package/dist/mcp/context.js +43 -5
- package/dist/mcp/deployMethods.js +192 -0
- package/dist/mcp/logger.js +5 -10
- package/dist/mcp/modules/bulkMembers.js +476 -0
- package/dist/mcp/modules/changedLines.js +189 -0
- package/dist/mcp/modules/compare.js +198 -0
- package/dist/mcp/modules/compile.js +515 -0
- package/dist/mcp/modules/ifsOps.js +123 -0
- package/dist/mcp/modules/index.js +38 -0
- package/dist/mcp/modules/inspect.js +811 -0
- package/dist/mcp/modules/libl.js +246 -0
- package/dist/mcp/modules/members.js +171 -0
- package/dist/mcp/modules/objects.js +339 -0
- package/dist/mcp/modules/system.js +266 -0
- package/dist/mcp/modules/tn5250.js +515 -0
- package/dist/mcp/policy.js +412 -0
- package/dist/mcp/tools.js +1700 -698
- package/dist/security/credentialStore.js +62 -13
- package/dist/security/localPaths.js +138 -0
- package/dist/security/redact.js +79 -0
- package/dist/util/atomicWrite.js +101 -0
- package/dist/util/fileLock.js +206 -0
- package/docs/capabilities.md +50 -7
- package/docs/configuration.md +189 -60
- package/docs/examples.md +109 -73
- package/docs/overview.md +15 -15
- package/docs/security.md +101 -16
- package/docs/tools.md +363 -188
- package/package.json +56 -53
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import net from "net";
|
|
3
|
+
import tls from "tls";
|
|
4
|
+
import { Tools } from "./Tools.js";
|
|
5
|
+
import { splitSqlStatements, isReadOnlySql } from "./sqlParse.js";
|
|
6
|
+
import { checkCertificate, certificateFingerprint } from "./knownHosts.js";
|
|
7
|
+
import { desiredLibraryList, existingLibrariesSql, invalidNamesWarning, libraryListCommand, libraryNamesFromRows, referencedLibraries, resolveAgainstExisting } from "./libraryList.js";
|
|
8
|
+
export const DEFAULT_MAPEPIRE_PORT = 8076;
|
|
9
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 4000;
|
|
10
|
+
const DEFAULT_CANCEL_GRACE_MS = 5000;
|
|
11
|
+
const DEFAULT_OPEN_WAIT_MS = 10000;
|
|
12
|
+
const FETCH_SIZE = 1000;
|
|
13
|
+
const MAPEPIRE_SERVER_PATHS = [
|
|
14
|
+
"/QOpenSys/pkgs/lib/mapepire/mapepire-server.jar",
|
|
15
|
+
"/opt/mapepire/lib/mapepire/mapepire-server.jar"
|
|
16
|
+
];
|
|
17
|
+
const JAVA_PATHS = [
|
|
18
|
+
"/QOpenSys/QIBM/ProdData/JavaVM/jdk21/64bit/bin/java",
|
|
19
|
+
"/QOpenSys/QIBM/ProdData/JavaVM/jdk17/64bit/bin/java",
|
|
20
|
+
"/QOpenSys/QIBM/ProdData/JavaVM/jdk11/64bit/bin/java",
|
|
21
|
+
"/QOpenSys/QIBM/ProdData/JavaVM/jdk80/64bit/bin/java"
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* JDBC properties for the job. System naming makes unqualified names resolve
|
|
25
|
+
* through the library list (like a 5250 session); commitment control is off so
|
|
26
|
+
* writes to files that are not journaled behave as they do under QZDFMDB2.
|
|
27
|
+
*/
|
|
28
|
+
const JDBC_OPTIONS = {
|
|
29
|
+
naming: "system",
|
|
30
|
+
"transaction isolation": "none",
|
|
31
|
+
"date format": "iso",
|
|
32
|
+
"time format": "iso"
|
|
33
|
+
};
|
|
34
|
+
const NUMERIC_TYPES = new Set(["DECIMAL", "NUMERIC", "INTEGER", "SMALLINT", "BIGINT", "DECFLOAT", "DOUBLE", "REAL", "FLOAT"]);
|
|
35
|
+
/** A CL command failed; the message carries its diagnostic and escape messages. */
|
|
36
|
+
export class ClCommandError extends Tools.SqlError {
|
|
37
|
+
command;
|
|
38
|
+
messages;
|
|
39
|
+
constructor(command, messages) {
|
|
40
|
+
super(clFailureText(command, messages));
|
|
41
|
+
this.command = command;
|
|
42
|
+
this.messages = messages;
|
|
43
|
+
this.name = "ClCommandError";
|
|
44
|
+
this.sqlstate = "38501";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function clFailureText(command, messages) {
|
|
48
|
+
const relevant = messages.filter(m => /^(ESC|DIAG)/i.test(m.type || ""));
|
|
49
|
+
const detail = (relevant.length ? relevant : messages).map(m => (m.id ? `${m.id}: ${m.text}` : m.text)).join(" ");
|
|
50
|
+
return `${command.trim().split(/\s+/)[0]} failed${detail ? `: ${detail}` : ""}`;
|
|
51
|
+
}
|
|
52
|
+
/** Messages from the server's own machinery that say nothing about the command. */
|
|
53
|
+
export function isNoiseMessage(message) {
|
|
54
|
+
return message.id === "MCH3401" && /\bPPTT\b/.test(message.text);
|
|
55
|
+
}
|
|
56
|
+
const QCMDEXC_CALL = /^CALL\s+(?:(?:QSYS2|QSYS)\s*[./]\s*)?QCMDEXC\s*\(\s*'((?:[^']|'')*)'\s*(?:,\s*\d+(?:\.\d*)?\s*)?\)\s*$/i;
|
|
57
|
+
/** The CL command of `CALL QSYS2.QCMDEXC('...')` with a literal, else undefined. */
|
|
58
|
+
export function qcmdexcLiteral(statement) {
|
|
59
|
+
const match = statement.trim().match(QCMDEXC_CALL);
|
|
60
|
+
return match ? match[1].replace(/''/g, "'") : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** Mapepire is not installed, not running or not reachable: falling back is fine. */
|
|
63
|
+
export class SqlJobUnavailableError extends Error {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "SqlJobUnavailableError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function normalizeSqlTransport(value) {
|
|
70
|
+
const v = String(value ?? "").trim().toLowerCase();
|
|
71
|
+
return v === "mapepire" || v === "qzdfmdb2" ? v : "auto";
|
|
72
|
+
}
|
|
73
|
+
export function normalizeMapepireMode(value) {
|
|
74
|
+
const v = String(value ?? "").trim().toLowerCase();
|
|
75
|
+
return v === "daemon" || v === "ssh" ? v : "auto";
|
|
76
|
+
}
|
|
77
|
+
const JOB_NAME_RE = /^\d{6}\/[A-Z0-9#@$_.]{1,10}\/[A-Z0-9#@$_.]{1,10}$/i;
|
|
78
|
+
export function isQualifiedJobName(value) {
|
|
79
|
+
return typeof value === "string" && JOB_NAME_RE.test(value);
|
|
80
|
+
}
|
|
81
|
+
export class PersistentSqlJob {
|
|
82
|
+
options;
|
|
83
|
+
handle;
|
|
84
|
+
opening;
|
|
85
|
+
queue = Promise.resolve();
|
|
86
|
+
unavailableReason;
|
|
87
|
+
lastError;
|
|
88
|
+
libraryListWarning;
|
|
89
|
+
lastCancel;
|
|
90
|
+
/** The configured library list last applied, and to which job. */
|
|
91
|
+
appliedLibraryList;
|
|
92
|
+
closed = false;
|
|
93
|
+
factory;
|
|
94
|
+
constructor(options) {
|
|
95
|
+
this.options = options;
|
|
96
|
+
this.factory = options.factory || mapepireJobFactory;
|
|
97
|
+
}
|
|
98
|
+
transport() {
|
|
99
|
+
return normalizeSqlTransport(this.options.getConfig().sqlTransport);
|
|
100
|
+
}
|
|
101
|
+
status() {
|
|
102
|
+
const transport = this.transport();
|
|
103
|
+
const live = this.handle && this.handle.isAlive() ? this.handle : undefined;
|
|
104
|
+
let active = "not-started";
|
|
105
|
+
if (transport === "qzdfmdb2" || (transport === "auto" && this.unavailableReason))
|
|
106
|
+
active = "qzdfmdb2";
|
|
107
|
+
else if (live)
|
|
108
|
+
active = "mapepire";
|
|
109
|
+
const libraryListWarning = active === "qzdfmdb2"
|
|
110
|
+
? this.options.fallbackLibraryListWarning?.()
|
|
111
|
+
: this.libraryListWarning ?? this.options.fallbackLibraryListWarning?.();
|
|
112
|
+
return {
|
|
113
|
+
transport,
|
|
114
|
+
active,
|
|
115
|
+
...(live ? { mode: live.mode, ...(live.technique ? { technique: live.technique } : {}), jobName: live.jobName } : {}),
|
|
116
|
+
...(this.unavailableReason ? { unavailableReason: this.unavailableReason } : {}),
|
|
117
|
+
...(this.lastError ? { lastError: this.lastError } : {}),
|
|
118
|
+
...(libraryListWarning ? { libraryListWarning } : {}),
|
|
119
|
+
...(this.lastCancel ? { lastCancel: this.lastCancel } : {})
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** True when the persistent job is open and alive. */
|
|
123
|
+
isOpen() {
|
|
124
|
+
return Boolean(this.handle && this.handle.isAlive());
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Runs SQL (one or more statements, `@` lines run as CL) in the persistent
|
|
128
|
+
* job. Returns undefined when the caller should use QZDFMDB2 instead.
|
|
129
|
+
*/
|
|
130
|
+
async runSql(statements, options = {}) {
|
|
131
|
+
const handle = await this.acquire();
|
|
132
|
+
if (!handle)
|
|
133
|
+
return undefined;
|
|
134
|
+
try {
|
|
135
|
+
return await this.exclusive(options.signal, async () => {
|
|
136
|
+
await this.ensureLibraryList(handle);
|
|
137
|
+
return this.runAll(handle, statements, options.signal);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
// The job died under a read: nothing was changed, so reopen and retry once.
|
|
142
|
+
if (!handle.isAlive() && !options.signal?.aborted && isReadOnlySql(statements)) {
|
|
143
|
+
await this.discard(handle);
|
|
144
|
+
const next = await this.acquire();
|
|
145
|
+
if (!next)
|
|
146
|
+
return undefined;
|
|
147
|
+
return this.exclusive(options.signal, async () => {
|
|
148
|
+
await this.ensureLibraryList(next);
|
|
149
|
+
return this.runAll(next, statements, options.signal);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (!handle.isAlive())
|
|
153
|
+
await this.discard(handle);
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Runs a CL command in the persistent job. Returns undefined when the job is
|
|
159
|
+
* not used, otherwise the job log messages the command sent.
|
|
160
|
+
*/
|
|
161
|
+
async runCl(command, options = {}) {
|
|
162
|
+
const handle = await this.acquire();
|
|
163
|
+
if (!handle)
|
|
164
|
+
return undefined;
|
|
165
|
+
try {
|
|
166
|
+
return await this.exclusive(options.signal, async () => {
|
|
167
|
+
await this.ensureLibraryList(handle);
|
|
168
|
+
return this.execCl(handle, command, options.signal);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (!handle.isAlive())
|
|
173
|
+
await this.discard(handle);
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async close() {
|
|
178
|
+
this.closed = true;
|
|
179
|
+
const opening = this.opening;
|
|
180
|
+
if (opening)
|
|
181
|
+
await opening.catch(() => undefined);
|
|
182
|
+
const handle = this.handle;
|
|
183
|
+
this.handle = undefined;
|
|
184
|
+
if (handle)
|
|
185
|
+
await handle.close().catch(() => undefined);
|
|
186
|
+
}
|
|
187
|
+
async acquire() {
|
|
188
|
+
if (this.closed)
|
|
189
|
+
return undefined;
|
|
190
|
+
const transport = this.transport();
|
|
191
|
+
if (transport === "qzdfmdb2") {
|
|
192
|
+
if (this.handle)
|
|
193
|
+
await this.discard(this.handle);
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
if (this.handle) {
|
|
197
|
+
if (this.handle.isAlive())
|
|
198
|
+
return this.handle;
|
|
199
|
+
this.lastError = "The persistent SQL job ended; a new job was opened.";
|
|
200
|
+
await this.discard(this.handle);
|
|
201
|
+
}
|
|
202
|
+
if (transport === "auto" && this.unavailableReason)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (!this.opening) {
|
|
205
|
+
this.opening = this.open(transport).finally(() => {
|
|
206
|
+
this.opening = undefined;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (transport === "mapepire")
|
|
210
|
+
return this.opening;
|
|
211
|
+
// In auto mode a slow start (single mode starts a JVM) must not eat the
|
|
212
|
+
// caller's timeout: this call uses QZDFMDB2 and the job keeps opening.
|
|
213
|
+
const waitMs = this.options.openWaitMs ?? DEFAULT_OPEN_WAIT_MS;
|
|
214
|
+
let timer;
|
|
215
|
+
try {
|
|
216
|
+
return await Promise.race([
|
|
217
|
+
this.opening,
|
|
218
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), waitMs); })
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
if (timer)
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async open(transport) {
|
|
227
|
+
const request = this.options.getOpenRequest();
|
|
228
|
+
if (!request) {
|
|
229
|
+
if (transport === "mapepire")
|
|
230
|
+
throw new Error("Not connected");
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
let handle;
|
|
234
|
+
try {
|
|
235
|
+
handle = await this.factory(request);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
const message = err?.message || String(err);
|
|
239
|
+
this.lastError = message;
|
|
240
|
+
if (transport === "mapepire") {
|
|
241
|
+
throw new Error(`Mapepire SQL job could not be opened (sqlTransport is "mapepire"): ${message}`);
|
|
242
|
+
}
|
|
243
|
+
// Cached for this connection: later SQL goes straight to QZDFMDB2.
|
|
244
|
+
this.unavailableReason = message;
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
if (this.closed) {
|
|
248
|
+
await handle.close().catch(() => undefined);
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
this.handle = handle;
|
|
252
|
+
this.unavailableReason = undefined;
|
|
253
|
+
return handle;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Applies the connection's configured library list again, even when it was
|
|
257
|
+
* applied before. Returns undefined when the persistent job is not used.
|
|
258
|
+
*/
|
|
259
|
+
async reapplyLibraryList() {
|
|
260
|
+
const handle = await this.acquire();
|
|
261
|
+
if (!handle)
|
|
262
|
+
return undefined;
|
|
263
|
+
return this.exclusive(undefined, async () => {
|
|
264
|
+
this.appliedLibraryList = undefined;
|
|
265
|
+
await this.ensureLibraryList(handle);
|
|
266
|
+
return this.libraryListWarning ? { warning: this.libraryListWarning } : {};
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Puts the connection's configured library list and current library in
|
|
271
|
+
* effect in the job, unless that list was already applied to this job. Runs
|
|
272
|
+
* inside the queue. A failure never fails the caller's statement: missing
|
|
273
|
+
* libraries are left out and the reason is kept for the session status.
|
|
274
|
+
*/
|
|
275
|
+
async ensureLibraryList(handle) {
|
|
276
|
+
const desired = desiredLibraryList(this.options.getConfig());
|
|
277
|
+
const applied = this.appliedLibraryList;
|
|
278
|
+
if (applied && applied.handle === handle && applied.signature === desired.signature)
|
|
279
|
+
return;
|
|
280
|
+
// Recorded first, so a list that cannot be applied is not retried on every call.
|
|
281
|
+
this.appliedLibraryList = { handle, signature: desired.signature };
|
|
282
|
+
this.libraryListWarning = invalidNamesWarning(desired);
|
|
283
|
+
const command = libraryListCommand(desired.libraries, desired.currentLibrary);
|
|
284
|
+
if (!command)
|
|
285
|
+
return;
|
|
286
|
+
try {
|
|
287
|
+
await this.execCl(handle, command);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (!handle.isAlive())
|
|
292
|
+
throw err;
|
|
293
|
+
const firstError = err?.message || String(err);
|
|
294
|
+
try {
|
|
295
|
+
const names = referencedLibraries(desired);
|
|
296
|
+
const result = await this.runStatement(handle, existingLibrariesSql(names)).catch(err => {
|
|
297
|
+
if (!handle.isAlive())
|
|
298
|
+
throw err;
|
|
299
|
+
// Older releases do not know *ALLSIMPLE.
|
|
300
|
+
return this.runStatement(handle, existingLibrariesSql(names, false));
|
|
301
|
+
});
|
|
302
|
+
const resolved = resolveAgainstExisting(desired, libraryNamesFromRows(result.rows));
|
|
303
|
+
if (!resolved.warning) {
|
|
304
|
+
this.libraryListWarning = `${command} failed in the SQL job: ${firstError}`;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (resolved.command)
|
|
308
|
+
await this.execCl(handle, resolved.command);
|
|
309
|
+
this.libraryListWarning = resolved.warning;
|
|
310
|
+
}
|
|
311
|
+
catch (retryErr) {
|
|
312
|
+
if (!handle.isAlive())
|
|
313
|
+
throw retryErr;
|
|
314
|
+
this.libraryListWarning = `${command} failed in the SQL job: ${firstError}`;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Runs CL so that its library list changes last (see the note at the top). */
|
|
319
|
+
async execCl(handle, command, signal) {
|
|
320
|
+
if (!handle.runCl) {
|
|
321
|
+
await this.runStatement(handle, "CALL QSYS2.QCMDEXC(?)", [command], signal);
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const result = await this.withCancel(handle, handle.runCl(command, { signal }), signal);
|
|
325
|
+
const messages = (result.messages || []).filter(m => !isNoiseMessage(m));
|
|
326
|
+
if (!result.success)
|
|
327
|
+
throw new ClCommandError(command, messages);
|
|
328
|
+
return messages;
|
|
329
|
+
}
|
|
330
|
+
async discard(handle) {
|
|
331
|
+
if (this.handle === handle)
|
|
332
|
+
this.handle = undefined;
|
|
333
|
+
await handle.close().catch(() => undefined);
|
|
334
|
+
}
|
|
335
|
+
/** Statements share one job, so they run one at a time. */
|
|
336
|
+
exclusive(signal, task) {
|
|
337
|
+
const run = async () => {
|
|
338
|
+
if (signal?.aborted)
|
|
339
|
+
throw new Error("SQL statement cancelled before it started");
|
|
340
|
+
return task();
|
|
341
|
+
};
|
|
342
|
+
const result = this.queue.then(run, run);
|
|
343
|
+
this.queue = result.catch(() => undefined);
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
async runAll(handle, statements, signal) {
|
|
347
|
+
const rows = [];
|
|
348
|
+
let updateCount;
|
|
349
|
+
for (const statement of splitSqlStatements(statements)) {
|
|
350
|
+
if (signal?.aborted)
|
|
351
|
+
throw new Error("SQL statement cancelled");
|
|
352
|
+
const isCl = statement.tokens.length === 1 && statement.tokens[0].type === "cl";
|
|
353
|
+
const clCommand = isCl ? statement.tokens[0].value : qcmdexcLiteral(statement.text);
|
|
354
|
+
if (clCommand !== undefined) {
|
|
355
|
+
await this.execCl(handle, clCommand, signal);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const result = await this.runStatement(handle, statement.text, undefined, signal);
|
|
359
|
+
rows.push(...result.rows);
|
|
360
|
+
if (typeof result.updateCount === "number" && result.updateCount >= 0) {
|
|
361
|
+
updateCount = (updateCount ?? 0) + result.updateCount;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (updateCount !== undefined) {
|
|
365
|
+
Object.defineProperty(rows, "updateCount", { value: updateCount, enumerable: false });
|
|
366
|
+
}
|
|
367
|
+
return rows;
|
|
368
|
+
}
|
|
369
|
+
async runStatement(handle, sql, parameters, signal) {
|
|
370
|
+
const work = handle.run(sql, { parameters, signal }).then(result => ({ rows: result.rows.map(row => normalizeRow(row, result.columns)), updateCount: result.updateCount }), err => { throw toSqlError(err, sql); });
|
|
371
|
+
return this.withCancel(handle, work, signal);
|
|
372
|
+
}
|
|
373
|
+
/** When `signal` aborts while `work` runs, the statement is cancelled on the IBM i. */
|
|
374
|
+
async withCancel(handle, work, signal) {
|
|
375
|
+
if (!signal)
|
|
376
|
+
return work;
|
|
377
|
+
if (signal.aborted) {
|
|
378
|
+
void this.cancelInFlight(handle, work);
|
|
379
|
+
return work;
|
|
380
|
+
}
|
|
381
|
+
const onAbort = () => { void this.cancelInFlight(handle, work); };
|
|
382
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
383
|
+
try {
|
|
384
|
+
return await work;
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
signal.removeEventListener("abort", onAbort);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The caller timed out: ask the IBM i to cancel the statement (QSYS2.CANCEL_SQL
|
|
392
|
+
* from a separate job). If it is still running after a grace period, end the
|
|
393
|
+
* persistent job; the next call opens a new one and reapplies the library list.
|
|
394
|
+
*/
|
|
395
|
+
async cancelInFlight(handle, work) {
|
|
396
|
+
let done = false;
|
|
397
|
+
const settled = work.then(() => { done = true; }, () => { done = true; });
|
|
398
|
+
const notes = [];
|
|
399
|
+
if (handle.jobName && isQualifiedJobName(handle.jobName) && this.options.cancelStatement) {
|
|
400
|
+
try {
|
|
401
|
+
await this.options.cancelStatement(handle.jobName);
|
|
402
|
+
notes.push(`CANCEL_SQL requested for ${handle.jobName}`);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
notes.push(`CANCEL_SQL failed: ${err?.message || String(err)}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!done) {
|
|
409
|
+
const grace = this.options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS;
|
|
410
|
+
let timer;
|
|
411
|
+
await Promise.race([settled, new Promise(resolve => { timer = setTimeout(resolve, grace); })]);
|
|
412
|
+
if (timer)
|
|
413
|
+
clearTimeout(timer);
|
|
414
|
+
}
|
|
415
|
+
if (!done) {
|
|
416
|
+
notes.push("statement still running; the SQL job was ended and will be reopened on the next call");
|
|
417
|
+
await this.discard(handle);
|
|
418
|
+
}
|
|
419
|
+
this.lastCancel = `${new Date().toISOString()}: ${notes.join("; ") || "cancelled"}`;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** Mapepire values to the row shape QZDFMDB2 parsing produces (trimmed strings, numbers, null). */
|
|
423
|
+
export function normalizeRow(row, columns) {
|
|
424
|
+
const types = new Map();
|
|
425
|
+
for (const column of columns || []) {
|
|
426
|
+
if (column?.name)
|
|
427
|
+
types.set(column.name, String(column.type || "").toUpperCase());
|
|
428
|
+
}
|
|
429
|
+
const out = {};
|
|
430
|
+
for (const [key, value] of Object.entries(row || {})) {
|
|
431
|
+
if (value === undefined || value === null) {
|
|
432
|
+
out[key] = null;
|
|
433
|
+
}
|
|
434
|
+
else if (typeof value === "number") {
|
|
435
|
+
out[key] = value;
|
|
436
|
+
}
|
|
437
|
+
else if (typeof value === "string") {
|
|
438
|
+
const type = types.get(key);
|
|
439
|
+
if (type && NUMERIC_TYPES.has(type) && value.trim() !== "" && Number.isFinite(Number(value))) {
|
|
440
|
+
out[key] = Number(value);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
out[key] = value.trimEnd();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else if (typeof value === "boolean") {
|
|
447
|
+
out[key] = value ? "true" : "false";
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
out[key] = JSON.stringify(value);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
/** Mapepire errors look like "<message>, <SQLSTATE>, <SQLCODE>". */
|
|
456
|
+
export function toSqlError(err, sql) {
|
|
457
|
+
if (err instanceof Tools.SqlError)
|
|
458
|
+
return err;
|
|
459
|
+
const message = String(err?.message || err || "SQL error");
|
|
460
|
+
const match = message.match(/^(.*), ([0-9A-Z]{5}), (-?\d+)$/s);
|
|
461
|
+
if (!match)
|
|
462
|
+
return err instanceof Error ? err : new Error(message);
|
|
463
|
+
const sqlError = new Tools.SqlError(`${match[1]} (${match[2]})`);
|
|
464
|
+
sqlError.sqlstate = match[2];
|
|
465
|
+
sqlError.cause = sql;
|
|
466
|
+
return sqlError;
|
|
467
|
+
}
|
|
468
|
+
let mapepireModule;
|
|
469
|
+
async function loadMapepire() {
|
|
470
|
+
if (!mapepireModule) {
|
|
471
|
+
mapepireModule = import("@ibm/mapepire-js").then((m) => m.default ?? m);
|
|
472
|
+
mapepireModule.catch(() => { mapepireModule = undefined; });
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
return await mapepireModule;
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
throw new SqlJobUnavailableError(`The @ibm/mapepire-js package could not be loaded: ${err?.message || String(err)}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Opens a Mapepire job. "daemon" connects to the Mapepire server (port 8076,
|
|
483
|
+
* TLS, user and password). "ssh" starts mapepire-server in single mode over the
|
|
484
|
+
* connection's own SSH session, so it also works with key-based logins and
|
|
485
|
+
* needs no open port, only the server JAR and Java on the IBM i. "auto" uses
|
|
486
|
+
* the daemon when a password is available and falls back to ssh.
|
|
487
|
+
*/
|
|
488
|
+
export const mapepireJobFactory = async (request) => {
|
|
489
|
+
const mapepire = await loadMapepire();
|
|
490
|
+
const mode = request.mode;
|
|
491
|
+
if (mode === "daemon" || (mode === "auto" && request.password)) {
|
|
492
|
+
try {
|
|
493
|
+
return await openDaemonJob(mapepire, request);
|
|
494
|
+
}
|
|
495
|
+
catch (err) {
|
|
496
|
+
if (mode === "daemon" || !(err instanceof SqlJobUnavailableError) || !request.ssh)
|
|
497
|
+
throw err;
|
|
498
|
+
try {
|
|
499
|
+
return await openSshJob(mapepire, request);
|
|
500
|
+
}
|
|
501
|
+
catch (sshErr) {
|
|
502
|
+
throw new SqlJobUnavailableError(`${err.message}; single mode over SSH: ${sshErr?.message || String(sshErr)}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return openSshJob(mapepire, request);
|
|
507
|
+
};
|
|
508
|
+
async function openDaemonJob(mapepire, request) {
|
|
509
|
+
if (!request.password) {
|
|
510
|
+
throw new SqlJobUnavailableError("The Mapepire server needs a password; this connection has none (use mapepireMode \"ssh\")");
|
|
511
|
+
}
|
|
512
|
+
const pin = await resolveCertificatePin(request.host, request.port, request.probeTimeoutMs);
|
|
513
|
+
const transport = new PinnedWebSocketTransport(pin, request.probeTimeoutMs * 2);
|
|
514
|
+
const job = new mapepire.SQLJob({ ...JDBC_OPTIONS }, transport);
|
|
515
|
+
try {
|
|
516
|
+
await job.connect({ host: request.host, port: request.port, user: request.username, password: request.password }, "mcp-for-i");
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
await Promise.resolve(job.close()).catch(() => undefined);
|
|
520
|
+
throw err;
|
|
521
|
+
}
|
|
522
|
+
return wrapMapepireJob(job, "daemon", () => transport.isConnected());
|
|
523
|
+
}
|
|
524
|
+
async function openSshJob(mapepire, request) {
|
|
525
|
+
if (!request.ssh)
|
|
526
|
+
throw new SqlJobUnavailableError("No SSH session for Mapepire single mode");
|
|
527
|
+
const serverPath = await findRemoteFile(request, request.serverPath ? [request.serverPath] : MAPEPIRE_SERVER_PATHS);
|
|
528
|
+
if (!serverPath) {
|
|
529
|
+
throw new SqlJobUnavailableError(`mapepire-server.jar not found (${(request.serverPath ? [request.serverPath] : MAPEPIRE_SERVER_PATHS).join(", ")}); install it with "yum install mapepire-server"`);
|
|
530
|
+
}
|
|
531
|
+
const javaPath = await findRemoteFile(request, request.javaPath ? [request.javaPath] : JAVA_PATHS);
|
|
532
|
+
if (!javaPath)
|
|
533
|
+
throw new SqlJobUnavailableError("No Java runtime found for Mapepire single mode (set mapepireJavaPath)");
|
|
534
|
+
const start = async () => {
|
|
535
|
+
const job = mapepire.SQLJob.withConfig({
|
|
536
|
+
transport: "ssh-single",
|
|
537
|
+
sshSingle: {
|
|
538
|
+
exec: mapepire.createNodeSSHExec(request.ssh),
|
|
539
|
+
serverPath,
|
|
540
|
+
javaPath,
|
|
541
|
+
startupTimeout: 60000
|
|
542
|
+
}
|
|
543
|
+
}, { ...JDBC_OPTIONS });
|
|
544
|
+
try {
|
|
545
|
+
await job.connect(undefined, "mcp-for-i");
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
await Promise.resolve(job.close()).catch(() => undefined);
|
|
549
|
+
throw err;
|
|
550
|
+
}
|
|
551
|
+
return job;
|
|
552
|
+
};
|
|
553
|
+
const wrap = (job, technique) => wrapMapepireJob(job, "ssh", () => {
|
|
554
|
+
try {
|
|
555
|
+
return Boolean(job.getTransport()?.isConnected());
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}, technique);
|
|
561
|
+
// Single mode connects with the in-process CLI technique, which ignores the
|
|
562
|
+
// JDBC properties (so SQL naming, and unqualified names do not use the
|
|
563
|
+
// library list) and runs each SQL request on its own thread, so library
|
|
564
|
+
// list changes do not last. Switch the server to the TCP technique: a
|
|
565
|
+
// database host server job on the same system, signed on as the same user,
|
|
566
|
+
// with system naming and a library list that persists.
|
|
567
|
+
const job = await start();
|
|
568
|
+
try {
|
|
569
|
+
const response = await job.send({
|
|
570
|
+
id: mapepire.SQLJob.getNewUniqueId(),
|
|
571
|
+
type: "connect",
|
|
572
|
+
technique: "tcp",
|
|
573
|
+
application: "mcp-for-i",
|
|
574
|
+
props: jdbcPropertiesString(JDBC_OPTIONS)
|
|
575
|
+
});
|
|
576
|
+
if (response?.success === true) {
|
|
577
|
+
if (typeof response.job === "string")
|
|
578
|
+
job.id = response.job;
|
|
579
|
+
return wrap(job, "tcp");
|
|
580
|
+
}
|
|
581
|
+
throw new Error(response?.error || "connect failed");
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// The failed reconnect may have dropped the CLI connection: start again.
|
|
585
|
+
await Promise.resolve(job.close()).catch(() => undefined);
|
|
586
|
+
}
|
|
587
|
+
return wrap(await start(), "cli");
|
|
588
|
+
}
|
|
589
|
+
/** JDBC properties as Mapepire's connect request carries them. */
|
|
590
|
+
export function jdbcPropertiesString(options) {
|
|
591
|
+
return Object.entries(options).map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(",") : value}`).join(";");
|
|
592
|
+
}
|
|
593
|
+
/** Mapepire reports single-mode CLI jobs as number-user-name. */
|
|
594
|
+
export function normalizeJobName(value) {
|
|
595
|
+
if (typeof value !== "string" || !value)
|
|
596
|
+
return undefined;
|
|
597
|
+
const dashed = value.match(/^(\d{6})-([^-/]{1,10})-([^-/]{1,10})$/);
|
|
598
|
+
return dashed ? `${dashed[1]}/${dashed[2]}/${dashed[3]}` : value;
|
|
599
|
+
}
|
|
600
|
+
async function findRemoteFile(request, candidates) {
|
|
601
|
+
const script = candidates
|
|
602
|
+
.map(p => `if [ -f ${Tools.shellQuote(p)} ]; then echo ${Tools.shellQuote(p)}; exit 0; fi`)
|
|
603
|
+
.join("; ");
|
|
604
|
+
try {
|
|
605
|
+
const result = await request.sendCommand({ command: `${script}; exit 1` });
|
|
606
|
+
const found = result.stdout.split(/\r?\n/).map(l => l.trim()).find(Boolean);
|
|
607
|
+
return found && candidates.includes(found) ? found : undefined;
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function wrapMapepireJob(job, mode, connected, technique = "tcp") {
|
|
614
|
+
// SQLJob.close() drops its response listeners, so requests still waiting on
|
|
615
|
+
// the job would never settle. Track them and fail them on close.
|
|
616
|
+
const pending = new Set();
|
|
617
|
+
let closed = false;
|
|
618
|
+
const runQuery = async (sql, options) => {
|
|
619
|
+
const query = job.query(sql, options.parameters ? { parameters: options.parameters } : {});
|
|
620
|
+
let result = await query.execute(FETCH_SIZE);
|
|
621
|
+
const rows = Array.isArray(result.data) ? [...result.data] : [];
|
|
622
|
+
const columns = (result.metadata?.columns || []).map((c) => ({ name: c.name || c.label, type: c.type }));
|
|
623
|
+
while (result.has_results && !result.is_done) {
|
|
624
|
+
if (options.signal?.aborted) {
|
|
625
|
+
await Promise.resolve(query.close()).catch(() => undefined);
|
|
626
|
+
throw new Error("SQL statement cancelled");
|
|
627
|
+
}
|
|
628
|
+
result = await query.fetchMore(FETCH_SIZE);
|
|
629
|
+
if (Array.isArray(result.data))
|
|
630
|
+
rows.push(...result.data);
|
|
631
|
+
}
|
|
632
|
+
const updateCount = result.has_results ? undefined : result.update_count;
|
|
633
|
+
return { rows, columns, updateCount: typeof updateCount === "number" ? updateCount : undefined };
|
|
634
|
+
};
|
|
635
|
+
// A CL request answers with the job log messages the command sent, and
|
|
636
|
+
// success false when it failed; it does not reject.
|
|
637
|
+
const runClCommand = async (command) => {
|
|
638
|
+
const query = job.clcommand(command);
|
|
639
|
+
const result = await query.execute(FETCH_SIZE);
|
|
640
|
+
if (result.has_results && !result.is_done)
|
|
641
|
+
await Promise.resolve(query.close()).catch(() => undefined);
|
|
642
|
+
const rows = Array.isArray(result.data) ? result.data : [];
|
|
643
|
+
const messages = rows.map(row => ({
|
|
644
|
+
id: row.MESSAGE_ID ? String(row.MESSAGE_ID).trim() : undefined,
|
|
645
|
+
type: row.MESSAGE_TYPE ? String(row.MESSAGE_TYPE).trim() : undefined,
|
|
646
|
+
text: String(row.MESSAGE_TEXT ?? row.SUMMARY ?? "").trim()
|
|
647
|
+
}));
|
|
648
|
+
if (result.success === false && messages.length === 0 && result.error) {
|
|
649
|
+
messages.push({ type: "ESCAPE", text: String(result.error) });
|
|
650
|
+
}
|
|
651
|
+
return { success: result.success !== false, messages };
|
|
652
|
+
};
|
|
653
|
+
return {
|
|
654
|
+
mode,
|
|
655
|
+
technique,
|
|
656
|
+
get jobName() {
|
|
657
|
+
return normalizeJobName(job.id);
|
|
658
|
+
},
|
|
659
|
+
isAlive() {
|
|
660
|
+
if (closed)
|
|
661
|
+
return false;
|
|
662
|
+
const status = String(job.getStatus?.() ?? "");
|
|
663
|
+
return status !== "ended" && status !== "notStarted" && connected();
|
|
664
|
+
},
|
|
665
|
+
run(sql, options) {
|
|
666
|
+
if (closed)
|
|
667
|
+
return Promise.reject(new Error("SQL job closed"));
|
|
668
|
+
return new Promise((resolve, reject) => {
|
|
669
|
+
pending.add(reject);
|
|
670
|
+
runQuery(sql, options).then(resolve, reject).finally(() => pending.delete(reject));
|
|
671
|
+
});
|
|
672
|
+
},
|
|
673
|
+
runCl(command) {
|
|
674
|
+
if (closed)
|
|
675
|
+
return Promise.reject(new Error("SQL job closed"));
|
|
676
|
+
return new Promise((resolve, reject) => {
|
|
677
|
+
pending.add(reject);
|
|
678
|
+
runClCommand(command).then(resolve, reject).finally(() => pending.delete(reject));
|
|
679
|
+
});
|
|
680
|
+
},
|
|
681
|
+
async close() {
|
|
682
|
+
closed = true;
|
|
683
|
+
const error = new Error("SQL job closed");
|
|
684
|
+
for (const reject of pending)
|
|
685
|
+
reject(error);
|
|
686
|
+
pending.clear();
|
|
687
|
+
await job.close();
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Reads the server certificate chain (no credentials are sent on this
|
|
693
|
+
* connection). A chain that ends in a publicly trusted CA is verified the
|
|
694
|
+
* normal way. Otherwise (self-signed, as Mapepire generates by default, or a
|
|
695
|
+
* private CA) the root is pinned on first use in known_hosts.json and must
|
|
696
|
+
* match on every later connection.
|
|
697
|
+
*/
|
|
698
|
+
export async function resolveCertificatePin(host, port, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
699
|
+
const leaf = await fetchCertificateChain(host, port, timeoutMs);
|
|
700
|
+
const root = findRootCertificate(leaf);
|
|
701
|
+
const pem = derToPem(root.raw);
|
|
702
|
+
const isPublic = tls.rootCertificates.some(ca => ca.replace(/\s+/g, "") === pem.replace(/\s+/g, ""));
|
|
703
|
+
if (isPublic)
|
|
704
|
+
return { host, port };
|
|
705
|
+
const subject = root.subject ? Object.entries(root.subject).map(([k, v]) => `${k}=${v}`).join(", ") : undefined;
|
|
706
|
+
const { fingerprint } = await checkCertificate({ host, port, der: root.raw, subject });
|
|
707
|
+
return { host, port, ca: pem, fingerprint };
|
|
708
|
+
}
|
|
709
|
+
function fetchCertificateChain(host, port, timeoutMs) {
|
|
710
|
+
return new Promise((resolve, reject) => {
|
|
711
|
+
let finished = false;
|
|
712
|
+
const finish = (fn) => {
|
|
713
|
+
if (finished)
|
|
714
|
+
return;
|
|
715
|
+
finished = true;
|
|
716
|
+
clearTimeout(timer);
|
|
717
|
+
socket.destroy();
|
|
718
|
+
fn();
|
|
719
|
+
};
|
|
720
|
+
// Chain verification happens later against the pinned certificate; this
|
|
721
|
+
// connection only reads the certificate and sends nothing.
|
|
722
|
+
const socket = tls.connect({
|
|
723
|
+
host,
|
|
724
|
+
port,
|
|
725
|
+
servername: net.isIP(host) ? undefined : host,
|
|
726
|
+
rejectUnauthorized: false
|
|
727
|
+
});
|
|
728
|
+
const timer = setTimeout(() => {
|
|
729
|
+
finish(() => reject(new SqlJobUnavailableError(`Mapepire server not reachable on ${host}:${port} (no answer in ${timeoutMs}ms)`)));
|
|
730
|
+
}, timeoutMs);
|
|
731
|
+
socket.once("secureConnect", () => {
|
|
732
|
+
const cert = socket.getPeerCertificate(true);
|
|
733
|
+
finish(() => {
|
|
734
|
+
if (!cert || !cert.raw)
|
|
735
|
+
reject(new Error(`Mapepire server on ${host}:${port} presented no certificate`));
|
|
736
|
+
else
|
|
737
|
+
resolve(cert);
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
socket.once("error", (err) => {
|
|
741
|
+
finish(() => {
|
|
742
|
+
const code = err?.code ? ` (${err.code})` : "";
|
|
743
|
+
reject(new SqlJobUnavailableError(`Mapepire server not reachable on ${host}:${port}${code}: ${err?.message || String(err)}`));
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
function findRootCertificate(leaf) {
|
|
749
|
+
let current = leaf;
|
|
750
|
+
const seen = new Set();
|
|
751
|
+
while (current.issuerCertificate && current.issuerCertificate !== current) {
|
|
752
|
+
const fp = current.fingerprint256;
|
|
753
|
+
if (seen.has(fp))
|
|
754
|
+
break;
|
|
755
|
+
seen.add(fp);
|
|
756
|
+
if (!current.issuerCertificate.raw)
|
|
757
|
+
break;
|
|
758
|
+
current = current.issuerCertificate;
|
|
759
|
+
}
|
|
760
|
+
return current;
|
|
761
|
+
}
|
|
762
|
+
export function derToPem(der) {
|
|
763
|
+
const b64 = der.toString("base64");
|
|
764
|
+
const lines = [];
|
|
765
|
+
for (let i = 0; i < b64.length; i += 64)
|
|
766
|
+
lines.push(b64.slice(i, i + 64));
|
|
767
|
+
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----\n`;
|
|
768
|
+
}
|
|
769
|
+
/** True when the presented chain contains the pinned certificate. */
|
|
770
|
+
export function chainHasFingerprint(cert, fingerprint) {
|
|
771
|
+
let current = cert;
|
|
772
|
+
const seen = new Set();
|
|
773
|
+
while (current && current.raw && !seen.has(current)) {
|
|
774
|
+
seen.add(current);
|
|
775
|
+
if (certificateFingerprint(current.raw) === fingerprint)
|
|
776
|
+
return true;
|
|
777
|
+
current = current.issuerCertificate;
|
|
778
|
+
}
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* WebSocket transport for Mapepire's SQLJob (same protocol as the library's
|
|
783
|
+
* WebSocketTransport) that verifies the server against the pinned certificate.
|
|
784
|
+
* The chain must validate against the pinned root and contain it; the host
|
|
785
|
+
* name is not compared because Mapepire's self-signed certificate rarely
|
|
786
|
+
* matches the name or address used to connect. Without a pin the normal CA and
|
|
787
|
+
* host name checks apply. Certificate errors are never ignored.
|
|
788
|
+
*/
|
|
789
|
+
export class PinnedWebSocketTransport {
|
|
790
|
+
pin;
|
|
791
|
+
timeoutMs;
|
|
792
|
+
socket;
|
|
793
|
+
connected = false;
|
|
794
|
+
responseEmitter = new EventEmitter();
|
|
795
|
+
constructor(pin, timeoutMs = 8000) {
|
|
796
|
+
this.pin = pin;
|
|
797
|
+
this.timeoutMs = timeoutMs;
|
|
798
|
+
}
|
|
799
|
+
async connect(server) {
|
|
800
|
+
const { default: WebSocket } = await import("ws");
|
|
801
|
+
const host = server.host;
|
|
802
|
+
const port = server.port || DEFAULT_MAPEPIRE_PORT;
|
|
803
|
+
const urlHost = net.isIPv6(host) ? `[${host}]` : host;
|
|
804
|
+
const fingerprint = this.pin.fingerprint;
|
|
805
|
+
const options = {
|
|
806
|
+
headers: {
|
|
807
|
+
authorization: `Basic ${Buffer.from(`${server.user}:${server.password}`).toString("base64")}`
|
|
808
|
+
},
|
|
809
|
+
handshakeTimeout: this.timeoutMs,
|
|
810
|
+
rejectUnauthorized: true
|
|
811
|
+
};
|
|
812
|
+
if (this.pin.ca && fingerprint) {
|
|
813
|
+
options.ca = this.pin.ca;
|
|
814
|
+
options.allowPartialTrustChain = true;
|
|
815
|
+
options.checkServerIdentity = (_host, cert) => chainHasFingerprint(cert, fingerprint)
|
|
816
|
+
? undefined
|
|
817
|
+
: new Error(`Mapepire server certificate does not match the pinned certificate ${fingerprint}`);
|
|
818
|
+
}
|
|
819
|
+
await new Promise((resolve, reject) => {
|
|
820
|
+
let opened = false;
|
|
821
|
+
const ws = new WebSocket(`wss://${urlHost}:${port}/db/`, options);
|
|
822
|
+
ws.on("error", (err) => {
|
|
823
|
+
if (!opened)
|
|
824
|
+
reject(err);
|
|
825
|
+
});
|
|
826
|
+
ws.on("message", (data) => {
|
|
827
|
+
let response;
|
|
828
|
+
try {
|
|
829
|
+
response = JSON.parse(data.toString(), ((_key, value, context) => {
|
|
830
|
+
// Keep the server's exact text for numbers JavaScript would round.
|
|
831
|
+
if (context && typeof value === "number" && context.source !== undefined && String(value) !== context.source) {
|
|
832
|
+
return context.source;
|
|
833
|
+
}
|
|
834
|
+
return value;
|
|
835
|
+
}));
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (response && typeof response.id === "string")
|
|
841
|
+
this.responseEmitter.emit(response.id, response);
|
|
842
|
+
});
|
|
843
|
+
ws.on("close", (code, reason) => {
|
|
844
|
+
this.connected = false;
|
|
845
|
+
this.notifyConnectionFailure(code, reason?.toString() || "");
|
|
846
|
+
if (!opened)
|
|
847
|
+
reject(new Error(`Mapepire connection closed (${code})`));
|
|
848
|
+
});
|
|
849
|
+
ws.once("open", () => {
|
|
850
|
+
opened = true;
|
|
851
|
+
this.socket = ws;
|
|
852
|
+
this.connected = true;
|
|
853
|
+
resolve();
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async send(request) {
|
|
858
|
+
if (!this.socket || !this.connected) {
|
|
859
|
+
// Fail the waiting request instead of leaving it pending.
|
|
860
|
+
setImmediate(() => this.responseEmitter.emit(`${request.id}_conn_fail`, new Error("Mapepire connection is not open")));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
this.socket.send(JSON.stringify(request));
|
|
864
|
+
}
|
|
865
|
+
async close() {
|
|
866
|
+
const socket = this.socket;
|
|
867
|
+
this.socket = undefined;
|
|
868
|
+
this.connected = false;
|
|
869
|
+
if (socket)
|
|
870
|
+
socket.close();
|
|
871
|
+
this.notifyConnectionFailure(1000, "closed");
|
|
872
|
+
}
|
|
873
|
+
getResponseEmitter() {
|
|
874
|
+
return this.responseEmitter;
|
|
875
|
+
}
|
|
876
|
+
isConnected() {
|
|
877
|
+
return this.connected;
|
|
878
|
+
}
|
|
879
|
+
enableTrace() { }
|
|
880
|
+
disableTrace() { }
|
|
881
|
+
notifyConnectionFailure(code, reason) {
|
|
882
|
+
const events = this.responseEmitter.eventNames().filter(e => typeof e === "string" && e.endsWith("_conn_fail"));
|
|
883
|
+
for (const event of events) {
|
|
884
|
+
this.responseEmitter.emit(event, new Error(`Connection failed with code ${code}${reason ? `: ${reason}` : ""}`));
|
|
885
|
+
}
|
|
886
|
+
this.responseEmitter.removeAllListeners();
|
|
887
|
+
}
|
|
888
|
+
}
|