typescript-virtual-container 1.2.4 → 1.2.6
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 +1056 -1239
- package/benchmark-results.txt +20 -20
- package/dist/SSHMimic/exec.js +2 -2
- package/dist/SSHMimic/executor.d.ts +6 -7
- package/dist/SSHMimic/executor.d.ts.map +1 -1
- package/dist/SSHMimic/executor.js +77 -60
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +106 -24
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +14 -0
- package/dist/VirtualFileSystem/index.d.ts +115 -88
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +389 -264
- package/dist/VirtualShell/index.d.ts +3 -4
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +4 -6
- package/dist/VirtualShell/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +19 -2
- package/dist/VirtualShell/shellParser.d.ts +20 -2
- package/dist/VirtualShell/shellParser.d.ts.map +1 -1
- package/dist/VirtualShell/shellParser.js +229 -120
- package/dist/VirtualUserManager/index.d.ts +25 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +33 -0
- package/dist/commands/adduser.d.ts.map +1 -1
- package/dist/commands/adduser.js +2 -0
- package/dist/commands/awk.d.ts +3 -0
- package/dist/commands/awk.d.ts.map +1 -0
- package/dist/commands/awk.js +29 -0
- package/dist/commands/base64.d.ts +3 -0
- package/dist/commands/base64.d.ts.map +1 -0
- package/dist/commands/base64.js +20 -0
- package/dist/commands/cat.d.ts.map +1 -1
- package/dist/commands/cat.js +2 -0
- package/dist/commands/cd.d.ts.map +1 -1
- package/dist/commands/cd.js +2 -0
- package/dist/commands/chmod.d.ts +3 -0
- package/dist/commands/chmod.d.ts.map +1 -0
- package/dist/commands/chmod.js +33 -0
- package/dist/commands/clear.d.ts.map +1 -1
- package/dist/commands/clear.js +4 -1
- package/dist/commands/cp.d.ts +3 -0
- package/dist/commands/cp.d.ts.map +1 -0
- package/dist/commands/cp.js +70 -0
- package/dist/commands/curl.d.ts.map +1 -1
- package/dist/commands/curl.js +2 -0
- package/dist/commands/cut.d.ts +3 -0
- package/dist/commands/cut.d.ts.map +1 -0
- package/dist/commands/cut.js +27 -0
- package/dist/commands/date.d.ts +3 -0
- package/dist/commands/date.d.ts.map +1 -0
- package/dist/commands/date.js +22 -0
- package/dist/commands/deluser.d.ts.map +1 -1
- package/dist/commands/deluser.js +2 -0
- package/dist/commands/df.d.ts +3 -0
- package/dist/commands/df.d.ts.map +1 -0
- package/dist/commands/df.js +16 -0
- package/dist/commands/diff.d.ts +3 -0
- package/dist/commands/diff.d.ts.map +1 -0
- package/dist/commands/diff.js +40 -0
- package/dist/commands/du.d.ts +3 -0
- package/dist/commands/du.d.ts.map +1 -0
- package/dist/commands/du.js +39 -0
- package/dist/commands/echo.d.ts.map +1 -1
- package/dist/commands/echo.js +2 -0
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +6 -14
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +11 -21
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.d.ts.map +1 -0
- package/dist/commands/find.js +50 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +58 -35
- package/dist/commands/groups.d.ts +3 -0
- package/dist/commands/groups.d.ts.map +1 -0
- package/dist/commands/groups.js +12 -0
- package/dist/commands/gzip.d.ts +4 -0
- package/dist/commands/gzip.d.ts.map +1 -0
- package/dist/commands/gzip.js +40 -0
- package/dist/commands/head.d.ts +3 -0
- package/dist/commands/head.d.ts.map +1 -0
- package/dist/commands/head.js +32 -0
- package/dist/commands/help.d.ts +1 -1
- package/dist/commands/help.d.ts.map +1 -1
- package/dist/commands/help.js +75 -3
- package/dist/commands/hostname.d.ts.map +1 -1
- package/dist/commands/hostname.js +2 -0
- package/dist/commands/htop.d.ts.map +1 -1
- package/dist/commands/htop.js +2 -0
- package/dist/commands/id.d.ts +3 -0
- package/dist/commands/id.d.ts.map +1 -0
- package/dist/commands/id.js +14 -0
- package/dist/commands/index.d.ts +5 -2
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +104 -87
- package/dist/commands/kill.d.ts +3 -0
- package/dist/commands/kill.d.ts.map +1 -0
- package/dist/commands/kill.js +13 -0
- package/dist/commands/ln.d.ts +3 -0
- package/dist/commands/ln.d.ts.map +1 -0
- package/dist/commands/ln.js +44 -0
- package/dist/commands/ls.d.ts.map +1 -1
- package/dist/commands/ls.js +2 -0
- package/dist/commands/mkdir.d.ts.map +1 -1
- package/dist/commands/mkdir.js +2 -0
- package/dist/commands/mv.d.ts +3 -0
- package/dist/commands/mv.d.ts.map +1 -0
- package/dist/commands/mv.js +37 -0
- package/dist/commands/nano.d.ts.map +1 -1
- package/dist/commands/nano.js +2 -0
- package/dist/commands/neofetch.d.ts.map +1 -1
- package/dist/commands/neofetch.js +2 -0
- package/dist/commands/passwd.d.ts.map +1 -1
- package/dist/commands/passwd.js +2 -0
- package/dist/commands/ping.d.ts +3 -0
- package/dist/commands/ping.d.ts.map +1 -0
- package/dist/commands/ping.js +18 -0
- package/dist/commands/ps.d.ts +3 -0
- package/dist/commands/ps.d.ts.map +1 -0
- package/dist/commands/ps.js +17 -0
- package/dist/commands/pwd.d.ts.map +1 -1
- package/dist/commands/pwd.js +2 -0
- package/dist/commands/rm.d.ts.map +1 -1
- package/dist/commands/rm.js +2 -0
- package/dist/commands/sed.d.ts +3 -0
- package/dist/commands/sed.d.ts.map +1 -0
- package/dist/commands/sed.js +47 -0
- package/dist/commands/set.d.ts +3 -0
- package/dist/commands/set.d.ts.map +1 -1
- package/dist/commands/set.js +19 -46
- package/dist/commands/sh.d.ts +0 -1
- package/dist/commands/sh.d.ts.map +1 -1
- package/dist/commands/sh.js +228 -35
- package/dist/commands/sleep.d.ts +3 -0
- package/dist/commands/sleep.d.ts.map +1 -0
- package/dist/commands/sleep.js +13 -0
- package/dist/commands/sort.d.ts +3 -0
- package/dist/commands/sort.d.ts.map +1 -0
- package/dist/commands/sort.js +37 -0
- package/dist/commands/su.d.ts.map +1 -1
- package/dist/commands/su.js +2 -0
- package/dist/commands/sudo.d.ts.map +1 -1
- package/dist/commands/sudo.js +2 -0
- package/dist/commands/tail.d.ts +3 -0
- package/dist/commands/tail.d.ts.map +1 -0
- package/dist/commands/tail.js +35 -0
- package/dist/commands/tar.d.ts +3 -0
- package/dist/commands/tar.d.ts.map +1 -0
- package/dist/commands/tar.js +64 -0
- package/dist/commands/tee.d.ts +3 -0
- package/dist/commands/tee.d.ts.map +1 -0
- package/dist/commands/tee.js +29 -0
- package/dist/commands/touch.d.ts.map +1 -1
- package/dist/commands/touch.js +2 -0
- package/dist/commands/tr.d.ts +3 -0
- package/dist/commands/tr.d.ts.map +1 -0
- package/dist/commands/tr.js +24 -0
- package/dist/commands/tree.d.ts.map +1 -1
- package/dist/commands/tree.js +2 -0
- package/dist/commands/uname.d.ts +3 -0
- package/dist/commands/uname.d.ts.map +1 -0
- package/dist/commands/uname.js +21 -0
- package/dist/commands/uniq.d.ts +3 -0
- package/dist/commands/uniq.d.ts.map +1 -0
- package/dist/commands/uniq.js +33 -0
- package/dist/commands/unset.d.ts.map +1 -1
- package/dist/commands/unset.js +6 -10
- package/dist/commands/wc.d.ts +3 -0
- package/dist/commands/wc.d.ts.map +1 -0
- package/dist/commands/wc.js +50 -0
- package/dist/commands/wget.d.ts.map +1 -1
- package/dist/commands/wget.js +2 -0
- package/dist/commands/who.d.ts.map +1 -1
- package/dist/commands/who.js +2 -0
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +2 -0
- package/dist/commands/xargs.d.ts +3 -0
- package/dist/commands/xargs.d.ts.map +1 -0
- package/dist/commands/xargs.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/types/commands.d.ts +13 -0
- package/dist/types/commands.d.ts.map +1 -1
- package/dist/types/pipeline.d.ts +20 -0
- package/dist/types/pipeline.d.ts.map +1 -1
- package/package.json +5 -2
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/exec.ts +2 -2
- package/src/SSHMimic/executor.ts +95 -98
- package/src/SSHMimic/index.ts +138 -57
- package/src/SSHMimic/sftp.ts +15 -0
- package/src/VirtualFileSystem/index.ts +464 -292
- package/src/VirtualShell/index.ts +4 -6
- package/src/VirtualShell/shell.ts +19 -2
- package/src/VirtualShell/shellParser.ts +202 -168
- package/src/VirtualUserManager/index.ts +36 -0
- package/src/commands/adduser.ts +2 -0
- package/src/commands/awk.ts +30 -0
- package/src/commands/base64.ts +18 -0
- package/src/commands/cat.ts +2 -0
- package/src/commands/cd.ts +2 -0
- package/src/commands/chmod.ts +35 -0
- package/src/commands/clear.ts +4 -1
- package/src/commands/cp.ts +78 -0
- package/src/commands/curl.ts +2 -0
- package/src/commands/cut.ts +29 -0
- package/src/commands/date.ts +24 -0
- package/src/commands/deluser.ts +2 -0
- package/src/commands/df.ts +18 -0
- package/src/commands/diff.ts +29 -0
- package/src/commands/du.ts +39 -0
- package/src/commands/echo.ts +2 -0
- package/src/commands/env.ts +6 -16
- package/src/commands/export.ts +11 -24
- package/src/commands/find.ts +63 -0
- package/src/commands/grep.ts +51 -38
- package/src/commands/groups.ts +14 -0
- package/src/commands/gzip.ts +31 -0
- package/src/commands/head.ts +37 -0
- package/src/commands/help.ts +81 -3
- package/src/commands/hostname.ts +2 -0
- package/src/commands/htop.ts +2 -0
- package/src/commands/id.ts +16 -0
- package/src/commands/index.ts +114 -133
- package/src/commands/kill.ts +14 -0
- package/src/commands/ln.ts +49 -0
- package/src/commands/ls.ts +2 -0
- package/src/commands/mkdir.ts +2 -0
- package/src/commands/mv.ts +45 -0
- package/src/commands/nano.ts +2 -0
- package/src/commands/neofetch.ts +2 -0
- package/src/commands/passwd.ts +2 -0
- package/src/commands/ping.ts +20 -0
- package/src/commands/ps.ts +19 -0
- package/src/commands/pwd.ts +2 -0
- package/src/commands/rm.ts +2 -0
- package/src/commands/sed.ts +45 -0
- package/src/commands/set.ts +19 -50
- package/src/commands/sh.ts +192 -43
- package/src/commands/sleep.ts +14 -0
- package/src/commands/sort.ts +37 -0
- package/src/commands/su.ts +2 -0
- package/src/commands/sudo.ts +2 -0
- package/src/commands/tail.ts +39 -0
- package/src/commands/tar.ts +58 -0
- package/src/commands/tee.ts +25 -0
- package/src/commands/touch.ts +2 -0
- package/src/commands/tr.ts +24 -0
- package/src/commands/tree.ts +2 -0
- package/src/commands/uname.ts +20 -0
- package/src/commands/uniq.ts +28 -0
- package/src/commands/unset.ts +5 -12
- package/src/commands/wc.ts +50 -0
- package/src/commands/wget.ts +2 -0
- package/src/commands/who.ts +2 -0
- package/src/commands/whoami.ts +2 -0
- package/src/commands/xargs.ts +17 -0
- package/src/index.ts +1 -0
- package/src/types/commands.ts +14 -0
- package/src/types/pipeline.ts +23 -0
- package/standalone.js +93 -55
- package/standalone.js.map +4 -4
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +42 -88
package/src/SSHMimic/executor.ts
CHANGED
|
@@ -1,13 +1,79 @@
|
|
|
1
1
|
import { runCommand as runSingleCommand } from "../commands";
|
|
2
2
|
import { resolvePath } from "../commands/helpers";
|
|
3
|
-
import type { CommandMode, CommandResult } from "../types/commands";
|
|
4
|
-
import type { Pipeline, PipelineCommand } from "../types/pipeline";
|
|
3
|
+
import type { CommandMode, CommandResult, ShellEnv } from "../types/commands";
|
|
4
|
+
import type { Pipeline, PipelineCommand, Script, Statement } from "../types/pipeline";
|
|
5
5
|
import type { VirtualShell } from "../VirtualShell";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// ── Script executor (handles &&/||/;) ────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export async function executeScript(
|
|
10
|
+
script: Script,
|
|
11
|
+
authUser: string,
|
|
12
|
+
hostname: string,
|
|
13
|
+
mode: CommandMode,
|
|
14
|
+
cwd: string,
|
|
15
|
+
shell: VirtualShell,
|
|
16
|
+
env: ShellEnv,
|
|
17
|
+
): Promise<CommandResult> {
|
|
18
|
+
if (!script.isValid) return { stderr: script.error || "Syntax error", exitCode: 1 };
|
|
19
|
+
|
|
20
|
+
let lastResult: CommandResult = { exitCode: 0 };
|
|
21
|
+
|
|
22
|
+
for (const stmt of script.statements) {
|
|
23
|
+
// Decide whether to run this statement based on previous op
|
|
24
|
+
lastResult = await executePipeline(stmt.pipeline, authUser, hostname, mode, cwd, shell, env);
|
|
25
|
+
env.lastExitCode = lastResult.exitCode ?? 0;
|
|
26
|
+
|
|
27
|
+
// Propagate session-control signals
|
|
28
|
+
if (lastResult.closeSession || lastResult.switchUser || lastResult.nextCwd) {
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return lastResult;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Execute statements connected by &&/||/; */
|
|
37
|
+
export async function executeStatements(
|
|
38
|
+
statements: Statement[],
|
|
39
|
+
authUser: string,
|
|
40
|
+
hostname: string,
|
|
41
|
+
mode: CommandMode,
|
|
42
|
+
cwd: string,
|
|
43
|
+
shell: VirtualShell,
|
|
44
|
+
env: ShellEnv,
|
|
45
|
+
): Promise<CommandResult> {
|
|
46
|
+
let last: CommandResult = { exitCode: 0 };
|
|
47
|
+
let i = 0;
|
|
48
|
+
|
|
49
|
+
while (i < statements.length) {
|
|
50
|
+
const stmt = statements[i]!;
|
|
51
|
+
last = await executePipeline(stmt.pipeline, authUser, hostname, mode, cwd, shell, env);
|
|
52
|
+
env.lastExitCode = last.exitCode ?? 0;
|
|
53
|
+
|
|
54
|
+
if (last.closeSession || last.switchUser) return last;
|
|
55
|
+
|
|
56
|
+
const op = stmt.op;
|
|
57
|
+
if (!op || op === ";") {
|
|
58
|
+
// always run next
|
|
59
|
+
} else if (op === "&&") {
|
|
60
|
+
if ((last.exitCode ?? 0) !== 0) {
|
|
61
|
+
// skip until next ; or end
|
|
62
|
+
while (i < statements.length && statements[i]?.op === "&&") i++;
|
|
63
|
+
}
|
|
64
|
+
} else if (op === "||") {
|
|
65
|
+
if ((last.exitCode ?? 0) === 0) {
|
|
66
|
+
// skip until next ; or end
|
|
67
|
+
while (i < statements.length && statements[i]?.op === "||") i++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
return last;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Pipeline executor ─────────────────────────────────────────────────────────
|
|
76
|
+
|
|
11
77
|
export async function executePipeline(
|
|
12
78
|
pipeline: Pipeline,
|
|
13
79
|
authUser: string,
|
|
@@ -15,37 +81,26 @@ export async function executePipeline(
|
|
|
15
81
|
mode: CommandMode,
|
|
16
82
|
cwd: string,
|
|
17
83
|
shell: VirtualShell,
|
|
84
|
+
env?: ShellEnv,
|
|
18
85
|
): Promise<CommandResult> {
|
|
19
|
-
if (pipeline.
|
|
20
|
-
|
|
21
|
-
|
|
86
|
+
if (!pipeline.isValid) return { stderr: pipeline.error || "Syntax error", exitCode: 1 };
|
|
87
|
+
if (pipeline.commands.length === 0) return { exitCode: 0 };
|
|
88
|
+
|
|
89
|
+
const shellEnv: ShellEnv = env ?? { vars: {}, lastExitCode: 0 };
|
|
22
90
|
|
|
23
91
|
if (pipeline.commands.length === 1) {
|
|
24
|
-
// Single command with possible redirections
|
|
25
92
|
return executeSingleCommandWithRedirections(
|
|
26
93
|
pipeline.commands[0] as PipelineCommand,
|
|
27
|
-
authUser,
|
|
28
|
-
hostname,
|
|
29
|
-
mode,
|
|
30
|
-
cwd,
|
|
31
|
-
shell,
|
|
94
|
+
authUser, hostname, mode, cwd, shell, shellEnv,
|
|
32
95
|
);
|
|
33
96
|
}
|
|
34
97
|
|
|
35
|
-
// Multiple commands in a pipeline
|
|
36
98
|
return executePipelineChain(
|
|
37
99
|
pipeline.commands as PipelineCommand[],
|
|
38
|
-
authUser,
|
|
39
|
-
hostname,
|
|
40
|
-
mode,
|
|
41
|
-
cwd,
|
|
42
|
-
shell,
|
|
100
|
+
authUser, hostname, mode, cwd, shell, shellEnv,
|
|
43
101
|
);
|
|
44
102
|
}
|
|
45
103
|
|
|
46
|
-
/**
|
|
47
|
-
* Execute a single command with input/output redirections
|
|
48
|
-
*/
|
|
49
104
|
async function executeSingleCommandWithRedirections(
|
|
50
105
|
cmd: PipelineCommand,
|
|
51
106
|
authUser: string,
|
|
@@ -53,66 +108,37 @@ async function executeSingleCommandWithRedirections(
|
|
|
53
108
|
mode: CommandMode,
|
|
54
109
|
cwd: string,
|
|
55
110
|
shell: VirtualShell,
|
|
111
|
+
env: ShellEnv,
|
|
56
112
|
): Promise<CommandResult> {
|
|
57
|
-
// Prepare input if input file specified
|
|
58
113
|
let stdin: string | undefined;
|
|
59
114
|
if (cmd.inputFile) {
|
|
60
115
|
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
61
|
-
try {
|
|
62
|
-
|
|
63
|
-
} catch {
|
|
64
|
-
return {
|
|
65
|
-
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
66
|
-
exitCode: 1,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
116
|
+
try { stdin = shell.vfs.readFile(inputPath); }
|
|
117
|
+
catch { return { stderr: `${cmd.inputFile}: No such file or directory`, exitCode: 1 }; }
|
|
69
118
|
}
|
|
70
119
|
|
|
71
|
-
// Build raw input for the command
|
|
72
120
|
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
121
|
+
const result = await runSingleCommand(rawInput, authUser, hostname, mode, cwd, shell, stdin, env);
|
|
73
122
|
|
|
74
|
-
// Run the command with potential input
|
|
75
|
-
const result = await runSingleCommand(
|
|
76
|
-
rawInput,
|
|
77
|
-
authUser,
|
|
78
|
-
hostname,
|
|
79
|
-
mode,
|
|
80
|
-
cwd,
|
|
81
|
-
shell,
|
|
82
|
-
stdin,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
// Handle output redirection
|
|
86
123
|
if (cmd.outputFile) {
|
|
87
124
|
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
88
125
|
const output = result.stdout || "";
|
|
89
126
|
try {
|
|
90
127
|
if (cmd.appendOutput) {
|
|
91
|
-
try {
|
|
92
|
-
|
|
93
|
-
shell.writeFileAsUser(authUser, outputPath, existing + output);
|
|
94
|
-
} catch {
|
|
95
|
-
shell.writeFileAsUser(authUser, outputPath, output);
|
|
96
|
-
}
|
|
128
|
+
const existing = (() => { try { return shell.vfs.readFile(outputPath); } catch { return ""; } })();
|
|
129
|
+
shell.writeFileAsUser(authUser, outputPath, existing + output);
|
|
97
130
|
} else {
|
|
98
131
|
shell.writeFileAsUser(authUser, outputPath, output);
|
|
99
132
|
}
|
|
100
133
|
return { ...result, stdout: "" };
|
|
101
134
|
} catch {
|
|
102
|
-
return {
|
|
103
|
-
...result,
|
|
104
|
-
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
105
|
-
exitCode: 1,
|
|
106
|
-
};
|
|
135
|
+
return { ...result, stderr: `Failed to write to ${cmd.outputFile}`, exitCode: 1 };
|
|
107
136
|
}
|
|
108
137
|
}
|
|
109
138
|
|
|
110
139
|
return result;
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
/**
|
|
114
|
-
* Execute a chain of commands connected by pipes
|
|
115
|
-
*/
|
|
116
142
|
async function executePipelineChain(
|
|
117
143
|
commands: PipelineCommand[],
|
|
118
144
|
authUser: string,
|
|
@@ -120,6 +146,7 @@ async function executePipelineChain(
|
|
|
120
146
|
mode: CommandMode,
|
|
121
147
|
cwd: string,
|
|
122
148
|
shell: VirtualShell,
|
|
149
|
+
env: ShellEnv,
|
|
123
150
|
): Promise<CommandResult> {
|
|
124
151
|
let currentOutput = "";
|
|
125
152
|
let exitCode = 0;
|
|
@@ -127,66 +154,36 @@ async function executePipelineChain(
|
|
|
127
154
|
for (let i = 0; i < commands.length; i++) {
|
|
128
155
|
const cmd = commands[i] as PipelineCommand;
|
|
129
156
|
|
|
130
|
-
// Handle input file for first command
|
|
131
157
|
if (i === 0 && cmd.inputFile) {
|
|
132
158
|
const inputPath = resolvePath(cwd, cmd.inputFile);
|
|
133
|
-
try {
|
|
134
|
-
|
|
135
|
-
} catch {
|
|
136
|
-
return {
|
|
137
|
-
stderr: `cat: ${cmd.inputFile}: No such file or directory`,
|
|
138
|
-
exitCode: 1,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
159
|
+
try { currentOutput = shell.vfs.readFile(inputPath); }
|
|
160
|
+
catch { return { stderr: `${cmd.inputFile}: No such file or directory`, exitCode: 1 }; }
|
|
141
161
|
}
|
|
142
162
|
|
|
143
|
-
// Build raw input
|
|
144
163
|
const rawInput = [cmd.name, ...cmd.args].join(" ");
|
|
145
|
-
|
|
146
|
-
// Create a modified context that might accept stdin
|
|
147
|
-
// For now, we'll append input as an additional arg for commands that support it
|
|
148
|
-
const result = await runSingleCommand(
|
|
149
|
-
rawInput,
|
|
150
|
-
authUser,
|
|
151
|
-
hostname,
|
|
152
|
-
mode,
|
|
153
|
-
cwd,
|
|
154
|
-
shell,
|
|
155
|
-
currentOutput,
|
|
156
|
-
);
|
|
157
|
-
|
|
164
|
+
const result = await runSingleCommand(rawInput, authUser, hostname, mode, cwd, shell, currentOutput, env);
|
|
158
165
|
exitCode = result.exitCode ?? 0;
|
|
159
166
|
|
|
160
|
-
// Handle output redirection (only for last command)
|
|
161
167
|
if (i === commands.length - 1 && cmd.outputFile) {
|
|
162
168
|
const outputPath = resolvePath(cwd, cmd.outputFile);
|
|
163
169
|
const output = result.stdout || "";
|
|
164
170
|
try {
|
|
165
171
|
if (cmd.appendOutput) {
|
|
166
|
-
try {
|
|
167
|
-
|
|
168
|
-
shell.writeFileAsUser(authUser, outputPath, existing + output);
|
|
169
|
-
} catch {
|
|
170
|
-
shell.writeFileAsUser(authUser, outputPath, output);
|
|
171
|
-
}
|
|
172
|
+
const existing = (() => { try { return shell.vfs.readFile(outputPath); } catch { return ""; } })();
|
|
173
|
+
shell.writeFileAsUser(authUser, outputPath, existing + output);
|
|
172
174
|
} else {
|
|
173
175
|
shell.writeFileAsUser(authUser, outputPath, output);
|
|
174
176
|
}
|
|
175
177
|
currentOutput = "";
|
|
176
178
|
} catch {
|
|
177
|
-
return {
|
|
178
|
-
stderr: `Failed to write to ${cmd.outputFile}`,
|
|
179
|
-
exitCode: 1,
|
|
180
|
-
};
|
|
179
|
+
return { stderr: `Failed to write to ${cmd.outputFile}`, exitCode: 1 };
|
|
181
180
|
}
|
|
182
181
|
} else {
|
|
183
|
-
// Pass output to next command
|
|
184
182
|
currentOutput = result.stdout || "";
|
|
185
183
|
}
|
|
186
184
|
|
|
187
|
-
if (result.stderr && exitCode !== 0) {
|
|
188
|
-
|
|
189
|
-
}
|
|
185
|
+
if (result.stderr && exitCode !== 0) return { stderr: result.stderr, exitCode };
|
|
186
|
+
if (result.closeSession || result.switchUser) return result;
|
|
190
187
|
}
|
|
191
188
|
|
|
192
189
|
return { stdout: currentOutput, exitCode };
|
package/src/SSHMimic/index.ts
CHANGED
|
@@ -11,14 +11,31 @@ import { loadOrCreateHostKey } from "./hostKey";
|
|
|
11
11
|
* This class is exported as `VirtualSshServer` for public API compatibility.
|
|
12
12
|
* Create an instance, call {@link SshMimic.start}, and stop it with
|
|
13
13
|
* {@link SshMimic.stop} when your process exits.
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Password authentication
|
|
17
|
+
* - Public-key authentication
|
|
18
|
+
* - Per-IP rate limiting / lockout for brute-force protection
|
|
19
|
+
* - Interactive shell sessions
|
|
20
|
+
* - Non-interactive exec sessions
|
|
14
21
|
*/
|
|
15
22
|
const perf: PerfLogger = createPerfLogger("SshMimic");
|
|
16
23
|
|
|
24
|
+
interface RateLimitEntry {
|
|
25
|
+
attempts: number;
|
|
26
|
+
lockedUntil: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
class SshMimic extends EventEmitter {
|
|
18
30
|
port: number;
|
|
19
31
|
server: SshServer | null;
|
|
20
32
|
private shell: VirtualShell;
|
|
21
|
-
|
|
33
|
+
|
|
34
|
+
/** Max failed auth attempts before an IP is temporarily locked. */
|
|
35
|
+
private readonly maxAuthAttempts: number;
|
|
36
|
+
/** How long (ms) a locked IP must wait before retrying. */
|
|
37
|
+
private readonly lockoutDurationMs: number;
|
|
38
|
+
private readonly authAttempts = new Map<string, RateLimitEntry>();
|
|
22
39
|
|
|
23
40
|
/**
|
|
24
41
|
* Creates a new SSH mimic server instance.
|
|
@@ -26,24 +43,73 @@ class SshMimic extends EventEmitter {
|
|
|
26
43
|
* @param port TCP port to bind on localhost.
|
|
27
44
|
* @param hostname Virtual hostname used for the SSH ident and default shell label.
|
|
28
45
|
* @param shell Optional preconfigured virtual shell instance to reuse.
|
|
46
|
+
* @param maxAuthAttempts Max failed attempts per IP before lockout (default: 5).
|
|
47
|
+
* @param lockoutDurationMs Lockout window in ms after exceeding attempts (default: 60 000).
|
|
29
48
|
*/
|
|
30
49
|
constructor({
|
|
31
50
|
port,
|
|
32
51
|
hostname = "typescript-vm",
|
|
33
52
|
shell = new VirtualShell(hostname),
|
|
53
|
+
maxAuthAttempts = 5,
|
|
54
|
+
lockoutDurationMs = 60_000,
|
|
34
55
|
}: {
|
|
35
56
|
port: number;
|
|
36
57
|
hostname?: string;
|
|
37
58
|
shell?: VirtualShell;
|
|
59
|
+
maxAuthAttempts?: number;
|
|
60
|
+
lockoutDurationMs?: number;
|
|
38
61
|
}) {
|
|
39
62
|
super();
|
|
40
63
|
perf.mark("constructor");
|
|
41
64
|
this.port = port;
|
|
42
|
-
this.shellHostname = hostname;
|
|
43
65
|
this.server = null;
|
|
44
66
|
this.shell = shell;
|
|
67
|
+
this.maxAuthAttempts = maxAuthAttempts;
|
|
68
|
+
this.lockoutDurationMs = lockoutDurationMs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Rate limiting ────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
private isLockedOut(ip: string): boolean {
|
|
74
|
+
const entry = this.authAttempts.get(ip);
|
|
75
|
+
if (!entry) return false;
|
|
76
|
+
if (Date.now() < entry.lockedUntil) return true;
|
|
77
|
+
if (entry.lockedUntil > 0) {
|
|
78
|
+
this.authAttempts.delete(ip);
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private recordFailure(ip: string): void {
|
|
84
|
+
const entry = this.authAttempts.get(ip) ?? { attempts: 0, lockedUntil: 0 };
|
|
85
|
+
entry.attempts += 1;
|
|
86
|
+
if (entry.attempts >= this.maxAuthAttempts) {
|
|
87
|
+
entry.lockedUntil = Date.now() + this.lockoutDurationMs;
|
|
88
|
+
this.emit("auth:lockout", { ip, until: new Date(entry.lockedUntil) });
|
|
89
|
+
}
|
|
90
|
+
this.authAttempts.set(ip, entry);
|
|
45
91
|
}
|
|
46
92
|
|
|
93
|
+
private recordSuccess(ip: string): void {
|
|
94
|
+
this.authAttempts.delete(ip);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Home directory bootstrap ─────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
private ensureHomeDir(authUser: string): void {
|
|
100
|
+
const homePath = `/home/${authUser}`;
|
|
101
|
+
if (!this.shell.vfs.exists(homePath)) {
|
|
102
|
+
this.shell.vfs.mkdir(homePath, 0o755);
|
|
103
|
+
this.shell.vfs.writeFile(
|
|
104
|
+
`${homePath}/README.txt`,
|
|
105
|
+
`Welcome to ${this.shell.hostname}\n`,
|
|
106
|
+
);
|
|
107
|
+
void this.shell.vfs.flushMirror();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Server lifecycle ─────────────────────────────────────────────────────
|
|
112
|
+
|
|
47
113
|
/**
|
|
48
114
|
* Starts server and initializes virtual filesystem, users, and handlers.
|
|
49
115
|
*
|
|
@@ -54,7 +120,6 @@ class SshMimic extends EventEmitter {
|
|
|
54
120
|
const shell = this.shell;
|
|
55
121
|
const privateKey = loadOrCreateHostKey();
|
|
56
122
|
|
|
57
|
-
// Ensure VirtualShell is fully initialized before accepting connections
|
|
58
123
|
await shell.ensureInitialized();
|
|
59
124
|
|
|
60
125
|
this.server = new SshServer(
|
|
@@ -70,32 +135,27 @@ class SshMimic extends EventEmitter {
|
|
|
70
135
|
this.emit("client:connect");
|
|
71
136
|
|
|
72
137
|
client.on("authentication", (ctx) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const candidateUser = ctx.username || "root";
|
|
76
|
-
remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
|
|
138
|
+
const candidateUser = ctx.username || "root";
|
|
139
|
+
remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
|
|
77
140
|
|
|
141
|
+
// Rate-limit check
|
|
142
|
+
if (this.isLockedOut(remoteAddress)) {
|
|
143
|
+
this.emit("auth:failure", { username: candidateUser, remoteAddress, reason: "lockout" });
|
|
144
|
+
ctx.reject();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Password auth ──────────────────────────────────────
|
|
149
|
+
if (ctx.method === "password") {
|
|
78
150
|
if (!shell.users.hasPassword(candidateUser)) {
|
|
79
151
|
console.log(
|
|
80
152
|
`User ${candidateUser} has no password set, allowing login without verification`,
|
|
81
153
|
);
|
|
82
154
|
authUser = candidateUser;
|
|
83
|
-
sessionId = shell.users.registerSession(
|
|
84
|
-
|
|
85
|
-
remoteAddress,
|
|
86
|
-
).id;
|
|
155
|
+
sessionId = shell.users.registerSession(authUser, remoteAddress).id;
|
|
156
|
+
this.recordSuccess(remoteAddress);
|
|
87
157
|
this.emit("auth:success", { username: authUser, remoteAddress });
|
|
88
|
-
|
|
89
|
-
const homePath = `/home/${authUser}`;
|
|
90
|
-
if (!shell.vfs.exists(homePath)) {
|
|
91
|
-
shell.vfs.mkdir(homePath, 0o755);
|
|
92
|
-
shell.vfs.writeFile(
|
|
93
|
-
`${homePath}/README.txt`,
|
|
94
|
-
`Welcome to ${shell?.hostname ?? this.shellHostname}`,
|
|
95
|
-
);
|
|
96
|
-
void shell.vfs.flushMirror();
|
|
97
|
-
}
|
|
98
|
-
|
|
158
|
+
this.ensureHomeDir(authUser);
|
|
99
159
|
ctx.accept();
|
|
100
160
|
return;
|
|
101
161
|
}
|
|
@@ -105,33 +165,60 @@ class SshMimic extends EventEmitter {
|
|
|
105
165
|
ctx.password === "" ||
|
|
106
166
|
!shell.users.verifyPassword(candidateUser, ctx.password)
|
|
107
167
|
) {
|
|
108
|
-
this.
|
|
109
|
-
|
|
110
|
-
remoteAddress,
|
|
111
|
-
});
|
|
168
|
+
this.recordFailure(remoteAddress);
|
|
169
|
+
this.emit("auth:failure", { username: candidateUser, remoteAddress });
|
|
112
170
|
ctx.reject();
|
|
113
171
|
return;
|
|
114
172
|
}
|
|
115
173
|
|
|
116
174
|
authUser = candidateUser;
|
|
117
175
|
sessionId = shell.users.registerSession(authUser, remoteAddress).id;
|
|
176
|
+
this.recordSuccess(remoteAddress);
|
|
118
177
|
this.emit("auth:success", { username: authUser, remoteAddress });
|
|
178
|
+
this.ensureHomeDir(authUser);
|
|
179
|
+
ctx.accept();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
119
182
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
void shell.vfs.flushMirror();
|
|
183
|
+
// ── Public-key auth ────────────────────────────────────
|
|
184
|
+
if (ctx.method === "publickey") {
|
|
185
|
+
const authorizedKeys = shell.users.getAuthorizedKeys(candidateUser);
|
|
186
|
+
if (authorizedKeys.length === 0) {
|
|
187
|
+
// No keys configured — reject cleanly
|
|
188
|
+
ctx.reject();
|
|
189
|
+
return;
|
|
128
190
|
}
|
|
129
191
|
|
|
130
|
-
ctx.
|
|
192
|
+
const incomingKey = ctx.key;
|
|
193
|
+
const keyMatches = authorizedKeys.some(
|
|
194
|
+
(k) =>
|
|
195
|
+
k.algo === incomingKey.algo &&
|
|
196
|
+
k.data.equals(incomingKey.data),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (!keyMatches) {
|
|
200
|
+
this.recordFailure(remoteAddress);
|
|
201
|
+
this.emit("auth:failure", { username: candidateUser, remoteAddress, method: "publickey" });
|
|
202
|
+
ctx.reject();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Key matched — if this is a signature check step, accept
|
|
207
|
+
if (ctx.signature) {
|
|
208
|
+
authUser = candidateUser;
|
|
209
|
+
sessionId = shell.users.registerSession(authUser, remoteAddress).id;
|
|
210
|
+
this.recordSuccess(remoteAddress);
|
|
211
|
+
this.emit("auth:success", { username: authUser, remoteAddress, method: "publickey" });
|
|
212
|
+
this.ensureHomeDir(authUser);
|
|
213
|
+
ctx.accept();
|
|
214
|
+
} else {
|
|
215
|
+
// Key exists but no signature yet — ssh2 will call again with signature
|
|
216
|
+
ctx.accept();
|
|
217
|
+
}
|
|
131
218
|
return;
|
|
132
219
|
}
|
|
133
220
|
|
|
134
|
-
ctx.reject();
|
|
221
|
+
ctx.reject(["password", "publickey"]);
|
|
135
222
|
});
|
|
136
223
|
|
|
137
224
|
client.on("close", () => {
|
|
@@ -151,35 +238,20 @@ class SshMimic extends EventEmitter {
|
|
|
151
238
|
acceptPty();
|
|
152
239
|
});
|
|
153
240
|
|
|
154
|
-
session.on(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
terminalSize.rows = info?.rows ?? terminalSize.rows;
|
|
159
|
-
},
|
|
160
|
-
);
|
|
241
|
+
session.on("window-change", (_acceptChange, _rejectChange, info) => {
|
|
242
|
+
terminalSize.cols = info?.cols ?? terminalSize.cols;
|
|
243
|
+
terminalSize.rows = info?.rows ?? terminalSize.rows;
|
|
244
|
+
});
|
|
161
245
|
|
|
162
246
|
session.on("shell", (acceptShell) => {
|
|
163
247
|
const stream = acceptShell();
|
|
164
|
-
shell?.startInteractiveSession(
|
|
165
|
-
stream,
|
|
166
|
-
authUser,
|
|
167
|
-
sessionId,
|
|
168
|
-
remoteAddress,
|
|
169
|
-
terminalSize,
|
|
170
|
-
);
|
|
248
|
+
shell?.startInteractiveSession(stream, authUser, sessionId, remoteAddress, terminalSize);
|
|
171
249
|
});
|
|
172
250
|
|
|
173
251
|
session.on("exec", (acceptExec, _rejectExec, info) => {
|
|
174
252
|
const stream = acceptExec();
|
|
175
253
|
if (stream) {
|
|
176
|
-
runExec(
|
|
177
|
-
stream,
|
|
178
|
-
info.command.trim(),
|
|
179
|
-
authUser,
|
|
180
|
-
shell.hostname,
|
|
181
|
-
shell,
|
|
182
|
-
);
|
|
254
|
+
runExec(stream, info.command.trim(), authUser, shell.hostname, shell);
|
|
183
255
|
}
|
|
184
256
|
});
|
|
185
257
|
});
|
|
@@ -209,7 +281,16 @@ class SshMimic extends EventEmitter {
|
|
|
209
281
|
});
|
|
210
282
|
}
|
|
211
283
|
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Manually clears the rate-limit record for an IP address.
|
|
287
|
+
* Useful in tests or admin tooling.
|
|
288
|
+
*/
|
|
289
|
+
public clearLockout(ip: string): void {
|
|
290
|
+
this.authAttempts.delete(ip);
|
|
291
|
+
}
|
|
212
292
|
}
|
|
213
293
|
|
|
214
294
|
export { SftpMimic } from "./sftp";
|
|
215
295
|
export { SshMimic };
|
|
296
|
+
|
package/src/SSHMimic/sftp.ts
CHANGED
|
@@ -253,6 +253,14 @@ export class SftpMimic extends EventEmitter {
|
|
|
253
253
|
);
|
|
254
254
|
|
|
255
255
|
if (ctx.method === "password") {
|
|
256
|
+
// If no password is set for the user, allow login without verification
|
|
257
|
+
if (!this.getUsers().hasPassword(candidateUser)) {
|
|
258
|
+
acceptSession(candidateUser);
|
|
259
|
+
this.emit("auth:success", { username: authUser, remoteAddress });
|
|
260
|
+
ctx.accept();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
256
264
|
if (
|
|
257
265
|
!this.getUsers().verifyPassword(candidateUser, ctx.password ?? "")
|
|
258
266
|
) {
|
|
@@ -272,6 +280,13 @@ export class SftpMimic extends EventEmitter {
|
|
|
272
280
|
|
|
273
281
|
if (ctx.method === "keyboard-interactive") {
|
|
274
282
|
const keyboardCtx = ctx as KeyboardAuthContext;
|
|
283
|
+
// If no password is set, accept immediately
|
|
284
|
+
if (!this.getUsers().hasPassword(candidateUser)) {
|
|
285
|
+
acceptSession(candidateUser);
|
|
286
|
+
this.emit("auth:success", { username: authUser, remoteAddress });
|
|
287
|
+
keyboardCtx.accept();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
275
290
|
keyboardCtx.prompt(
|
|
276
291
|
[{ prompt: "Password: ", echo: false }],
|
|
277
292
|
(answers) => {
|