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.
- 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 +0 -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/curl.ts +2 -2
- package/src/VirtualShell/commands/deluser.ts +2 -2
- package/src/VirtualShell/commands/grep.ts +2 -2
- package/src/VirtualShell/commands/index.ts +13 -107
- 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 +3 -6
- package/src/VirtualShell/commands/touch.ts +3 -3
- package/src/VirtualShell/commands/tree.ts +2 -2
- package/src/VirtualShell/commands/wget.ts +2 -2
- package/src/VirtualShell/commands/who.ts +2 -2
- package/src/VirtualShell/index.ts +114 -25
- package/src/VirtualShell/shell.ts +25 -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 +3 -6
- 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,
|
|
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,
|
|
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,
|
|
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:
|
|
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,
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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: ({
|
|
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}`;
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
*
|
|
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);
|