typescript-virtual-container 0.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/.github/ISSUE_TEMPLATE/bug_report.yml +50 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +31 -0
- package/.github/dependabot.yml +27 -0
- package/.github/pull_request_template.md +21 -0
- package/.github/workflows/create-pull-request.yml +83 -0
- package/.github/workflows/test-battery.yml +57 -0
- package/CHANGELOG.md +27 -0
- package/CODE_OF_CONDUCT.md +39 -0
- package/CONTRIBUTING.md +59 -0
- package/LICENSE +21 -0
- package/README.md +1283 -0
- package/SECURITY.md +33 -0
- package/biome.json +20 -0
- package/bun.lock +99 -0
- package/package.json +38 -0
- package/src/SSHMimic/client.ts +248 -0
- package/src/SSHMimic/commands/adduser.ts +22 -0
- package/src/SSHMimic/commands/cat.ts +16 -0
- package/src/SSHMimic/commands/cd.ts +20 -0
- package/src/SSHMimic/commands/clear.ts +7 -0
- package/src/SSHMimic/commands/curl.ts +27 -0
- package/src/SSHMimic/commands/deluser.ts +19 -0
- package/src/SSHMimic/commands/exit.ts +7 -0
- package/src/SSHMimic/commands/help.ts +9 -0
- package/src/SSHMimic/commands/helpers.ts +137 -0
- package/src/SSHMimic/commands/hostname.ts +7 -0
- package/src/SSHMimic/commands/htop.ts +13 -0
- package/src/SSHMimic/commands/index.ts +120 -0
- package/src/SSHMimic/commands/ls.ts +14 -0
- package/src/SSHMimic/commands/mkdir.ts +17 -0
- package/src/SSHMimic/commands/nano.ts +30 -0
- package/src/SSHMimic/commands/pwd.ts +7 -0
- package/src/SSHMimic/commands/rm.ts +26 -0
- package/src/SSHMimic/commands/su.ts +31 -0
- package/src/SSHMimic/commands/sudo.ts +90 -0
- package/src/SSHMimic/commands/touch.ts +20 -0
- package/src/SSHMimic/commands/tree.ts +11 -0
- package/src/SSHMimic/commands/wget.ts +33 -0
- package/src/SSHMimic/commands/who.ts +18 -0
- package/src/SSHMimic/commands/whoami.ts +7 -0
- package/src/SSHMimic/exec.ts +37 -0
- package/src/SSHMimic/hostKey.ts +21 -0
- package/src/SSHMimic/index.ts +203 -0
- package/src/SSHMimic/loginFormat.ts +10 -0
- package/src/SSHMimic/prompt.ts +14 -0
- package/src/SSHMimic/shell.ts +740 -0
- package/src/SSHMimic/users.ts +336 -0
- package/src/VirtualFileSystem.ts +420 -0
- package/src/index.ts +34 -0
- package/src/standalone.ts +14 -0
- package/src/types/commands.ts +98 -0
- package/src/types/streams.ts +32 -0
- package/src/types/tar-stream.d.ts +38 -0
- package/src/types/vfs.ts +81 -0
- package/src/vfs/archive.ts +74 -0
- package/src/vfs/internalTypes.ts +19 -0
- package/src/vfs/path.ts +74 -0
- package/src/vfs/snapshot.ts +84 -0
- package/src/vfs/tree.ts +34 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
2
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { ShellStream } from "../types/streams";
|
|
5
|
+
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
6
|
+
import { getCommandNames, runCommand } from "./commands";
|
|
7
|
+
import { formatLoginDate } from "./loginFormat";
|
|
8
|
+
import { buildPrompt } from "./prompt";
|
|
9
|
+
import type { VirtualUserManager } from "./users";
|
|
10
|
+
|
|
11
|
+
interface NanoSession {
|
|
12
|
+
kind: "nano" | "htop";
|
|
13
|
+
targetPath: string;
|
|
14
|
+
tempPath: string;
|
|
15
|
+
process: ChildProcessWithoutNullStreams;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PendingSudo {
|
|
19
|
+
username: string;
|
|
20
|
+
targetUser: string;
|
|
21
|
+
commandLine: string | null;
|
|
22
|
+
loginShell: boolean;
|
|
23
|
+
prompt: string;
|
|
24
|
+
buffer: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function shellQuote(value: string): string {
|
|
28
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TerminalSize {
|
|
32
|
+
cols: number;
|
|
33
|
+
rows: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function startShell(
|
|
37
|
+
stream: ShellStream,
|
|
38
|
+
authUser: string,
|
|
39
|
+
vfs: VirtualFileSystem,
|
|
40
|
+
hostname: string,
|
|
41
|
+
users: VirtualUserManager,
|
|
42
|
+
sessionId: string | null,
|
|
43
|
+
remoteAddress = "unknown",
|
|
44
|
+
terminalSize: TerminalSize = { cols: 80, rows: 24 },
|
|
45
|
+
): void {
|
|
46
|
+
let lineBuffer = "";
|
|
47
|
+
let cursorPos = 0;
|
|
48
|
+
let history = loadHistory(vfs);
|
|
49
|
+
let historyIndex: number | null = null;
|
|
50
|
+
let historyDraft = "";
|
|
51
|
+
let cwd = `/home/${authUser}`;
|
|
52
|
+
let nanoSession: NanoSession | null = null;
|
|
53
|
+
let pendingSudo: PendingSudo | null = null;
|
|
54
|
+
const buildCurrentPrompt = (): string => {
|
|
55
|
+
const homePath = `/home/${authUser}`;
|
|
56
|
+
const cwdLabel = cwd === homePath ? "~" : path.posix.basename(cwd) || "/";
|
|
57
|
+
return buildPrompt(authUser, hostname, cwdLabel);
|
|
58
|
+
};
|
|
59
|
+
const commandNames = Array.from(new Set(getCommandNames())).sort();
|
|
60
|
+
|
|
61
|
+
async function collectChildPids(parentPid: number): Promise<number[]> {
|
|
62
|
+
try {
|
|
63
|
+
const childrenRaw = await readFile(
|
|
64
|
+
`/proc/${parentPid}/task/${parentPid}/children`,
|
|
65
|
+
"utf8",
|
|
66
|
+
);
|
|
67
|
+
const directChildren = childrenRaw
|
|
68
|
+
.trim()
|
|
69
|
+
.split(/\s+/)
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map((value) => Number.parseInt(value, 10))
|
|
72
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
73
|
+
|
|
74
|
+
const nested = await Promise.all(
|
|
75
|
+
directChildren.map((pid) => collectChildPids(pid)),
|
|
76
|
+
);
|
|
77
|
+
return [...directChildren, ...nested.flat()];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function getVisibleHtopPidList(): Promise<string | null> {
|
|
84
|
+
const rootPid = process.pid;
|
|
85
|
+
const descendants = await collectChildPids(rootPid);
|
|
86
|
+
const unique = Array.from(new Set(descendants)).sort((a, b) => a - b);
|
|
87
|
+
if (unique.length === 0) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return unique.join(",");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function withTerminalSize(command: string): string {
|
|
95
|
+
const cols =
|
|
96
|
+
Number.isFinite(terminalSize.cols) && terminalSize.cols > 0
|
|
97
|
+
? Math.floor(terminalSize.cols)
|
|
98
|
+
: 80;
|
|
99
|
+
const rows =
|
|
100
|
+
Number.isFinite(terminalSize.rows) && terminalSize.rows > 0
|
|
101
|
+
? Math.floor(terminalSize.rows)
|
|
102
|
+
: 24;
|
|
103
|
+
return `stty cols ${cols} rows ${rows} 2>/dev/null; ${command}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolvePath(base: string, inputPath: string): string {
|
|
107
|
+
if (!inputPath || inputPath.trim() === "" || inputPath === ".") {
|
|
108
|
+
return base;
|
|
109
|
+
}
|
|
110
|
+
return inputPath.startsWith("/")
|
|
111
|
+
? path.posix.normalize(inputPath)
|
|
112
|
+
: path.posix.normalize(path.posix.join(base, inputPath));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderLine(): void {
|
|
116
|
+
const prompt = buildCurrentPrompt();
|
|
117
|
+
stream.write(`\r${prompt}${lineBuffer}\u001b[K`);
|
|
118
|
+
|
|
119
|
+
const moveLeft = lineBuffer.length - cursorPos;
|
|
120
|
+
if (moveLeft > 0) {
|
|
121
|
+
stream.write(`\u001b[${moveLeft}D`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clearCurrentLine(): void {
|
|
126
|
+
stream.write("\r\u001b[K");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function startSudoPrompt(challenge: {
|
|
130
|
+
username: string;
|
|
131
|
+
targetUser: string;
|
|
132
|
+
commandLine: string | null;
|
|
133
|
+
loginShell: boolean;
|
|
134
|
+
prompt: string;
|
|
135
|
+
}): void {
|
|
136
|
+
pendingSudo = {
|
|
137
|
+
...challenge,
|
|
138
|
+
buffer: "",
|
|
139
|
+
};
|
|
140
|
+
clearCurrentLine();
|
|
141
|
+
stream.write(challenge.prompt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function finishSudoPrompt(success: boolean): Promise<void> {
|
|
145
|
+
if (!pendingSudo) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const challenge = pendingSudo;
|
|
150
|
+
pendingSudo = null;
|
|
151
|
+
|
|
152
|
+
if (!success) {
|
|
153
|
+
stream.write("\r\nSorry, try again.\r\n");
|
|
154
|
+
renderLine();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!challenge.commandLine) {
|
|
159
|
+
authUser = challenge.targetUser;
|
|
160
|
+
cwd = `/home/${authUser}`;
|
|
161
|
+
users.updateSession(sessionId, authUser, remoteAddress);
|
|
162
|
+
stream.write("\r\n");
|
|
163
|
+
renderLine();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const runCwd = challenge.loginShell ? `/home/${challenge.targetUser}` : cwd;
|
|
168
|
+
const result = await Promise.resolve(
|
|
169
|
+
runCommand(
|
|
170
|
+
challenge.commandLine,
|
|
171
|
+
challenge.targetUser,
|
|
172
|
+
hostname,
|
|
173
|
+
users,
|
|
174
|
+
"shell",
|
|
175
|
+
runCwd,
|
|
176
|
+
vfs,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
stream.write("\r\n");
|
|
181
|
+
|
|
182
|
+
if (result.openEditor) {
|
|
183
|
+
await startNanoEditor(
|
|
184
|
+
result.openEditor.targetPath,
|
|
185
|
+
result.openEditor.initialContent,
|
|
186
|
+
result.openEditor.tempPath,
|
|
187
|
+
);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (result.openHtop) {
|
|
192
|
+
await startHtop();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (result.clearScreen) {
|
|
197
|
+
stream.write("\u001b[2J\u001b[H");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (result.stdout) {
|
|
201
|
+
stream.write(`${result.stdout}\r\n`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (result.stderr) {
|
|
205
|
+
stream.write(`${result.stderr}\r\n`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (result.switchUser) {
|
|
209
|
+
authUser = result.switchUser;
|
|
210
|
+
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
211
|
+
users.updateSession(sessionId, authUser, remoteAddress);
|
|
212
|
+
} else if (result.nextCwd) {
|
|
213
|
+
cwd = result.nextCwd;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await vfs.flushMirror();
|
|
217
|
+
renderLine();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function finishNanoEditor(): Promise<void> {
|
|
221
|
+
if (!nanoSession) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const activeSession = nanoSession;
|
|
226
|
+
|
|
227
|
+
if (activeSession.kind === "nano") {
|
|
228
|
+
try {
|
|
229
|
+
const updatedContent = await readFile(activeSession.tempPath, "utf8");
|
|
230
|
+
vfs.writeFile(activeSession.targetPath, updatedContent);
|
|
231
|
+
await vfs.flushMirror();
|
|
232
|
+
} catch {
|
|
233
|
+
// If temp file does not exist, nano exited without writing.
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await unlink(activeSession.tempPath).catch(() => undefined);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
nanoSession = null;
|
|
240
|
+
lineBuffer = "";
|
|
241
|
+
cursorPos = 0;
|
|
242
|
+
stream.write("\r\n");
|
|
243
|
+
renderLine();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function startNanoEditor(
|
|
247
|
+
targetPath: string,
|
|
248
|
+
initialContent: string,
|
|
249
|
+
tempPath: string,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
if (vfs.exists(targetPath)) {
|
|
252
|
+
await writeFile(tempPath, initialContent, "utf8");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const command = withTerminalSize(`nano -- ${shellQuote(tempPath)}`);
|
|
256
|
+
const editor = spawn("script", ["-qfec", command, "/dev/null"], {
|
|
257
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
258
|
+
env: {
|
|
259
|
+
...process.env,
|
|
260
|
+
// biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
|
|
261
|
+
TERM: process.env.TERM ?? "xterm-256color",
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
editor.stdout.on("data", (data: Buffer) => {
|
|
266
|
+
stream.write(data.toString("utf8"));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
editor.stderr.on("data", (data: Buffer) => {
|
|
270
|
+
stream.write(data.toString("utf8"));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
editor.on("error", (error: Error) => {
|
|
274
|
+
stream.write(`nano: ${error.message}\r\n`);
|
|
275
|
+
void finishNanoEditor();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
editor.on("close", () => {
|
|
279
|
+
void finishNanoEditor();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
nanoSession = {
|
|
283
|
+
kind: "nano",
|
|
284
|
+
targetPath,
|
|
285
|
+
tempPath,
|
|
286
|
+
process: editor,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function startHtop(): Promise<void> {
|
|
291
|
+
const pidList = await getVisibleHtopPidList();
|
|
292
|
+
if (!pidList) {
|
|
293
|
+
stream.write("htop: no child_process processes to display\r\n");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const command = withTerminalSize(`htop -p ${shellQuote(pidList)}`);
|
|
298
|
+
const monitor = spawn("script", ["-qfec", command, "/dev/null"], {
|
|
299
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
300
|
+
env: {
|
|
301
|
+
...process.env,
|
|
302
|
+
// biome-ignore lint/style/useNamingConvention: TERM is an environment variable conventionally in uppercase
|
|
303
|
+
TERM: process.env.TERM ?? "xterm-256color",
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
monitor.stdout.on("data", (data: Buffer) => {
|
|
308
|
+
stream.write(data.toString("utf8"));
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
monitor.stderr.on("data", (data: Buffer) => {
|
|
312
|
+
stream.write(data.toString("utf8"));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
monitor.on("error", (error: Error) => {
|
|
316
|
+
stream.write(`htop: ${error.message}\r\n`);
|
|
317
|
+
void finishNanoEditor();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
monitor.on("close", () => {
|
|
321
|
+
void finishNanoEditor();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
nanoSession = {
|
|
325
|
+
kind: "htop",
|
|
326
|
+
targetPath: "",
|
|
327
|
+
tempPath: "",
|
|
328
|
+
process: monitor,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function applyHistoryLine(nextLine: string): void {
|
|
333
|
+
lineBuffer = nextLine;
|
|
334
|
+
cursorPos = lineBuffer.length;
|
|
335
|
+
renderLine();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function insertText(text: string): void {
|
|
339
|
+
lineBuffer = `${lineBuffer.slice(0, cursorPos)}${text}${lineBuffer.slice(cursorPos)}`;
|
|
340
|
+
cursorPos += text.length;
|
|
341
|
+
renderLine();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function getTokenRange(
|
|
345
|
+
line: string,
|
|
346
|
+
cursor: number,
|
|
347
|
+
): { start: number; end: number } {
|
|
348
|
+
let start = cursor;
|
|
349
|
+
while (start > 0 && !/\s/.test(line[start - 1]!)) {
|
|
350
|
+
start -= 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let end = cursor;
|
|
354
|
+
while (end < line.length && !/\s/.test(line[end]!)) {
|
|
355
|
+
end += 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { start, end };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function listPathCompletions(prefix: string): string[] {
|
|
362
|
+
const slashIndex = prefix.lastIndexOf("/");
|
|
363
|
+
const dirPart = slashIndex >= 0 ? prefix.slice(0, slashIndex + 1) : "";
|
|
364
|
+
const namePart = slashIndex >= 0 ? prefix.slice(slashIndex + 1) : prefix;
|
|
365
|
+
const basePath = resolvePath(cwd, dirPart || ".");
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
return vfs
|
|
369
|
+
.list(basePath)
|
|
370
|
+
.filter((entry) => !entry.startsWith("."))
|
|
371
|
+
.filter((entry) => entry.startsWith(namePart))
|
|
372
|
+
.map((entry) => {
|
|
373
|
+
const fullPath = path.posix.join(basePath, entry);
|
|
374
|
+
const st = vfs.stat(fullPath);
|
|
375
|
+
const suffix = st.type === "directory" ? "/" : "";
|
|
376
|
+
return `${dirPart}${entry}${suffix}`;
|
|
377
|
+
})
|
|
378
|
+
.sort();
|
|
379
|
+
} catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function handleTabCompletion(): void {
|
|
385
|
+
const { start, end } = getTokenRange(lineBuffer, cursorPos);
|
|
386
|
+
const token = lineBuffer.slice(start, cursorPos);
|
|
387
|
+
|
|
388
|
+
if (token.length === 0) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const firstToken = lineBuffer.slice(0, start).trim().length === 0;
|
|
393
|
+
const commandCandidates = firstToken
|
|
394
|
+
? commandNames.filter((name) => name.startsWith(token))
|
|
395
|
+
: [];
|
|
396
|
+
const pathCandidates = listPathCompletions(token);
|
|
397
|
+
const candidates = Array.from(
|
|
398
|
+
new Set([...commandCandidates, ...pathCandidates]),
|
|
399
|
+
).sort();
|
|
400
|
+
|
|
401
|
+
if (candidates.length === 0) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (candidates.length === 1) {
|
|
406
|
+
const completed = candidates[0]!;
|
|
407
|
+
const suffix = completed.endsWith("/") ? "" : " ";
|
|
408
|
+
lineBuffer = `${lineBuffer.slice(0, start)}${completed}${suffix}${lineBuffer.slice(end)}`;
|
|
409
|
+
cursorPos = start + completed.length + suffix.length;
|
|
410
|
+
renderLine();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
stream.write("\r\n");
|
|
415
|
+
stream.write(`${candidates.join(" ")}\r\n`);
|
|
416
|
+
renderLine();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function pushHistory(cmd: string): void {
|
|
420
|
+
if (cmd.length === 0) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
history.push(cmd);
|
|
425
|
+
if (history.length > 500) {
|
|
426
|
+
history = history.slice(history.length - 500);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const data = history.length > 0 ? `${history.join("\n")}\n` : "";
|
|
430
|
+
vfs.writeFile("/virtual-env-js/.bash_history", data);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function readLastLogin(): { at: string; from: string } | null {
|
|
434
|
+
const lastlogPath = `/virtual-env-js/.lastlog/${authUser}.json`;
|
|
435
|
+
if (!vfs.exists(lastlogPath)) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
return JSON.parse(vfs.readFile(lastlogPath)) as {
|
|
441
|
+
at: string;
|
|
442
|
+
from: string;
|
|
443
|
+
};
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function writeLastLogin(nowIso: string): void {
|
|
450
|
+
const dir = "/virtual-env-js/.lastlog";
|
|
451
|
+
if (!vfs.exists(dir)) {
|
|
452
|
+
vfs.mkdir(dir, 0o700);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const lastlogPath = `${dir}/${authUser}.json`;
|
|
456
|
+
vfs.writeFile(
|
|
457
|
+
lastlogPath,
|
|
458
|
+
JSON.stringify({ at: nowIso, from: remoteAddress }),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderLoginBanner(): void {
|
|
463
|
+
// const kernel = os.release();
|
|
464
|
+
// const arch = os.arch();
|
|
465
|
+
|
|
466
|
+
// Our own kernel and arch strings to avoid leaking host info and to provide a more "Linux-like" feel
|
|
467
|
+
const kernel = "5.15.0-1051-azure";
|
|
468
|
+
const arch = "x86_64";
|
|
469
|
+
|
|
470
|
+
const last = readLastLogin();
|
|
471
|
+
const nowIso = new Date().toISOString();
|
|
472
|
+
|
|
473
|
+
stream.write(`Linux ${hostname} ${kernel} ${arch}\r\n`);
|
|
474
|
+
stream.write("\r\n");
|
|
475
|
+
stream.write(
|
|
476
|
+
"The programs included with the Debian GNU/Linux system are free software;\r\n",
|
|
477
|
+
);
|
|
478
|
+
stream.write(
|
|
479
|
+
"the exact distribution terms for each program are described in the\r\n",
|
|
480
|
+
);
|
|
481
|
+
stream.write("individual files in /usr/share/doc/*/copyright.\r\n");
|
|
482
|
+
stream.write("\r\n");
|
|
483
|
+
stream.write(
|
|
484
|
+
"Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent\r\n",
|
|
485
|
+
);
|
|
486
|
+
stream.write("permitted by applicable law.\r\n");
|
|
487
|
+
|
|
488
|
+
if (last) {
|
|
489
|
+
const when = new Date(last.at);
|
|
490
|
+
const displayed = Number.isNaN(when.getTime())
|
|
491
|
+
? last.at
|
|
492
|
+
: formatLoginDate(when);
|
|
493
|
+
stream.write(
|
|
494
|
+
`Last login: ${displayed} from ${last.from || "unknown"}\r\n`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
stream.write("\r\n");
|
|
499
|
+
writeLastLogin(nowIso);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
renderLoginBanner();
|
|
503
|
+
renderLine();
|
|
504
|
+
|
|
505
|
+
stream.on("data", async (chunk: Buffer) => {
|
|
506
|
+
if (nanoSession) {
|
|
507
|
+
nanoSession.process.stdin.write(chunk);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (pendingSudo) {
|
|
512
|
+
const input = chunk.toString("utf8");
|
|
513
|
+
|
|
514
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
515
|
+
const ch = input[i]!;
|
|
516
|
+
|
|
517
|
+
if (ch === "\u0003") {
|
|
518
|
+
pendingSudo = null;
|
|
519
|
+
stream.write("^C\r\n");
|
|
520
|
+
renderLine();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
525
|
+
pendingSudo.buffer = pendingSudo.buffer.slice(0, -1);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (ch === "\r" || ch === "\n") {
|
|
530
|
+
const password = pendingSudo.buffer;
|
|
531
|
+
pendingSudo.buffer = "";
|
|
532
|
+
const valid = users.verifyPassword(pendingSudo.username, password);
|
|
533
|
+
await finishSudoPrompt(valid);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (ch >= " ") {
|
|
538
|
+
pendingSudo.buffer += ch;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const input = chunk.toString("utf8");
|
|
546
|
+
|
|
547
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
548
|
+
const ch = input[i]!;
|
|
549
|
+
|
|
550
|
+
if (ch === "\u0004") {
|
|
551
|
+
stream.write("logout\r\n");
|
|
552
|
+
stream.exit(0);
|
|
553
|
+
stream.end();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (ch === "\t") {
|
|
558
|
+
handleTabCompletion();
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (ch === "\u001b") {
|
|
563
|
+
const next = input[i + 1];
|
|
564
|
+
const third = input[i + 2];
|
|
565
|
+
const fourth = input[i + 3];
|
|
566
|
+
|
|
567
|
+
if (next === "[" && third) {
|
|
568
|
+
if (third === "A") {
|
|
569
|
+
i += 2;
|
|
570
|
+
if (history.length > 0) {
|
|
571
|
+
if (historyIndex === null) {
|
|
572
|
+
historyDraft = lineBuffer;
|
|
573
|
+
historyIndex = history.length - 1;
|
|
574
|
+
} else if (historyIndex > 0) {
|
|
575
|
+
historyIndex -= 1;
|
|
576
|
+
}
|
|
577
|
+
applyHistoryLine(history[historyIndex] ?? "");
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (third === "B") {
|
|
583
|
+
i += 2;
|
|
584
|
+
if (historyIndex !== null) {
|
|
585
|
+
if (historyIndex < history.length - 1) {
|
|
586
|
+
historyIndex += 1;
|
|
587
|
+
applyHistoryLine(history[historyIndex] ?? "");
|
|
588
|
+
} else {
|
|
589
|
+
historyIndex = null;
|
|
590
|
+
applyHistoryLine(historyDraft);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (third === "C") {
|
|
597
|
+
i += 2;
|
|
598
|
+
if (cursorPos < lineBuffer.length) {
|
|
599
|
+
cursorPos += 1;
|
|
600
|
+
stream.write("\u001b[C");
|
|
601
|
+
}
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (third === "D") {
|
|
606
|
+
i += 2;
|
|
607
|
+
if (cursorPos > 0) {
|
|
608
|
+
cursorPos -= 1;
|
|
609
|
+
stream.write("\u001b[D");
|
|
610
|
+
}
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (third === "3" && fourth === "~") {
|
|
615
|
+
i += 3;
|
|
616
|
+
if (cursorPos < lineBuffer.length) {
|
|
617
|
+
lineBuffer = `${lineBuffer.slice(0, cursorPos)}${lineBuffer.slice(cursorPos + 1)}`;
|
|
618
|
+
renderLine();
|
|
619
|
+
}
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (ch === "\u0003") {
|
|
626
|
+
lineBuffer = "";
|
|
627
|
+
cursorPos = 0;
|
|
628
|
+
historyIndex = null;
|
|
629
|
+
historyDraft = "";
|
|
630
|
+
stream.write("^C\r\n");
|
|
631
|
+
renderLine();
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (ch === "\r" || ch === "\n") {
|
|
636
|
+
const line = lineBuffer.trim();
|
|
637
|
+
lineBuffer = "";
|
|
638
|
+
cursorPos = 0;
|
|
639
|
+
historyIndex = null;
|
|
640
|
+
historyDraft = "";
|
|
641
|
+
stream.write("\r\n");
|
|
642
|
+
|
|
643
|
+
if (line.length > 0) {
|
|
644
|
+
const result = await Promise.resolve(
|
|
645
|
+
runCommand(line, authUser, hostname, users, "shell", cwd, vfs),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
pushHistory(line);
|
|
649
|
+
|
|
650
|
+
if (result.openEditor) {
|
|
651
|
+
await startNanoEditor(
|
|
652
|
+
result.openEditor.targetPath,
|
|
653
|
+
result.openEditor.initialContent,
|
|
654
|
+
result.openEditor.tempPath,
|
|
655
|
+
);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (result.openHtop) {
|
|
660
|
+
await startHtop();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (result.sudoChallenge) {
|
|
665
|
+
startSudoPrompt(result.sudoChallenge);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (result.clearScreen) {
|
|
670
|
+
stream.write("\u001b[2J\u001b[H");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (result.stdout) {
|
|
674
|
+
stream.write(`${result.stdout}\r\n`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (result.stderr) {
|
|
678
|
+
stream.write(`${result.stderr}\r\n`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (result.closeSession) {
|
|
682
|
+
stream.write("logout\r\n");
|
|
683
|
+
stream.exit(result.exitCode ?? 0);
|
|
684
|
+
stream.end();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (result.nextCwd) {
|
|
689
|
+
cwd = result.nextCwd;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (result.switchUser) {
|
|
693
|
+
authUser = result.switchUser;
|
|
694
|
+
cwd = result.nextCwd ?? `/home/${authUser}`;
|
|
695
|
+
users.updateSession(sessionId, authUser, remoteAddress);
|
|
696
|
+
lineBuffer = "";
|
|
697
|
+
cursorPos = 0;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await vfs.flushMirror();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
renderLine();
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
708
|
+
if (cursorPos > 0) {
|
|
709
|
+
lineBuffer = `${lineBuffer.slice(0, cursorPos - 1)}${lineBuffer.slice(cursorPos)}`;
|
|
710
|
+
cursorPos -= 1;
|
|
711
|
+
renderLine();
|
|
712
|
+
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
insertText(ch);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
stream.on("close", () => {
|
|
721
|
+
if (nanoSession) {
|
|
722
|
+
nanoSession.process.kill("SIGTERM");
|
|
723
|
+
nanoSession = null;
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function loadHistory(vfs: VirtualFileSystem): string[] {
|
|
729
|
+
const historyPath = "/virtual-env-js/.bash_history";
|
|
730
|
+
if (!vfs.exists(historyPath)) {
|
|
731
|
+
vfs.writeFile(historyPath, "");
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const raw = vfs.readFile(historyPath);
|
|
736
|
+
return raw
|
|
737
|
+
.split("\n")
|
|
738
|
+
.map((line) => line.trim())
|
|
739
|
+
.filter((line) => line.length > 0);
|
|
740
|
+
}
|