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.
Files changed (67) hide show
  1. package/README.md +17 -0
  2. package/dist/config/store.js +229 -109
  3. package/dist/controlplane/auth.js +128 -0
  4. package/dist/controlplane/clientIntegrationService.js +83 -4
  5. package/dist/controlplane/commands.js +14 -9
  6. package/dist/controlplane/connectionService.js +110 -5
  7. package/dist/controlplane/connectionTest.js +329 -0
  8. package/dist/controlplane/errors.js +12 -0
  9. package/dist/controlplane/runtimeService.js +98 -26
  10. package/dist/controlplane/server.js +122 -11
  11. package/dist/controlplane/serverShared.js +25 -6
  12. package/dist/controlplane/serviceManager.js +113 -100
  13. package/dist/controlplane/ui.js +1742 -920
  14. package/dist/ibmi/CompileTools.js +29 -1
  15. package/dist/ibmi/IBMiContent.js +435 -63
  16. package/dist/ibmi/IbmDocs.js +685 -0
  17. package/dist/ibmi/LocalLanguageActions.js +129 -3
  18. package/dist/ibmi/Search.js +158 -52
  19. package/dist/ibmi/Tools.js +7 -10
  20. package/dist/ibmi/client.js +220 -31
  21. package/dist/ibmi/evfevent.js +256 -0
  22. package/dist/ibmi/knownHosts.js +190 -0
  23. package/dist/ibmi/libraryList.js +93 -0
  24. package/dist/ibmi/sourceInspect.js +815 -0
  25. package/dist/ibmi/sqlJob.js +888 -0
  26. package/dist/ibmi/sqlParse.js +317 -0
  27. package/dist/ibmi/tn5250/certTrust.js +59 -0
  28. package/dist/ibmi/tn5250/client.js +310 -0
  29. package/dist/ibmi/tn5250/datastream.js +109 -0
  30. package/dist/ibmi/tn5250/ebcdic.js +74 -0
  31. package/dist/ibmi/tn5250/index.js +6 -0
  32. package/dist/ibmi/tn5250/screen.js +827 -0
  33. package/dist/ibmi/tn5250/telnet.js +186 -0
  34. package/dist/ibmi/variables.js +18 -7
  35. package/dist/index.js +43 -22
  36. package/dist/mcp/annotations.js +155 -0
  37. package/dist/mcp/audit.js +490 -133
  38. package/dist/mcp/confirm.js +48 -0
  39. package/dist/mcp/context.js +43 -5
  40. package/dist/mcp/deployMethods.js +192 -0
  41. package/dist/mcp/logger.js +5 -10
  42. package/dist/mcp/modules/bulkMembers.js +476 -0
  43. package/dist/mcp/modules/changedLines.js +189 -0
  44. package/dist/mcp/modules/compare.js +198 -0
  45. package/dist/mcp/modules/compile.js +515 -0
  46. package/dist/mcp/modules/ifsOps.js +123 -0
  47. package/dist/mcp/modules/index.js +38 -0
  48. package/dist/mcp/modules/inspect.js +811 -0
  49. package/dist/mcp/modules/libl.js +246 -0
  50. package/dist/mcp/modules/members.js +171 -0
  51. package/dist/mcp/modules/objects.js +339 -0
  52. package/dist/mcp/modules/system.js +266 -0
  53. package/dist/mcp/modules/tn5250.js +515 -0
  54. package/dist/mcp/policy.js +412 -0
  55. package/dist/mcp/tools.js +1700 -698
  56. package/dist/security/credentialStore.js +62 -13
  57. package/dist/security/localPaths.js +138 -0
  58. package/dist/security/redact.js +79 -0
  59. package/dist/util/atomicWrite.js +101 -0
  60. package/dist/util/fileLock.js +206 -0
  61. package/docs/capabilities.md +50 -7
  62. package/docs/configuration.md +189 -60
  63. package/docs/examples.md +109 -73
  64. package/docs/overview.md +15 -15
  65. package/docs/security.md +101 -16
  66. package/docs/tools.md +363 -188
  67. package/package.json +56 -53
@@ -1,4 +1,5 @@
1
- import fs from "fs/promises";
1
+ import fs from "fs";
2
+ import fsp from "fs/promises";
2
3
  import os from "os";
3
4
  import path from "path";
4
5
  import { spawn } from "child_process";
@@ -229,7 +230,7 @@ export class ClientIntegrationService {
229
230
  async isCodeConfigured() {
230
231
  const userConfigPath = getVsCodeUserMcpPath();
231
232
  try {
232
- const raw = await fs.readFile(userConfigPath, "utf8");
233
+ const raw = await fsp.readFile(userConfigPath, "utf8");
233
234
  const parsed = JSON.parse(raw);
234
235
  if (Array.isArray(parsed?.servers)) {
235
236
  return parsed.servers.some((entry) => entry?.name === SERVER_NAME);
@@ -263,10 +264,88 @@ async function commandExists(command) {
263
264
  const result = await runCommand(command, ["--help"]);
264
265
  return result.ok;
265
266
  }
266
- async function runCommand(command, args) {
267
+ /**
268
+ * Finds a command on PATH the way cmd.exe does (PATHEXT order). Returns the
269
+ * full path, or undefined when nothing matches.
270
+ */
271
+ export function resolveWindowsCommand(command, env = process.env) {
272
+ const pathExt = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
273
+ .split(";")
274
+ .map(ext => ext.trim().toLowerCase())
275
+ .filter(Boolean);
276
+ const hasExt = pathExt.includes(path.extname(command).toLowerCase());
277
+ const candidates = hasExt ? [command] : pathExt.map(ext => command + ext);
278
+ const isPath = command.includes("/") || command.includes("\\");
279
+ const dirs = isPath
280
+ ? [""]
281
+ : String(env.PATH || env.Path || "").split(";").map(dir => dir.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
282
+ for (const dir of dirs) {
283
+ for (const candidate of candidates) {
284
+ const full = dir ? path.join(dir, candidate) : candidate;
285
+ try {
286
+ if (fs.statSync(full).isFile())
287
+ return full;
288
+ }
289
+ catch {
290
+ // not here
291
+ }
292
+ }
293
+ }
294
+ return undefined;
295
+ }
296
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
297
+ /** Quotes one argument for cmd.exe, following the MSVCRT argv rules. */
298
+ export function escapeCmdArgument(value, doubleEscapeMeta) {
299
+ let arg = String(value);
300
+ // Double the backslashes that precede a quote, then escape the quote.
301
+ arg = arg.replace(/(\\*)"/g, '$1$1\\"');
302
+ // Double trailing backslashes so they do not escape the closing quote.
303
+ arg = arg.replace(/(\\*)$/, "$1$1");
304
+ arg = `"${arg}"`;
305
+ arg = arg.replace(CMD_META, "^$1");
306
+ // .cmd shims re-parse their arguments (%*), so they need a second pass.
307
+ if (doubleEscapeMeta)
308
+ arg = arg.replace(CMD_META, "^$1");
309
+ return arg;
310
+ }
311
+ /**
312
+ * Builds how to start a command. On Windows, npm-installed CLIs (codex,
313
+ * claude, code) are .cmd shims that only start through cmd.exe, so they are
314
+ * resolved via PATHEXT and launched with every argument escaped. The
315
+ * arguments are fixed by this module, never taken from a request.
316
+ */
317
+ export function buildSpawnSpec(command, args, platform = process.platform, env = process.env) {
318
+ if (platform !== "win32") {
319
+ return { file: command, args, windowsVerbatimArguments: false };
320
+ }
321
+ const resolved = resolveWindowsCommand(command, env);
322
+ if (!resolved)
323
+ return undefined;
324
+ if (!/\.(cmd|bat)$/i.test(resolved)) {
325
+ return { file: resolved, args, windowsVerbatimArguments: false };
326
+ }
327
+ const commandLine = [
328
+ resolved.replace(CMD_META, "^$1"),
329
+ ...args.map(arg => escapeCmdArgument(arg, true))
330
+ ].join(" ");
331
+ return {
332
+ file: env.ComSpec || env.COMSPEC || "cmd.exe",
333
+ args: ["/d", "/s", "/c", `"${commandLine}"`],
334
+ windowsVerbatimArguments: true
335
+ };
336
+ }
337
+ export async function runCommand(command, args, env = process.env) {
267
338
  return new Promise(resolve => {
268
- const child = spawn(command, args, {
339
+ const spec = buildSpawnSpec(command, args, process.platform, env);
340
+ if (!spec) {
341
+ resolve({ ok: false, stdout: "", stderr: `${command} was not found on PATH` });
342
+ return;
343
+ }
344
+ const child = spawn(spec.file, spec.args, {
345
+ env,
269
346
  windowsHide: true,
347
+ windowsVerbatimArguments: spec.windowsVerbatimArguments,
348
+ timeout: 120000,
270
349
  stdio: ["ignore", "pipe", "pipe"]
271
350
  });
272
351
  const stdout = [];
@@ -29,25 +29,30 @@ export async function runControlCommand(args, options) {
29
29
  return;
30
30
  }
31
31
  }
32
+ /**
33
+ * Runs the control plane in the foreground. Resolves only after the server has
34
+ * stopped listening, so a caller that exits when this returns keeps it alive.
35
+ */
32
36
  async function serve(stderr) {
37
+ let server;
33
38
  try {
34
- const server = await startControlPlaneServer();
35
- stderr.write(`mcp-for-i control plane running at http://${server.host}:${server.port}\n`);
36
- const shutdown = async () => {
37
- await server.close();
38
- process.exit(0);
39
- };
40
- process.on("SIGINT", shutdown);
41
- process.on("SIGTERM", shutdown);
39
+ server = await startControlPlaneServer();
42
40
  }
43
41
  catch (err) {
44
42
  if (err?.code === "EADDRINUSE") {
45
43
  const url = getControlPlaneUrl();
46
44
  stderr.write(`mcp-for-i control plane already running at ${url}\n`);
47
- process.exit(0);
45
+ return;
48
46
  }
49
47
  throw err;
50
48
  }
49
+ stderr.write(`mcp-for-i control plane running at ${getControlPlaneUrl()}\n`);
50
+ const shutdown = () => {
51
+ server.close().catch(() => undefined);
52
+ };
53
+ process.once("SIGINT", shutdown);
54
+ process.once("SIGTERM", shutdown);
55
+ await server.closed;
51
56
  }
52
57
  async function printStatus(stderr, scriptPath, nodePath) {
53
58
  const url = getControlPlaneUrl();
@@ -1,16 +1,96 @@
1
1
  import { deletePassword, getPassword, setPassword } from "../security/credentialStore.js";
2
+ import { ControlPlaneError } from "./errors.js";
3
+ import { testConnection } from "./connectionTest.js";
4
+ /** True when host, port or username differ (host compared case-insensitively). */
5
+ export function connectionEndpointChanged(before, after) {
6
+ const host = (value) => String(value ?? "").trim().toLowerCase();
7
+ const port = (value) => Number(value || 22);
8
+ const user = (value) => String(value ?? "").trim().toUpperCase();
9
+ return host(before.host) !== host(after.host)
10
+ || port(before.port) !== port(after.port)
11
+ || user(before.username) !== user(after.username);
12
+ }
2
13
  export class ConnectionService {
3
14
  store;
4
- constructor(store) {
15
+ runTest;
16
+ /** Last test result per saved connection, kept in memory only. */
17
+ lastTests = new Map();
18
+ /** Endpoints with a test in progress, so a double click signs on once. */
19
+ running = new Set();
20
+ constructor(store, runTest = testConnection) {
5
21
  this.store = store;
22
+ this.runTest = runTest;
6
23
  }
7
24
  async list() {
8
- return this.store.listConnections();
25
+ const list = await this.store.listConnections();
26
+ return list.map(conn => {
27
+ const lastTest = this.lastTests.get(conn.name);
28
+ return lastTest ? { ...conn, lastTest } : conn;
29
+ });
30
+ }
31
+ /** Tests a saved connection with its stored details and password. */
32
+ async testSaved(name) {
33
+ const stored = await this.store.getConnection(name);
34
+ if (!stored)
35
+ throw new ControlPlaneError(404, `Connection ${name} not found`);
36
+ const password = await getPassword(name).catch(() => undefined);
37
+ const result = await this.guardedTest({
38
+ host: stored.host,
39
+ port: stored.port,
40
+ username: stored.username,
41
+ password: password || undefined,
42
+ privateKeyPath: stored.privateKeyPath
43
+ });
44
+ this.lastTests.set(name, result);
45
+ return result;
46
+ }
47
+ /**
48
+ * Tests details that may not be saved yet (the add and edit form). A saved
49
+ * password is only used for the same host, port and user it was saved for.
50
+ */
51
+ async testDetails(input) {
52
+ const host = typeof input?.host === "string" ? input.host.trim() : "";
53
+ const username = typeof input?.username === "string" ? input.username.trim() : "";
54
+ if (!host || !username)
55
+ throw new ControlPlaneError(400, "host and username are required");
56
+ const port = Number(input.port || 22);
57
+ let password = typeof input.password === "string" && input.password ? input.password : undefined;
58
+ const name = typeof input.name === "string" ? input.name.trim() : "";
59
+ const stored = name ? await this.store.getConnection(name) : undefined;
60
+ if (!password && stored && !connectionEndpointChanged(stored, { host, port, username })) {
61
+ password = (await getPassword(name).catch(() => undefined)) || undefined;
62
+ }
63
+ const privateKeyPath = typeof input.privateKeyPath === "string" && input.privateKeyPath.trim()
64
+ ? input.privateKeyPath.trim()
65
+ : undefined;
66
+ return this.guardedTest({ host, port, username, password, privateKeyPath });
67
+ }
68
+ async guardedTest(target) {
69
+ const key = [target.host.trim().toLowerCase(), Number(target.port || 22), target.username.trim().toUpperCase()].join("|");
70
+ if (this.running.has(key)) {
71
+ throw new ControlPlaneError(409, "A test of this connection is already running");
72
+ }
73
+ this.running.add(key);
74
+ try {
75
+ return await this.runTest(target);
76
+ }
77
+ finally {
78
+ this.running.delete(key);
79
+ }
9
80
  }
10
81
  async get(name) {
11
82
  return this.store.getConnection(name);
12
83
  }
13
84
  async add(input) {
85
+ const name = typeof input?.name === "string" ? input.name.trim() : "";
86
+ if (!name || !input.host || !input.username) {
87
+ throw new ControlPlaneError(400, "name, host and username are required");
88
+ }
89
+ // Adding must never overwrite an existing entry: that would let a caller
90
+ // repoint a connection without the checks in update().
91
+ if (await this.store.getConnection(input.name)) {
92
+ throw new ControlPlaneError(409, `Connection ${input.name} already exists`);
93
+ }
14
94
  await this.store.upsertConnection({
15
95
  name: input.name,
16
96
  host: input.host,
@@ -28,7 +108,7 @@ export class ConnectionService {
28
108
  async update(name, input) {
29
109
  const existing = await this.store.getConnection(name);
30
110
  if (!existing)
31
- throw new Error(`Connection ${name} not found`);
111
+ throw new ControlPlaneError(404, `Connection ${name} not found`);
32
112
  const next = {
33
113
  ...existing,
34
114
  host: input.host ?? existing.host,
@@ -38,9 +118,28 @@ export class ConnectionService {
38
118
  settings: input.settings ? { ...(existing.settings || {}), ...input.settings } : existing.settings,
39
119
  policy: input.policy ?? existing.policy
40
120
  };
121
+ // A saved password must never follow the connection to a different
122
+ // endpoint or user. Changing host, port or username therefore needs the
123
+ // password in the same request.
124
+ const endpointChanged = connectionEndpointChanged(existing, next);
125
+ const newPassword = typeof input.password === "string" && input.password ? input.password : undefined;
126
+ if (endpointChanged && !newPassword && await getPassword(name)) {
127
+ throw new ControlPlaneError(400, "Changing host, port or username requires entering the password again in the same request");
128
+ }
41
129
  await this.store.upsertConnection(next);
42
- if (input.storePassword && input.password) {
43
- await setPassword(name, input.password);
130
+ // An earlier result says nothing about new details or a new password.
131
+ if (endpointChanged || newPassword || next.privateKeyPath !== existing.privateKeyPath) {
132
+ this.lastTests.delete(name);
133
+ }
134
+ if (newPassword && (input.storePassword || endpointChanged)) {
135
+ if (input.storePassword === false) {
136
+ // The caller does not want it stored; drop the old one so it is not
137
+ // sent to the new endpoint.
138
+ await deletePassword(name);
139
+ }
140
+ else {
141
+ await setPassword(name, newPassword);
142
+ }
44
143
  }
45
144
  return this.get(name);
46
145
  }
@@ -64,16 +163,22 @@ export class ConnectionService {
64
163
  await setPassword(newName, password);
65
164
  }
66
165
  await deletePassword(oldName);
166
+ const lastTest = this.lastTests.get(oldName);
167
+ this.lastTests.delete(oldName);
168
+ if (lastTest)
169
+ this.lastTests.set(newName, lastTest);
67
170
  return this.get(newName);
68
171
  }
69
172
  async delete(name) {
70
173
  await this.store.deleteConnection(name);
71
174
  await deletePassword(name);
175
+ this.lastTests.delete(name);
72
176
  }
73
177
  async setConnectionPassword(name, password) {
74
178
  const existing = await this.store.getConnection(name);
75
179
  if (!existing)
76
180
  throw new Error(`Connection ${name} not found`);
77
181
  await setPassword(name, password);
182
+ this.lastTests.delete(name);
78
183
  }
79
184
  }
@@ -0,0 +1,329 @@
1
+ import crypto from "crypto";
2
+ import dns from "dns/promises";
3
+ import fs from "fs/promises";
4
+ import net from "net";
5
+ import { createRequire } from "module";
6
+ import { Tools } from "../ibmi/Tools.js";
7
+ import { ACCEPT_NEW_HOSTKEY_ENV, getKnownHostsPath, hostKeyFingerprint, isNewHostKeyAccepted, knownHostId, readKnownHosts } from "../ibmi/knownHosts.js";
8
+ const DNS_TIMEOUT_MS = 5000;
9
+ const PORT_TIMEOUT_MS = 5000;
10
+ const SSH_TIMEOUT_MS = 20000;
11
+ const PROFILE_TIMEOUT_MS = 20000;
12
+ const REJECTED_TTL_MS = 15 * 60 * 1000;
13
+ const PASSWORD_WARNING_DAYS = 14;
14
+ /** Passwords the IBM i rejected recently, by hash of host, port, user and password. */
15
+ const rejected = new Map();
16
+ function rejectionKey(target, port) {
17
+ return crypto.createHash("sha256")
18
+ .update([target.host.trim().toLowerCase(), port, target.username.trim().toUpperCase(), target.password || ""].join("\0"))
19
+ .digest("hex");
20
+ }
21
+ function wasRejected(key, now) {
22
+ const at = rejected.get(key);
23
+ if (at === undefined)
24
+ return false;
25
+ if (now - at > REJECTED_TTL_MS) {
26
+ rejected.delete(key);
27
+ return false;
28
+ }
29
+ return true;
30
+ }
31
+ /** Forgets every remembered rejection (for tests). */
32
+ export function clearRejectedPasswords() {
33
+ rejected.clear();
34
+ }
35
+ export async function testConnection(target, deps = {}) {
36
+ const d = { ...defaultDeps, ...deps };
37
+ const started = d.now();
38
+ const steps = [];
39
+ const warnings = [];
40
+ let hostKey;
41
+ let profile;
42
+ const finish = (code, message) => ({
43
+ ok: code === "ok",
44
+ code,
45
+ message,
46
+ steps,
47
+ warnings,
48
+ ...(hostKey ? { hostKey } : {}),
49
+ ...(profile ? { profile } : {}),
50
+ testedAt: new Date(d.now()).toISOString(),
51
+ durationMs: Math.max(0, d.now() - started)
52
+ });
53
+ const host = String(target?.host ?? "").trim();
54
+ const username = String(target?.username ?? "").trim();
55
+ const port = Number(target?.port || 22);
56
+ if (!host || !username)
57
+ return finish("invalid-input", "Enter a host and a username to test.");
58
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
59
+ return finish("invalid-input", "The SSH port must be between 1 and 65535.");
60
+ // 1. Name lookup
61
+ let address;
62
+ try {
63
+ address = await withTimeout(d.lookup(host), DNS_TIMEOUT_MS, "lookup");
64
+ steps.push({ name: "Host lookup", ok: true, detail: address === host ? host : `${host} is ${address}` });
65
+ }
66
+ catch (err) {
67
+ steps.push({ name: "Host lookup", ok: false, detail: err?.code || err?.message || String(err) });
68
+ return finish("host-not-found", `Host ${host} was not found. Check the spelling, or that this computer can resolve it (VPN or DNS).`);
69
+ }
70
+ // 2. SSH port
71
+ try {
72
+ const banner = await d.probePort(address, port, PORT_TIMEOUT_MS);
73
+ if (banner && !banner.startsWith("SSH-")) {
74
+ steps.push({ name: "SSH port", ok: false, detail: `Port ${port} answered, but not with SSH` });
75
+ return finish("not-ssh", `Port ${port} on ${host} is open but is not an SSH server. Check the port number.`);
76
+ }
77
+ steps.push({ name: "SSH port", ok: true, detail: banner ? banner.trim() : `Port ${port} accepted the connection` });
78
+ }
79
+ catch (err) {
80
+ const code = err?.code;
81
+ steps.push({ name: "SSH port", ok: false, detail: code || err?.message || String(err) });
82
+ if (code === "ECONNREFUSED") {
83
+ return finish("port-closed", `${host} refused a connection on port ${port}. SSH may not be running there (STRTCPSVR SERVER(*SSHD)), or the port is wrong.`);
84
+ }
85
+ if (code === "ETIMEDOUT" || code === "TIMEOUT") {
86
+ return finish("port-timeout", `No answer from ${host} on port ${port}. The system may be down, or a firewall or VPN is blocking the port.`);
87
+ }
88
+ if (code === "EHOSTUNREACH" || code === "ENETUNREACH") {
89
+ return finish("host-unreachable", `${host} cannot be reached from this computer. Check the network or VPN.`);
90
+ }
91
+ return finish("error", `Could not open port ${port} on ${host}: ${err?.message || err}`);
92
+ }
93
+ // 3. Credentials available?
94
+ let privateKey;
95
+ if (target.privateKeyPath) {
96
+ try {
97
+ privateKey = await d.readKey(target.privateKeyPath);
98
+ }
99
+ catch (err) {
100
+ steps.push({ name: "Private key", ok: false, detail: err?.code || err?.message || String(err) });
101
+ return finish("key-unreadable", `The private key ${target.privateKeyPath} could not be read on this computer.`);
102
+ }
103
+ }
104
+ if (!target.password && !privateKey) {
105
+ return finish("no-credentials", "The host and SSH port work. Enter a password or a private key path to test the sign-on.");
106
+ }
107
+ const key = rejectionKey(target, port);
108
+ if (target.password && !privateKey && wasRejected(key, d.now())) {
109
+ return finish("password-already-rejected", `${host} already rejected this password for ${username.toUpperCase()}. It was not sent again, because every failed sign-on counts toward disabling the profile. Enter a different password.`);
110
+ }
111
+ // 4. Host key and sign-on
112
+ const outcome = await d.ssh({
113
+ host,
114
+ port,
115
+ username,
116
+ password: target.password,
117
+ privateKey,
118
+ timeoutMs: SSH_TIMEOUT_MS,
119
+ checkHostKey: async (raw) => {
120
+ const check = await d.checkHostKey(host, port, raw);
121
+ hostKey = check;
122
+ return check;
123
+ }
124
+ });
125
+ if (hostKey && hostKey.status !== "changed") {
126
+ steps.push({
127
+ name: "Host key",
128
+ ok: true,
129
+ detail: hostKey.status === "trusted" ? "Matches the saved key" : `New key ${hostKey.fingerprint}, saved on the first connection`
130
+ });
131
+ }
132
+ if (outcome.kind === "host-key-changed") {
133
+ hostKey = outcome.hostKey;
134
+ steps.push({ name: "Host key", ok: false, detail: `Saved ${outcome.hostKey.stored}, server sent ${outcome.hostKey.fingerprint}` });
135
+ return finish("host-key-changed", `The SSH host key of ${host} has changed, so the password was not sent. If the system was rebuilt or its keys were regenerated, remove its entry from ${getKnownHostsPath()} or set ${ACCEPT_NEW_HOSTKEY_ENV}=${knownHostId(host, port)}.`);
136
+ }
137
+ if (outcome.kind === "password-expired") {
138
+ steps.push({ name: "Sign-on", ok: false, detail: outcome.prompt || "Password change required" });
139
+ return finish("password-expired", `The password for ${username.toUpperCase()} has expired. Change it on the IBM i (for example with a 5250 sign-on), then save the new one here.`);
140
+ }
141
+ if (outcome.kind === "error") {
142
+ const err = outcome.error;
143
+ const level = err?.level;
144
+ const text = String(err?.message || err);
145
+ steps.push({ name: "Sign-on", ok: false, detail: text });
146
+ if (level === "client-authentication" || /authentication methods failed/i.test(text)) {
147
+ if (target.password && !privateKey)
148
+ rejected.set(key, d.now());
149
+ return finish("auth-failed", privateKey && !target.password
150
+ ? `${host} did not accept the private key for ${username.toUpperCase()}. Check that its public key is in the user's ~/.ssh/authorized_keys on the IBM i.`
151
+ : `${host} rejected the username or password for ${username.toUpperCase()}. A disabled profile or an expired password gets the same answer over SSH, so check those too if the password is right.`);
152
+ }
153
+ if (level === "client-timeout" || /timed out/i.test(text)) {
154
+ return finish("timeout", `${host} did not finish the SSH sign-on in time. The system may be busy; try again.`);
155
+ }
156
+ if (/privateKey|private key|Unsupported key format|Cannot parse/i.test(text)) {
157
+ return finish("key-unreadable", `The private key ${target.privateKeyPath} could not be used: ${text}`);
158
+ }
159
+ return finish("error", `The SSH sign-on failed: ${text}`);
160
+ }
161
+ rejected.delete(key);
162
+ steps.push({ name: "Sign-on", ok: true, detail: `Signed on as ${username.toUpperCase()} and signed off` });
163
+ const days = outcome.profile?.daysUntilPasswordExpires;
164
+ if (typeof days === "number") {
165
+ profile = { daysUntilPasswordExpires: days };
166
+ if (days >= 0 && days <= PASSWORD_WARNING_DAYS) {
167
+ warnings.push(days === 0 ? "The password expires today." : `The password expires in ${days} day${days === 1 ? "" : "s"}.`);
168
+ }
169
+ }
170
+ return finish("ok", `Connected to ${host} as ${username.toUpperCase()}.`);
171
+ }
172
+ function withTimeout(promise, ms, what) {
173
+ return new Promise((resolve, reject) => {
174
+ const timer = setTimeout(() => reject(Object.assign(new Error(`${what} timed out`), { code: "TIMEOUT" })), ms);
175
+ promise.then(value => { clearTimeout(timer); resolve(value); }, err => { clearTimeout(timer); reject(err); });
176
+ });
177
+ }
178
+ async function lookupHost(host) {
179
+ if (net.isIP(host))
180
+ return host;
181
+ const result = await dns.lookup(host);
182
+ return result.address;
183
+ }
184
+ function probePort(address, port, timeoutMs) {
185
+ return new Promise((resolve, reject) => {
186
+ const socket = net.connect({ host: address, port });
187
+ let connected = false;
188
+ let banner = "";
189
+ const done = (err) => {
190
+ clearTimeout(timer);
191
+ socket.destroy();
192
+ if (err)
193
+ reject(err);
194
+ else
195
+ resolve(banner);
196
+ };
197
+ const timer = setTimeout(() => {
198
+ // Connected but silent: still a working port, just no banner yet.
199
+ if (connected)
200
+ done();
201
+ else
202
+ done(Object.assign(new Error("Connection timed out"), { code: "ETIMEDOUT" }));
203
+ }, timeoutMs);
204
+ socket.once("connect", () => { connected = true; });
205
+ socket.on("data", chunk => {
206
+ banner += chunk.toString("latin1");
207
+ if (banner.includes("\n") || banner.length >= 255) {
208
+ banner = banner.split(/\r?\n/)[0];
209
+ done();
210
+ }
211
+ });
212
+ socket.once("error", err => done(err));
213
+ socket.once("end", () => done());
214
+ });
215
+ }
216
+ async function checkStoredHostKey(host, port, key) {
217
+ const fingerprint = hostKeyFingerprint(key);
218
+ const data = await readKnownHosts(getKnownHostsPath());
219
+ const existing = data.hosts[knownHostId(host, port)];
220
+ if (!existing)
221
+ return { status: "new", fingerprint };
222
+ if (existing.fingerprint === fingerprint)
223
+ return { status: "trusted", fingerprint };
224
+ if (isNewHostKeyAccepted(process.env[ACCEPT_NEW_HOSTKEY_ENV], host, port, fingerprint)) {
225
+ return { status: "new", fingerprint, stored: existing.fingerprint };
226
+ }
227
+ return { status: "changed", fingerprint, stored: existing.fingerprint };
228
+ }
229
+ /** Days until the password expires (null when it never does). */
230
+ const PROFILE_SQL = "SELECT DAYS_UNTIL_PASSWORD_EXPIRES AS DAYS FROM QSYS2.USER_INFO WHERE AUTHORIZATION_NAME = USER";
231
+ const PROFILE_COMMAND = "LC_ALL=EN_US.UTF-8 /QOpenSys/usr/bin/qsh -c " +
232
+ Tools.shellQuote(`system "call QSYS/QZDFMDB2 PARM('-d' '-i' '-t')"`);
233
+ function sshAttempt(options) {
234
+ const require = createRequire(import.meta.url);
235
+ const { Client } = require("ssh2");
236
+ return new Promise(resolve => {
237
+ const client = new Client();
238
+ let settled = false;
239
+ let hostKeyChanged;
240
+ const settle = (outcome) => {
241
+ if (settled)
242
+ return;
243
+ settled = true;
244
+ try {
245
+ client.end();
246
+ }
247
+ catch { /* already closed */ }
248
+ resolve(outcome);
249
+ };
250
+ client.on("change password", (prompt) => {
251
+ // Never change the password from a test; just report it.
252
+ settle({ kind: "password-expired", prompt });
253
+ });
254
+ client.on("error", (error) => {
255
+ if (hostKeyChanged)
256
+ settle({ kind: "host-key-changed", hostKey: hostKeyChanged });
257
+ else
258
+ settle({ kind: "error", error });
259
+ });
260
+ client.on("close", () => {
261
+ if (hostKeyChanged)
262
+ settle({ kind: "host-key-changed", hostKey: hostKeyChanged });
263
+ else
264
+ settle({ kind: "error", error: new Error("The server closed the connection") });
265
+ });
266
+ client.on("ready", async () => {
267
+ const profile = await readProfile(client).catch(() => undefined);
268
+ settle({ kind: "ready", profile });
269
+ });
270
+ client.connect({
271
+ host: options.host,
272
+ port: options.port,
273
+ username: options.username,
274
+ password: options.password,
275
+ privateKey: options.privateKey,
276
+ tryKeyboard: false,
277
+ readyTimeout: options.timeoutMs,
278
+ hostVerifier: (key, verify) => {
279
+ options.checkHostKey(key)
280
+ .then(check => {
281
+ if (check.status === "changed")
282
+ hostKeyChanged = check;
283
+ verify(check.status !== "changed");
284
+ })
285
+ .catch(() => verify(false));
286
+ }
287
+ });
288
+ });
289
+ }
290
+ function readProfile(client) {
291
+ return new Promise((resolve, reject) => {
292
+ const timer = setTimeout(() => reject(new Error("profile query timed out")), PROFILE_TIMEOUT_MS);
293
+ client.exec(PROFILE_COMMAND, (err, stream) => {
294
+ if (err) {
295
+ clearTimeout(timer);
296
+ reject(err);
297
+ return;
298
+ }
299
+ let stdout = "";
300
+ stream.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
301
+ stream.stderr.on("data", () => undefined);
302
+ stream.on("close", () => {
303
+ clearTimeout(timer);
304
+ try {
305
+ const rows = Tools.db2Parse(stdout, PROFILE_SQL);
306
+ const row = rows[0];
307
+ if (!row)
308
+ return resolve(undefined);
309
+ const days = row.DAYS === null || row.DAYS === undefined || row.DAYS === "" ? undefined : Number(row.DAYS);
310
+ resolve({
311
+ daysUntilPasswordExpires: Number.isFinite(days) ? days : undefined
312
+ });
313
+ }
314
+ catch (parseErr) {
315
+ reject(parseErr);
316
+ }
317
+ });
318
+ stream.end(`${PROFILE_SQL}\n`);
319
+ });
320
+ });
321
+ }
322
+ const defaultDeps = {
323
+ lookup: lookupHost,
324
+ probePort,
325
+ readKey: path => fs.readFile(path, "utf8"),
326
+ checkHostKey: checkStoredHostKey,
327
+ ssh: sshAttempt,
328
+ now: () => Date.now()
329
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Error carrying an HTTP status code. The control-plane server maps it to the
3
+ * response status; any other error becomes a 500.
4
+ */
5
+ export class ControlPlaneError extends Error {
6
+ status;
7
+ constructor(status, message) {
8
+ super(message);
9
+ this.status = status;
10
+ this.name = "ControlPlaneError";
11
+ }
12
+ }