typescript-virtual-container 1.0.8 → 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.
Files changed (34) hide show
  1. package/README.md +138 -87
  2. package/package.json +1 -1
  3. package/src/SSHMimic/client.ts +15 -18
  4. package/src/SSHMimic/exec.ts +5 -16
  5. package/src/SSHMimic/executor.ts +18 -29
  6. package/src/SSHMimic/index.ts +23 -85
  7. package/src/VirtualFileSystem/index.ts +0 -1
  8. package/src/VirtualShell/commands/adduser.ts +2 -2
  9. package/src/VirtualShell/commands/cat.ts +3 -3
  10. package/src/VirtualShell/commands/cd.ts +2 -2
  11. package/src/VirtualShell/commands/curl.ts +2 -2
  12. package/src/VirtualShell/commands/deluser.ts +2 -2
  13. package/src/VirtualShell/commands/grep.ts +2 -2
  14. package/src/VirtualShell/commands/index.ts +13 -107
  15. package/src/VirtualShell/commands/ls.ts +6 -4
  16. package/src/VirtualShell/commands/mkdir.ts +2 -2
  17. package/src/VirtualShell/commands/nano.ts +3 -3
  18. package/src/VirtualShell/commands/neofetch.ts +2 -2
  19. package/src/VirtualShell/commands/rm.ts +2 -2
  20. package/src/VirtualShell/commands/sh.ts +2 -13
  21. package/src/VirtualShell/commands/su.ts +2 -1
  22. package/src/VirtualShell/commands/sudo.ts +3 -6
  23. package/src/VirtualShell/commands/touch.ts +3 -3
  24. package/src/VirtualShell/commands/tree.ts +2 -2
  25. package/src/VirtualShell/commands/wget.ts +2 -2
  26. package/src/VirtualShell/commands/who.ts +2 -2
  27. package/src/VirtualShell/index.ts +114 -25
  28. package/src/VirtualShell/shell.ts +25 -35
  29. package/src/{SSHMimic/users.ts → VirtualUserManager/index.ts} +6 -3
  30. package/src/index.ts +4 -4
  31. package/src/standalone.ts +19 -14
  32. package/src/types/commands.ts +3 -11
  33. package/tests/parser-executor.test.ts +3 -6
  34. package/tests/users.test.ts +1 -1
@@ -5,7 +5,7 @@ import { assertPathAccess, resolvePath } from "./helpers";
5
5
  export const mkdirCommand: ShellModule = {
6
6
  name: "mkdir",
7
7
  params: ["<dir>"],
8
- run: ({ authUser, vfs, cwd, args }) => {
8
+ run: ({ authUser, shell, cwd, args }) => {
9
9
  if (args.length === 0) {
10
10
  return { stderr: "mkdir: missing operand", exitCode: 1 };
11
11
  }
@@ -17,7 +17,7 @@ export const mkdirCommand: ShellModule = {
17
17
  }
18
18
  const target = resolvePath(cwd, dir);
19
19
  assertPathAccess(authUser, target, "mkdir");
20
- vfs.mkdir(target);
20
+ shell.vfs.mkdir(target);
21
21
  }
22
22
  return { exitCode: 0 };
23
23
  },
@@ -5,7 +5,7 @@ import { assertPathAccess, resolvePath } from "./helpers";
5
5
  export const nanoCommand: ShellModule = {
6
6
  name: "nano",
7
7
  params: ["<file>"],
8
- run: ({ authUser, vfs, cwd, args }) => {
8
+ run: ({ authUser, shell, cwd, args }) => {
9
9
  const fileArg = args[0];
10
10
  if (!fileArg) {
11
11
  return { stderr: "nano: missing file operand", exitCode: 1 };
@@ -13,8 +13,8 @@ export const nanoCommand: ShellModule = {
13
13
 
14
14
  const targetPath = resolvePath(cwd, fileArg);
15
15
  assertPathAccess(authUser, targetPath, "nano");
16
- const initialContent = vfs.exists(targetPath)
17
- ? vfs.readFile(targetPath)
16
+ const initialContent = shell.vfs.exists(targetPath)
17
+ ? shell.vfs.readFile(targetPath)
18
18
  : "";
19
19
  const safeName = path.posix.basename(targetPath) || "buffer";
20
20
  const tempPath = `/tmp/sshmimic-nano-${Date.now()}-${safeName}.tmp`;
@@ -6,7 +6,7 @@ import { getAllEnvVars } from "./set";
6
6
  export const neofetchCommand: ShellModule = {
7
7
  name: "neofetch",
8
8
  params: ["[--off]"],
9
- run: ({ args, authUser, hostname, shellProps }) => {
9
+ run: ({ args, authUser, hostname, shell }) => {
10
10
  const env = getAllEnvVars(authUser);
11
11
 
12
12
  if (ifFlag(args, "--help")) {
@@ -28,7 +28,7 @@ export const neofetchCommand: ShellModule = {
28
28
  user: authUser,
29
29
  host: hostname,
30
30
  shell: env.SHELL,
31
- shellProps: shellProps,
31
+ shellProps: shell.properties,
32
32
  terminal: env.TERM,
33
33
  }),
34
34
  exitCode: 0,
@@ -5,7 +5,7 @@ import { assertPathAccess, resolvePath } from "./helpers";
5
5
  export const rmCommand: ShellModule = {
6
6
  name: "rm",
7
7
  params: ["[-r|-rf] <path>"],
8
- run: ({ authUser, vfs, cwd, args }) => {
8
+ run: ({ authUser, shell, cwd, args }) => {
9
9
  if (args.length === 0) {
10
10
  return { stderr: "rm: missing operand", exitCode: 1 };
11
11
  }
@@ -27,7 +27,7 @@ export const rmCommand: ShellModule = {
27
27
  for (const target of targets) {
28
28
  const resolvedTarget = resolvePath(cwd, target);
29
29
  assertPathAccess(authUser, resolvedTarget, "rm");
30
- vfs.remove(resolvedTarget, { recursive });
30
+ shell.vfs.remove(resolvedTarget, { recursive });
31
31
  }
32
32
 
33
33
  return { exitCode: 0 };
@@ -1,4 +1,3 @@
1
- import { defaultShellProperties } from "..";
2
1
  import type { CommandContext, ShellModule } from "../../types/commands";
3
2
  import { getArg, getFlag } from "./command-helpers";
4
3
  import { runCommand } from "./index";
@@ -9,8 +8,7 @@ export const shCommand: ShellModule = {
9
8
  params: ["-c <script>", "[<file>]"],
10
9
  aliases: ["bash"],
11
10
  run: async (ctx: CommandContext) => {
12
- const { vfs, args, authUser, hostname, users, mode, cwd } = ctx;
13
-
11
+ const { args, authUser, hostname, mode, cwd } = ctx;
14
12
  // Handle -c option: sh -c "command"
15
13
  if (getFlag(args, "-c") && args.length >= 2) {
16
14
  const script = getArg(args, 1) ?? "";
@@ -39,16 +37,7 @@ export const shCommand: ShellModule = {
39
37
 
40
38
  // Execute the command
41
39
  const result = await Promise.resolve(
42
- runCommand(
43
- command,
44
- authUser,
45
- hostname,
46
- users,
47
- mode,
48
- cwd,
49
- defaultShellProperties,
50
- vfs,
51
- ),
40
+ runCommand(command, authUser, hostname, mode, cwd, ctx.shell),
52
41
  );
53
42
 
54
43
  if (result.stdout) {
@@ -4,7 +4,8 @@ import { getArg } from "./command-helpers";
4
4
  export const suCommand: ShellModule = {
5
5
  name: "su",
6
6
  params: ["- <username>"],
7
- run: ({ authUser, users, args }) => {
7
+ run: ({ authUser, shell, args }) => {
8
+ const users = shell.users!;
8
9
  const targetUser = getArg(args, 0, { flags: ["-"] });
9
10
 
10
11
  if (!targetUser) {
@@ -1,4 +1,3 @@
1
- import { defaultShellProperties } from "..";
2
1
  import type { ShellModule } from "../../types/commands";
3
2
  import { parseArgs } from "./command-helpers";
4
3
  import { runCommand } from "./index";
@@ -23,10 +22,10 @@ function parseSudoArgs(args: string[]): {
23
22
  export const sudoCommand: ShellModule = {
24
23
  name: "sudo",
25
24
  params: ["<command...>"],
26
- run: async ({ authUser, hostname, users, mode, cwd, vfs, args }) => {
25
+ run: async ({ authUser, hostname, mode, cwd, shell, args }) => {
27
26
  const { targetUser, loginShell, commandLine } = parseSudoArgs(args);
28
27
 
29
- if (authUser !== "root" && !users.isSudoer(authUser)) {
28
+ if (authUser !== "root" && !shell.users.isSudoer(authUser)) {
30
29
  return { stderr: "sudo: permission denied", exitCode: 1 };
31
30
  }
32
31
 
@@ -50,11 +49,9 @@ export const sudoCommand: ShellModule = {
50
49
  commandLine,
51
50
  effectiveUser,
52
51
  hostname,
53
- users,
54
52
  mode,
55
53
  loginShell ? `/home/${effectiveUser}` : cwd,
56
- defaultShellProperties,
57
- vfs,
54
+ shell,
58
55
  );
59
56
  }
60
57
 
@@ -4,7 +4,7 @@ import { assertPathAccess, resolvePath } from "./helpers";
4
4
  export const touchCommand: ShellModule = {
5
5
  name: "touch",
6
6
  params: ["<file>"],
7
- run: ({ authUser, vfs, cwd, args }) => {
7
+ run: ({ authUser, shell, cwd, args }) => {
8
8
  if (args.length === 0) {
9
9
  return { stderr: "touch: missing file operand", exitCode: 1 };
10
10
  }
@@ -12,8 +12,8 @@ export const touchCommand: ShellModule = {
12
12
  for (const file of args) {
13
13
  const target = resolvePath(cwd, file);
14
14
  assertPathAccess(authUser, target, "touch");
15
- if (!vfs.exists(target)) {
16
- vfs.writeFile(target, "");
15
+ if (!shell.vfs.exists(target)) {
16
+ shell.vfs.writeFile(target, "");
17
17
  }
18
18
  }
19
19
  return { exitCode: 0 };
@@ -5,9 +5,9 @@ import { assertPathAccess, resolvePath } from "./helpers";
5
5
  export const treeCommand: ShellModule = {
6
6
  name: "tree",
7
7
  params: ["[path]"],
8
- run: ({ authUser, vfs, cwd, args }) => {
8
+ run: ({ authUser, shell, cwd, args }) => {
9
9
  const target = resolvePath(cwd, getArg(args, 0) ?? cwd);
10
10
  assertPathAccess(authUser, target, "tree");
11
- return { stdout: vfs.tree(target), exitCode: 0 };
11
+ return { stdout: shell.vfs.tree(target), exitCode: 0 };
12
12
  },
13
13
  };
@@ -83,7 +83,7 @@ function runHostWget(args: string[]): Promise<{
83
83
  export const wgetCommand: ShellModule = {
84
84
  name: "wget",
85
85
  params: ["[url]"],
86
- run: async ({ authUser, vfs, cwd, args }) => {
86
+ run: async ({ authUser, cwd, args, shell }) => {
87
87
  const { flagsWithValues, positionals } = parseArgs(args, {
88
88
  flagsWithValue: ["-o", "-O", "--output", "--output-document"],
89
89
  });
@@ -131,7 +131,7 @@ export const wgetCommand: ShellModule = {
131
131
  const content = await readFile(tempFile, "utf8");
132
132
  const target = resolvePath(cwd, outputPath || stripUrlFilename(url));
133
133
  assertPathAccess(authUser, target, "wget");
134
- vfs.writeFile(target, content);
134
+ shell.vfs.writeFile(target, content);
135
135
 
136
136
  return {
137
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: ({ users }) => {
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 type { VirtualUserManager } from "../SSHMimic/users";
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 type VirtualFileSystem from "../VirtualFileSystem";
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: "Fortune GNU/Linux x64";
11
- arch: "x86_64";
11
+ os: string;
12
+ arch: string;
12
13
  }
13
14
 
14
- export const defaultShellProperties: ShellProperties = {
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
- private vfs: VirtualFileSystem;
22
- private users: VirtualUserManager;
23
- private hostname: string;
24
- public properties: ShellProperties;
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 { defaultShellProperties, type ShellProperties } from ".";
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}`;
@@ -170,7 +168,7 @@ export function startShell(
170
168
  if (!challenge.commandLine) {
171
169
  authUser = challenge.targetUser;
172
170
  cwd = `/home/${authUser}`;
173
- users.updateSession(sessionId, authUser, remoteAddress);
171
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
174
172
  stream.write("\r\n");
175
173
  renderLine();
176
174
  return;
@@ -182,11 +180,9 @@ export function startShell(
182
180
  challenge.commandLine,
183
181
  challenge.targetUser,
184
182
  hostname,
185
- users,
186
183
  "shell",
187
184
  runCwd,
188
- defaultShellProperties,
189
- vfs,
185
+ shell,
190
186
  ),
191
187
  );
192
188
 
@@ -221,12 +217,12 @@ export function startShell(
221
217
  if (result.switchUser) {
222
218
  authUser = result.switchUser;
223
219
  cwd = result.nextCwd ?? `/home/${authUser}`;
224
- users.updateSession(sessionId, authUser, remoteAddress);
220
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
225
221
  } else if (result.nextCwd) {
226
222
  cwd = result.nextCwd;
227
223
  }
228
224
 
229
- await vfs.flushMirror();
225
+ await shell.vfs.flushMirror();
230
226
  renderLine();
231
227
  }
232
228
 
@@ -240,8 +236,8 @@ export function startShell(
240
236
  if (activeSession.kind === "nano") {
241
237
  try {
242
238
  const updatedContent = await readFile(activeSession.tempPath, "utf8");
243
- vfs.writeFile(activeSession.targetPath, updatedContent);
244
- await vfs.flushMirror();
239
+ shell.vfs.writeFile(activeSession.targetPath, updatedContent);
240
+ await shell.vfs.flushMirror();
245
241
  } catch {
246
242
  // If temp file does not exist, nano exited without writing.
247
243
  }
@@ -261,7 +257,7 @@ export function startShell(
261
257
  initialContent: string,
262
258
  tempPath: string,
263
259
  ): Promise<void> {
264
- if (vfs.exists(targetPath)) {
260
+ if (shell.vfs.exists(targetPath)) {
265
261
  await writeFile(tempPath, initialContent, "utf8");
266
262
  }
267
263
 
@@ -378,13 +374,13 @@ export function startShell(
378
374
  const basePath = resolvePath(cwd, dirPart || ".");
379
375
 
380
376
  try {
381
- return vfs
377
+ return shell.vfs
382
378
  .list(basePath)
383
379
  .filter((entry) => !entry.startsWith("."))
384
380
  .filter((entry) => entry.startsWith(namePart))
385
381
  .map((entry) => {
386
382
  const fullPath = path.posix.join(basePath, entry);
387
- const st = vfs.stat(fullPath);
383
+ const st = shell.vfs.stat(fullPath);
388
384
  const suffix = st.type === "directory" ? "/" : "";
389
385
  return `${dirPart}${entry}${suffix}`;
390
386
  })
@@ -440,17 +436,17 @@ export function startShell(
440
436
  }
441
437
 
442
438
  const data = history.length > 0 ? `${history.join("\n")}\n` : "";
443
- vfs.writeFile("/virtual-env-js/.bash_history", data);
439
+ shell.vfs.writeFile("/virtual-env-js/.bash_history", data);
444
440
  }
445
441
 
446
442
  function readLastLogin(): { at: string; from: string } | null {
447
443
  const lastlogPath = `/virtual-env-js/.lastlog/${authUser}.json`;
448
- if (!vfs.exists(lastlogPath)) {
444
+ if (!shell.vfs.exists(lastlogPath)) {
449
445
  return null;
450
446
  }
451
447
 
452
448
  try {
453
- return JSON.parse(vfs.readFile(lastlogPath)) as {
449
+ return JSON.parse(shell.vfs.readFile(lastlogPath)) as {
454
450
  at: string;
455
451
  from: string;
456
452
  };
@@ -461,12 +457,12 @@ export function startShell(
461
457
 
462
458
  function writeLastLogin(nowIso: string): void {
463
459
  const dir = "/virtual-env-js/.lastlog";
464
- if (!vfs.exists(dir)) {
465
- vfs.mkdir(dir, 0o700);
460
+ if (!shell.vfs.exists(dir)) {
461
+ shell.vfs.mkdir(dir, 0o700);
466
462
  }
467
463
 
468
464
  const lastlogPath = `${dir}/${authUser}.json`;
469
- vfs.writeFile(
465
+ shell.vfs.writeFile(
470
466
  lastlogPath,
471
467
  JSON.stringify({ at: nowIso, from: remoteAddress }),
472
468
  );
@@ -537,7 +533,10 @@ export function startShell(
537
533
  if (ch === "\r" || ch === "\n") {
538
534
  const password = pendingSudo.buffer;
539
535
  pendingSudo.buffer = "";
540
- const valid = users.verifyPassword(pendingSudo.username, password);
536
+ const valid = shell.users.verifyPassword(
537
+ pendingSudo.username,
538
+ password,
539
+ );
541
540
  await finishSudoPrompt(valid);
542
541
  return;
543
542
  }
@@ -650,16 +649,7 @@ export function startShell(
650
649
 
651
650
  if (line.length > 0) {
652
651
  const result = await Promise.resolve(
653
- runCommand(
654
- line,
655
- authUser,
656
- hostname,
657
- users,
658
- "shell",
659
- cwd,
660
- defaultShellProperties,
661
- vfs,
662
- ),
652
+ runCommand(line, authUser, hostname, "shell", cwd, shell),
663
653
  );
664
654
 
665
655
  pushHistory(line);
@@ -709,12 +699,12 @@ export function startShell(
709
699
  if (result.switchUser) {
710
700
  authUser = result.switchUser;
711
701
  cwd = result.nextCwd ?? `/home/${authUser}`;
712
- users.updateSession(sessionId, authUser, remoteAddress);
702
+ shell.users.updateSession(sessionId, authUser, remoteAddress);
713
703
  lineBuffer = "";
714
704
  cursorPos = 0;
715
705
  }
716
706
 
717
- await vfs.flushMirror();
707
+ await shell.vfs.flushMirror();
718
708
  }
719
709
 
720
710
  renderLine();
@@ -26,7 +26,9 @@ export interface VirtualActiveSession {
26
26
  }
27
27
 
28
28
  /**
29
- * User, sudoers, and active session manager for SSH mimic runtime.
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 missing.
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 type { VirtualShell } from "./VirtualShell";
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
- SshMimic as VirtualMachine,
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 { VirtualMachine } from ".";
1
+ import { VirtualShell, VirtualSshServer } from ".";
2
2
 
3
- const sshHostname = process.env.SSH_MIMIC_HOSTNAME ?? "typescript-vm";
4
- const sshMimic = new VirtualMachine({ port: 2222, hostname: sshHostname });
3
+ const hostname = process.env.SSH_MIMIC_HOSTNAME ?? "typescript-vm";
4
+ const virtualShell = new VirtualShell(hostname);
5
5
 
6
- sshMimic
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.shell) console.error("Failed to initialize SSH Mimic shell.");
10
- else {
11
- console.log(`SSH Mimic initialized. Listening on port ${port}.`);
12
- sshMimic.shell.addCommand("demo", [], () => {
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);