typescript-virtual-container 1.0.8 → 1.1.1
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/.vscode/settings.json +18 -0
- package/README.md +182 -91
- package/modules/shellInteractive.ts +45 -0
- package/modules/shellRuntime.ts +76 -0
- package/package.json +1 -1
- package/src/{SSHMimic/client.ts → SSHClient/index.ts} +17 -20
- package/src/SSHMimic/exec.ts +6 -17
- package/src/SSHMimic/executor.ts +20 -31
- package/src/SSHMimic/index.ts +23 -85
- package/src/VirtualFileSystem/index.ts +26 -1
- package/src/VirtualShell/index.ts +131 -26
- package/src/VirtualShell/shell.ts +43 -141
- package/src/VirtualShell/shellParser.ts +32 -7
- package/src/{SSHMimic/users.ts → VirtualUserManager/index.ts} +155 -3
- package/src/{VirtualShell/commands → commands}/adduser.ts +3 -3
- package/src/{VirtualShell/commands → commands}/cat.ts +4 -4
- package/src/{VirtualShell/commands → commands}/cd.ts +3 -3
- package/src/{VirtualShell/commands → commands}/clear.ts +1 -1
- package/src/{VirtualShell/commands → commands}/curl.ts +3 -3
- package/src/{VirtualShell/commands → commands}/deluser.ts +3 -3
- package/src/{VirtualShell/commands → commands}/echo.ts +1 -1
- package/src/{VirtualShell/commands → commands}/env.ts +1 -1
- package/src/{VirtualShell/commands → commands}/exit.ts +1 -1
- package/src/{VirtualShell/commands → commands}/export.ts +1 -1
- package/src/{VirtualShell/commands → commands}/grep.ts +3 -3
- package/src/{VirtualShell/commands → commands}/help.ts +1 -1
- package/src/{VirtualShell/commands → commands}/helpers.ts +1 -1
- package/src/{VirtualShell/commands → commands}/hostname.ts +1 -1
- package/src/{VirtualShell/commands → commands}/htop.ts +1 -1
- package/src/{VirtualShell/commands → commands}/index.ts +19 -110
- package/src/{VirtualShell/commands → commands}/ls.ts +7 -5
- package/src/{VirtualShell/commands → commands}/mkdir.ts +3 -3
- package/src/{VirtualShell/commands → commands}/nano.ts +4 -4
- package/src/{VirtualShell/commands → commands}/neofetch.ts +4 -4
- package/src/{VirtualShell/commands → commands}/pwd.ts +1 -1
- package/src/{VirtualShell/commands → commands}/rm.ts +3 -3
- package/src/{VirtualShell/commands → commands}/set.ts +1 -1
- package/src/{VirtualShell/commands → commands}/sh.ts +3 -14
- package/src/{VirtualShell/commands → commands}/su.ts +3 -2
- package/src/{VirtualShell/commands → commands}/sudo.ts +4 -7
- package/src/{VirtualShell/commands → commands}/touch.ts +4 -4
- package/src/{VirtualShell/commands → commands}/tree.ts +3 -3
- package/src/{VirtualShell/commands → commands}/unset.ts +1 -1
- package/src/{VirtualShell/commands → commands}/wget.ts +3 -3
- package/src/{VirtualShell/commands → commands}/who.ts +4 -4
- package/src/{VirtualShell/commands → commands}/whoami.ts +1 -1
- package/src/index.ts +6 -6
- package/src/standalone.ts +19 -14
- package/src/types/commands.ts +3 -11
- package/tests/command-helpers.test.ts +1 -1
- package/tests/helpers.test.ts +1 -1
- package/tests/parser-executor.test.ts +3 -6
- package/tests/users.test.ts +61 -1
- /package/src/{VirtualShell/commands → commands}/command-helpers.ts +0 -0
|
@@ -1,40 +1,96 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createCustomCommand, registerCommand, runCommand } from "../commands";
|
|
2
3
|
import type { CommandContext, CommandResult } from "../types/commands";
|
|
3
4
|
import type { ShellStream } from "../types/streams";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
5
|
+
import VirtualFileSystem from "../VirtualFileSystem";
|
|
6
|
+
import { VirtualUserManager } from "../VirtualUserManager";
|
|
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,56 @@ 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
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Writes a file on behalf of a user with quota enforcement.
|
|
176
|
+
*
|
|
177
|
+
* @param authUser User performing the write.
|
|
178
|
+
* @param targetPath Destination path.
|
|
179
|
+
* @param content File content.
|
|
180
|
+
*/
|
|
181
|
+
public writeFileAsUser(
|
|
182
|
+
authUser: string,
|
|
183
|
+
targetPath: string,
|
|
184
|
+
content: string | Buffer,
|
|
185
|
+
): void {
|
|
186
|
+
this.users.assertWriteWithinQuota(authUser, targetPath, content);
|
|
187
|
+
this.vfs.writeFile(targetPath, content);
|
|
188
|
+
}
|
|
84
189
|
}
|
|
85
190
|
|
|
86
191
|
export { VirtualShell };
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } 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
|
+
import {
|
|
6
|
+
spawnHtopProcess,
|
|
7
|
+
spawnNanoEditorProcess,
|
|
8
|
+
} from "../../modules/shellInteractive";
|
|
9
|
+
import {
|
|
10
|
+
getVisibleHtopPidList,
|
|
11
|
+
resolvePath,
|
|
12
|
+
type TerminalSize,
|
|
13
|
+
toTtyLines,
|
|
14
|
+
} from "../../modules/shellRuntime";
|
|
15
|
+
import { getCommandNames, runCommand } from "../commands";
|
|
5
16
|
import { formatLoginDate } from "../SSHMimic/loginFormat";
|
|
6
17
|
import { buildPrompt } from "../SSHMimic/prompt";
|
|
7
|
-
import type { VirtualUserManager } from "../SSHMimic/users";
|
|
8
18
|
import type { ShellStream } from "../types/streams";
|
|
9
19
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
10
|
-
import { getCommandNames, runCommand } from "./commands";
|
|
11
20
|
|
|
12
21
|
interface NanoSession {
|
|
13
22
|
kind: "nano" | "htop";
|
|
@@ -25,36 +34,19 @@ interface PendingSudo {
|
|
|
25
34
|
buffer: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
function shellQuote(value: string): string {
|
|
29
|
-
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface TerminalSize {
|
|
33
|
-
cols: number;
|
|
34
|
-
rows: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function toTtyLines(text: string): string {
|
|
38
|
-
return text
|
|
39
|
-
.replace(/\r\n/g, "\n")
|
|
40
|
-
.replace(/\r/g, "\n")
|
|
41
|
-
.replace(/\n/g, "\r\n");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
export function startShell(
|
|
45
38
|
properties: ShellProperties,
|
|
46
39
|
stream: ShellStream,
|
|
47
40
|
authUser: string,
|
|
48
|
-
vfs: VirtualFileSystem,
|
|
49
41
|
hostname: string,
|
|
50
|
-
users: VirtualUserManager,
|
|
51
42
|
sessionId: string | null,
|
|
52
43
|
remoteAddress = "unknown",
|
|
53
44
|
terminalSize: TerminalSize = { cols: 80, rows: 24 },
|
|
45
|
+
shell: VirtualShell,
|
|
54
46
|
): void {
|
|
55
47
|
let lineBuffer = "";
|
|
56
48
|
let cursorPos = 0;
|
|
57
|
-
let history = loadHistory(vfs);
|
|
49
|
+
let history = loadHistory(shell.vfs);
|
|
58
50
|
let historyIndex: number | null = null;
|
|
59
51
|
let historyDraft = "";
|
|
60
52
|
let cwd = `/home/${authUser}`;
|
|
@@ -70,60 +62,6 @@ export function startShell(
|
|
|
70
62
|
`[${sessionId}] Shell started for user '${authUser}' at ${remoteAddress}`,
|
|
71
63
|
);
|
|
72
64
|
|
|
73
|
-
async function collectChildPids(parentPid: number): Promise<number[]> {
|
|
74
|
-
try {
|
|
75
|
-
const childrenRaw = await readFile(
|
|
76
|
-
`/proc/${parentPid}/task/${parentPid}/children`,
|
|
77
|
-
"utf8",
|
|
78
|
-
);
|
|
79
|
-
const directChildren = childrenRaw
|
|
80
|
-
.trim()
|
|
81
|
-
.split(/\s+/)
|
|
82
|
-
.filter(Boolean)
|
|
83
|
-
.map((value) => Number.parseInt(value, 10))
|
|
84
|
-
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
85
|
-
|
|
86
|
-
const nested = await Promise.all(
|
|
87
|
-
directChildren.map((pid) => collectChildPids(pid)),
|
|
88
|
-
);
|
|
89
|
-
return [...directChildren, ...nested.flat()];
|
|
90
|
-
} catch {
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function getVisibleHtopPidList(): Promise<string | null> {
|
|
96
|
-
const rootPid = process.pid;
|
|
97
|
-
const descendants = await collectChildPids(rootPid);
|
|
98
|
-
const unique = Array.from(new Set(descendants)).sort((a, b) => a - b);
|
|
99
|
-
if (unique.length === 0) {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return unique.join(",");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function withTerminalSize(command: string): string {
|
|
107
|
-
const cols =
|
|
108
|
-
Number.isFinite(terminalSize.cols) && terminalSize.cols > 0
|
|
109
|
-
? Math.floor(terminalSize.cols)
|
|
110
|
-
: 80;
|
|
111
|
-
const rows =
|
|
112
|
-
Number.isFinite(terminalSize.rows) && terminalSize.rows > 0
|
|
113
|
-
? Math.floor(terminalSize.rows)
|
|
114
|
-
: 24;
|
|
115
|
-
return `stty cols ${cols} rows ${rows} 2>/dev/null; ${command}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function resolvePath(base: string, inputPath: string): string {
|
|
119
|
-
if (!inputPath || inputPath.trim() === "" || inputPath === ".") {
|
|
120
|
-
return base;
|
|
121
|
-
}
|
|
122
|
-
return inputPath.startsWith("/")
|
|
123
|
-
? path.posix.normalize(inputPath)
|
|
124
|
-
: path.posix.normalize(path.posix.join(base, inputPath));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
65
|
function renderLine(): void {
|
|
128
66
|
const prompt = buildCurrentPrompt();
|
|
129
67
|
stream.write(`\r${prompt}${lineBuffer}\u001b[K`);
|
|
@@ -170,7 +108,7 @@ export function startShell(
|
|
|
170
108
|
if (!challenge.commandLine) {
|
|
171
109
|
authUser = challenge.targetUser;
|
|
172
110
|
cwd = `/home/${authUser}`;
|
|
173
|
-
users.updateSession(sessionId, authUser, remoteAddress);
|
|
111
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
174
112
|
stream.write("\r\n");
|
|
175
113
|
renderLine();
|
|
176
114
|
return;
|
|
@@ -182,11 +120,9 @@ export function startShell(
|
|
|
182
120
|
challenge.commandLine,
|
|
183
121
|
challenge.targetUser,
|
|
184
122
|
hostname,
|
|
185
|
-
users,
|
|
186
123
|
"shell",
|
|
187
124
|
runCwd,
|
|
188
|
-
|
|
189
|
-
vfs,
|
|
125
|
+
shell,
|
|
190
126
|
),
|
|
191
127
|
);
|
|
192
128
|
|
|
@@ -221,12 +157,12 @@ export function startShell(
|
|
|
221
157
|
if (result.switchUser) {
|
|
222
158
|
authUser = result.switchUser;
|
|
223
159
|
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
224
|
-
users.updateSession(sessionId, authUser, remoteAddress);
|
|
160
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
225
161
|
} else if (result.nextCwd) {
|
|
226
162
|
cwd = result.nextCwd;
|
|
227
163
|
}
|
|
228
164
|
|
|
229
|
-
await vfs.flushMirror();
|
|
165
|
+
await shell.vfs.flushMirror();
|
|
230
166
|
renderLine();
|
|
231
167
|
}
|
|
232
168
|
|
|
@@ -240,8 +176,12 @@ export function startShell(
|
|
|
240
176
|
if (activeSession.kind === "nano") {
|
|
241
177
|
try {
|
|
242
178
|
const updatedContent = await readFile(activeSession.tempPath, "utf8");
|
|
243
|
-
|
|
244
|
-
|
|
179
|
+
shell.writeFileAsUser(
|
|
180
|
+
authUser,
|
|
181
|
+
activeSession.targetPath,
|
|
182
|
+
updatedContent,
|
|
183
|
+
);
|
|
184
|
+
await shell.vfs.flushMirror();
|
|
245
185
|
} catch {
|
|
246
186
|
// If temp file does not exist, nano exited without writing.
|
|
247
187
|
}
|
|
@@ -261,27 +201,11 @@ export function startShell(
|
|
|
261
201
|
initialContent: string,
|
|
262
202
|
tempPath: string,
|
|
263
203
|
): Promise<void> {
|
|
264
|
-
if (vfs.exists(targetPath)) {
|
|
204
|
+
if (shell.vfs.exists(targetPath)) {
|
|
265
205
|
await writeFile(tempPath, initialContent, "utf8");
|
|
266
206
|
}
|
|
267
207
|
|
|
268
|
-
const
|
|
269
|
-
const editor = spawn("script", ["-qfec", command, "/dev/null"], {
|
|
270
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
271
|
-
env: {
|
|
272
|
-
...process.env,
|
|
273
|
-
// biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
|
|
274
|
-
TERM: process.env.TERM ?? "xterm-256color",
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
editor.stdout.on("data", (data: Buffer) => {
|
|
279
|
-
stream.write(data.toString("utf8"));
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
editor.stderr.on("data", (data: Buffer) => {
|
|
283
|
-
stream.write(data.toString("utf8"));
|
|
284
|
-
});
|
|
208
|
+
const editor = spawnNanoEditorProcess(tempPath, terminalSize, stream);
|
|
285
209
|
|
|
286
210
|
editor.on("error", (error: Error) => {
|
|
287
211
|
stream.write(`nano: ${error.message}\r\n`);
|
|
@@ -307,23 +231,7 @@ export function startShell(
|
|
|
307
231
|
return;
|
|
308
232
|
}
|
|
309
233
|
|
|
310
|
-
const
|
|
311
|
-
const monitor = spawn("script", ["-qfec", command, "/dev/null"], {
|
|
312
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
313
|
-
env: {
|
|
314
|
-
...process.env,
|
|
315
|
-
// biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
|
|
316
|
-
TERM: process.env.TERM ?? "xterm-256color",
|
|
317
|
-
},
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
monitor.stdout.on("data", (data: Buffer) => {
|
|
321
|
-
stream.write(data.toString("utf8"));
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
monitor.stderr.on("data", (data: Buffer) => {
|
|
325
|
-
stream.write(data.toString("utf8"));
|
|
326
|
-
});
|
|
234
|
+
const monitor = spawnHtopProcess(pidList, terminalSize, stream);
|
|
327
235
|
|
|
328
236
|
monitor.on("error", (error: Error) => {
|
|
329
237
|
stream.write(`htop: ${error.message}\r\n`);
|
|
@@ -378,13 +286,13 @@ export function startShell(
|
|
|
378
286
|
const basePath = resolvePath(cwd, dirPart || ".");
|
|
379
287
|
|
|
380
288
|
try {
|
|
381
|
-
return vfs
|
|
289
|
+
return shell.vfs
|
|
382
290
|
.list(basePath)
|
|
383
291
|
.filter((entry) => !entry.startsWith("."))
|
|
384
292
|
.filter((entry) => entry.startsWith(namePart))
|
|
385
293
|
.map((entry) => {
|
|
386
294
|
const fullPath = path.posix.join(basePath, entry);
|
|
387
|
-
const st = vfs.stat(fullPath);
|
|
295
|
+
const st = shell.vfs.stat(fullPath);
|
|
388
296
|
const suffix = st.type === "directory" ? "/" : "";
|
|
389
297
|
return `${dirPart}${entry}${suffix}`;
|
|
390
298
|
})
|
|
@@ -440,17 +348,17 @@ export function startShell(
|
|
|
440
348
|
}
|
|
441
349
|
|
|
442
350
|
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
443
|
-
vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
351
|
+
shell.vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
444
352
|
}
|
|
445
353
|
|
|
446
354
|
function readLastLogin(): { at: string; from: string } | null {
|
|
447
355
|
const lastlogPath = `/virtual-env-js/.lastlog/${authUser}.json`;
|
|
448
|
-
if (!vfs.exists(lastlogPath)) {
|
|
356
|
+
if (!shell.vfs.exists(lastlogPath)) {
|
|
449
357
|
return null;
|
|
450
358
|
}
|
|
451
359
|
|
|
452
360
|
try {
|
|
453
|
-
return JSON.parse(vfs.readFile(lastlogPath)) as {
|
|
361
|
+
return JSON.parse(shell.vfs.readFile(lastlogPath)) as {
|
|
454
362
|
at: string;
|
|
455
363
|
from: string;
|
|
456
364
|
};
|
|
@@ -461,12 +369,12 @@ export function startShell(
|
|
|
461
369
|
|
|
462
370
|
function writeLastLogin(nowIso: string): void {
|
|
463
371
|
const dir = "/virtual-env-js/.lastlog";
|
|
464
|
-
if (!vfs.exists(dir)) {
|
|
465
|
-
vfs.mkdir(dir, 0o700);
|
|
372
|
+
if (!shell.vfs.exists(dir)) {
|
|
373
|
+
shell.vfs.mkdir(dir, 0o700);
|
|
466
374
|
}
|
|
467
375
|
|
|
468
376
|
const lastlogPath = `${dir}/${authUser}.json`;
|
|
469
|
-
vfs.writeFile(
|
|
377
|
+
shell.vfs.writeFile(
|
|
470
378
|
lastlogPath,
|
|
471
379
|
JSON.stringify({ at: nowIso, from: remoteAddress }),
|
|
472
380
|
);
|
|
@@ -537,7 +445,10 @@ export function startShell(
|
|
|
537
445
|
if (ch === "\r" || ch === "\n") {
|
|
538
446
|
const password = pendingSudo.buffer;
|
|
539
447
|
pendingSudo.buffer = "";
|
|
540
|
-
const valid = users.verifyPassword(
|
|
448
|
+
const valid = shell.users.verifyPassword(
|
|
449
|
+
pendingSudo.username,
|
|
450
|
+
password,
|
|
451
|
+
);
|
|
541
452
|
await finishSudoPrompt(valid);
|
|
542
453
|
return;
|
|
543
454
|
}
|
|
@@ -650,16 +561,7 @@ export function startShell(
|
|
|
650
561
|
|
|
651
562
|
if (line.length > 0) {
|
|
652
563
|
const result = await Promise.resolve(
|
|
653
|
-
runCommand(
|
|
654
|
-
line,
|
|
655
|
-
authUser,
|
|
656
|
-
hostname,
|
|
657
|
-
users,
|
|
658
|
-
"shell",
|
|
659
|
-
cwd,
|
|
660
|
-
defaultShellProperties,
|
|
661
|
-
vfs,
|
|
662
|
-
),
|
|
564
|
+
runCommand(line, authUser, hostname, "shell", cwd, shell),
|
|
663
565
|
);
|
|
664
566
|
|
|
665
567
|
pushHistory(line);
|
|
@@ -709,12 +611,12 @@ export function startShell(
|
|
|
709
611
|
if (result.switchUser) {
|
|
710
612
|
authUser = result.switchUser;
|
|
711
613
|
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
712
|
-
users.updateSession(sessionId, authUser, remoteAddress);
|
|
614
|
+
shell.users.updateSession(sessionId, authUser, remoteAddress);
|
|
713
615
|
lineBuffer = "";
|
|
714
616
|
cursorPos = 0;
|
|
715
617
|
}
|
|
716
618
|
|
|
717
|
-
await vfs.flushMirror();
|
|
619
|
+
await shell.vfs.flushMirror();
|
|
718
620
|
}
|
|
719
621
|
|
|
720
622
|
renderLine();
|
|
@@ -9,7 +9,16 @@ export function parseShellPipeline(rawInput: string): Pipeline {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
const commands: PipelineCommand[] = [];
|
|
12
|
-
const
|
|
12
|
+
const tokenized = tokenizePipeline(trimmed);
|
|
13
|
+
if (tokenized.error) {
|
|
14
|
+
return {
|
|
15
|
+
commands: [],
|
|
16
|
+
isValid: false,
|
|
17
|
+
error: tokenized.error,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pipeTokens = tokenized.tokens;
|
|
13
22
|
|
|
14
23
|
for (const token of pipeTokens) {
|
|
15
24
|
const cmd = parseCommandWithRedirections(token);
|
|
@@ -29,7 +38,7 @@ export function parseShellPipeline(rawInput: string): Pipeline {
|
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/** Tokenize input by pipes, respecting quoted strings */
|
|
32
|
-
function tokenizePipeline(input: string): string[] {
|
|
41
|
+
function tokenizePipeline(input: string): { tokens: string[]; error?: string } {
|
|
33
42
|
const tokens: string[] = [];
|
|
34
43
|
let current = "";
|
|
35
44
|
let inQuotes = false;
|
|
@@ -49,9 +58,13 @@ function tokenizePipeline(input: string): string[] {
|
|
|
49
58
|
current += ch;
|
|
50
59
|
i++;
|
|
51
60
|
} else if (ch === "|" && !inQuotes) {
|
|
52
|
-
if (current.trim()) {
|
|
53
|
-
|
|
61
|
+
if (!current.trim()) {
|
|
62
|
+
return {
|
|
63
|
+
tokens: [],
|
|
64
|
+
error: "Syntax error near unexpected token '|'",
|
|
65
|
+
};
|
|
54
66
|
}
|
|
67
|
+
tokens.push(current.trim());
|
|
55
68
|
current = "";
|
|
56
69
|
i++;
|
|
57
70
|
} else {
|
|
@@ -60,11 +73,23 @@ function tokenizePipeline(input: string): string[] {
|
|
|
60
73
|
}
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
76
|
+
if (inQuotes) {
|
|
77
|
+
return {
|
|
78
|
+
tokens: [],
|
|
79
|
+
error: "Syntax error: unterminated quote",
|
|
80
|
+
};
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
|
|
83
|
+
if (!current.trim()) {
|
|
84
|
+
return {
|
|
85
|
+
tokens: [],
|
|
86
|
+
error: "Syntax error near unexpected token '|'",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
tokens.push(current.trim());
|
|
91
|
+
|
|
92
|
+
return { tokens };
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
interface ParseResult {
|