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,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small lexical SQL analyser for the text sent to QZDFMDB2.
|
|
3
|
+
*
|
|
4
|
+
* It does not parse SQL grammar. It splits the text into segments (code,
|
|
5
|
+
* string literals, delimited identifiers, comments and `@` CL lines) so that
|
|
6
|
+
* callers can split statements, find embedded CL commands and decide whether
|
|
7
|
+
* a request is read-only without being fooled by quotes or comments.
|
|
8
|
+
*/
|
|
9
|
+
const READ_ONLY_FIRST_KEYWORDS = new Set(["SELECT", "WITH", "VALUES"]);
|
|
10
|
+
const DATA_CHANGE_TABLE_KEYWORDS = new Set(["FINAL", "NEW", "OLD"]);
|
|
11
|
+
const DATA_CHANGE_STATEMENTS = new Set(["INSERT", "UPDATE", "DELETE", "MERGE"]);
|
|
12
|
+
/**
|
|
13
|
+
* Splits SQL text into lexical segments. A line whose first character is `@`
|
|
14
|
+
* while the lexer is not inside a literal or block comment is a CL line; this
|
|
15
|
+
* matches what Tools.fixSQL converts into a QCMDEXC call.
|
|
16
|
+
*/
|
|
17
|
+
export function lexSqlSegments(sql) {
|
|
18
|
+
const segments = [];
|
|
19
|
+
const n = sql.length;
|
|
20
|
+
let i = 0;
|
|
21
|
+
let codeStart = 0;
|
|
22
|
+
const flushCode = (end) => {
|
|
23
|
+
if (end > codeStart) {
|
|
24
|
+
segments.push({ kind: "code", start: codeStart, end, text: sql.slice(codeStart, end) });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
while (i < n) {
|
|
28
|
+
const ch = sql[i];
|
|
29
|
+
const atLineStart = i === 0 || sql[i - 1] === "\n";
|
|
30
|
+
if (atLineStart && ch === "@") {
|
|
31
|
+
flushCode(i);
|
|
32
|
+
let end = sql.indexOf("\n", i);
|
|
33
|
+
if (end === -1)
|
|
34
|
+
end = n;
|
|
35
|
+
const text = sql.slice(i, end);
|
|
36
|
+
let command = text.slice(1).replace(/\r$/, "");
|
|
37
|
+
if (command.endsWith(";"))
|
|
38
|
+
command = command.slice(0, -1);
|
|
39
|
+
segments.push({ kind: "cl", start: i, end, text, value: command });
|
|
40
|
+
i = end;
|
|
41
|
+
codeStart = i;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === "'" || ch === "\"") {
|
|
45
|
+
flushCode(i);
|
|
46
|
+
const quote = ch;
|
|
47
|
+
let j = i + 1;
|
|
48
|
+
let value = "";
|
|
49
|
+
let terminated = false;
|
|
50
|
+
while (j < n) {
|
|
51
|
+
if (sql[j] === quote) {
|
|
52
|
+
if (sql[j + 1] === quote) {
|
|
53
|
+
value += quote;
|
|
54
|
+
j += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
terminated = true;
|
|
58
|
+
j++;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
value += sql[j];
|
|
62
|
+
j++;
|
|
63
|
+
}
|
|
64
|
+
segments.push({
|
|
65
|
+
kind: quote === "'" ? "string" : "qident",
|
|
66
|
+
start: i,
|
|
67
|
+
end: j,
|
|
68
|
+
text: sql.slice(i, j),
|
|
69
|
+
value,
|
|
70
|
+
...(terminated ? {} : { unterminated: true })
|
|
71
|
+
});
|
|
72
|
+
i = j;
|
|
73
|
+
codeStart = i;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
77
|
+
flushCode(i);
|
|
78
|
+
let end = sql.indexOf("\n", i);
|
|
79
|
+
if (end === -1)
|
|
80
|
+
end = n;
|
|
81
|
+
segments.push({ kind: "line", start: i, end, text: sql.slice(i, end) });
|
|
82
|
+
i = end;
|
|
83
|
+
codeStart = i;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
87
|
+
flushCode(i);
|
|
88
|
+
// Block comments are treated as non-nested: the comment ends at the first */.
|
|
89
|
+
// If Db2 nests them it sees more text as comment, never less.
|
|
90
|
+
const close = sql.indexOf("*/", i + 2);
|
|
91
|
+
const end = close === -1 ? n : close + 2;
|
|
92
|
+
segments.push({
|
|
93
|
+
kind: "block",
|
|
94
|
+
start: i,
|
|
95
|
+
end,
|
|
96
|
+
text: sql.slice(i, end),
|
|
97
|
+
...(close === -1 ? { unterminated: true } : {})
|
|
98
|
+
});
|
|
99
|
+
i = end;
|
|
100
|
+
codeStart = i;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
flushCode(n);
|
|
106
|
+
return segments;
|
|
107
|
+
}
|
|
108
|
+
/** Converts segments into significant tokens (comments dropped). */
|
|
109
|
+
export function tokenizeSql(sql) {
|
|
110
|
+
return segmentsToTokens(lexSqlSegments(sql));
|
|
111
|
+
}
|
|
112
|
+
function segmentsToTokens(segments) {
|
|
113
|
+
const tokens = [];
|
|
114
|
+
for (const seg of segments) {
|
|
115
|
+
switch (seg.kind) {
|
|
116
|
+
case "string":
|
|
117
|
+
case "qident":
|
|
118
|
+
tokens.push({ type: seg.kind, value: seg.value ?? "", start: seg.start, end: seg.end });
|
|
119
|
+
break;
|
|
120
|
+
case "cl":
|
|
121
|
+
tokens.push({ type: "cl", value: seg.value ?? "", start: seg.start, end: seg.end });
|
|
122
|
+
break;
|
|
123
|
+
case "code": {
|
|
124
|
+
const re = /([A-Za-z_#$@][A-Za-z0-9_#$@]*)|(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+(?:[eE][+-]?\d+)?)|(\S)/g;
|
|
125
|
+
let m;
|
|
126
|
+
while ((m = re.exec(seg.text))) {
|
|
127
|
+
const start = seg.start + m.index;
|
|
128
|
+
const end = start + m[0].length;
|
|
129
|
+
if (m[1])
|
|
130
|
+
tokens.push({ type: "word", value: m[1].toUpperCase(), start, end });
|
|
131
|
+
else if (m[2])
|
|
132
|
+
tokens.push({ type: "number", value: m[2], start, end });
|
|
133
|
+
else
|
|
134
|
+
tokens.push({ type: "punct", value: m[3], start, end });
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return tokens;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Splits SQL into statements on `;`, ignoring semicolons inside literals,
|
|
146
|
+
* delimited identifiers and comments. Each `@` CL line is its own statement.
|
|
147
|
+
* Statements with no significant tokens are dropped.
|
|
148
|
+
*/
|
|
149
|
+
export function splitSqlStatements(sql) {
|
|
150
|
+
const tokens = tokenizeSql(sql);
|
|
151
|
+
const statements = [];
|
|
152
|
+
let current = [];
|
|
153
|
+
const flush = () => {
|
|
154
|
+
if (current.length > 0) {
|
|
155
|
+
const start = current[0].start;
|
|
156
|
+
const end = current[current.length - 1].end;
|
|
157
|
+
statements.push({ text: sql.slice(start, end), start, end, tokens: current });
|
|
158
|
+
}
|
|
159
|
+
current = [];
|
|
160
|
+
};
|
|
161
|
+
for (const token of tokens) {
|
|
162
|
+
if (token.type === "cl") {
|
|
163
|
+
flush();
|
|
164
|
+
current.push(token);
|
|
165
|
+
flush();
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token.type === "punct" && token.value === ";") {
|
|
169
|
+
flush();
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
current.push(token);
|
|
173
|
+
}
|
|
174
|
+
flush();
|
|
175
|
+
return statements;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The statement type of the first statement: its first keyword, upper-cased
|
|
179
|
+
* ("SELECT", "INSERT", ...), "CL" for an `@` line, or "UNKNOWN".
|
|
180
|
+
*/
|
|
181
|
+
export function getSqlStatementType(sql) {
|
|
182
|
+
const first = splitSqlStatements(sql)[0];
|
|
183
|
+
if (!first)
|
|
184
|
+
return "UNKNOWN";
|
|
185
|
+
const token = first.tokens.find(t => !(t.type === "punct" && t.value === "("));
|
|
186
|
+
if (!token)
|
|
187
|
+
return "UNKNOWN";
|
|
188
|
+
if (token.type === "cl")
|
|
189
|
+
return "CL";
|
|
190
|
+
if (token.type === "word")
|
|
191
|
+
return token.value;
|
|
192
|
+
return "UNKNOWN";
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Lists the CL commands embedded in SQL text: `@` lines (which Tools.fixSQL
|
|
196
|
+
* turns into QCMDEXC calls) and calls to QCMDEXC (procedure or scalar
|
|
197
|
+
* function, qualified or not). For QCMDEXC the first argument is returned when
|
|
198
|
+
* it is a single string literal; otherwise `command` is null because the
|
|
199
|
+
* command is built at run time and cannot be checked statically.
|
|
200
|
+
*/
|
|
201
|
+
export function extractEmbeddedClCommands(sql) {
|
|
202
|
+
const tokens = tokenizeSql(sql);
|
|
203
|
+
const result = [];
|
|
204
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
205
|
+
const token = tokens[i];
|
|
206
|
+
if (token.type === "cl") {
|
|
207
|
+
result.push({ source: "at-line", command: token.value, offset: token.start });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!isQcmdexcName(token))
|
|
211
|
+
continue;
|
|
212
|
+
const open = tokens[i + 1];
|
|
213
|
+
const arg = tokens[i + 2];
|
|
214
|
+
const after = tokens[i + 3];
|
|
215
|
+
let command = null;
|
|
216
|
+
if (open?.type === "punct" && open.value === "(" &&
|
|
217
|
+
arg?.type === "string" &&
|
|
218
|
+
after?.type === "punct" && (after.value === "," || after.value === ")")) {
|
|
219
|
+
command = arg.value;
|
|
220
|
+
}
|
|
221
|
+
result.push({ source: "qcmdexc", command, offset: token.start });
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
function isQcmdexcName(token) {
|
|
226
|
+
return (token.type === "word" || token.type === "qident") && token.value.toUpperCase() === "QCMDEXC";
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Decides whether SQL may run through the read-only `ibmi.sql.query` tool.
|
|
230
|
+
*
|
|
231
|
+
* Allowed: exactly one SELECT, WITH or VALUES statement (optionally wrapped
|
|
232
|
+
* in parentheses, optionally ending with one `;`). Rejected:
|
|
233
|
+
* - more than one statement;
|
|
234
|
+
* - any line starting with `@` (a CL command);
|
|
235
|
+
* - any reference to QCMDEXC;
|
|
236
|
+
* - data-change table references such as FINAL TABLE (INSERT ...);
|
|
237
|
+
* - unterminated literals or comments;
|
|
238
|
+
* - a `;` inside a literal or comment, because QZDFMDB2 (-t) splits
|
|
239
|
+
* statements itself and is not known to respect quotes.
|
|
240
|
+
*/
|
|
241
|
+
export function checkReadOnlySql(sql) {
|
|
242
|
+
if (/^\s*@/m.test(sql)) {
|
|
243
|
+
return { readOnly: false, reason: "lines starting with @ run CL commands" };
|
|
244
|
+
}
|
|
245
|
+
const segments = lexSqlSegments(sql);
|
|
246
|
+
for (const seg of segments) {
|
|
247
|
+
if (seg.kind === "cl") {
|
|
248
|
+
return { readOnly: false, reason: "lines starting with @ run CL commands" };
|
|
249
|
+
}
|
|
250
|
+
if (seg.unterminated) {
|
|
251
|
+
return { readOnly: false, reason: "unterminated string, identifier or comment" };
|
|
252
|
+
}
|
|
253
|
+
if (seg.kind !== "code" && seg.text.includes(";")) {
|
|
254
|
+
return { readOnly: false, reason: "a semicolon inside a string, identifier or comment is not allowed in read-only SQL" };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const tokens = segmentsToTokens(segments);
|
|
258
|
+
const significant = tokens.filter(t => !(t.type === "punct" && t.value === ";"));
|
|
259
|
+
if (significant.length === 0) {
|
|
260
|
+
return { readOnly: false, reason: "no SQL statement" };
|
|
261
|
+
}
|
|
262
|
+
// Only one statement: every ';' must come after the last significant token.
|
|
263
|
+
const lastSignificant = significant[significant.length - 1];
|
|
264
|
+
const semicolons = tokens.filter(t => t.type === "punct" && t.value === ";");
|
|
265
|
+
if (semicolons.some(t => t.start < lastSignificant.start)) {
|
|
266
|
+
return { readOnly: false, reason: "multiple statements are not allowed" };
|
|
267
|
+
}
|
|
268
|
+
if (semicolons.length > 1) {
|
|
269
|
+
return { readOnly: false, reason: "multiple statements are not allowed" };
|
|
270
|
+
}
|
|
271
|
+
const first = significant.find(t => !(t.type === "punct" && t.value === "("));
|
|
272
|
+
if (!first || first.type !== "word" || !READ_ONLY_FIRST_KEYWORDS.has(first.value)) {
|
|
273
|
+
return { readOnly: false, reason: "only SELECT, WITH and VALUES statements are read-only" };
|
|
274
|
+
}
|
|
275
|
+
for (let i = 0; i < significant.length; i++) {
|
|
276
|
+
const t = significant[i];
|
|
277
|
+
if (isQcmdexcName(t)) {
|
|
278
|
+
return { readOnly: false, reason: "QCMDEXC runs CL commands" };
|
|
279
|
+
}
|
|
280
|
+
if (t.type === "word" && DATA_CHANGE_TABLE_KEYWORDS.has(t.value)) {
|
|
281
|
+
const next = significant[i + 1];
|
|
282
|
+
const open = significant[i + 2];
|
|
283
|
+
const stmt = significant[i + 3];
|
|
284
|
+
if (next?.type === "word" && next.value === "TABLE" &&
|
|
285
|
+
open?.type === "punct" && open.value === "(" &&
|
|
286
|
+
stmt?.type === "word" && DATA_CHANGE_STATEMENTS.has(stmt.value)) {
|
|
287
|
+
return { readOnly: false, reason: `${t.value} TABLE (${stmt.value} ...) changes data` };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { readOnly: true };
|
|
292
|
+
}
|
|
293
|
+
export function isReadOnlySql(sql) {
|
|
294
|
+
return checkReadOnlySql(sql).readOnly;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Rewrites SQL for QZDFMDB2: `@` lines become QCMDEXC calls and, when
|
|
298
|
+
* removeComments is set, `--` comments are removed. Literals, delimited
|
|
299
|
+
* identifiers and block comments are left untouched, so `--` inside a string
|
|
300
|
+
* is kept.
|
|
301
|
+
*/
|
|
302
|
+
export function rewriteSqlForQzdfmdb2(sql, removeComments = false) {
|
|
303
|
+
let out = "";
|
|
304
|
+
for (const seg of lexSqlSegments(sql)) {
|
|
305
|
+
switch (seg.kind) {
|
|
306
|
+
case "cl":
|
|
307
|
+
out += `Call QSYS2.QCMDEXC('${(seg.value ?? "").replaceAll("'", "''")}');`;
|
|
308
|
+
break;
|
|
309
|
+
case "line":
|
|
310
|
+
out += removeComments ? "" : `\n${seg.text}`;
|
|
311
|
+
break;
|
|
312
|
+
default:
|
|
313
|
+
out += seg.text;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { getConfigDir } from "../../config/store.js";
|
|
3
|
+
import { hostKeyFingerprint, isNewHostKeyAccepted, knownHostId, readKnownHosts, writeKnownHosts } from "../knownHosts.js";
|
|
4
|
+
/**
|
|
5
|
+
* Trust for TN5250 TLS server certificates.
|
|
6
|
+
*
|
|
7
|
+
* A certificate that chains to a trusted CA and matches the host name is
|
|
8
|
+
* accepted as is. Any other certificate (typically self-signed on IBM i) is
|
|
9
|
+
* trusted on first use: its SHA-256 fingerprint is pinned per host:port in
|
|
10
|
+
* known_tls_certs.json, and a different certificate later is refused unless
|
|
11
|
+
* explicitly accepted.
|
|
12
|
+
*/
|
|
13
|
+
export const ACCEPT_NEW_TLS_CERT_ENV = "MCP_FOR_I_ACCEPT_NEW_TLSCERT";
|
|
14
|
+
export class TlsCertificateMismatchError extends Error {
|
|
15
|
+
hostKey;
|
|
16
|
+
expected;
|
|
17
|
+
presented;
|
|
18
|
+
filePath;
|
|
19
|
+
constructor(hostKey, expected, presented, filePath) {
|
|
20
|
+
super(`TLS certificate for ${hostKey} has changed. Stored ${expected}, server presented ${presented}. ` +
|
|
21
|
+
`This can mean a man-in-the-middle attack, so the connection was refused. ` +
|
|
22
|
+
`If the change is expected (for example the certificate was renewed), ` +
|
|
23
|
+
`set ${ACCEPT_NEW_TLS_CERT_ENV}=${hostKey} (or =${presented}) and connect again, ` +
|
|
24
|
+
`or remove the "${hostKey}" entry from ${filePath}.`);
|
|
25
|
+
this.hostKey = hostKey;
|
|
26
|
+
this.expected = expected;
|
|
27
|
+
this.presented = presented;
|
|
28
|
+
this.filePath = filePath;
|
|
29
|
+
this.name = "TlsCertificateMismatchError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function getKnownTlsCertsPath() {
|
|
33
|
+
return path.join(getConfigDir(), "known_tls_certs.json");
|
|
34
|
+
}
|
|
35
|
+
let queue = Promise.resolve();
|
|
36
|
+
export function checkTlsCertificate(options) {
|
|
37
|
+
const fingerprint = hostKeyFingerprint(options.certificate);
|
|
38
|
+
if (options.authorized)
|
|
39
|
+
return Promise.resolve({ status: "ca-verified", fingerprint });
|
|
40
|
+
const run = async () => {
|
|
41
|
+
const filePath = options.filePath || getKnownTlsCertsPath();
|
|
42
|
+
const acceptNew = options.acceptNew ?? process.env[ACCEPT_NEW_TLS_CERT_ENV];
|
|
43
|
+
const id = knownHostId(options.host, options.port);
|
|
44
|
+
const data = await readKnownHosts(filePath);
|
|
45
|
+
const existing = data.hosts[id];
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
if (existing && existing.fingerprint === fingerprint)
|
|
48
|
+
return { status: "trusted", fingerprint };
|
|
49
|
+
if (existing && !isNewHostKeyAccepted(acceptNew, options.host, options.port, fingerprint)) {
|
|
50
|
+
throw new TlsCertificateMismatchError(id, existing.fingerprint, fingerprint, filePath);
|
|
51
|
+
}
|
|
52
|
+
data.hosts[id] = { fingerprint, keyType: "x509", addedAt: existing ? existing.addedAt : now, updatedAt: now };
|
|
53
|
+
await writeKnownHosts(filePath, data);
|
|
54
|
+
return { status: existing ? "replaced" : "added", fingerprint };
|
|
55
|
+
};
|
|
56
|
+
const result = queue.then(run, run);
|
|
57
|
+
queue = result.catch(() => undefined);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import net from "net";
|
|
3
|
+
import tls from "tls";
|
|
4
|
+
import { AID, FLAG_ATN, FLAG_SRQ, OP_NO_OP, buildGdsRecord, parseGdsRecord } from "./datastream.js";
|
|
5
|
+
import { Screen } from "./screen.js";
|
|
6
|
+
import { DO, DONT, ENV_SEND, OPT_BINARY, OPT_EOR, OPT_NEW_ENVIRON, OPT_TERMINAL_TYPE, TT_SEND, TelnetParser, WILL, WONT, envIs, frameRecord, parseEnvSend, telnetCommand, terminalTypeIs } from "./telnet.js";
|
|
7
|
+
const AID_ALIASES = {
|
|
8
|
+
ENTER: AID.ENTER,
|
|
9
|
+
RETURN: AID.ENTER,
|
|
10
|
+
HELP: AID.HELP,
|
|
11
|
+
PAGEUP: AID.ROLLDOWN,
|
|
12
|
+
ROLLDOWN: AID.ROLLDOWN,
|
|
13
|
+
PGUP: AID.ROLLDOWN,
|
|
14
|
+
PAGEDOWN: AID.ROLLUP,
|
|
15
|
+
ROLLUP: AID.ROLLUP,
|
|
16
|
+
PGDN: AID.ROLLUP,
|
|
17
|
+
PRINT: AID.PRINT,
|
|
18
|
+
CLEAR: AID.CLEAR,
|
|
19
|
+
RECORDBACKSPACE: AID.RECORD_BACKSPACE
|
|
20
|
+
};
|
|
21
|
+
/** AID byte for a key name, or undefined when it is not an AID key. */
|
|
22
|
+
export function aidForKey(name) {
|
|
23
|
+
const key = name.toUpperCase().replace(/[\s_-]/g, "");
|
|
24
|
+
if (key in AID_ALIASES)
|
|
25
|
+
return AID_ALIASES[key];
|
|
26
|
+
const f = /^F([1-9]|1[0-9]|2[0-4])$/.exec(key);
|
|
27
|
+
if (f)
|
|
28
|
+
return AID[`F${f[1]}`];
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const SUPPORTED_DO = new Set([OPT_BINARY, OPT_TERMINAL_TYPE, OPT_EOR, OPT_NEW_ENVIRON]);
|
|
32
|
+
const SUPPORTED_WILL = new Set([OPT_BINARY, OPT_EOR]);
|
|
33
|
+
export class Tn5250Client extends EventEmitter {
|
|
34
|
+
options;
|
|
35
|
+
screen;
|
|
36
|
+
closed = false;
|
|
37
|
+
closeReason;
|
|
38
|
+
secure = false;
|
|
39
|
+
certificateStatus;
|
|
40
|
+
autoSignOnOffered = false;
|
|
41
|
+
negotiated = { binary: false, eor: false, terminalType: false, newEnviron: false };
|
|
42
|
+
socket;
|
|
43
|
+
parser = new TelnetParser();
|
|
44
|
+
willSent = new Set();
|
|
45
|
+
doSent = new Set();
|
|
46
|
+
lastUpdateAt = 0;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
super();
|
|
49
|
+
this.options = options;
|
|
50
|
+
this.setMaxListeners(50);
|
|
51
|
+
const terminalType = options.terminalType || "IBM-3179-2";
|
|
52
|
+
this.screen = new Screen({ ccsid: options.ccsid, deviceType: terminalType === "IBM-3477-FC" ? "3477" : "3179" });
|
|
53
|
+
}
|
|
54
|
+
get terminalType() {
|
|
55
|
+
return this.options.terminalType || "IBM-3179-2";
|
|
56
|
+
}
|
|
57
|
+
/** Open the connection. Resolves once TCP (and TLS, if used) is established and trusted. */
|
|
58
|
+
connect() {
|
|
59
|
+
const { host, port } = this.options;
|
|
60
|
+
const timeoutMs = this.options.connectTimeoutMs ?? 15000;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const fail = (err) => {
|
|
64
|
+
if (settled)
|
|
65
|
+
return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
this.markClosed(err.message);
|
|
69
|
+
socket.destroy();
|
|
70
|
+
reject(err);
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(() => fail(new Error(`TN5250 connection to ${host}:${port} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
73
|
+
const ready = () => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve();
|
|
79
|
+
};
|
|
80
|
+
let socket;
|
|
81
|
+
if (this.options.tls) {
|
|
82
|
+
const tlsSocket = tls.connect({
|
|
83
|
+
host,
|
|
84
|
+
port,
|
|
85
|
+
servername: net.isIP(host) ? undefined : host,
|
|
86
|
+
// Verification is done below so self-signed IBM i certificates can be pinned.
|
|
87
|
+
rejectUnauthorized: false
|
|
88
|
+
});
|
|
89
|
+
socket = tlsSocket;
|
|
90
|
+
tlsSocket.once("secureConnect", async () => {
|
|
91
|
+
try {
|
|
92
|
+
const cert = tlsSocket.getPeerCertificate(false);
|
|
93
|
+
if (!cert || !cert.raw)
|
|
94
|
+
throw new Error("TLS server did not present a certificate");
|
|
95
|
+
this.certificateStatus = await (this.options.checkCertificate
|
|
96
|
+
? this.options.checkCertificate({
|
|
97
|
+
host,
|
|
98
|
+
port,
|
|
99
|
+
certificate: cert.raw,
|
|
100
|
+
authorized: tlsSocket.authorized,
|
|
101
|
+
authorizationError: tlsSocket.authorizationError ? String(tlsSocket.authorizationError) : undefined
|
|
102
|
+
})
|
|
103
|
+
: Promise.reject(new Error("No TLS certificate check configured")));
|
|
104
|
+
this.secure = true;
|
|
105
|
+
this.attach(socket);
|
|
106
|
+
ready();
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
fail(err instanceof Error ? err : new Error(String(err)));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
socket = net.connect({ host, port });
|
|
115
|
+
socket.once("connect", () => {
|
|
116
|
+
this.attach(socket);
|
|
117
|
+
ready();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
socket.on("error", err => {
|
|
121
|
+
if (!settled)
|
|
122
|
+
fail(err);
|
|
123
|
+
else
|
|
124
|
+
this.markClosed(err.message);
|
|
125
|
+
});
|
|
126
|
+
socket.on("close", () => this.markClosed(this.closeReason || "connection closed by host"));
|
|
127
|
+
this.socket = socket;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
attach(socket) {
|
|
131
|
+
socket.setNoDelay(true);
|
|
132
|
+
socket.on("data", (chunk) => this.onData(typeof chunk === "string" ? Buffer.from(chunk, "latin1") : chunk));
|
|
133
|
+
}
|
|
134
|
+
markClosed(reason) {
|
|
135
|
+
if (this.closed)
|
|
136
|
+
return;
|
|
137
|
+
this.closed = true;
|
|
138
|
+
this.closeReason = reason;
|
|
139
|
+
this.emit("update");
|
|
140
|
+
this.emit("close", reason);
|
|
141
|
+
}
|
|
142
|
+
close() {
|
|
143
|
+
this.markClosed("disconnected");
|
|
144
|
+
this.socket?.destroy();
|
|
145
|
+
}
|
|
146
|
+
write(bytes) {
|
|
147
|
+
if (this.closed || !this.socket)
|
|
148
|
+
throw new Error(`TN5250 session is closed (${this.closeReason || "not connected"})`);
|
|
149
|
+
this.socket.write(bytes);
|
|
150
|
+
}
|
|
151
|
+
/** Feed raw bytes (used by the socket handler and by tests). */
|
|
152
|
+
onData(chunk) {
|
|
153
|
+
let events;
|
|
154
|
+
try {
|
|
155
|
+
events = this.parser.push(chunk);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.screen.warnings.push(`telnet parse error: ${err?.message || err}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const event of events) {
|
|
162
|
+
if (event.type === "command")
|
|
163
|
+
this.onCommand(event.command, event.option);
|
|
164
|
+
else if (event.type === "subnegotiation")
|
|
165
|
+
this.onSubnegotiation(event.option, event.data);
|
|
166
|
+
else
|
|
167
|
+
this.onRecord(event.data);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
onCommand(command, option) {
|
|
171
|
+
if (command === DO) {
|
|
172
|
+
if (SUPPORTED_DO.has(option)) {
|
|
173
|
+
if (!this.willSent.has(option)) {
|
|
174
|
+
this.willSent.add(option);
|
|
175
|
+
this.write(telnetCommand(WILL, option));
|
|
176
|
+
}
|
|
177
|
+
if (option === OPT_BINARY)
|
|
178
|
+
this.negotiated.binary = true;
|
|
179
|
+
if (option === OPT_EOR)
|
|
180
|
+
this.negotiated.eor = true;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
this.write(telnetCommand(WONT, option));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else if (command === WILL) {
|
|
187
|
+
if (SUPPORTED_WILL.has(option)) {
|
|
188
|
+
if (!this.doSent.has(option)) {
|
|
189
|
+
this.doSent.add(option);
|
|
190
|
+
this.write(telnetCommand(DO, option));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
this.write(telnetCommand(DONT, option));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (command === DONT) {
|
|
198
|
+
if (this.willSent.delete(option))
|
|
199
|
+
this.write(telnetCommand(WONT, option));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
onSubnegotiation(option, data) {
|
|
203
|
+
if (option === OPT_TERMINAL_TYPE && data[0] === TT_SEND) {
|
|
204
|
+
this.negotiated.terminalType = true;
|
|
205
|
+
this.write(terminalTypeIs(this.terminalType));
|
|
206
|
+
}
|
|
207
|
+
else if (option === OPT_NEW_ENVIRON && data[0] === ENV_SEND) {
|
|
208
|
+
this.negotiated.newEnviron = true;
|
|
209
|
+
parseEnvSend(data.subarray(1));
|
|
210
|
+
this.write(envIs(this.environment()));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** NEW-ENVIRON values sent to the server. */
|
|
214
|
+
environment() {
|
|
215
|
+
const ascii = (text) => Buffer.from(text, "ascii");
|
|
216
|
+
const values = [];
|
|
217
|
+
if (this.options.deviceName)
|
|
218
|
+
values.push({ kind: "uservar", name: "DEVNAME", value: ascii(this.options.deviceName) });
|
|
219
|
+
values.push({ kind: "uservar", name: "KBDTYPE", value: ascii("USB") });
|
|
220
|
+
values.push({ kind: "uservar", name: "CODEPAGE", value: ascii("37") });
|
|
221
|
+
values.push({ kind: "uservar", name: "CHARSET", value: ascii("697") });
|
|
222
|
+
const signOn = this.options.autoSignOn;
|
|
223
|
+
if (signOn) {
|
|
224
|
+
// RFC 4777: an empty IBMRSEED means IBMSUBSPW carries the password unencrypted.
|
|
225
|
+
values.push({ kind: "var", name: "USER", value: ascii(signOn.user) });
|
|
226
|
+
values.push({ kind: "uservar", name: "IBMRSEED", value: Buffer.alloc(0) });
|
|
227
|
+
values.push({ kind: "uservar", name: "IBMSUBSPW", value: ascii(signOn.password) });
|
|
228
|
+
this.autoSignOnOffered = true;
|
|
229
|
+
}
|
|
230
|
+
return values;
|
|
231
|
+
}
|
|
232
|
+
onRecord(record) {
|
|
233
|
+
try {
|
|
234
|
+
const responses = this.screen.process(parseGdsRecord(record));
|
|
235
|
+
for (const response of responses)
|
|
236
|
+
this.write(frameRecord(response));
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
this.screen.warnings.push(err?.message || String(err));
|
|
240
|
+
}
|
|
241
|
+
this.lastUpdateAt = Date.now();
|
|
242
|
+
this.emit("update");
|
|
243
|
+
}
|
|
244
|
+
/** Send an AID key (Enter, F1-F24, PageUp, ...) or Attn / SysReq. */
|
|
245
|
+
sendKey(name) {
|
|
246
|
+
const key = name.toUpperCase().replace(/[\s_-]/g, "");
|
|
247
|
+
if (key === "ATTN") {
|
|
248
|
+
this.write(frameRecord(buildGdsRecord(OP_NO_OP, new Uint8Array(0), FLAG_ATN)));
|
|
249
|
+
this.screen.keyboardLocked = true;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (key === "SYSREQ") {
|
|
253
|
+
this.write(frameRecord(buildGdsRecord(OP_NO_OP, new Uint8Array(0), FLAG_SRQ)));
|
|
254
|
+
this.screen.keyboardLocked = true;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const aid = aidForKey(key);
|
|
258
|
+
if (aid === undefined)
|
|
259
|
+
throw new Error(`Unknown AID key: ${name}`);
|
|
260
|
+
this.lastUpdateAt = Date.now();
|
|
261
|
+
this.write(frameRecord(this.screen.buildAidResponse(aid)));
|
|
262
|
+
}
|
|
263
|
+
/** True once the host has handed control back to the operator. */
|
|
264
|
+
isReady() {
|
|
265
|
+
return !this.screen.keyboardLocked || this.screen.inputInhibited;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Wait until the predicate holds. Re-checks on every screen update.
|
|
269
|
+
* Resolves false on timeout or when the connection closes first.
|
|
270
|
+
*/
|
|
271
|
+
waitFor(predicate, timeoutMs) {
|
|
272
|
+
if (predicate())
|
|
273
|
+
return Promise.resolve(true);
|
|
274
|
+
if (this.closed)
|
|
275
|
+
return Promise.resolve(false);
|
|
276
|
+
return new Promise(resolve => {
|
|
277
|
+
const done = (value) => {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
this.off("update", check);
|
|
280
|
+
resolve(value);
|
|
281
|
+
};
|
|
282
|
+
const check = () => {
|
|
283
|
+
if (predicate())
|
|
284
|
+
done(true);
|
|
285
|
+
else if (this.closed)
|
|
286
|
+
done(false);
|
|
287
|
+
};
|
|
288
|
+
const timer = setTimeout(() => done(predicate()), timeoutMs);
|
|
289
|
+
this.on("update", check);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
/** Wait for the keyboard to unlock, then for a short quiet period with no more records. */
|
|
293
|
+
async waitForSettled(timeoutMs, quietMs = 150) {
|
|
294
|
+
const deadline = Date.now() + timeoutMs;
|
|
295
|
+
for (;;) {
|
|
296
|
+
const remaining = deadline - Date.now();
|
|
297
|
+
if (remaining <= 0)
|
|
298
|
+
return this.isReady();
|
|
299
|
+
const ready = await this.waitFor(() => this.isReady(), remaining);
|
|
300
|
+
if (!ready)
|
|
301
|
+
return false;
|
|
302
|
+
const sinceUpdate = Date.now() - this.lastUpdateAt;
|
|
303
|
+
if (sinceUpdate >= quietMs)
|
|
304
|
+
return true;
|
|
305
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(quietMs - sinceUpdate, Math.max(0, deadline - Date.now()))));
|
|
306
|
+
if (Date.now() - this.lastUpdateAt >= quietMs && this.isReady())
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|