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,10 +1,8 @@
|
|
|
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
|
-
import {
|
|
5
|
-
import { getNode, getParentDirectory, normalizePath, splitPath } from "./path";
|
|
6
|
-
import { applySnapshot, createSnapshot } from "./snapshot";
|
|
7
|
-
import { renderTree } from "./tree";
|
|
5
|
+
import { normalizePath } from "./path";
|
|
8
6
|
/**
|
|
9
7
|
* In-memory virtual filesystem with tar.gz mirror persistence.
|
|
10
8
|
*
|
|
@@ -12,36 +10,74 @@ import { renderTree } from "./tree";
|
|
|
12
10
|
* {@link VirtualFileSystem.restoreMirror} on startup and
|
|
13
11
|
* {@link VirtualFileSystem.flushMirror} to persist pending changes.
|
|
14
12
|
*/
|
|
15
|
-
class VirtualFileSystem {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
class VirtualFileSystem extends EventEmitter {
|
|
14
|
+
mirrorRoot;
|
|
15
|
+
ensureMirrorRoot() {
|
|
16
|
+
fs.mkdirSync(this.mirrorRoot, { recursive: true, mode: 0o755 });
|
|
17
|
+
}
|
|
18
|
+
resolveFsPath(targetPath) {
|
|
19
|
+
const normalized = normalizePath(targetPath);
|
|
20
|
+
const relativePath = normalized.slice(1);
|
|
21
|
+
const resolved = path.resolve(this.mirrorRoot, relativePath || ".");
|
|
22
|
+
const relative = path.relative(this.mirrorRoot, resolved);
|
|
23
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
24
|
+
throw new Error(`Invalid path '${targetPath}'.`);
|
|
25
|
+
}
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
detectGzipFile(targetPath) {
|
|
29
|
+
const fd = fs.openSync(targetPath, "r");
|
|
30
|
+
try {
|
|
31
|
+
const header = Buffer.alloc(2);
|
|
32
|
+
const bytesRead = fs.readSync(fd, header, 0, 2, 0);
|
|
33
|
+
return bytesRead === 2 && header[0] === 0x1f && header[1] === 0x8b;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
fs.closeSync(fd);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
computeDiskUsageBytes(targetPath) {
|
|
40
|
+
const stats = fs.statSync(targetPath);
|
|
41
|
+
if (stats.isFile()) {
|
|
42
|
+
return stats.size;
|
|
22
43
|
}
|
|
23
44
|
let total = 0;
|
|
24
|
-
for (const
|
|
25
|
-
total += this.
|
|
45
|
+
for (const entry of fs.readdirSync(targetPath)) {
|
|
46
|
+
total += this.computeDiskUsageBytes(path.join(targetPath, entry));
|
|
26
47
|
}
|
|
27
48
|
return total;
|
|
28
49
|
}
|
|
50
|
+
renderTreeLines(targetPath, label) {
|
|
51
|
+
const lines = [label];
|
|
52
|
+
const walk = (currentPath, prefix) => {
|
|
53
|
+
const entries = fs
|
|
54
|
+
.readdirSync(currentPath, { withFileTypes: true })
|
|
55
|
+
.map((entry) => entry.name)
|
|
56
|
+
.sort((left, right) => left.localeCompare(right));
|
|
57
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
58
|
+
const name = entries[i];
|
|
59
|
+
const isLast = i === entries.length - 1;
|
|
60
|
+
const connector = isLast ? "└── " : "├── ";
|
|
61
|
+
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
|
|
62
|
+
const entryPath = path.join(currentPath, name);
|
|
63
|
+
const isDirectory = fs.statSync(entryPath).isDirectory();
|
|
64
|
+
lines.push(`${prefix}${connector}${name}`);
|
|
65
|
+
if (isDirectory) {
|
|
66
|
+
walk(entryPath, nextPrefix);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(targetPath, "");
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
29
73
|
/**
|
|
30
74
|
* Creates a virtual filesystem instance.
|
|
31
75
|
*
|
|
32
76
|
* @param baseDir Base directory used to resolve mirror archive location.
|
|
33
77
|
*/
|
|
34
78
|
constructor(baseDir = process.cwd()) {
|
|
35
|
-
|
|
36
|
-
this.
|
|
37
|
-
this.root = {
|
|
38
|
-
type: "directory",
|
|
39
|
-
name: "",
|
|
40
|
-
mode: 0o755,
|
|
41
|
-
createdAt: now,
|
|
42
|
-
updatedAt: now,
|
|
43
|
-
children: new Map(),
|
|
44
|
-
};
|
|
79
|
+
super();
|
|
80
|
+
this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
|
|
45
81
|
}
|
|
46
82
|
/**
|
|
47
83
|
* Restores filesystem state from mirror archive.
|
|
@@ -49,19 +85,7 @@ class VirtualFileSystem {
|
|
|
49
85
|
* If archive does not exist or cannot be read, creates fresh mirror file.
|
|
50
86
|
*/
|
|
51
87
|
async restoreMirror() {
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
const compressed = await fs.readFile(this.archivePath);
|
|
55
|
-
const tarBuffer = gunzipSync(compressed);
|
|
56
|
-
const snapshot = await readSnapshotFromTar(tarBuffer);
|
|
57
|
-
applySnapshot(this.root, snapshot);
|
|
58
|
-
this.dirty = false;
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
console.warn(`No valid mirror archive found at '${this.archivePath}'. Starting with empty filesystem.`);
|
|
63
|
-
await this.flushMirror();
|
|
64
|
-
}
|
|
88
|
+
this.ensureMirrorRoot();
|
|
65
89
|
}
|
|
66
90
|
/**
|
|
67
91
|
* Persists current filesystem state to mirror archive.
|
|
@@ -69,15 +93,8 @@ class VirtualFileSystem {
|
|
|
69
93
|
* No-op when nothing changed and archive already exists.
|
|
70
94
|
*/
|
|
71
95
|
async flushMirror() {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
await fs.mkdir(path.dirname(this.archivePath), { recursive: true });
|
|
76
|
-
const snapshotJson = JSON.stringify(createSnapshot(this.root), null, 2);
|
|
77
|
-
const tarBuffer = await createTarBuffer(snapshotJson);
|
|
78
|
-
const compressed = gzipSync(tarBuffer);
|
|
79
|
-
await fs.writeFile(this.archivePath, compressed);
|
|
80
|
-
this.dirty = false;
|
|
96
|
+
this.ensureMirrorRoot();
|
|
97
|
+
this.emit("mirror:flush");
|
|
81
98
|
}
|
|
82
99
|
/**
|
|
83
100
|
* Creates directory and any missing parent directories.
|
|
@@ -86,32 +103,13 @@ class VirtualFileSystem {
|
|
|
86
103
|
* @param mode POSIX-like mode bits for new directories.
|
|
87
104
|
*/
|
|
88
105
|
mkdir(targetPath, mode = 0o755) {
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const existing = current.children.get(part);
|
|
94
|
-
if (!existing) {
|
|
95
|
-
const now = new Date();
|
|
96
|
-
const nextDir = {
|
|
97
|
-
type: "directory",
|
|
98
|
-
name: part,
|
|
99
|
-
mode,
|
|
100
|
-
createdAt: now,
|
|
101
|
-
updatedAt: now,
|
|
102
|
-
children: new Map(),
|
|
103
|
-
};
|
|
104
|
-
current.children.set(part, nextDir);
|
|
105
|
-
current.updatedAt = now;
|
|
106
|
-
this.dirty = true;
|
|
107
|
-
current = nextDir;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (existing.type !== "directory") {
|
|
111
|
-
throw new Error(`Cannot create directory '${normalized}': '${part}' is a file.`);
|
|
112
|
-
}
|
|
113
|
-
current = existing;
|
|
106
|
+
this.ensureMirrorRoot();
|
|
107
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
108
|
+
if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
|
|
109
|
+
throw new Error(`Cannot create directory '${normalizePath(targetPath)}': path is a file.`);
|
|
114
110
|
}
|
|
111
|
+
fs.mkdirSync(fsPath, { recursive: true, mode });
|
|
112
|
+
this.emit("dir:create", { path: normalizePath(targetPath), mode });
|
|
115
113
|
}
|
|
116
114
|
/**
|
|
117
115
|
* Writes UTF-8 text or binary content into file.
|
|
@@ -123,31 +121,22 @@ class VirtualFileSystem {
|
|
|
123
121
|
* @param options Optional write behavior (mode, compression).
|
|
124
122
|
*/
|
|
125
123
|
writeFile(targetPath, content, options = {}) {
|
|
124
|
+
this.ensureMirrorRoot();
|
|
126
125
|
const normalized = normalizePath(targetPath);
|
|
127
|
-
const
|
|
128
|
-
const
|
|
126
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
127
|
+
const parentPath = path.dirname(fsPath);
|
|
128
|
+
fs.mkdirSync(parentPath, { recursive: true, mode: 0o755 });
|
|
129
129
|
const rawContent = Buffer.isBuffer(content)
|
|
130
130
|
? content
|
|
131
131
|
: Buffer.from(content, "utf8");
|
|
132
132
|
const shouldCompress = options.compress ?? false;
|
|
133
133
|
const storedContent = shouldCompress ? gzipSync(rawContent) : rawContent;
|
|
134
|
-
|
|
135
|
-
if (existing && existing.type === "directory") {
|
|
134
|
+
if (fs.existsSync(fsPath) && fs.statSync(fsPath).isDirectory()) {
|
|
136
135
|
throw new Error(`Cannot write file '${normalized}': path is a directory.`);
|
|
137
136
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
type: "file",
|
|
142
|
-
name,
|
|
143
|
-
mode,
|
|
144
|
-
createdAt,
|
|
145
|
-
updatedAt: now,
|
|
146
|
-
content: storedContent,
|
|
147
|
-
compressed: shouldCompress,
|
|
148
|
-
});
|
|
149
|
-
parent.updatedAt = now;
|
|
150
|
-
this.dirty = true;
|
|
137
|
+
fs.writeFileSync(fsPath, storedContent);
|
|
138
|
+
fs.chmodSync(fsPath, options.mode ?? 0o644);
|
|
139
|
+
this.emit("file:write", { path: normalized, size: storedContent.length });
|
|
151
140
|
}
|
|
152
141
|
/**
|
|
153
142
|
* Reads file content as UTF-8 text.
|
|
@@ -158,11 +147,15 @@ class VirtualFileSystem {
|
|
|
158
147
|
* @returns UTF-8 string content.
|
|
159
148
|
*/
|
|
160
149
|
readFile(targetPath) {
|
|
161
|
-
|
|
162
|
-
|
|
150
|
+
this.ensureMirrorRoot();
|
|
151
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
152
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
163
153
|
throw new Error(`Cannot read '${targetPath}': not a file.`);
|
|
164
154
|
}
|
|
165
|
-
const
|
|
155
|
+
const stored = fs.readFileSync(fsPath);
|
|
156
|
+
const raw = this.detectGzipFile(fsPath) ? gunzipSync(stored) : stored;
|
|
157
|
+
const normalized = normalizePath(targetPath);
|
|
158
|
+
this.emit("file:read", { path: normalized, size: raw.length });
|
|
166
159
|
return raw.toString("utf8");
|
|
167
160
|
}
|
|
168
161
|
/**
|
|
@@ -173,8 +166,8 @@ class VirtualFileSystem {
|
|
|
173
166
|
*/
|
|
174
167
|
exists(targetPath) {
|
|
175
168
|
try {
|
|
176
|
-
|
|
177
|
-
return
|
|
169
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
170
|
+
return fs.existsSync(fsPath);
|
|
178
171
|
}
|
|
179
172
|
catch {
|
|
180
173
|
return false;
|
|
@@ -187,10 +180,11 @@ class VirtualFileSystem {
|
|
|
187
180
|
* @param mode New POSIX-like mode.
|
|
188
181
|
*/
|
|
189
182
|
chmod(targetPath, mode) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
183
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
184
|
+
if (!fs.existsSync(fsPath)) {
|
|
185
|
+
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
186
|
+
}
|
|
187
|
+
fs.chmodSync(fsPath, mode);
|
|
194
188
|
}
|
|
195
189
|
/**
|
|
196
190
|
* Returns metadata for file or directory.
|
|
@@ -199,28 +193,35 @@ class VirtualFileSystem {
|
|
|
199
193
|
* @returns Typed stat object based on node type.
|
|
200
194
|
*/
|
|
201
195
|
stat(targetPath) {
|
|
196
|
+
this.ensureMirrorRoot();
|
|
202
197
|
const normalized = normalizePath(targetPath);
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
198
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
199
|
+
if (!fs.existsSync(fsPath)) {
|
|
200
|
+
throw new Error(`Path '${normalized}' does not exist.`);
|
|
201
|
+
}
|
|
202
|
+
const stats = fs.statSync(fsPath);
|
|
203
|
+
const mode = stats.mode & 0o777;
|
|
204
|
+
const name = normalized === "/" ? "" : path.posix.basename(normalized);
|
|
205
|
+
if (stats.isFile()) {
|
|
205
206
|
return {
|
|
206
207
|
type: "file",
|
|
207
|
-
name
|
|
208
|
+
name,
|
|
208
209
|
path: normalized,
|
|
209
|
-
mode
|
|
210
|
-
createdAt:
|
|
211
|
-
updatedAt:
|
|
212
|
-
compressed:
|
|
213
|
-
size:
|
|
210
|
+
mode,
|
|
211
|
+
createdAt: stats.birthtime,
|
|
212
|
+
updatedAt: stats.mtime,
|
|
213
|
+
compressed: this.detectGzipFile(fsPath),
|
|
214
|
+
size: stats.size,
|
|
214
215
|
};
|
|
215
216
|
}
|
|
216
217
|
return {
|
|
217
218
|
type: "directory",
|
|
218
|
-
name
|
|
219
|
+
name,
|
|
219
220
|
path: normalized,
|
|
220
|
-
mode
|
|
221
|
-
createdAt:
|
|
222
|
-
updatedAt:
|
|
223
|
-
childrenCount:
|
|
221
|
+
mode,
|
|
222
|
+
createdAt: stats.birthtime,
|
|
223
|
+
updatedAt: stats.mtime,
|
|
224
|
+
childrenCount: fs.readdirSync(fsPath).length,
|
|
224
225
|
};
|
|
225
226
|
}
|
|
226
227
|
/**
|
|
@@ -230,11 +231,11 @@ class VirtualFileSystem {
|
|
|
230
231
|
* @returns Sorted child names.
|
|
231
232
|
*/
|
|
232
233
|
list(dirPath = "/") {
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
234
|
+
const fsPath = this.resolveFsPath(dirPath);
|
|
235
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
235
236
|
throw new Error(`Cannot list '${dirPath}': not a directory.`);
|
|
236
237
|
}
|
|
237
|
-
return
|
|
238
|
+
return fs.readdirSync(fsPath).sort();
|
|
238
239
|
}
|
|
239
240
|
/**
|
|
240
241
|
* Renders ASCII tree view of directory hierarchy.
|
|
@@ -243,12 +244,12 @@ class VirtualFileSystem {
|
|
|
243
244
|
* @returns Multi-line tree string.
|
|
244
245
|
*/
|
|
245
246
|
tree(dirPath = "/") {
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
247
|
+
const fsPath = this.resolveFsPath(dirPath);
|
|
248
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
248
249
|
throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
|
|
249
250
|
}
|
|
250
251
|
const rootLabel = dirPath === "/" ? "/" : path.posix.basename(normalizePath(dirPath));
|
|
251
|
-
return
|
|
252
|
+
return this.renderTreeLines(fsPath, rootLabel);
|
|
252
253
|
}
|
|
253
254
|
/**
|
|
254
255
|
* Computes total stored file bytes under a path.
|
|
@@ -260,8 +261,11 @@ class VirtualFileSystem {
|
|
|
260
261
|
* @returns Total byte usage for file content under target path.
|
|
261
262
|
*/
|
|
262
263
|
getUsageBytes(targetPath = "/") {
|
|
263
|
-
const
|
|
264
|
-
|
|
264
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
265
|
+
if (!fs.existsSync(fsPath)) {
|
|
266
|
+
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
267
|
+
}
|
|
268
|
+
return this.computeDiskUsageBytes(fsPath);
|
|
265
269
|
}
|
|
266
270
|
/**
|
|
267
271
|
* Compresses file content with gzip and flags node as compressed.
|
|
@@ -269,15 +273,13 @@ class VirtualFileSystem {
|
|
|
269
273
|
* @param targetPath Path to file.
|
|
270
274
|
*/
|
|
271
275
|
compressFile(targetPath) {
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
276
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
277
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
274
278
|
throw new Error(`Cannot compress '${targetPath}': not a file.`);
|
|
275
279
|
}
|
|
276
|
-
if (!
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
node.updatedAt = new Date();
|
|
280
|
-
this.dirty = true;
|
|
280
|
+
if (!this.detectGzipFile(fsPath)) {
|
|
281
|
+
const content = fs.readFileSync(fsPath);
|
|
282
|
+
fs.writeFileSync(fsPath, gzipSync(content));
|
|
281
283
|
}
|
|
282
284
|
}
|
|
283
285
|
/**
|
|
@@ -286,15 +288,13 @@ class VirtualFileSystem {
|
|
|
286
288
|
* @param targetPath Path to file.
|
|
287
289
|
*/
|
|
288
290
|
decompressFile(targetPath) {
|
|
289
|
-
const
|
|
290
|
-
if (
|
|
291
|
+
const fsPath = this.resolveFsPath(targetPath);
|
|
292
|
+
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
291
293
|
throw new Error(`Cannot decompress '${targetPath}': not a file.`);
|
|
292
294
|
}
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
node.updatedAt = new Date();
|
|
297
|
-
this.dirty = true;
|
|
295
|
+
if (this.detectGzipFile(fsPath)) {
|
|
296
|
+
const content = fs.readFileSync(fsPath);
|
|
297
|
+
fs.writeFileSync(fsPath, gunzipSync(content));
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
300
|
/**
|
|
@@ -308,19 +308,23 @@ class VirtualFileSystem {
|
|
|
308
308
|
if (normalized === "/") {
|
|
309
309
|
throw new Error("Cannot remove root directory.");
|
|
310
310
|
}
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
if (!node) {
|
|
311
|
+
const fsPath = this.resolveFsPath(normalized);
|
|
312
|
+
if (!fs.existsSync(fsPath)) {
|
|
314
313
|
throw new Error(`Path '${normalized}' does not exist.`);
|
|
315
314
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
315
|
+
const stats = fs.statSync(fsPath);
|
|
316
|
+
if (stats.isDirectory() && !options.recursive) {
|
|
317
|
+
const entries = fs.readdirSync(fsPath);
|
|
318
|
+
if (entries.length > 0) {
|
|
319
|
+
throw new Error(`Directory '${normalized}' is not empty. Use recursive option.`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (stats.isDirectory()) {
|
|
323
|
+
fs.rmSync(fsPath, { recursive: options.recursive ?? false });
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
fs.rmSync(fsPath);
|
|
320
327
|
}
|
|
321
|
-
parent.children.delete(name);
|
|
322
|
-
parent.updatedAt = new Date();
|
|
323
|
-
this.dirty = true;
|
|
324
328
|
}
|
|
325
329
|
/**
|
|
326
330
|
* Moves or renames node to destination path.
|
|
@@ -334,22 +338,16 @@ class VirtualFileSystem {
|
|
|
334
338
|
if (fromNormalized === "/" || toNormalized === "/") {
|
|
335
339
|
throw new Error("Cannot move root directory.");
|
|
336
340
|
}
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
if (!
|
|
341
|
+
const fromFsPath = this.resolveFsPath(fromNormalized);
|
|
342
|
+
const toFsPath = this.resolveFsPath(toNormalized);
|
|
343
|
+
if (!fs.existsSync(fromFsPath)) {
|
|
340
344
|
throw new Error(`Path '${fromNormalized}' does not exist.`);
|
|
341
345
|
}
|
|
342
|
-
|
|
343
|
-
if (toParent.children.has(toName)) {
|
|
346
|
+
if (fs.existsSync(toFsPath)) {
|
|
344
347
|
throw new Error(`Destination '${toNormalized}' already exists.`);
|
|
345
348
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
node.updatedAt = new Date();
|
|
349
|
-
toParent.children.set(toName, node);
|
|
350
|
-
fromParent.updatedAt = new Date();
|
|
351
|
-
toParent.updatedAt = new Date();
|
|
352
|
-
this.dirty = true;
|
|
349
|
+
fs.mkdirSync(path.dirname(toFsPath), { recursive: true, mode: 0o755 });
|
|
350
|
+
fs.renameSync(fromFsPath, toFsPath);
|
|
353
351
|
}
|
|
354
352
|
}
|
|
355
353
|
export default VirtualFileSystem;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
1
2
|
import type { CommandContext, CommandResult } from "../types/commands";
|
|
2
3
|
import type { ShellStream } from "../types/streams";
|
|
3
4
|
import VirtualFileSystem from "../VirtualFileSystem";
|
|
@@ -13,12 +14,13 @@ export interface ShellProperties {
|
|
|
13
14
|
* Instances are used both by the SSH server facade and by the programmatic
|
|
14
15
|
* client API.
|
|
15
16
|
*/
|
|
16
|
-
declare class VirtualShell {
|
|
17
|
+
declare class VirtualShell extends EventEmitter {
|
|
17
18
|
basePath: string;
|
|
18
19
|
vfs: VirtualFileSystem;
|
|
19
20
|
users: VirtualUserManager;
|
|
20
21
|
hostname: string;
|
|
21
22
|
properties: ShellProperties;
|
|
23
|
+
private initialized;
|
|
22
24
|
/**
|
|
23
25
|
* Creates a new virtual shell instance.
|
|
24
26
|
*
|
|
@@ -27,6 +29,11 @@ declare class VirtualShell {
|
|
|
27
29
|
* @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
|
|
28
30
|
*/
|
|
29
31
|
constructor(hostname: string, properties?: ShellProperties, basePath?: string);
|
|
32
|
+
/**
|
|
33
|
+
* Ensures initialization is complete before allowing operations.
|
|
34
|
+
* Call this before any authentication or command execution.
|
|
35
|
+
*/
|
|
36
|
+
ensureInitialized(): Promise<void>;
|
|
30
37
|
/**
|
|
31
38
|
* Registers a new command in the shell runtime.
|
|
32
39
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,iBAAiB,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAG3D,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;CACb;AA8BD;;;;;GAKG;AACH,cAAM,YAAa,SAAQ,YAAY;IACtC,QAAQ,EAAE,MAAM,CAAO;IACvB,GAAG,EAAE,iBAAiB,CAAC;IACvB,KAAK,EAAE,kBAAkB,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,CAAC,WAAW,CAAgB;IAEnC;;;;;;OAMG;gBAEF,QAAQ,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,eAAe,EAC5B,QAAQ,CAAC,EAAE,MAAM;IAyBlB;;;OAGG;IACU,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAI/C;;;;;;OAMG;IACH,UAAU,CACT,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EAAE,EAChB,QAAQ,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,GACvE,IAAI;IASP;;;;;;OAMG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;IAKrE;;;;;;;OAOG;IAEH,uBAAuB,CACtB,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAC1C,IAAI;IAeP;;;;OAIG;IACI,MAAM,IAAI,iBAAiB,GAAG,IAAI;IAIzC;;;;OAIG;IACI,QAAQ,IAAI,kBAAkB,GAAG,IAAI;IAI5C;;;;OAIG;IACI,WAAW,IAAI,MAAM;IAI5B;;;;;;OAMG;IACI,eAAe,CACrB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,GAAG,MAAM,GACtB,IAAI;CAIP;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -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 VirtualFileSystem from "../VirtualFileSystem";
|
|
4
5
|
import { VirtualUserManager } from "../VirtualUserManager";
|
|
@@ -30,12 +31,13 @@ function resolveAutoSudoForNewUsers() {
|
|
|
30
31
|
* Instances are used both by the SSH server facade and by the programmatic
|
|
31
32
|
* client API.
|
|
32
33
|
*/
|
|
33
|
-
class VirtualShell {
|
|
34
|
+
class VirtualShell extends EventEmitter {
|
|
34
35
|
basePath = ".";
|
|
35
36
|
vfs;
|
|
36
37
|
users;
|
|
37
38
|
hostname;
|
|
38
39
|
properties;
|
|
40
|
+
initialized;
|
|
39
41
|
/**
|
|
40
42
|
* Creates a new virtual shell instance.
|
|
41
43
|
*
|
|
@@ -44,15 +46,28 @@ class VirtualShell {
|
|
|
44
46
|
* @param basePath Optional base path for the virtual filesystem (defaults to process.cwd()).
|
|
45
47
|
*/
|
|
46
48
|
constructor(hostname, properties, basePath) {
|
|
49
|
+
super();
|
|
47
50
|
this.hostname = hostname;
|
|
48
51
|
this.properties = properties || defaultShellProperties;
|
|
49
52
|
this.basePath = basePath || ".";
|
|
50
53
|
this.vfs = new VirtualFileSystem(this.basePath);
|
|
51
54
|
this.users = new VirtualUserManager(this.vfs, resolveRootPassword(), resolveAutoSudoForNewUsers());
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Store references to avoid TypeScript "used before assigned" errors
|
|
56
|
+
const vfs = this.vfs;
|
|
57
|
+
const users = this.users;
|
|
58
|
+
// Initialize both VFS mirror and users, ensuring all is ready before auth
|
|
59
|
+
this.initialized = (async () => {
|
|
60
|
+
await vfs.restoreMirror();
|
|
61
|
+
await users.initialize();
|
|
62
|
+
this.emit("initialized");
|
|
63
|
+
})();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Ensures initialization is complete before allowing operations.
|
|
67
|
+
* Call this before any authentication or command execution.
|
|
68
|
+
*/
|
|
69
|
+
async ensureInitialized() {
|
|
70
|
+
await this.initialized;
|
|
56
71
|
}
|
|
57
72
|
/**
|
|
58
73
|
* Registers a new command in the shell runtime.
|
|
@@ -77,6 +92,7 @@ class VirtualShell {
|
|
|
77
92
|
*/
|
|
78
93
|
executeCommand(rawInput, authUser, cwd) {
|
|
79
94
|
runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
|
|
95
|
+
this.emit("command", { command: rawInput, user: authUser, cwd });
|
|
80
96
|
}
|
|
81
97
|
/**
|
|
82
98
|
* Starts an interactive session with the shell.
|
|
@@ -88,6 +104,7 @@ class VirtualShell {
|
|
|
88
104
|
*/
|
|
89
105
|
startInteractiveSession(stream, authUser, sessionId, remoteAddress, terminalSize) {
|
|
90
106
|
// Interactive shell logic
|
|
107
|
+
this.emit("session:start", { user: authUser, sessionId, remoteAddress });
|
|
91
108
|
startShell(this.properties, stream, authUser, this.hostname, sessionId, remoteAddress, terminalSize, this);
|
|
92
109
|
}
|
|
93
110
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/shell.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,GAAG,CAAC;AAMvD,OAAO,EAGN,KAAK,YAAY,EAEjB,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAmBpD,wBAAgB,UAAU,CACzB,UAAU,EAAE,eAAe,EAC3B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,aAAa,oBAAY,EACzB,YAAY,EAAE,YAAY,YAAyB,EACnD,KAAK,EAAE,YAAY,GACjB,IAAI,
|
|
1
|
+
{"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/VirtualShell/shell.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,GAAG,CAAC;AAMvD,OAAO,EAGN,KAAK,YAAY,EAEjB,MAAM,yBAAyB,CAAC;AAGjC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAmBpD,wBAAgB,UAAU,CACzB,UAAU,EAAE,eAAe,EAC3B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,aAAa,oBAAY,EACzB,YAAY,EAAE,YAAY,YAAyB,EACnD,KAAK,EAAE,YAAY,GACjB,IAAI,CA8lBN"}
|
|
@@ -312,6 +312,13 @@ export function startShell(properties, stream, authUser, hostname, sessionId, re
|
|
|
312
312
|
for (let i = 0; i < input.length; i += 1) {
|
|
313
313
|
const ch = input[i];
|
|
314
314
|
if (ch === "\u0004") {
|
|
315
|
+
lineBuffer = "";
|
|
316
|
+
cursorPos = 0;
|
|
317
|
+
historyIndex = null;
|
|
318
|
+
historyDraft = "";
|
|
319
|
+
stream.write("bye\r\n");
|
|
320
|
+
pushHistory("bye");
|
|
321
|
+
await shell.vfs.flushMirror();
|
|
315
322
|
stream.write("logout\r\n");
|
|
316
323
|
stream.exit(0);
|
|
317
324
|
stream.end();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
1
2
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
2
3
|
/** Persisted virtual user credential record. */
|
|
3
4
|
export interface VirtualUserRecord {
|
|
@@ -26,7 +27,7 @@ export interface VirtualActiveSession {
|
|
|
26
27
|
*
|
|
27
28
|
* Passwords are hashed with scrypt and stored in the backing virtual filesystem.
|
|
28
29
|
*/
|
|
29
|
-
export declare class VirtualUserManager {
|
|
30
|
+
export declare class VirtualUserManager extends EventEmitter {
|
|
30
31
|
private readonly vfs;
|
|
31
32
|
private readonly defaultRootPassword;
|
|
32
33
|
private readonly autoSudoForNewUsers;
|
|
@@ -49,6 +50,7 @@ export declare class VirtualUserManager {
|
|
|
49
50
|
constructor(vfs: VirtualFileSystem, defaultRootPassword?: string, autoSudoForNewUsers?: boolean);
|
|
50
51
|
/**
|
|
51
52
|
* Loads users/sudoers from disk and ensures root account exists.
|
|
53
|
+
* Also creates the current system user if not already present.
|
|
52
54
|
*/
|
|
53
55
|
initialize(): Promise<void>;
|
|
54
56
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualUserManager/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/VirtualUserManager/index.ts"],"names":[],"mappings":"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"}
|