typescript-virtual-container 1.1.3 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/HONEYPOT.md +358 -0
- package/README.md +471 -16
- package/dist/SSHMimic/exec.d.ts.map +1 -1
- package/dist/SSHMimic/exec.js +8 -2
- package/dist/SSHMimic/index.d.ts +3 -1
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +21 -4
- package/dist/SSHMimic/sftp.d.ts +48 -0
- package/dist/SSHMimic/sftp.d.ts.map +1 -0
- package/dist/SSHMimic/sftp.js +595 -0
- package/dist/VirtualFileSystem/index.d.ts +8 -5
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +152 -154
- package/dist/VirtualShell/index.d.ts +8 -1
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +22 -5
- package/dist/VirtualShell/shell.d.ts.map +1 -1
- package/dist/VirtualShell/shell.js +7 -0
- package/dist/VirtualUserManager/index.d.ts +3 -1
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +34 -1
- package/dist/commands/exit.d.ts.map +1 -1
- package/dist/commands/exit.js +1 -0
- package/dist/honeypot.d.ts +132 -0
- package/dist/honeypot.d.ts.map +1 -0
- package/dist/honeypot.js +289 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/standalone.js +10 -1
- package/examples/README.md +210 -0
- package/examples/honeypot-audit.ts +180 -0
- package/examples/honeypot-export.ts +253 -0
- package/examples/honeypot-quickstart.ts +110 -0
- package/package.json +1 -1
- package/src/Honeypot/index.ts +422 -0
- package/src/SSHMimic/exec.ts +18 -12
- package/src/SSHMimic/index.ts +29 -8
- package/src/SSHMimic/sftp.ts +853 -0
- package/src/VirtualFileSystem/index.ts +167 -190
- package/src/VirtualShell/index.ts +25 -9
- package/src/VirtualShell/shell.ts +7 -0
- package/src/VirtualUserManager/index.ts +41 -3
- package/src/commands/exit.ts +1 -0
- package/src/index.ts +8 -1
- package/src/standalone.ts +11 -1
- package/tests/sftp.test.ts +319 -0
- package/tests/ssh-exec.test.ts +45 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
4
5
|
import type {
|
|
@@ -6,11 +7,7 @@ import type {
|
|
|
6
7
|
VfsNodeStats,
|
|
7
8
|
WriteFileOptions,
|
|
8
9
|
} from "../types/vfs";
|
|
9
|
-
import {
|
|
10
|
-
import type { InternalDirectoryNode, InternalNode } from "./internalTypes";
|
|
11
|
-
import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
|
|
12
|
-
import { applySnapshot, createSnapshot } from "./snapshot";
|
|
13
|
-
import { renderTree } from "./tree";
|
|
10
|
+
import { normalizePath } from "./path";
|
|
14
11
|
|
|
15
12
|
/**
|
|
16
13
|
* In-memory virtual filesystem with tar.gz mirror persistence.
|
|
@@ -19,39 +16,86 @@ import { renderTree } from "./tree";
|
|
|
19
16
|
* {@link VirtualFileSystem.restoreMirror} on startup and
|
|
20
17
|
* {@link VirtualFileSystem.flushMirror} to persist pending changes.
|
|
21
18
|
*/
|
|
22
|
-
class VirtualFileSystem {
|
|
23
|
-
private readonly
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
class VirtualFileSystem extends EventEmitter {
|
|
20
|
+
private readonly mirrorRoot: string;
|
|
21
|
+
|
|
22
|
+
private ensureMirrorRoot(): void {
|
|
23
|
+
fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private resolveFsPath(targetPath: string): string {
|
|
27
|
+
const normalized = normalizePath(targetPath);
|
|
28
|
+
const relativePath = normalized.slice(1);
|
|
29
|
+
const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
|
|
30
|
+
const relative = path.relative(this.mirrorRoot, resolved);
|
|
31
|
+
|
|
32
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
33
|
+
throw new Error(`Invalid path '${targetPath}'.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private detectGzipFile(targetPath: string): boolean {
|
|
40
|
+
const fd = fs.openSync(targetPath, "r");
|
|
41
|
+
try {
|
|
42
|
+
const header = Buffer.alloc(2);
|
|
43
|
+
const bytesRead = fs.readSync(fd, header, 0, 2, 0);
|
|
44
|
+
return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
|
|
45
|
+
} finally {
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private computeDiskUsageBytes(targetPath: string): number {
|
|
51
|
+
const stats = fs.statSync(targetPath);
|
|
52
|
+
if (stats.isFile()) {
|
|
53
|
+
return stats.size;
|
|
30
54
|
}
|
|
31
55
|
|
|
32
56
|
let total = 0;
|
|
33
|
-
for (const
|
|
34
|
-
total += this.
|
|
57
|
+
for (const entry of fs.readdirSync(targetPath)) {
|
|
58
|
+
total += this.computeDiskUsageBytes(path.join(targetPath, entry));
|
|
35
59
|
}
|
|
36
60
|
return total;
|
|
37
61
|
}
|
|
38
62
|
|
|
63
|
+
private renderTreeLines(targetPath: string, label: string): string {
|
|
64
|
+
const lines = [label];
|
|
65
|
+
|
|
66
|
+
const walk = (currentPath: string, prefix: string): void => {
|
|
67
|
+
const entries = fs
|
|
68
|
+
.readdirSync(currentPath, { withFileTypes: true })
|
|
69
|
+
.map((entry) => entry.name)
|
|
70
|
+
.sort((left, right) => left.localeCompare(right));
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
73
|
+
const name = entries[i]!;
|
|
74
|
+
const isLast = i === entries.length - 1;
|
|
75
|
+
const connector = isLast ? "└── " : "├── ";
|
|
76
|
+
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
|
|
77
|
+
const entryPath = path.join(currentPath, name);
|
|
78
|
+
const isDirectory = fs.statSync(entryPath).isDirectory();
|
|
79
|
+
|
|
80
|
+
lines.push(`${prefix}${connector}${name}`);
|
|
81
|
+
if (isDirectory) {
|
|
82
|
+
walk(entryPath, nextPrefix);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
walk(targetPath, "");
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
39
91
|
/**
|
|
40
92
|
* Creates a virtual filesystem instance.
|
|
41
93
|
*
|
|
42
94
|
* @param baseDir Base directory used to resolve mirror archive location.
|
|
43
95
|
*/
|
|
44
96
|
constructor(baseDir: string = process.cwd()) {
|
|
45
|
-
|
|
46
|
-
this.
|
|
47
|
-
this.root = {
|
|
48
|
-
type: "directory",
|
|
49
|
-
name: "",
|
|
50
|
-
mode: 0o755,
|
|
51
|
-
createdAt: now,
|
|
52
|
-
updatedAt: now,
|
|
53
|
-
children: new Map<string, InternalNode>(),
|
|
54
|
-
};
|
|
97
|
+
super();
|
|
98
|
+
this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
/**
|
|
@@ -60,20 +104,7 @@ class VirtualFileSystem {
|
|
|
60
104
|
* If archive does not exist or cannot be read, creates fresh mirror file.
|
|
61
105
|
*/
|
|
62
106
|
public async restoreMirror(): Promise<void> {
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const compressed = await fs.readFile(this.archivePath);
|
|
66
|
-
const tarBuffer = gunzipSync(compressed);
|
|
67
|
-
const snapshot = await readSnapshotFromTar(tarBuffer);
|
|
68
|
-
applySnapshot(this.root, snapshot);
|
|
69
|
-
this.dirty = false;
|
|
70
|
-
return;
|
|
71
|
-
} catch {
|
|
72
|
-
console.warn(
|
|
73
|
-
`No valid mirror archive found at '${this.archivePath}'. Starting with empty filesystem.`,
|
|
74
|
-
);
|
|
75
|
-
await this.flushMirror();
|
|
76
|
-
}
|
|
107
|
+
this.ensureMirrorRoot();
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
/**
|
|
@@ -82,16 +113,8 @@ class VirtualFileSystem {
|
|
|
82
113
|
* No-op when nothing changed and archive already exists.
|
|
83
114
|
*/
|
|
84
115
|
public async flushMirror(): Promise<void> {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
|
|
90
|
-
const snapshotJson = JSON.stringify(createSnapshot(this.root), null, 2);
|
|
91
|
-
const tarBuffer = await createTarBuffer(snapshotJson);
|
|
92
|
-
const compressed = gzipSync(tarBuffer);
|
|
93
|
-
await fs.writeFile(this.archivePath, compressed);
|
|
94
|
-
this.dirty = false;
|
|
116
|
+
this.ensureMirrorRoot();
|
|
117
|
+
this.emit("mirror:flush");
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
/**
|
|
@@ -101,36 +124,15 @@ class VirtualFileSystem {
|
|
|
101
124
|
* @param mode POSIX-like mode bits for new directories.
|
|
102
125
|
*/
|
|
103
126
|
public mkdir(targetPath: string, mode: number = 0o755): void {
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!existing) {
|
|
111
|
-
const now = new Date();
|
|
112
|
-
const nextDir: InternalDirectoryNode = {
|
|
113
|
-
type: "directory",
|
|
114
|
-
name: part,
|
|
115
|
-
mode,
|
|
116
|
-
createdAt: now,
|
|
117
|
-
updatedAt: now,
|
|
118
|
-
children: new Map<string, InternalNode>(),
|
|
119
|
-
};
|
|
120
|
-
current.children.set(part, nextDir);
|
|
121
|
-
current.updatedAt = now;
|
|
122
|
-
this.dirty = true;
|
|
123
|
-
current = nextDir;
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (existing.type !== "directory") {
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Cannot create directory '${normalized}': '${part}' is a file.`,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
current = existing;
|
|
127
|
+
this.ensureMirrorRoot();
|
|
128
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
129
|
+
if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Cannot create directory '${normalizePath(targetPath)}': path is a file.`,
|
|
132
|
+
);
|
|
133
133
|
}
|
|
134
|
+
fs.mkdirSync(fsPath, { recursive: true, mode });
|
|
135
|
+
this.emit("dir:create", { path: normalizePath(targetPath), mode });
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
/**
|
|
@@ -147,44 +149,27 @@ class VirtualFileSystem {
|
|
|
147
149
|
content: string | Buffer,
|
|
148
150
|
options: WriteFileOptions = {},
|
|
149
151
|
): void {
|
|
152
|
+
this.ensureMirrorRoot();
|
|
150
153
|
const normalized = normalizePath(targetPath);
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
true,
|
|
155
|
-
(pathToCreate) => this.mkdir(pathToCreate),
|
|
156
|
-
);
|
|
157
|
-
const now = new Date();
|
|
154
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
155
|
+
const parentPath = path.dirname(fsPath);
|
|
156
|
+
fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
|
|
158
157
|
|
|
159
158
|
const rawContent = Buffer.isBuffer(content)
|
|
160
159
|
? content
|
|
161
160
|
: Buffer.from(content, "utf8");
|
|
162
161
|
const shouldCompress = options.compress ?? false;
|
|
163
162
|
const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
|
|
164
|
-
const existing = parent.children.get(name);
|
|
165
163
|
|
|
166
|
-
if (
|
|
164
|
+
if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
|
|
167
165
|
throw new Error(
|
|
168
166
|
`Cannot write file '${normalized}': path is a directory.`,
|
|
169
167
|
);
|
|
170
168
|
}
|
|
171
169
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
parent.children.set(name, {
|
|
177
|
-
type: "file",
|
|
178
|
-
name,
|
|
179
|
-
mode,
|
|
180
|
-
createdAt,
|
|
181
|
-
updatedAt: now,
|
|
182
|
-
content: storedContent,
|
|
183
|
-
compressed: shouldCompress,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
parent.updatedAt = now;
|
|
187
|
-
this.dirty = true;
|
|
170
|
+
fs.writeFileSync(fsPath, storedContent);
|
|
171
|
+
fs.chmodSync(fsPath, options.mode ?? 0o644);
|
|
172
|
+
this.emit("file:write", { path: normalized, size: storedContent.length });
|
|
188
173
|
}
|
|
189
174
|
|
|
190
175
|
/**
|
|
@@ -196,12 +181,16 @@ class VirtualFileSystem {
|
|
|
196
181
|
* @returns UTF-8 string content.
|
|
197
182
|
*/
|
|
198
183
|
public readFile(targetPath: string): string {
|
|
199
|
-
|
|
200
|
-
|
|
184
|
+
this.ensureMirrorRoot();
|
|
185
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
186
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
201
187
|
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
202
188
|
}
|
|
203
189
|
|
|
204
|
-
const
|
|
190
|
+
const stored = fs.readFileSync(fsPath);
|
|
191
|
+
const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
|
|
192
|
+
const normalized = normalizePath(targetPath);
|
|
193
|
+
this.emit("file:read", { path: normalized, size: raw.length });
|
|
205
194
|
return raw.toString("utf8");
|
|
206
195
|
}
|
|
207
196
|
|
|
@@ -213,8 +202,8 @@ class VirtualFileSystem {
|
|
|
213
202
|
*/
|
|
214
203
|
public exists(targetPath: string): boolean {
|
|
215
204
|
try {
|
|
216
|
-
|
|
217
|
-
return
|
|
205
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
206
|
+
return fs.existsSync(fsPath);
|
|
218
207
|
} catch {
|
|
219
208
|
return false;
|
|
220
209
|
}
|
|
@@ -227,10 +216,11 @@ class VirtualFileSystem {
|
|
|
227
216
|
* @param mode New POSIX-like mode.
|
|
228
217
|
*/
|
|
229
218
|
public chmod(targetPath: string, mode: number): void {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
219
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
220
|
+
if (!fs.existsSync(fsPath)) {
|
|
221
|
+
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
222
|
+
}
|
|
223
|
+
fs.chmodSync(fsPath, mode);
|
|
234
224
|
}
|
|
235
225
|
|
|
236
226
|
/**
|
|
@@ -240,30 +230,39 @@ class VirtualFileSystem {
|
|
|
240
230
|
* @returns Typed stat object based on node type.
|
|
241
231
|
*/
|
|
242
232
|
public stat(targetPath: string): VfsNodeStats {
|
|
233
|
+
this.ensureMirrorRoot();
|
|
243
234
|
const normalized = normalizePath(targetPath);
|
|
244
|
-
const
|
|
235
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
236
|
+
|
|
237
|
+
if (!fs.existsSync(fsPath)) {
|
|
238
|
+
throw new Error(`Path '${normalized}' does not exist.`);
|
|
239
|
+
}
|
|
245
240
|
|
|
246
|
-
|
|
241
|
+
const stats = fs.statSync(fsPath);
|
|
242
|
+
const mode = stats.mode & 0o777;
|
|
243
|
+
const name = normalized === "/" ? "" : path.posix.basename(normalized);
|
|
244
|
+
|
|
245
|
+
if (stats.isFile()) {
|
|
247
246
|
return {
|
|
248
247
|
type: "file",
|
|
249
|
-
name
|
|
248
|
+
name,
|
|
250
249
|
path: normalized,
|
|
251
|
-
mode
|
|
252
|
-
createdAt:
|
|
253
|
-
updatedAt:
|
|
254
|
-
compressed:
|
|
255
|
-
size:
|
|
250
|
+
mode,
|
|
251
|
+
createdAt: stats.birthtime,
|
|
252
|
+
updatedAt: stats.mtime,
|
|
253
|
+
compressed: this.detectGzipFile(fsPath),
|
|
254
|
+
size: stats.size,
|
|
256
255
|
};
|
|
257
256
|
}
|
|
258
257
|
|
|
259
258
|
return {
|
|
260
259
|
type: "directory",
|
|
261
|
-
name
|
|
260
|
+
name,
|
|
262
261
|
path: normalized,
|
|
263
|
-
mode
|
|
264
|
-
createdAt:
|
|
265
|
-
updatedAt:
|
|
266
|
-
childrenCount:
|
|
262
|
+
mode,
|
|
263
|
+
createdAt: stats.birthtime,
|
|
264
|
+
updatedAt: stats.mtime,
|
|
265
|
+
childrenCount: fs.readdirSync(fsPath).length,
|
|
267
266
|
};
|
|
268
267
|
}
|
|
269
268
|
|
|
@@ -274,12 +273,12 @@ class VirtualFileSystem {
|
|
|
274
273
|
* @returns Sorted child names.
|
|
275
274
|
*/
|
|
276
275
|
public list(dirPath: string = "/"): string[] {
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
276
|
+
const fsPath = this.resolveFsPath(dirPath);
|
|
277
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
279
278
|
throw new Error(`Cannot list '${dirPath}': not a directory.`);
|
|
280
279
|
}
|
|
281
280
|
|
|
282
|
-
return
|
|
281
|
+
return fs.readdirSync(fsPath).sort();
|
|
283
282
|
}
|
|
284
283
|
|
|
285
284
|
/**
|
|
@@ -289,14 +288,14 @@ class VirtualFileSystem {
|
|
|
289
288
|
* @returns Multi-line tree string.
|
|
290
289
|
*/
|
|
291
290
|
public tree(dirPath: string = "/"): string {
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
291
|
+
const fsPath = this.resolveFsPath(dirPath);
|
|
292
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
294
293
|
throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
|
|
295
294
|
}
|
|
296
295
|
|
|
297
296
|
const rootLabel =
|
|
298
297
|
dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
|
|
299
|
-
return
|
|
298
|
+
return this.renderTreeLines(fsPath, rootLabel);
|
|
300
299
|
}
|
|
301
300
|
|
|
302
301
|
/**
|
|
@@ -309,8 +308,11 @@ class VirtualFileSystem {
|
|
|
309
308
|
* @returns Total byte usage for file content under target path.
|
|
310
309
|
*/
|
|
311
310
|
public getUsageBytes(targetPath: string = "/"): number {
|
|
312
|
-
const
|
|
313
|
-
|
|
311
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
312
|
+
if (!fs.existsSync(fsPath)) {
|
|
313
|
+
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
314
|
+
}
|
|
315
|
+
return this.computeDiskUsageBytes(fsPath);
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
/**
|
|
@@ -319,16 +321,14 @@ class VirtualFileSystem {
|
|
|
319
321
|
* @param targetPath Path to file.
|
|
320
322
|
*/
|
|
321
323
|
public compressFile(targetPath: string): void {
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
324
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
325
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
324
326
|
throw new Error(`Cannot compress '${targetPath}': not a file.`);
|
|
325
327
|
}
|
|
326
328
|
|
|
327
|
-
if (!
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
node.updatedAt = new Date();
|
|
331
|
-
this.dirty = true;
|
|
329
|
+
if (!this.detectGzipFile(fsPath)) {
|
|
330
|
+
const content = fs.readFileSync(fsPath);
|
|
331
|
+
fs.writeFileSync(fsPath, gzipSync(content));
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
|
|
@@ -338,16 +338,14 @@ class VirtualFileSystem {
|
|
|
338
338
|
* @param targetPath Path to file.
|
|
339
339
|
*/
|
|
340
340
|
public decompressFile(targetPath: string): void {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
341
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
342
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
343
343
|
throw new Error(`Cannot decompress '${targetPath}': not a file.`);
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
if (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
node.updatedAt = new Date();
|
|
350
|
-
this.dirty = true;
|
|
346
|
+
if (this.detectGzipFile(fsPath)) {
|
|
347
|
+
const content = fs.readFileSync(fsPath);
|
|
348
|
+
fs.writeFileSync(fsPath, gunzipSync(content));
|
|
351
349
|
}
|
|
352
350
|
}
|
|
353
351
|
|
|
@@ -362,32 +360,27 @@ class VirtualFileSystem {
|
|
|
362
360
|
if (normalized === "/") {
|
|
363
361
|
throw new Error("Cannot remove root directory.");
|
|
364
362
|
}
|
|
363
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
365
364
|
|
|
366
|
-
|
|
367
|
-
this.root,
|
|
368
|
-
normalized,
|
|
369
|
-
false,
|
|
370
|
-
() => undefined,
|
|
371
|
-
);
|
|
372
|
-
const node = parent.children.get(name);
|
|
373
|
-
|
|
374
|
-
if (!node) {
|
|
365
|
+
if (!fs.existsSync(fsPath)) {
|
|
375
366
|
throw new Error(`Path '${normalized}' does not exist.`);
|
|
376
367
|
}
|
|
377
368
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
369
|
+
const stats = fs.statSync(fsPath);
|
|
370
|
+
if (stats.isDirectory() && !options.recursive) {
|
|
371
|
+
const entries = fs.readdirSync(fsPath);
|
|
372
|
+
if (entries.length > 0) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Directory '${normalized}' is not empty. Use recursive option.`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
386
377
|
}
|
|
387
378
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
379
|
+
if (stats.isDirectory()) {
|
|
380
|
+
fs.rmSync(fsPath, { recursive: options.recursive ?? false });
|
|
381
|
+
} else {
|
|
382
|
+
fs.rmSync(fsPath);
|
|
383
|
+
}
|
|
391
384
|
}
|
|
392
385
|
|
|
393
386
|
/**
|
|
@@ -404,35 +397,19 @@ class VirtualFileSystem {
|
|
|
404
397
|
throw new Error("Cannot move root directory.");
|
|
405
398
|
}
|
|
406
399
|
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
fromNormalized,
|
|
410
|
-
false,
|
|
411
|
-
() => undefined,
|
|
412
|
-
);
|
|
413
|
-
const node = fromParent.children.get(fromName);
|
|
400
|
+
const fromFsPath = this.resolveFsPath(fromNormalized);
|
|
401
|
+
const toFsPath = this.resolveFsPath(toNormalized);
|
|
414
402
|
|
|
415
|
-
if (!
|
|
403
|
+
if (!fs.existsSync(fromFsPath)) {
|
|
416
404
|
throw new Error(`Path '${fromNormalized}' does not exist.`);
|
|
417
405
|
}
|
|
418
406
|
|
|
419
|
-
|
|
420
|
-
this.root,
|
|
421
|
-
toNormalized,
|
|
422
|
-
true,
|
|
423
|
-
(pathToCreate) => this.mkdir(pathToCreate),
|
|
424
|
-
);
|
|
425
|
-
if (toParent.children.has(toName)) {
|
|
407
|
+
if (fs.existsSync(toFsPath)) {
|
|
426
408
|
throw new Error(`Destination '${toNormalized}' already exists.`);
|
|
427
409
|
}
|
|
428
410
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
node.updatedAt = new Date();
|
|
432
|
-
toParent.children.set(toName, node);
|
|
433
|
-
fromParent.updatedAt = new Date();
|
|
434
|
-
toParent.updatedAt = new Date();
|
|
435
|
-
this.dirty = true;
|
|
411
|
+
fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
|
|
412
|
+
fs.renameSync(fromFsPath, toFsPath);
|
|
436
413
|
}
|
|
437
414
|
}
|
|
438
415
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
2
3
|
import { createCustomCommand, registerCommand, runCommand } from "../commands";
|
|
3
4
|
import type { CommandContext, CommandResult } from "../types/commands";
|
|
4
5
|
import type { ShellStream } from "../types/streams";
|
|
@@ -46,12 +47,13 @@ function resolveAutoSudoForNewUsers(): boolean {
|
|
|
46
47
|
* Instances are used both by the SSH server facade and by the programmatic
|
|
47
48
|
* client API.
|
|
48
49
|
*/
|
|
49
|
-
class VirtualShell {
|
|
50
|
+
class VirtualShell extends EventEmitter {
|
|
50
51
|
basePath: string = ".";
|
|
51
52
|
vfs: VirtualFileSystem;
|
|
52
53
|
users: VirtualUserManager;
|
|
53
54
|
hostname: string;
|
|
54
55
|
properties: ShellProperties;
|
|
56
|
+
private initialized: Promise<void>;
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
59
|
* Creates a new virtual shell instance.
|
|
@@ -65,6 +67,7 @@ class VirtualShell {
|
|
|
65
67
|
properties?: ShellProperties,
|
|
66
68
|
basePath?: string,
|
|
67
69
|
) {
|
|
70
|
+
super();
|
|
68
71
|
this.hostname = hostname;
|
|
69
72
|
this.properties = properties || defaultShellProperties;
|
|
70
73
|
this.basePath = basePath || ".";
|
|
@@ -74,14 +77,25 @@ class VirtualShell {
|
|
|
74
77
|
resolveRootPassword(),
|
|
75
78
|
resolveAutoSudoForNewUsers(),
|
|
76
79
|
);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
|
|
81
|
+
// Store references to avoid TypeScript "used before assigned" errors
|
|
82
|
+
const vfs = this.vfs;
|
|
83
|
+
const users = this.users;
|
|
84
|
+
|
|
85
|
+
// Initialize both VFS mirror and users, ensuring all is ready before auth
|
|
86
|
+
this.initialized = (async () => {
|
|
87
|
+
await vfs.restoreMirror();
|
|
88
|
+
await users.initialize();
|
|
89
|
+
this.emit("initialized");
|
|
90
|
+
})();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ensures initialization is complete before allowing operations.
|
|
95
|
+
* Call this before any authentication or command execution.
|
|
96
|
+
*/
|
|
97
|
+
public async ensureInitialized(): Promise<void> {
|
|
98
|
+
await this.initialized;
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
/**
|
|
@@ -113,6 +127,7 @@ class VirtualShell {
|
|
|
113
127
|
*/
|
|
114
128
|
executeCommand(rawInput: string, authUser: string, cwd: string): void {
|
|
115
129
|
runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
|
|
130
|
+
this.emit("command", { command: rawInput, user: authUser, cwd });
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
/**
|
|
@@ -132,6 +147,7 @@ class VirtualShell {
|
|
|
132
147
|
terminalSize: { cols: number; rows: number },
|
|
133
148
|
): void {
|
|
134
149
|
// Interactive shell logic
|
|
150
|
+
this.emit("session:start", { user: authUser, sessionId, remoteAddress });
|
|
135
151
|
startShell(
|
|
136
152
|
this.properties,
|
|
137
153
|
stream,
|
|
@@ -467,6 +467,13 @@ export function startShell(
|
|
|
467
467
|
const ch = input[i]!;
|
|
468
468
|
|
|
469
469
|
if (ch === "\u0004") {
|
|
470
|
+
lineBuffer = "";
|
|
471
|
+
cursorPos = 0;
|
|
472
|
+
historyIndex = null;
|
|
473
|
+
historyDraft = "";
|
|
474
|
+
stream.write("bye\r\n");
|
|
475
|
+
pushHistory("bye");
|
|
476
|
+
await shell.vfs.flushMirror();
|
|
470
477
|
stream.write("logout\r\n");
|
|
471
478
|
stream.exit(0);
|
|
472
479
|
stream.end();
|