@miraj181/ipingyou 2.1.18 → 2.1.22
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 +1 -2
- package/package.json +1 -1
- package/src/cli.js +22 -14
- package/src/lib/ai/safety.js +1 -1
- package/src/lib/{broker.js → client/broker.js} +25 -9
- package/src/lib/{path-browser.js → client/path-browser.js} +6 -2
- package/src/lib/{session-log.js → mod/session-log.js} +17 -8
- package/src/lib/{worker-runtime.js → mod/worker-runtime.js} +1 -1
- package/src/lib/{chat.js → services/chat.js} +2 -2
- package/src/lib/services/platform.js +364 -0
- package/src/lib/{tunnel.js → services/tunnel.js} +2 -2
- package/src/modes/ai.js +18 -9
- package/src/modes/client.js +99 -25
- package/src/modes/doctor.js +5 -7
- package/src/modes/host.js +326 -154
- package/src/server.js +55 -6
- package/src/lib/platform.js +0 -90
- /package/src/lib/{allowlist.js → mod/allowlist.js} +0 -0
- /package/src/lib/{animations.js → mod/animations.js} +0 -0
- /package/src/lib/{checksum.js → mod/checksum.js} +0 -0
- /package/src/lib/{cleanup.js → mod/cleanup.js} +0 -0
- /package/src/lib/{config.js → mod/config.js} +0 -0
- /package/src/lib/{crypto.js → mod/crypto.js} +0 -0
- /package/src/lib/{open-url.js → mod/open-url.js} +0 -0
- /package/src/lib/{secure-print.js → mod/secure-print.js} +0 -0
- /package/src/lib/{socket-firewall.js → mod/socket-firewall.js} +0 -0
- /package/src/lib/{tmux.js → mod/tmux.js} +0 -0
- /package/src/lib/{uid.js → mod/uid.js} +0 -0
- /package/src/lib/{ssh.js → services/ssh.js} +0 -0
package/src/modes/ai.js
CHANGED
|
@@ -8,16 +8,16 @@ import inquirer from 'inquirer';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { getAlias } from '../lib/config.js';
|
|
12
|
-
import { resolveUID } from '../lib/broker.js';
|
|
13
|
-
import { buildSshArgs, extractHostname, quoteRemoteShell } from '../lib/ssh.js';
|
|
14
|
-
import { addCleanupHook, cleanupAll } from '../lib/cleanup.js';
|
|
11
|
+
import { getAlias } from '../lib/mod/config.js';
|
|
12
|
+
import { resolveUID } from '../lib/client/broker.js';
|
|
13
|
+
import { buildSshArgs, extractHostname, quoteRemoteShell } from '../lib/services/ssh.js';
|
|
14
|
+
import { addCleanupHook, cleanupAll, trackPID, untrackPID } from '../lib/mod/cleanup.js';
|
|
15
15
|
import { startHostMode } from './host.js';
|
|
16
16
|
import { startClientMode } from './client.js';
|
|
17
17
|
import { performSCPNonInteractive } from './client.js';
|
|
18
18
|
import { DEFAULT_AI_MODEL, createGroqChatCompletion, getGroqApiKey, getRateLimitWarnings, listGroqModels, estimateTokensForMessages } from '../lib/ai/groq.js';
|
|
19
19
|
import { assertSafeReadablePath, classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
|
|
20
|
-
import { recordEvent } from '../lib/session-log.js';
|
|
20
|
+
import { recordEvent } from '../lib/mod/session-log.js';
|
|
21
21
|
|
|
22
22
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
23
23
|
|
|
@@ -223,11 +223,14 @@ async function runLocalCommand(command) {
|
|
|
223
223
|
if (args.length === 0) {
|
|
224
224
|
return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
|
|
225
225
|
}
|
|
226
|
-
const
|
|
226
|
+
const child = execa(args[0], args.slice(1), {
|
|
227
227
|
reject: false,
|
|
228
228
|
timeout: 30000,
|
|
229
229
|
maxBuffer: 1024 * 1024,
|
|
230
230
|
});
|
|
231
|
+
trackPID(child.pid);
|
|
232
|
+
const result = await child;
|
|
233
|
+
untrackPID(child.pid);
|
|
231
234
|
|
|
232
235
|
return {
|
|
233
236
|
exitCode: result.exitCode,
|
|
@@ -290,11 +293,14 @@ export function parseLocalCommand(command) {
|
|
|
290
293
|
async function runRemoteCommand(context, command) {
|
|
291
294
|
const sshArgs = buildSshArgs(context.hostname, context.privateKeyPath);
|
|
292
295
|
sshArgs.push(`${context.username}@${context.hostname}`, command);
|
|
293
|
-
const
|
|
296
|
+
const child = execa('ssh', sshArgs, {
|
|
294
297
|
reject: false,
|
|
295
298
|
timeout: 30000,
|
|
296
299
|
maxBuffer: 1024 * 1024,
|
|
297
300
|
});
|
|
301
|
+
trackPID(child.pid);
|
|
302
|
+
const result = await child;
|
|
303
|
+
untrackPID(child.pid);
|
|
298
304
|
|
|
299
305
|
return {
|
|
300
306
|
exitCode: result.exitCode,
|
|
@@ -650,7 +656,7 @@ async function tryAITransfer(task, context) {
|
|
|
650
656
|
if (context && context.scope === 'remote' && context.hostname && context.username) {
|
|
651
657
|
console.log(chalk.dim(` Using active remote session: ${context.username}@${context.hostname}`));
|
|
652
658
|
|
|
653
|
-
const { buildProxyCommandOption, getSshControlOptions, formatScpRemotePath } = await import('../lib/ssh.js');
|
|
659
|
+
const { buildProxyCommandOption, getSshControlOptions, formatScpRemotePath } = await import('../lib/services/ssh.js');
|
|
654
660
|
const scpArgs = [
|
|
655
661
|
'-r',
|
|
656
662
|
...buildProxyCommandOption(context.hostname),
|
|
@@ -670,7 +676,10 @@ async function tryAITransfer(task, context) {
|
|
|
670
676
|
}
|
|
671
677
|
|
|
672
678
|
try {
|
|
673
|
-
const
|
|
679
|
+
const child = execa('scp', scpArgs, { stdio: 'inherit', reject: false });
|
|
680
|
+
trackPID(child.pid);
|
|
681
|
+
const result = await child;
|
|
682
|
+
untrackPID(child.pid);
|
|
674
683
|
if (result.exitCode === 0) {
|
|
675
684
|
console.log(chalk.green(' ✅ Transfer completed via active remote session.'));
|
|
676
685
|
recordEvent('ai_transfer_success', { direction, localPath, remotePath, hostname: context.hostname, reusedContext: true });
|
package/src/modes/client.js
CHANGED
|
@@ -18,20 +18,64 @@ import inquirer from 'inquirer';
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import os from 'node:os';
|
|
21
|
-
import { cleanupAll, trackPID, untrackPID, addCleanupHook } from '../lib/cleanup.js';
|
|
22
|
-
import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, simulateTransferProgress } from '../lib/animations.js';
|
|
23
|
-
import { getConfig, saveAlias } from '../lib/config.js';
|
|
24
|
-
import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/broker.js';
|
|
25
|
-
import { calculateChecksum } from '../lib/checksum.js';
|
|
26
|
-
import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
|
|
27
|
-
import { buildProxyCommandOption, buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
|
|
21
|
+
import { cleanupAll, trackPID, untrackPID, addCleanupHook } from '../lib/mod/cleanup.js';
|
|
22
|
+
import { createSpinner, sshSpinner, networkSpinner, fileTransferSpinner, showConnectionTrace, simulateTransferProgress } from '../lib/mod/animations.js';
|
|
23
|
+
import { getConfig, saveAlias } from '../lib/mod/config.js';
|
|
24
|
+
import { pushTelemetry, requestHostApproval, resolveUID, revokeUID, waitForApproval } from '../lib/client/broker.js';
|
|
25
|
+
import { calculateChecksum } from '../lib/mod/checksum.js';
|
|
26
|
+
import { promptLocalPath, promptRemotePath } from '../lib/client/path-browser.js';
|
|
27
|
+
import { buildProxyCommandOption, buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/services/ssh.js';
|
|
28
|
+
import { openUrl } from '../lib/mod/open-url.js';
|
|
29
|
+
import { secureSensitiveUrl } from '../lib/mod/secure-print.js';
|
|
30
|
+
import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/mod/session-log.js';
|
|
32
31
|
|
|
33
32
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
34
33
|
|
|
34
|
+
function startLiveLogSync(username, hostname, privateKeyPath, remoteDropPath, localLogPath, persistKnownHosts = true) {
|
|
35
|
+
if (!remoteDropPath || !localLogPath) return;
|
|
36
|
+
|
|
37
|
+
let lastSize = -1;
|
|
38
|
+
let lastMtime = 0;
|
|
39
|
+
let isSyncing = false;
|
|
40
|
+
const interval = setInterval(async () => {
|
|
41
|
+
if (isSyncing) return;
|
|
42
|
+
isSyncing = true;
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.existsSync(localLogPath)) return;
|
|
45
|
+
const stats = fs.statSync(localLogPath);
|
|
46
|
+
if (stats.size === lastSize && stats.mtimeMs === lastMtime) return;
|
|
47
|
+
|
|
48
|
+
const scpArgs = [
|
|
49
|
+
'-O',
|
|
50
|
+
...buildProxyCommandOption(hostname),
|
|
51
|
+
...getKnownHostsOptions(persistKnownHosts),
|
|
52
|
+
'-o', 'IdentitiesOnly=yes',
|
|
53
|
+
...getSshControlOptions(hostname)
|
|
54
|
+
];
|
|
55
|
+
if (privateKeyPath) {
|
|
56
|
+
scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const clientName = `${os.userInfo().username}-${os.hostname()}`;
|
|
60
|
+
const remoteFilePath = `${remoteDropPath}/client-${clientName}.log`;
|
|
61
|
+
|
|
62
|
+
scpArgs.push(localLogPath, `${username}@${hostname}:${formatScpRemotePath(remoteFilePath)}`);
|
|
63
|
+
|
|
64
|
+
const result = await execa('scp', scpArgs, { reject: false });
|
|
65
|
+
if (result.exitCode === 0) {
|
|
66
|
+
lastSize = stats.size;
|
|
67
|
+
lastMtime = stats.mtimeMs;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Ignore background sync failures silently
|
|
71
|
+
} finally {
|
|
72
|
+
isSyncing = false;
|
|
73
|
+
}
|
|
74
|
+
}, 3000);
|
|
75
|
+
|
|
76
|
+
addCleanupHook(() => clearInterval(interval));
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
async function promptUsername() {
|
|
36
80
|
const { username } = await inquirer.prompt([
|
|
37
81
|
{
|
|
@@ -54,6 +98,15 @@ async function writeEphemeralPrivateKey(privateKey) {
|
|
|
54
98
|
const keyPath = path.join(os.tmpdir(), `ipingyou_client_${Date.now()}`);
|
|
55
99
|
fs.writeFileSync(keyPath, normalizePrivateKey(privateKey), { mode: 0o600 });
|
|
56
100
|
|
|
101
|
+
// On Windows, NTFS ignores POSIX mode bits — fix ACLs with icacls
|
|
102
|
+
if (process.platform === 'win32') {
|
|
103
|
+
const currentUser = os.userInfo().username;
|
|
104
|
+
// Remove all inherited permissions first
|
|
105
|
+
await execa('icacls', [keyPath, '/inheritance:r'], { reject: false });
|
|
106
|
+
// Grant only the current user full control
|
|
107
|
+
await execa('icacls', [keyPath, '/grant:r', `${currentUser}:(F)`], { reject: false });
|
|
108
|
+
}
|
|
109
|
+
|
|
57
110
|
const result = await execa('ssh-keygen', ['-y', '-f', keyPath], {
|
|
58
111
|
reject: false,
|
|
59
112
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -85,18 +138,11 @@ async function connectSSH(username, hostname, privateKeyPath, persistKnownHosts
|
|
|
85
138
|
|
|
86
139
|
const sshArgs = buildSshArgs(hostname, privateKeyPath, [
|
|
87
140
|
'-o', 'ServerAliveInterval=30',
|
|
88
|
-
'-o', 'ServerAliveCountMax=3'
|
|
141
|
+
'-o', 'ServerAliveCountMax=3',
|
|
142
|
+
'-t'
|
|
89
143
|
], { persistKnownHosts });
|
|
90
144
|
|
|
91
145
|
sshArgs.push(`${username}@${hostname}`);
|
|
92
|
-
const tmuxSession = buildTmuxSessionName(username);
|
|
93
|
-
const quotedSocket = quoteRemoteShell(TMUX_SOCKET_PATH);
|
|
94
|
-
const quotedSession = quoteRemoteShell(tmuxSession);
|
|
95
|
-
const tmuxSocketCmdStr = `tmux -S ${quotedSocket}`;
|
|
96
|
-
const tmuxPrepare = `${tmuxSocketCmdStr} has-session -t ${quotedSession} 2>/dev/null || ${tmuxSocketCmdStr} new-session -d -s ${quotedSession}`;
|
|
97
|
-
const tmuxAttach = `${tmuxSocketCmdStr} attach -t ${quotedSession}`;
|
|
98
|
-
const tmuxCommand = `if command -v tmux >/dev/null 2>&1; then (${tmuxPrepare} && ${tmuxAttach}) || exec $SHELL -l; else exec $SHELL -l; fi`;
|
|
99
|
-
sshArgs.push('-t', tmuxCommand);
|
|
100
146
|
|
|
101
147
|
const child = execa('ssh', sshArgs, {
|
|
102
148
|
stdio: 'inherit',
|
|
@@ -190,6 +236,7 @@ async function performSCP(username, hostname, direction, privateKeyPath, sharedD
|
|
|
190
236
|
// Construct SCP args
|
|
191
237
|
const scpArgs = [
|
|
192
238
|
'-r', // recursive just in case
|
|
239
|
+
'-O', // Force legacy SCP protocol so that shell quoting in formatScpRemotePath works correctly
|
|
193
240
|
...buildProxyCommandOption(hostname),
|
|
194
241
|
...getKnownHostsOptions(persistKnownHosts),
|
|
195
242
|
'-o', 'IdentitiesOnly=yes',
|
|
@@ -284,6 +331,7 @@ async function downloadSpecificRemotePath(username, hostname, privateKeyPath, re
|
|
|
284
331
|
await showConnectionTrace('Local', 'Remote SCP');
|
|
285
332
|
const scpArgs = [
|
|
286
333
|
'-r',
|
|
334
|
+
'-O', // Force legacy SCP protocol
|
|
287
335
|
...buildProxyCommandOption(hostname),
|
|
288
336
|
...getKnownHostsOptions(persistKnownHosts),
|
|
289
337
|
'-o', 'IdentitiesOnly=yes',
|
|
@@ -291,7 +339,10 @@ async function downloadSpecificRemotePath(username, hostname, privateKeyPath, re
|
|
|
291
339
|
];
|
|
292
340
|
if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
293
341
|
scpArgs.push(`${username}@${hostname}:${formatScpRemotePath(remotePath)}`, localPath);
|
|
294
|
-
const
|
|
342
|
+
const child = execa('scp', scpArgs, { stdio: 'inherit', reject: false });
|
|
343
|
+
trackPID(child.pid);
|
|
344
|
+
const result = await child;
|
|
345
|
+
untrackPID(child.pid);
|
|
295
346
|
return result.exitCode === 0;
|
|
296
347
|
}
|
|
297
348
|
|
|
@@ -426,12 +477,27 @@ export async function startClientMode(options = {}) {
|
|
|
426
477
|
console.log(chalk.dim(' The host has enabled approval gating. Submitting your access request...'));
|
|
427
478
|
|
|
428
479
|
try {
|
|
480
|
+
let localIp = '127.0.0.1';
|
|
481
|
+
try {
|
|
482
|
+
const interfaces = os.networkInterfaces();
|
|
483
|
+
for (const devName in interfaces) {
|
|
484
|
+
const iface = interfaces[devName];
|
|
485
|
+
for (const alias of iface) {
|
|
486
|
+
if (alias.family === 'IPv4' && !alias.internal) {
|
|
487
|
+
localIp = alias.address;
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {}
|
|
493
|
+
|
|
429
494
|
const approvalDetails = {
|
|
430
495
|
username: os.userInfo().username,
|
|
431
496
|
hostname: os.hostname(),
|
|
432
497
|
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
433
498
|
intent: 'connect',
|
|
434
499
|
time: new Date().toISOString(),
|
|
500
|
+
localIp,
|
|
435
501
|
};
|
|
436
502
|
|
|
437
503
|
const { requestId, status: reqStatus, approvalRequired } = await requestHostApproval(
|
|
@@ -448,9 +514,9 @@ export async function startClientMode(options = {}) {
|
|
|
448
514
|
console.log(chalk.dim(' This may take a few minutes. Press Ctrl+C to cancel.'));
|
|
449
515
|
console.log('');
|
|
450
516
|
|
|
451
|
-
const
|
|
517
|
+
const approvalResult = await waitForApproval(BROKER_URL, targetUid, requestId, 300000);
|
|
452
518
|
|
|
453
|
-
if (approved) {
|
|
519
|
+
if (approvalResult && approvalResult.approved) {
|
|
454
520
|
console.log(chalk.green(' ✅ Host approved your access request!'));
|
|
455
521
|
logSessionEvent('client_approval_granted', { uid: targetUid, requestId });
|
|
456
522
|
payload = await resolveUID(BROKER_URL, targetUid, targetPassword, false, requestId);
|
|
@@ -551,6 +617,11 @@ export async function startClientMode(options = {}) {
|
|
|
551
617
|
}
|
|
552
618
|
}
|
|
553
619
|
|
|
620
|
+
// Start background E2E client log sync if sharedDropPath is configured
|
|
621
|
+
if (payload.sharedDropPath && sessionLogPath) {
|
|
622
|
+
startLiveLogSync(username, hostname, privateKeyPath, payload.sharedDropPath, sessionLogPath, persistKnownHosts);
|
|
623
|
+
}
|
|
624
|
+
|
|
554
625
|
// ─── One-Time File Share Auto-Download ────────────────────
|
|
555
626
|
if (payload.oneTime && payload.oneTimeSharePath) {
|
|
556
627
|
console.log('');
|
|
@@ -675,7 +746,7 @@ export async function performSCPNonInteractive(params = {}) {
|
|
|
675
746
|
const privateKeyPath = payload.privateKey ? await writeEphemeralPrivateKey(payload.privateKey) : null;
|
|
676
747
|
|
|
677
748
|
// Build scp args similar to performSCP
|
|
678
|
-
const scpArgs = ['-r', ...buildProxyCommandOption(hostname), ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
|
|
749
|
+
const scpArgs = ['-r', '-O', ...buildProxyCommandOption(hostname), ...getKnownHostsOptions(persistKnownHosts), '-o', 'IdentitiesOnly=yes', ...getSshControlOptions(hostname)];
|
|
679
750
|
if (privateKeyPath) scpArgs.push('-i', privateKeyPath, '-o', 'IdentityAgent=none');
|
|
680
751
|
|
|
681
752
|
const remoteSpec = `${username}@${hostname}:${formatScpRemotePath(remotePath)}`;
|
|
@@ -686,7 +757,10 @@ export async function performSCPNonInteractive(params = {}) {
|
|
|
686
757
|
}
|
|
687
758
|
|
|
688
759
|
try {
|
|
689
|
-
const
|
|
760
|
+
const child = execa('scp', scpArgs, { stdio: 'inherit', reject: false });
|
|
761
|
+
trackPID(child.pid);
|
|
762
|
+
const result = await child;
|
|
763
|
+
untrackPID(child.pid);
|
|
690
764
|
if (result.exitCode === 0) {
|
|
691
765
|
recordEvent('scp_transfer_success', { direction, localPath, remotePath, hostname, automated: true });
|
|
692
766
|
return true;
|
package/src/modes/doctor.js
CHANGED
|
@@ -7,8 +7,8 @@ import chalk from 'chalk';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { commandExists, detectOS } from '../lib/platform.js';
|
|
11
|
-
import { pingBroker } from '../lib/broker.js';
|
|
10
|
+
import { commandExists, detectOS, isLinuxSSHActive } from '../lib/services/platform.js';
|
|
11
|
+
import { pingBroker } from '../lib/client/broker.js';
|
|
12
12
|
import { classifyCommand, redactSensitive } from '../lib/ai/safety.js';
|
|
13
13
|
|
|
14
14
|
let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
|
|
@@ -96,13 +96,12 @@ async function checkSshService() {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (osInfo.isLinux) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
if (sshd.exitCode === 0) return { status: 'pass', detail: 'SSH service is active' };
|
|
99
|
+
const active = await isLinuxSSHActive();
|
|
100
|
+
if (active) return { status: 'pass', detail: 'SSH service is active' };
|
|
102
101
|
return {
|
|
103
102
|
status: 'warn',
|
|
104
103
|
detail: 'SSH service is not reported active',
|
|
105
|
-
hint: 'Run `sudo systemctl start ssh
|
|
104
|
+
hint: 'Run `sudo systemctl start ssh`, `sudo service ssh start` or equivalent before hosting.',
|
|
106
105
|
};
|
|
107
106
|
}
|
|
108
107
|
|
|
@@ -232,7 +231,6 @@ export async function startDoctorMode(options = {}) {
|
|
|
232
231
|
await check('scp client', () => commandFound('scp'));
|
|
233
232
|
await check('ssh-keygen', () => commandFound('ssh-keygen'));
|
|
234
233
|
await check('cloudflared', () => commandVersion('cloudflared', ['--version']));
|
|
235
|
-
await check('tmux', () => commandVersion('tmux', ['-V']));
|
|
236
234
|
|
|
237
235
|
console.log(chalk.bold('\n Host readiness'));
|
|
238
236
|
await check('SSH service', checkSshService);
|