typescript-virtual-container 1.0.7 → 1.1.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/CHANGELOG.md +4 -0
- package/README.md +138 -87
- package/package.json +1 -1
- package/src/SSHMimic/client.ts +15 -18
- package/src/SSHMimic/exec.ts +5 -16
- package/src/SSHMimic/executor.ts +18 -29
- package/src/SSHMimic/index.ts +23 -85
- package/src/VirtualFileSystem/index.ts +3 -1
- package/src/VirtualShell/commands/adduser.ts +2 -2
- package/src/VirtualShell/commands/cat.ts +3 -3
- package/src/VirtualShell/commands/cd.ts +2 -2
- package/src/VirtualShell/commands/command-helpers.ts +64 -0
- package/src/VirtualShell/commands/curl.ts +14 -92
- package/src/VirtualShell/commands/deluser.ts +2 -2
- package/src/VirtualShell/commands/echo.ts +5 -12
- package/src/VirtualShell/commands/grep.ts +8 -16
- package/src/VirtualShell/commands/helpers.ts +74 -0
- package/src/VirtualShell/commands/index.ts +46 -112
- package/src/VirtualShell/commands/ls.ts +6 -4
- package/src/VirtualShell/commands/mkdir.ts +2 -2
- package/src/VirtualShell/commands/nano.ts +3 -3
- package/src/VirtualShell/commands/neofetch.ts +2 -2
- package/src/VirtualShell/commands/rm.ts +2 -2
- package/src/VirtualShell/commands/sh.ts +2 -13
- package/src/VirtualShell/commands/su.ts +2 -1
- package/src/VirtualShell/commands/sudo.ts +12 -25
- package/src/VirtualShell/commands/touch.ts +3 -3
- package/src/VirtualShell/commands/tree.ts +2 -2
- package/src/VirtualShell/commands/wget.ts +19 -29
- package/src/VirtualShell/commands/who.ts +2 -2
- package/src/VirtualShell/index.ts +114 -25
- package/src/VirtualShell/shell.ts +28 -35
- package/src/{SSHMimic/users.ts → VirtualUserManager/index.ts} +6 -3
- package/src/index.ts +4 -4
- package/src/standalone.ts +19 -14
- package/src/types/commands.ts +3 -11
- package/tests/parser-executor.test.ts +37 -0
- package/tests/users.test.ts +1 -1
|
@@ -3,11 +3,12 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { ShellModule } from "../../types/commands";
|
|
6
|
-
import {
|
|
6
|
+
import { ifFlag, parseArgs } from "./command-helpers";
|
|
7
7
|
import {
|
|
8
8
|
assertPathAccess,
|
|
9
9
|
normalizeTerminalOutput,
|
|
10
10
|
resolvePath,
|
|
11
|
+
runHostCommand,
|
|
11
12
|
stripUrlFilename,
|
|
12
13
|
} from "./helpers";
|
|
13
14
|
|
|
@@ -82,37 +83,26 @@ function runHostWget(args: string[]): Promise<{
|
|
|
82
83
|
export const wgetCommand: ShellModule = {
|
|
83
84
|
name: "wget",
|
|
84
85
|
params: ["[url]"],
|
|
85
|
-
run: async ({ authUser,
|
|
86
|
-
const
|
|
87
|
-
"-o",
|
|
88
|
-
"-O",
|
|
89
|
-
"--output",
|
|
90
|
-
"--output-document",
|
|
91
|
-
]);
|
|
92
|
-
const outputPath =
|
|
93
|
-
typeof outputPathValue === "string" && outputPathValue.length > 0
|
|
94
|
-
? outputPathValue
|
|
95
|
-
: null;
|
|
96
|
-
const parserOptions = {
|
|
86
|
+
run: async ({ authUser, cwd, args, shell }) => {
|
|
87
|
+
const { flagsWithValues, positionals } = parseArgs(args, {
|
|
97
88
|
flagsWithValue: ["-o", "-O", "--output", "--output-document"],
|
|
98
|
-
};
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
const url = inputArgs[0];
|
|
108
|
-
const isHelpLike = ifFlag(args, ["-h", "--help", "-V", "--version"]);
|
|
89
|
+
});
|
|
90
|
+
const outputPath =
|
|
91
|
+
flagsWithValues.get("-o") ||
|
|
92
|
+
flagsWithValues.get("-O") ||
|
|
93
|
+
flagsWithValues.get("--output") ||
|
|
94
|
+
flagsWithValues.get("--output-document") ||
|
|
95
|
+
null;
|
|
96
|
+
const url = positionals[0];
|
|
109
97
|
|
|
110
98
|
if (!url) {
|
|
111
99
|
return { stderr: "wget: missing URL", exitCode: 1 };
|
|
112
100
|
}
|
|
113
101
|
|
|
102
|
+
const isHelpLike = ifFlag(args, ["-h", "--help", "-V", "--version"]);
|
|
103
|
+
|
|
114
104
|
if (isHelpLike) {
|
|
115
|
-
const result = await runHostWget(
|
|
105
|
+
const result = await runHostWget(args);
|
|
116
106
|
return {
|
|
117
107
|
stdout: normalizeTerminalOutput(result.stdout),
|
|
118
108
|
stderr: result.stderr
|
|
@@ -126,8 +116,8 @@ export const wgetCommand: ShellModule = {
|
|
|
126
116
|
const tempFile = join(tempDir, "download");
|
|
127
117
|
|
|
128
118
|
try {
|
|
129
|
-
const hostArgs = [...
|
|
130
|
-
const result = await
|
|
119
|
+
const hostArgs = [...positionals, "-O", tempFile];
|
|
120
|
+
const result = await runHostCommand("wget", hostArgs);
|
|
131
121
|
|
|
132
122
|
if (result.exitCode !== 0) {
|
|
133
123
|
return {
|
|
@@ -139,9 +129,9 @@ export const wgetCommand: ShellModule = {
|
|
|
139
129
|
}
|
|
140
130
|
|
|
141
131
|
const content = await readFile(tempFile, "utf8");
|
|
142
|
-
const target = resolvePath(cwd, outputPath
|
|
132
|
+
const target = resolvePath(cwd, outputPath || stripUrlFilename(url));
|
|
143
133
|
assertPathAccess(authUser, target, "wget");
|
|
144
|
-
vfs.writeFile(target, content);
|
|
134
|
+
shell.vfs.writeFile(target, content);
|
|
145
135
|
|
|
146
136
|
return {
|
|
147
137
|
stdout: `saved ${target}`,
|
|
@@ -4,8 +4,8 @@ import type { ShellModule } from "../../types/commands";
|
|
|
4
4
|
export const whoCommand: ShellModule = {
|
|
5
5
|
name: "who",
|
|
6
6
|
params: [],
|
|
7
|
-
run: ({
|
|
8
|
-
const lines = users.listActiveSessions().map((session) => {
|
|
7
|
+
run: ({ shell }) => {
|
|
8
|
+
const lines = shell.users.listActiveSessions().map((session) => {
|
|
9
9
|
const loginAt = new Date(session.startedAt);
|
|
10
10
|
const displayDate = Number.isNaN(loginAt.getTime())
|
|
11
11
|
? session.startedAt
|
|
@@ -1,40 +1,96 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
2
|
import type { CommandContext, CommandResult } from "../types/commands";
|
|
3
3
|
import type { ShellStream } from "../types/streams";
|
|
4
|
-
import
|
|
4
|
+
import VirtualFileSystem from "../VirtualFileSystem";
|
|
5
|
+
import { VirtualUserManager } from "../VirtualUserManager";
|
|
5
6
|
import { createCustomCommand, registerCommand, runCommand } from "./commands";
|
|
6
7
|
import { startShell } from "./shell";
|
|
7
8
|
|
|
8
9
|
export interface ShellProperties {
|
|
9
10
|
kernel: string;
|
|
10
|
-
os:
|
|
11
|
-
arch:
|
|
11
|
+
os: string;
|
|
12
|
+
arch: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
const defaultShellProperties: ShellProperties = {
|
|
15
16
|
kernel: "1.0.0+itsrealfortune+1-amd64",
|
|
16
17
|
os: "Fortune GNU/Linux x64",
|
|
17
18
|
arch: "x86_64",
|
|
18
19
|
};
|
|
19
20
|
|
|
21
|
+
function resolveRootPassword(): string {
|
|
22
|
+
const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
|
|
23
|
+
if (configured && configured.trim().length > 0) {
|
|
24
|
+
return configured;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const generated = randomBytes(18).toString("base64url");
|
|
28
|
+
console.warn(
|
|
29
|
+
`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
|
|
30
|
+
);
|
|
31
|
+
return generated;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveAutoSudoForNewUsers(): boolean {
|
|
35
|
+
const configured = process.env.SSH_MIMIC_AUTO_SUDO_NEW_USERS;
|
|
36
|
+
if (!configured) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return !["0", "false", "no", "off"].includes(configured.toLowerCase());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Coordinates the virtual filesystem, user manager, and command runtime.
|
|
45
|
+
*
|
|
46
|
+
* Instances are used both by the SSH server facade and by the programmatic
|
|
47
|
+
* client API.
|
|
48
|
+
*/
|
|
20
49
|
class VirtualShell {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
50
|
+
basePath: string = ".";
|
|
51
|
+
vfs: VirtualFileSystem;
|
|
52
|
+
users: VirtualUserManager;
|
|
53
|
+
hostname: string;
|
|
54
|
+
properties: ShellProperties;
|
|
25
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Creates a new virtual shell instance.
|
|
58
|
+
*
|
|
59
|
+
* @param hostname Virtual hostname used for prompts and idents.
|
|
60
|
+
* @param properties Customizable properties shown in `uname -a` and similar commands.
|
|
61
|
+
* @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
|
|
62
|
+
*/
|
|
26
63
|
constructor(
|
|
27
|
-
vfs: VirtualFileSystem,
|
|
28
|
-
users: VirtualUserManager,
|
|
29
64
|
hostname: string,
|
|
30
65
|
properties?: ShellProperties,
|
|
66
|
+
basePath?: string,
|
|
31
67
|
) {
|
|
32
|
-
this.vfs = vfs;
|
|
33
|
-
this.users = users;
|
|
34
68
|
this.hostname = hostname;
|
|
35
69
|
this.properties = properties || defaultShellProperties;
|
|
70
|
+
this.basePath = basePath || ".";
|
|
71
|
+
this.vfs = new VirtualFileSystem(this.basePath);
|
|
72
|
+
this.users = new VirtualUserManager(
|
|
73
|
+
this.vfs,
|
|
74
|
+
resolveRootPassword(),
|
|
75
|
+
resolveAutoSudoForNewUsers(),
|
|
76
|
+
);
|
|
77
|
+
this.vfs.restoreMirror().then(() => {
|
|
78
|
+
this.users = new VirtualUserManager(
|
|
79
|
+
this.vfs,
|
|
80
|
+
resolveRootPassword(),
|
|
81
|
+
resolveAutoSudoForNewUsers(),
|
|
82
|
+
);
|
|
83
|
+
this.users.initialize();
|
|
84
|
+
});
|
|
36
85
|
}
|
|
37
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Registers a new command in the shell runtime.
|
|
89
|
+
*
|
|
90
|
+
* @param name Case-insensitive command name (no spaces).
|
|
91
|
+
* @param params List of parameter names for help text (no validation).
|
|
92
|
+
* @param callback Function invoked with command context on execution.
|
|
93
|
+
*/
|
|
38
94
|
addCommand(
|
|
39
95
|
name: string,
|
|
40
96
|
params: string[],
|
|
@@ -48,19 +104,26 @@ class VirtualShell {
|
|
|
48
104
|
registerCommand(createCustomCommand(normalized, params, callback));
|
|
49
105
|
}
|
|
50
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Executes a command line string in the context of this shell instance.
|
|
109
|
+
*
|
|
110
|
+
* @param rawInput
|
|
111
|
+
* @param authUser
|
|
112
|
+
* @param cwd
|
|
113
|
+
*/
|
|
51
114
|
executeCommand(rawInput: string, authUser: string, cwd: string): void {
|
|
52
|
-
runCommand(
|
|
53
|
-
rawInput,
|
|
54
|
-
authUser,
|
|
55
|
-
this.hostname,
|
|
56
|
-
this.users,
|
|
57
|
-
"shell",
|
|
58
|
-
cwd,
|
|
59
|
-
this.properties,
|
|
60
|
-
this.vfs,
|
|
61
|
-
);
|
|
115
|
+
runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
|
|
62
116
|
}
|
|
63
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Starts an interactive session with the shell.
|
|
120
|
+
*
|
|
121
|
+
* @param stream The stream for the interactive session.
|
|
122
|
+
* @param authUser The authenticated user for the session.
|
|
123
|
+
* @param sessionId The ID of the session.
|
|
124
|
+
* @param remoteAddress The address of the remote client.
|
|
125
|
+
*/
|
|
126
|
+
|
|
64
127
|
startInteractiveSession(
|
|
65
128
|
stream: ShellStream,
|
|
66
129
|
authUser: string,
|
|
@@ -73,14 +136,40 @@ class VirtualShell {
|
|
|
73
136
|
this.properties,
|
|
74
137
|
stream,
|
|
75
138
|
authUser,
|
|
76
|
-
this.vfs!,
|
|
77
139
|
this.hostname,
|
|
78
|
-
this.users!,
|
|
79
140
|
sessionId,
|
|
80
141
|
remoteAddress,
|
|
81
142
|
terminalSize,
|
|
143
|
+
this,
|
|
82
144
|
);
|
|
83
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Returns virtual filesystem instance after server started.
|
|
149
|
+
*
|
|
150
|
+
* @returns VirtualFileSystem or null when not started.
|
|
151
|
+
*/
|
|
152
|
+
public getVfs(): VirtualFileSystem | null {
|
|
153
|
+
return this?.vfs ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Returns user manager instance after server started.
|
|
158
|
+
*
|
|
159
|
+
* @returns VirtualUserManager or null when not started.
|
|
160
|
+
*/
|
|
161
|
+
public getUsers(): VirtualUserManager | null {
|
|
162
|
+
return this?.users ?? null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Returns hostname shown in prompts and idents.
|
|
167
|
+
*
|
|
168
|
+
* @returns Configured hostname label.
|
|
169
|
+
*/
|
|
170
|
+
public getHostname(): string {
|
|
171
|
+
return this?.hostname;
|
|
172
|
+
}
|
|
84
173
|
}
|
|
85
174
|
|
|
86
175
|
export { VirtualShell };
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
2
|
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import type { ShellProperties, VirtualShell } from ".";
|
|
5
5
|
import { formatLoginDate } from "../SSHMimic/loginFormat";
|
|
6
6
|
import { buildPrompt } from "../SSHMimic/prompt";
|
|
7
|
-
import type { VirtualUserManager } from "../SSHMimic/users";
|
|
8
7
|
import type { ShellStream } from "../types/streams";
|
|
9
8
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
10
9
|
import { getCommandNames, runCommand } from "./commands";
|
|
@@ -45,16 +44,15 @@ export function startShell(
|
|
|
45
44
|
properties: ShellProperties,
|
|
46
45
|
stream: ShellStream,
|
|
47
46
|
authUser: string,
|
|
48
|
-
vfs: VirtualFileSystem,
|
|
49
47
|
hostname: string,
|
|
50
|
-
users: VirtualUserManager,
|
|
51
48
|
sessionId: string | null,
|
|
52
49
|
remoteAddress = "unknown",
|
|
53
50
|
terminalSize: TerminalSize = { cols: 80, rows: 24 },
|
|
51
|
+
shell: VirtualShell,
|
|
54
52
|
): void {
|
|
55
53
|
let lineBuffer = "";
|
|
56
54
|
let cursorPos = 0;
|
|
57
|
-
let history = loadHistory(vfs);
|
|
55
|
+
let history = loadHistory(shell.vfs);
|
|
58
56
|
let historyIndex: number | null = null;
|
|
59
57
|
let historyDraft = "";
|
|
60
58
|
let cwd = `/home/${authUser}`;
|
|
@@ -66,6 +64,9 @@ export function startShell(
|
|
|
66
64
|
return buildPrompt(authUser, hostname, cwdLabel);
|
|
67
65
|
};
|
|
68
66
|
const commandNames = Array.from(new Set(getCommandNames())).sort();
|
|
67
|
+
console.log(
|
|
68
|
+
`[${sessionId}] Shell started for user '${authUser}' at ${remoteAddress}`,
|
|
69
|
+
);
|
|
69
70
|
|
|
70
71
|
async function collectChildPids(parentPid: number): Promise<number[]> {
|
|
71
72
|
try {
|
|
@@ -167,7 +168,7 @@ export function startShell(
|
|
|
167
168
|
if (!challenge.commandLine) {
|
|
168
169
|
authUser = challenge.targetUser;
|
|
169
170
|
cwd = `/home/${authUser}`;
|
|
170
|
-
users.updateSession(sessionId, authUser, remoteAddress);
|
|
171
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
171
172
|
stream.write("\r\n");
|
|
172
173
|
renderLine();
|
|
173
174
|
return;
|
|
@@ -179,11 +180,9 @@ export function startShell(
|
|
|
179
180
|
challenge.commandLine,
|
|
180
181
|
challenge.targetUser,
|
|
181
182
|
hostname,
|
|
182
|
-
users,
|
|
183
183
|
"shell",
|
|
184
184
|
runCwd,
|
|
185
|
-
|
|
186
|
-
vfs,
|
|
185
|
+
shell,
|
|
187
186
|
),
|
|
188
187
|
);
|
|
189
188
|
|
|
@@ -218,12 +217,12 @@ export function startShell(
|
|
|
218
217
|
if (result.switchUser) {
|
|
219
218
|
authUser = result.switchUser;
|
|
220
219
|
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
221
|
-
users.updateSession(sessionId, authUser, remoteAddress);
|
|
220
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
222
221
|
} else if (result.nextCwd) {
|
|
223
222
|
cwd = result.nextCwd;
|
|
224
223
|
}
|
|
225
224
|
|
|
226
|
-
await vfs.flushMirror();
|
|
225
|
+
await shell.vfs.flushMirror();
|
|
227
226
|
renderLine();
|
|
228
227
|
}
|
|
229
228
|
|
|
@@ -237,8 +236,8 @@ export function startShell(
|
|
|
237
236
|
if (activeSession.kind === "nano") {
|
|
238
237
|
try {
|
|
239
238
|
const updatedContent = await readFile(activeSession.tempPath, "utf8");
|
|
240
|
-
vfs.writeFile(activeSession.targetPath, updatedContent);
|
|
241
|
-
await vfs.flushMirror();
|
|
239
|
+
shell.vfs.writeFile(activeSession.targetPath, updatedContent);
|
|
240
|
+
await shell.vfs.flushMirror();
|
|
242
241
|
} catch {
|
|
243
242
|
// If temp file does not exist, nano exited without writing.
|
|
244
243
|
}
|
|
@@ -258,7 +257,7 @@ export function startShell(
|
|
|
258
257
|
initialContent: string,
|
|
259
258
|
tempPath: string,
|
|
260
259
|
): Promise<void> {
|
|
261
|
-
if (vfs.exists(targetPath)) {
|
|
260
|
+
if (shell.vfs.exists(targetPath)) {
|
|
262
261
|
await writeFile(tempPath, initialContent, "utf8");
|
|
263
262
|
}
|
|
264
263
|
|
|
@@ -375,13 +374,13 @@ export function startShell(
|
|
|
375
374
|
const basePath = resolvePath(cwd, dirPart || ".");
|
|
376
375
|
|
|
377
376
|
try {
|
|
378
|
-
return vfs
|
|
377
|
+
return shell.vfs
|
|
379
378
|
.list(basePath)
|
|
380
379
|
.filter((entry) => !entry.startsWith("."))
|
|
381
380
|
.filter((entry) => entry.startsWith(namePart))
|
|
382
381
|
.map((entry) => {
|
|
383
382
|
const fullPath = path.posix.join(basePath, entry);
|
|
384
|
-
const st = vfs.stat(fullPath);
|
|
383
|
+
const st = shell.vfs.stat(fullPath);
|
|
385
384
|
const suffix = st.type === "directory" ? "/" : "";
|
|
386
385
|
return `${dirPart}${entry}${suffix}`;
|
|
387
386
|
})
|
|
@@ -437,17 +436,17 @@ export function startShell(
|
|
|
437
436
|
}
|
|
438
437
|
|
|
439
438
|
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
440
|
-
vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
439
|
+
shell.vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
441
440
|
}
|
|
442
441
|
|
|
443
442
|
function readLastLogin(): { at: string; from: string } | null {
|
|
444
443
|
const lastlogPath = `/virtual-env-js/.lastlog/${authUser}.json`;
|
|
445
|
-
if (!vfs.exists(lastlogPath)) {
|
|
444
|
+
if (!shell.vfs.exists(lastlogPath)) {
|
|
446
445
|
return null;
|
|
447
446
|
}
|
|
448
447
|
|
|
449
448
|
try {
|
|
450
|
-
return JSON.parse(vfs.readFile(lastlogPath)) as {
|
|
449
|
+
return JSON.parse(shell.vfs.readFile(lastlogPath)) as {
|
|
451
450
|
at: string;
|
|
452
451
|
from: string;
|
|
453
452
|
};
|
|
@@ -458,12 +457,12 @@ export function startShell(
|
|
|
458
457
|
|
|
459
458
|
function writeLastLogin(nowIso: string): void {
|
|
460
459
|
const dir = "/virtual-env-js/.lastlog";
|
|
461
|
-
if (!vfs.exists(dir)) {
|
|
462
|
-
vfs.mkdir(dir, 0o700);
|
|
460
|
+
if (!shell.vfs.exists(dir)) {
|
|
461
|
+
shell.vfs.mkdir(dir, 0o700);
|
|
463
462
|
}
|
|
464
463
|
|
|
465
464
|
const lastlogPath = `${dir}/${authUser}.json`;
|
|
466
|
-
vfs.writeFile(
|
|
465
|
+
shell.vfs.writeFile(
|
|
467
466
|
lastlogPath,
|
|
468
467
|
JSON.stringify({ at: nowIso, from: remoteAddress }),
|
|
469
468
|
);
|
|
@@ -534,7 +533,10 @@ export function startShell(
|
|
|
534
533
|
if (ch === "\r" || ch === "\n") {
|
|
535
534
|
const password = pendingSudo.buffer;
|
|
536
535
|
pendingSudo.buffer = "";
|
|
537
|
-
const valid = users.verifyPassword(
|
|
536
|
+
const valid = shell.users.verifyPassword(
|
|
537
|
+
pendingSudo.username,
|
|
538
|
+
password,
|
|
539
|
+
);
|
|
538
540
|
await finishSudoPrompt(valid);
|
|
539
541
|
return;
|
|
540
542
|
}
|
|
@@ -647,16 +649,7 @@ export function startShell(
|
|
|
647
649
|
|
|
648
650
|
if (line.length > 0) {
|
|
649
651
|
const result = await Promise.resolve(
|
|
650
|
-
runCommand(
|
|
651
|
-
line,
|
|
652
|
-
authUser,
|
|
653
|
-
hostname,
|
|
654
|
-
users,
|
|
655
|
-
"shell",
|
|
656
|
-
cwd,
|
|
657
|
-
defaultShellProperties,
|
|
658
|
-
vfs,
|
|
659
|
-
),
|
|
652
|
+
runCommand(line, authUser, hostname, "shell", cwd, shell),
|
|
660
653
|
);
|
|
661
654
|
|
|
662
655
|
pushHistory(line);
|
|
@@ -706,12 +699,12 @@ export function startShell(
|
|
|
706
699
|
if (result.switchUser) {
|
|
707
700
|
authUser = result.switchUser;
|
|
708
701
|
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
709
|
-
users.updateSession(sessionId, authUser, remoteAddress);
|
|
702
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
710
703
|
lineBuffer = "";
|
|
711
704
|
cursorPos = 0;
|
|
712
705
|
}
|
|
713
706
|
|
|
714
|
-
await vfs.flushMirror();
|
|
707
|
+
await shell.vfs.flushMirror();
|
|
715
708
|
}
|
|
716
709
|
|
|
717
710
|
renderLine();
|
|
@@ -26,7 +26,9 @@ export interface VirtualActiveSession {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* Persistent user, sudoers, and active-session manager for the shell runtime.
|
|
30
|
+
*
|
|
31
|
+
* Passwords are hashed with scrypt and stored in the backing virtual filesystem.
|
|
30
32
|
*/
|
|
31
33
|
export class VirtualUserManager {
|
|
32
34
|
private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
|
|
@@ -38,10 +40,11 @@ export class VirtualUserManager {
|
|
|
38
40
|
private nextTty = 0;
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
* Creates user manager instance.
|
|
43
|
+
* Creates a user manager instance backed by a virtual filesystem.
|
|
42
44
|
*
|
|
43
45
|
* @param vfs Backing virtual filesystem used for persistence.
|
|
44
|
-
* @param defaultRootPassword Initial root password used when root
|
|
46
|
+
* @param defaultRootPassword Initial root password used when root is created.
|
|
47
|
+
* @param autoSudoForNewUsers Whether newly created users are added to sudoers.
|
|
45
48
|
*/
|
|
46
49
|
constructor(
|
|
47
50
|
private readonly vfs: VirtualFileSystem,
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { SshClient } from "./SSHMimic/client";
|
|
2
2
|
import { SshMimic } from "./SSHMimic/index";
|
|
3
|
-
import { VirtualUserManager } from "./SSHMimic/users";
|
|
4
3
|
import VirtualFileSystem from "./VirtualFileSystem";
|
|
5
|
-
import
|
|
4
|
+
import { VirtualShell } from "./VirtualShell";
|
|
5
|
+
import { VirtualUserManager } from "./VirtualUserManager";
|
|
6
6
|
|
|
7
7
|
export type {
|
|
8
8
|
CommandContext,
|
|
@@ -32,9 +32,9 @@ export type {
|
|
|
32
32
|
export {
|
|
33
33
|
SshClient,
|
|
34
34
|
VirtualFileSystem,
|
|
35
|
-
|
|
35
|
+
VirtualShell,
|
|
36
|
+
SshMimic as VirtualSshServer,
|
|
36
37
|
VirtualUserManager,
|
|
37
|
-
type VirtualShell,
|
|
38
38
|
};
|
|
39
39
|
|
|
40
40
|
export {
|
package/src/standalone.ts
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { VirtualShell, VirtualSshServer } from ".";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const hostname = process.env.SSH_MIMIC_HOSTNAME ?? "typescript-vm";
|
|
4
|
+
const virtualShell = new VirtualShell(hostname);
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
virtualShell.addCommand("demo", [], () => {
|
|
7
|
+
return {
|
|
8
|
+
stdout: "This is a demo command. It does nothing useful.",
|
|
9
|
+
exitCode: 0,
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
new VirtualSshServer({
|
|
14
|
+
port: 2222,
|
|
15
|
+
hostname,
|
|
16
|
+
shell: virtualShell,
|
|
17
|
+
})
|
|
7
18
|
.start()
|
|
8
19
|
.then((port: number) => {
|
|
9
|
-
if (!sshMimic
|
|
10
|
-
else {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return {
|
|
14
|
-
stdout: "This is a demo command. It does nothing useful.",
|
|
15
|
-
exitCode: 0,
|
|
16
|
-
};
|
|
17
|
-
});
|
|
18
|
-
}
|
|
20
|
+
// if (!sshMimic) console.error("Failed to initialize SSH Mimic shell.");
|
|
21
|
+
// else {
|
|
22
|
+
console.log(`SSH Mimic initialized. Listening on port ${port}.`);
|
|
23
|
+
// }
|
|
19
24
|
})
|
|
20
25
|
.catch((error: unknown) => {
|
|
21
26
|
console.error("Failed to start SSH Mimic:", error);
|
package/src/types/commands.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/** Command invocation mode used by shell runtime. */
|
|
2
2
|
export type CommandMode = "shell" | "exec";
|
|
3
3
|
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
VirtualUserManager,
|
|
7
|
-
} from "../SSHMimic/users";
|
|
8
|
-
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
9
|
-
import type { ShellProperties } from "../VirtualShell";
|
|
4
|
+
import type { VirtualShell } from "../VirtualShell";
|
|
5
|
+
import type { VirtualActiveSession } from "../VirtualUserManager";
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* Normalized command execution output.
|
|
@@ -67,8 +63,6 @@ export interface CommandContext {
|
|
|
67
63
|
authUser: string;
|
|
68
64
|
/** Virtual hostname shown in prompt and banners. */
|
|
69
65
|
hostname: string;
|
|
70
|
-
/** User and session manager instance. */
|
|
71
|
-
users: VirtualUserManager;
|
|
72
66
|
/** Snapshot of currently active user sessions. */
|
|
73
67
|
activeSessions: VirtualActiveSession[];
|
|
74
68
|
/** Original unparsed command line input. */
|
|
@@ -78,13 +72,11 @@ export interface CommandContext {
|
|
|
78
72
|
/** Tokenized arguments excluding command name. */
|
|
79
73
|
args: string[];
|
|
80
74
|
/** Virtual shell instance. */
|
|
81
|
-
|
|
75
|
+
shell: VirtualShell;
|
|
82
76
|
/** Optional stdin payload (used by pipes/redirections). */
|
|
83
77
|
stdin?: string;
|
|
84
78
|
/** Current working directory for command execution. */
|
|
85
79
|
cwd: string;
|
|
86
|
-
/** Virtual filesystem instance for IO operations. */
|
|
87
|
-
vfs: VirtualFileSystem;
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
/** Contract implemented by each shell command module. */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { VirtualShell } from "../src";
|
|
3
|
+
import { executePipeline } from "../src/SSHMimic/executor";
|
|
4
|
+
import { parseShellPipeline } from "../src/VirtualShell/shellParser";
|
|
5
|
+
|
|
6
|
+
describe("Pipeline parser and executor", () => {
|
|
7
|
+
test("parses simple pipeline", () => {
|
|
8
|
+
const pipeline = parseShellPipeline("echo hello | grep h");
|
|
9
|
+
expect(pipeline.isValid).toBe(true);
|
|
10
|
+
expect(pipeline.commands).toHaveLength(2);
|
|
11
|
+
expect(pipeline.commands[0]?.name).toBe("echo");
|
|
12
|
+
expect(pipeline.commands[1]?.name).toBe("grep");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("handles invalid syntax", () => {
|
|
16
|
+
const pipeline = parseShellPipeline("echo hello |");
|
|
17
|
+
expect(pipeline.isValid).toBe(false);
|
|
18
|
+
expect(pipeline.error).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("executes simple pipeline", async () => {
|
|
22
|
+
const shell = new VirtualShell("localhost");
|
|
23
|
+
const pipeline = parseShellPipeline("echo hello | grep h");
|
|
24
|
+
|
|
25
|
+
const result = await executePipeline(
|
|
26
|
+
pipeline,
|
|
27
|
+
"root",
|
|
28
|
+
"localhost",
|
|
29
|
+
"shell",
|
|
30
|
+
"/",
|
|
31
|
+
shell
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(result.exitCode).toBe(0);
|
|
35
|
+
expect(result.stdout).toContain("hello");
|
|
36
|
+
});
|
|
37
|
+
});
|
package/tests/users.test.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { VirtualUserManager } from "../src/SSHMimic/users";
|
|
6
5
|
import VirtualFileSystem from "../src/VirtualFileSystem";
|
|
6
|
+
import { VirtualUserManager } from "../src/VirtualUserManager";
|
|
7
7
|
|
|
8
8
|
async function withTempVfs(
|
|
9
9
|
run: (vfs: VirtualFileSystem) => Promise<void>,
|