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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import fs from "fs
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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
|
+
}
|