typescript-virtual-container 1.2.0 → 1.2.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/benchmark-results.txt +40 -0
- package/benchmark-virtualshell.ts +96 -0
- package/dist/Honeypot/index.d.ts.map +1 -1
- package/dist/Honeypot/index.js +9 -0
- package/dist/SSHClient/index.d.ts +0 -14
- package/dist/SSHClient/index.d.ts.map +1 -1
- package/dist/SSHClient/index.js +19 -0
- package/dist/SSHMimic/index.d.ts +0 -7
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +5 -0
- package/dist/SSHMimic/sftp.d.ts.map +1 -1
- package/dist/SSHMimic/sftp.js +5 -0
- package/dist/VirtualFileSystem/index.d.ts +0 -7
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +18 -0
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +14 -1
- package/dist/VirtualUserManager/index.d.ts +4 -1
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +72 -13
- package/dist/utils/perfLogger.d.ts +9 -0
- package/dist/utils/perfLogger.d.ts.map +1 -0
- package/dist/utils/perfLogger.js +49 -0
- package/package.json +1 -1
- package/src/Honeypot/index.ts +11 -0
- package/src/SSHClient/index.ts +23 -1
- package/src/SSHMimic/index.ts +6 -0
- package/src/SSHMimic/sftp.ts +8 -1
- package/src/VirtualFileSystem/index.ts +20 -0
- package/src/VirtualShell/index.ts +18 -1
- package/src/VirtualUserManager/index.ts +103 -26
- package/src/utils/perfLogger.ts +72 -0
package/src/SSHMimic/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { Server as SshServer } from "ssh2";
|
|
3
3
|
import { VirtualShell } from "../VirtualShell";
|
|
4
|
+
import { createPerfLogger, type PerfLogger } from "../utils/perfLogger";
|
|
4
5
|
import { runExec } from "./exec";
|
|
5
6
|
import { loadOrCreateHostKey } from "./hostKey";
|
|
6
7
|
|
|
@@ -11,6 +12,8 @@ import { loadOrCreateHostKey } from "./hostKey";
|
|
|
11
12
|
* Create an instance, call {@link SshMimic.start}, and stop it with
|
|
12
13
|
* {@link SshMimic.stop} when your process exits.
|
|
13
14
|
*/
|
|
15
|
+
const perf: PerfLogger = createPerfLogger("SshMimic");
|
|
16
|
+
|
|
14
17
|
class SshMimic extends EventEmitter {
|
|
15
18
|
port: number;
|
|
16
19
|
server: SshServer | null;
|
|
@@ -34,6 +37,7 @@ class SshMimic extends EventEmitter {
|
|
|
34
37
|
shell?: VirtualShell;
|
|
35
38
|
}) {
|
|
36
39
|
super();
|
|
40
|
+
perf.mark("constructor");
|
|
37
41
|
this.port = port;
|
|
38
42
|
this.shellHostname = hostname;
|
|
39
43
|
this.server = null;
|
|
@@ -46,6 +50,7 @@ class SshMimic extends EventEmitter {
|
|
|
46
50
|
* @returns Promise resolved with bound listening port.
|
|
47
51
|
*/
|
|
48
52
|
public async start(): Promise<number> {
|
|
53
|
+
perf.mark("start");
|
|
49
54
|
const shell = this.shell;
|
|
50
55
|
const privateKey = loadOrCreateHostKey();
|
|
51
56
|
|
|
@@ -169,6 +174,7 @@ class SshMimic extends EventEmitter {
|
|
|
169
174
|
* Stops server if running.
|
|
170
175
|
*/
|
|
171
176
|
public stop(): void {
|
|
177
|
+
perf.mark("stop");
|
|
172
178
|
if (this.server) {
|
|
173
179
|
this.server.close(() => {
|
|
174
180
|
console.log("SSH Mimic stopped");
|
package/src/SSHMimic/sftp.ts
CHANGED
|
@@ -3,10 +3,12 @@ import { EventEmitter } from "node:events";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AuthenticationType, KeyboardAuthContext } from "ssh2";
|
|
5
5
|
import { Server as SshServer } from "ssh2";
|
|
6
|
+
import type { VfsNodeStats } from "../types/vfs";
|
|
7
|
+
import type { PerfLogger } from "../utils/perfLogger";
|
|
8
|
+
import { createPerfLogger } from "../utils/perfLogger";
|
|
6
9
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
7
10
|
import { VirtualShell } from "../VirtualShell";
|
|
8
11
|
import type { VirtualUserManager } from "../VirtualUserManager";
|
|
9
|
-
import type { VfsNodeStats } from "../types/vfs";
|
|
10
12
|
import { loadOrCreateHostKey } from "./hostKey";
|
|
11
13
|
|
|
12
14
|
const SFTP_STATUS_CODE = {
|
|
@@ -30,6 +32,8 @@ const OPEN_MODE = {
|
|
|
30
32
|
EXCL: 0x00000020,
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
const perf: PerfLogger = createPerfLogger("SftpMimic");
|
|
36
|
+
|
|
33
37
|
interface SftpFileHandle {
|
|
34
38
|
type: "file";
|
|
35
39
|
path: string;
|
|
@@ -154,6 +158,7 @@ export class SftpMimic extends EventEmitter {
|
|
|
154
158
|
users,
|
|
155
159
|
}: SftpMimicOptions) {
|
|
156
160
|
super();
|
|
161
|
+
perf.mark("constructor");
|
|
157
162
|
this.port = port;
|
|
158
163
|
this.server = null;
|
|
159
164
|
this.hostname = hostname;
|
|
@@ -184,6 +189,7 @@ export class SftpMimic extends EventEmitter {
|
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
public async start(): Promise<number> {
|
|
192
|
+
perf.mark("start");
|
|
187
193
|
const privateKey = loadOrCreateHostKey();
|
|
188
194
|
|
|
189
195
|
// Ensure VirtualShell is fully initialized before accepting connections
|
|
@@ -336,6 +342,7 @@ export class SftpMimic extends EventEmitter {
|
|
|
336
342
|
}
|
|
337
343
|
|
|
338
344
|
public stop(): void {
|
|
345
|
+
perf.mark("stop");
|
|
339
346
|
if (this.server) {
|
|
340
347
|
this.server.close(() => {
|
|
341
348
|
console.log("SFTP Mimic stopped");
|
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
VfsNodeStats,
|
|
8
8
|
WriteFileOptions,
|
|
9
9
|
} from "../types/vfs";
|
|
10
|
+
import type { PerfLogger } from "../utils/perfLogger";
|
|
11
|
+
import { createPerfLogger } from "../utils/perfLogger";
|
|
10
12
|
import { normalizePath } from "./path";
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -16,6 +18,8 @@ import { normalizePath } from "./path";
|
|
|
16
18
|
* {@link VirtualFileSystem.restoreMirror} on startup and
|
|
17
19
|
* {@link VirtualFileSystem.flushMirror} to persist pending changes.
|
|
18
20
|
*/
|
|
21
|
+
const perf: PerfLogger = createPerfLogger("VirtualFileSystem");
|
|
22
|
+
|
|
19
23
|
class VirtualFileSystem extends EventEmitter {
|
|
20
24
|
private readonly mirrorRoot: string;
|
|
21
25
|
|
|
@@ -95,6 +99,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
95
99
|
*/
|
|
96
100
|
constructor(baseDir: string = process.cwd()) {
|
|
97
101
|
super();
|
|
102
|
+
perf.mark("constructor");
|
|
98
103
|
this.mirrorRoot = path.resolve(baseDir, ".vfs", "mirror");
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -104,6 +109,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
104
109
|
* If archive does not exist or cannot be read, creates fresh mirror file.
|
|
105
110
|
*/
|
|
106
111
|
public async restoreMirror(): Promise<void> {
|
|
112
|
+
perf.mark("restoreMirror");
|
|
107
113
|
this.ensureMirrorRoot();
|
|
108
114
|
}
|
|
109
115
|
|
|
@@ -113,6 +119,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
113
119
|
* No-op when nothing changed and archive already exists.
|
|
114
120
|
*/
|
|
115
121
|
public async flushMirror(): Promise<void> {
|
|
122
|
+
perf.mark("flushMirror");
|
|
116
123
|
this.ensureMirrorRoot();
|
|
117
124
|
this.emit("mirror:flush");
|
|
118
125
|
}
|
|
@@ -124,6 +131,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
124
131
|
* @param mode POSIX-like mode bits for new directories.
|
|
125
132
|
*/
|
|
126
133
|
public mkdir(targetPath: string, mode: number = 0o755): void {
|
|
134
|
+
perf.mark("mkdir");
|
|
127
135
|
this.ensureMirrorRoot();
|
|
128
136
|
const fsPath = this.resolveFsPath(targetPath);
|
|
129
137
|
if (fs.existsSync(fsPath) && !fs.statSync(fsPath).isDirectory()) {
|
|
@@ -149,6 +157,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
149
157
|
content: string | Buffer,
|
|
150
158
|
options: WriteFileOptions = {},
|
|
151
159
|
): void {
|
|
160
|
+
perf.mark("writeFile");
|
|
152
161
|
this.ensureMirrorRoot();
|
|
153
162
|
const normalized = normalizePath(targetPath);
|
|
154
163
|
const fsPath = this.resolveFsPath(normalized);
|
|
@@ -181,6 +190,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
181
190
|
* @returns UTF-8 string content.
|
|
182
191
|
*/
|
|
183
192
|
public readFile(targetPath: string): string {
|
|
193
|
+
perf.mark("readFile");
|
|
184
194
|
this.ensureMirrorRoot();
|
|
185
195
|
const fsPath = this.resolveFsPath(targetPath);
|
|
186
196
|
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
@@ -201,6 +211,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
201
211
|
* @returns True when file or directory exists.
|
|
202
212
|
*/
|
|
203
213
|
public exists(targetPath: string): boolean {
|
|
214
|
+
perf.mark("exists");
|
|
204
215
|
try {
|
|
205
216
|
const fsPath = this.resolveFsPath(targetPath);
|
|
206
217
|
return fs.existsSync(fsPath);
|
|
@@ -216,6 +227,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
216
227
|
* @param mode New POSIX-like mode.
|
|
217
228
|
*/
|
|
218
229
|
public chmod(targetPath: string, mode: number): void {
|
|
230
|
+
perf.mark("chmod");
|
|
219
231
|
const fsPath = this.resolveFsPath(targetPath);
|
|
220
232
|
if (!fs.existsSync(fsPath)) {
|
|
221
233
|
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
@@ -230,6 +242,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
230
242
|
* @returns Typed stat object based on node type.
|
|
231
243
|
*/
|
|
232
244
|
public stat(targetPath: string): VfsNodeStats {
|
|
245
|
+
perf.mark("stat");
|
|
233
246
|
this.ensureMirrorRoot();
|
|
234
247
|
const normalized = normalizePath(targetPath);
|
|
235
248
|
const fsPath = this.resolveFsPath(normalized);
|
|
@@ -273,6 +286,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
273
286
|
* @returns Sorted child names.
|
|
274
287
|
*/
|
|
275
288
|
public list(dirPath: string = "/"): string[] {
|
|
289
|
+
perf.mark("list");
|
|
276
290
|
const fsPath = this.resolveFsPath(dirPath);
|
|
277
291
|
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
278
292
|
throw new Error(`Cannot list '${dirPath}': not a directory.`);
|
|
@@ -288,6 +302,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
288
302
|
* @returns Multi-line tree string.
|
|
289
303
|
*/
|
|
290
304
|
public tree(dirPath: string = "/"): string {
|
|
305
|
+
perf.mark("tree");
|
|
291
306
|
const fsPath = this.resolveFsPath(dirPath);
|
|
292
307
|
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isDirectory()) {
|
|
293
308
|
throw new Error(`Cannot render tree for '${dirPath}': not a directory.`);
|
|
@@ -308,6 +323,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
308
323
|
* @returns Total byte usage for file content under target path.
|
|
309
324
|
*/
|
|
310
325
|
public getUsageBytes(targetPath: string = "/"): number {
|
|
326
|
+
perf.mark("getUsageBytes");
|
|
311
327
|
const fsPath = this.resolveFsPath(targetPath);
|
|
312
328
|
if (!fs.existsSync(fsPath)) {
|
|
313
329
|
throw new Error(`Path '${normalizePath(targetPath)}' does not exist.`);
|
|
@@ -321,6 +337,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
321
337
|
* @param targetPath Path to file.
|
|
322
338
|
*/
|
|
323
339
|
public compressFile(targetPath: string): void {
|
|
340
|
+
perf.mark("compressFile");
|
|
324
341
|
const fsPath = this.resolveFsPath(targetPath);
|
|
325
342
|
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
326
343
|
throw new Error(`Cannot compress '${targetPath}': not a file.`);
|
|
@@ -338,6 +355,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
338
355
|
* @param targetPath Path to file.
|
|
339
356
|
*/
|
|
340
357
|
public decompressFile(targetPath: string): void {
|
|
358
|
+
perf.mark("decompressFile");
|
|
341
359
|
const fsPath = this.resolveFsPath(targetPath);
|
|
342
360
|
if (!fs.existsSync(fsPath) || !fs.statSync(fsPath).isFile()) {
|
|
343
361
|
throw new Error(`Cannot decompress '${targetPath}': not a file.`);
|
|
@@ -356,6 +374,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
356
374
|
* @param options Removal options, including recursive delete.
|
|
357
375
|
*/
|
|
358
376
|
public remove(targetPath: string, options: RemoveOptions = {}): void {
|
|
377
|
+
perf.mark("remove");
|
|
359
378
|
const normalized = normalizePath(targetPath);
|
|
360
379
|
if (normalized === "/") {
|
|
361
380
|
throw new Error("Cannot remove root directory.");
|
|
@@ -390,6 +409,7 @@ class VirtualFileSystem extends EventEmitter {
|
|
|
390
409
|
* @param toPath Destination path.
|
|
391
410
|
*/
|
|
392
411
|
public move(fromPath: string, toPath: string): void {
|
|
412
|
+
perf.mark("move");
|
|
393
413
|
const fromNormalized = normalizePath(fromPath);
|
|
394
414
|
const toNormalized = normalizePath(toPath);
|
|
395
415
|
|
|
@@ -3,6 +3,8 @@ import { EventEmitter } from "node:events";
|
|
|
3
3
|
import { createCustomCommand, registerCommand, runCommand } from "../commands";
|
|
4
4
|
import type { CommandContext, CommandResult } from "../types/commands";
|
|
5
5
|
import type { ShellStream } from "../types/streams";
|
|
6
|
+
import type { PerfLogger } from "../utils/perfLogger";
|
|
7
|
+
import { createPerfLogger } from "../utils/perfLogger";
|
|
6
8
|
import VirtualFileSystem from "../VirtualFileSystem";
|
|
7
9
|
import { VirtualUserManager } from "../VirtualUserManager";
|
|
8
10
|
import { startShell } from "./shell";
|
|
@@ -19,13 +21,23 @@ const defaultShellProperties: ShellProperties = {
|
|
|
19
21
|
arch: "x86_64",
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
const perf: PerfLogger = createPerfLogger("VirtualShell");
|
|
25
|
+
|
|
26
|
+
let cachedRootPassword: string | null = null;
|
|
27
|
+
|
|
22
28
|
function resolveRootPassword(): string {
|
|
29
|
+
if (cachedRootPassword) {
|
|
30
|
+
return cachedRootPassword;
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
const configured = process.env.SSH_MIMIC_ROOT_PASSWORD;
|
|
24
34
|
if (configured && configured.trim().length > 0) {
|
|
25
|
-
|
|
35
|
+
cachedRootPassword = configured.trim();
|
|
36
|
+
return cachedRootPassword;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
const generated = randomBytes(18).toString("base64url");
|
|
40
|
+
cachedRootPassword = generated;
|
|
29
41
|
console.warn(
|
|
30
42
|
`[ssh-mimic] SSH_MIMIC_ROOT_PASSWORD missing; generated ephemeral root password: ${generated}`,
|
|
31
43
|
);
|
|
@@ -68,6 +80,7 @@ class VirtualShell extends EventEmitter {
|
|
|
68
80
|
basePath?: string,
|
|
69
81
|
) {
|
|
70
82
|
super();
|
|
83
|
+
perf.mark("constructor");
|
|
71
84
|
this.hostname = hostname;
|
|
72
85
|
this.properties = properties || defaultShellProperties;
|
|
73
86
|
this.basePath = basePath || ".";
|
|
@@ -95,6 +108,7 @@ class VirtualShell extends EventEmitter {
|
|
|
95
108
|
* Call this before any authentication or command execution.
|
|
96
109
|
*/
|
|
97
110
|
public async ensureInitialized(): Promise<void> {
|
|
111
|
+
perf.mark("ensureInitialized");
|
|
98
112
|
await this.initialized;
|
|
99
113
|
}
|
|
100
114
|
|
|
@@ -126,6 +140,7 @@ class VirtualShell extends EventEmitter {
|
|
|
126
140
|
* @param cwd
|
|
127
141
|
*/
|
|
128
142
|
executeCommand(rawInput: string, authUser: string, cwd: string): void {
|
|
143
|
+
perf.mark("executeCommand");
|
|
129
144
|
runCommand(rawInput, authUser, this.hostname, "shell", cwd, this);
|
|
130
145
|
this.emit("command", { command: rawInput, user: authUser, cwd });
|
|
131
146
|
}
|
|
@@ -146,6 +161,7 @@ class VirtualShell extends EventEmitter {
|
|
|
146
161
|
remoteAddress: string,
|
|
147
162
|
terminalSize: { cols: number; rows: number },
|
|
148
163
|
): void {
|
|
164
|
+
perf.mark("startInteractiveSession");
|
|
149
165
|
// Interactive shell logic
|
|
150
166
|
this.emit("session:start", { user: authUser, sessionId, remoteAddress });
|
|
151
167
|
startShell(
|
|
@@ -199,6 +215,7 @@ class VirtualShell extends EventEmitter {
|
|
|
199
215
|
targetPath: string,
|
|
200
216
|
content: string | Buffer,
|
|
201
217
|
): void {
|
|
218
|
+
perf.mark("writeFileAsUser");
|
|
202
219
|
this.users.assertWriteWithinQuota(authUser, targetPath, content);
|
|
203
220
|
this.vfs.writeFile(targetPath, content);
|
|
204
221
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import type { PerfLogger } from "../utils/perfLogger";
|
|
5
|
+
import { createPerfLogger } from "../utils/perfLogger";
|
|
4
6
|
import type VirtualFileSystem from "../VirtualFileSystem";
|
|
5
7
|
|
|
6
8
|
/** Persisted virtual user credential record. */
|
|
@@ -27,12 +29,24 @@ export interface VirtualActiveSession {
|
|
|
27
29
|
startedAt: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function resolveFastPasswordHash(): boolean {
|
|
33
|
+
const configured = process.env.SSH_MIMIC_FAST_PASSWORD_HASH;
|
|
34
|
+
return (
|
|
35
|
+
!!configured &&
|
|
36
|
+
!["0", "false", "no", "off"].includes(configured.toLowerCase())
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const perf: PerfLogger = createPerfLogger("VirtualUserManager");
|
|
41
|
+
|
|
30
42
|
/**
|
|
31
43
|
* Persistent user, sudoers, and active-session manager for the shell runtime.
|
|
32
44
|
*
|
|
33
|
-
* Passwords are hashed with scrypt and stored in the backing virtual filesystem.
|
|
45
|
+
* Passwords are hashed with scrypt by default and stored in the backing virtual filesystem.
|
|
34
46
|
*/
|
|
35
47
|
export class VirtualUserManager extends EventEmitter {
|
|
48
|
+
private static readonly recordCache = new Map<string, VirtualUserRecord>();
|
|
49
|
+
private static readonly fastPasswordHash = resolveFastPasswordHash();
|
|
36
50
|
private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
|
|
37
51
|
private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
|
|
38
52
|
private readonly quotasPath = "/virtual-env-js/.auth/quotas";
|
|
@@ -56,6 +70,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
56
70
|
private readonly autoSudoForNewUsers: boolean = true,
|
|
57
71
|
) {
|
|
58
72
|
super();
|
|
73
|
+
perf.mark("constructor");
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
/**
|
|
@@ -63,23 +78,30 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
63
78
|
* Also creates the current system user if not already present.
|
|
64
79
|
*/
|
|
65
80
|
public async initialize(): Promise<void> {
|
|
81
|
+
perf.mark("initialize");
|
|
66
82
|
this.loadFromVfs();
|
|
67
83
|
this.loadSudoersFromVfs();
|
|
68
84
|
this.loadQuotasFromVfs();
|
|
69
85
|
|
|
70
|
-
|
|
86
|
+
let changed = false;
|
|
87
|
+
if (!this.users.has("root")) {
|
|
88
|
+
this.users.set(
|
|
89
|
+
"root",
|
|
90
|
+
this.createRecord("root", this.defaultRootPassword),
|
|
91
|
+
);
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
71
94
|
|
|
72
95
|
this.sudoers.add("root");
|
|
73
96
|
|
|
74
97
|
// Auto-create current system user for easier authentication
|
|
75
98
|
const currentUser = process.env.USER || process.env.USERNAME;
|
|
76
99
|
if (currentUser && currentUser !== "root" && !this.users.has(currentUser)) {
|
|
77
|
-
// Use same password as root for convenience, or a generic default
|
|
78
100
|
const userPassword = this.defaultRootPassword;
|
|
79
101
|
this.users.set(currentUser, this.createRecord(currentUser, userPassword));
|
|
80
102
|
this.sudoers.add(currentUser);
|
|
103
|
+
changed = true;
|
|
81
104
|
|
|
82
|
-
// Create home directory for the system user
|
|
83
105
|
const homePath = `/home/${currentUser}`;
|
|
84
106
|
if (!this.vfs.exists(homePath)) {
|
|
85
107
|
this.vfs.mkdir(homePath, 0o755);
|
|
@@ -90,7 +112,9 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
|
|
115
|
+
if (changed) {
|
|
116
|
+
await this.persist();
|
|
117
|
+
}
|
|
94
118
|
this.emit("initialized");
|
|
95
119
|
}
|
|
96
120
|
|
|
@@ -104,6 +128,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
104
128
|
username: string,
|
|
105
129
|
maxBytes: number,
|
|
106
130
|
): Promise<void> {
|
|
131
|
+
perf.mark("setQuotaBytes");
|
|
107
132
|
this.validateUsername(username);
|
|
108
133
|
if (!this.users.has(username)) {
|
|
109
134
|
throw new Error(`quota: user '${username}' does not exist`);
|
|
@@ -123,6 +148,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
123
148
|
* @param username Target username.
|
|
124
149
|
*/
|
|
125
150
|
public async clearQuota(username: string): Promise<void> {
|
|
151
|
+
perf.mark("clearQuota");
|
|
126
152
|
this.validateUsername(username);
|
|
127
153
|
this.quotas.delete(username);
|
|
128
154
|
await this.persist();
|
|
@@ -135,6 +161,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
135
161
|
* @returns Quota in bytes, or null when unlimited.
|
|
136
162
|
*/
|
|
137
163
|
public getQuotaBytes(username: string): number | null {
|
|
164
|
+
perf.mark("getQuotaBytes");
|
|
138
165
|
return this.quotas.get(username) ?? null;
|
|
139
166
|
}
|
|
140
167
|
|
|
@@ -145,6 +172,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
145
172
|
* @returns Current usage in bytes.
|
|
146
173
|
*/
|
|
147
174
|
public getUsageBytes(username: string): number {
|
|
175
|
+
perf.mark("getUsageBytes");
|
|
148
176
|
const homePath = `/home/${username}`;
|
|
149
177
|
if (!this.vfs.exists(homePath)) {
|
|
150
178
|
return 0;
|
|
@@ -167,6 +195,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
167
195
|
targetPath: string,
|
|
168
196
|
nextContent: string | Buffer,
|
|
169
197
|
): void {
|
|
198
|
+
perf.mark("assertWriteWithinQuota");
|
|
170
199
|
const quota = this.quotas.get(username);
|
|
171
200
|
if (quota === undefined) {
|
|
172
201
|
return;
|
|
@@ -209,6 +238,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
209
238
|
* @returns True when credentials are valid.
|
|
210
239
|
*/
|
|
211
240
|
public verifyPassword(username: string, password: string): boolean {
|
|
241
|
+
perf.mark("verifyPassword");
|
|
212
242
|
const record = this.users.get(username);
|
|
213
243
|
if (!record) {
|
|
214
244
|
return false;
|
|
@@ -224,6 +254,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
224
254
|
* @param password Initial plaintext password.
|
|
225
255
|
*/
|
|
226
256
|
public async addUser(username: string, password: string): Promise<void> {
|
|
257
|
+
perf.mark("addUser");
|
|
227
258
|
this.validateUsername(username);
|
|
228
259
|
this.validatePassword(password);
|
|
229
260
|
|
|
@@ -255,6 +286,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
255
286
|
* @param password New plaintext password.
|
|
256
287
|
*/
|
|
257
288
|
public async setPassword(username: string, password: string): Promise<void> {
|
|
289
|
+
perf.mark("setPassword");
|
|
258
290
|
this.validateUsername(username);
|
|
259
291
|
this.validatePassword(password);
|
|
260
292
|
|
|
@@ -272,6 +304,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
272
304
|
* @param username Username to remove.
|
|
273
305
|
*/
|
|
274
306
|
public async deleteUser(username: string): Promise<void> {
|
|
307
|
+
perf.mark("deleteUser");
|
|
275
308
|
this.validateUsername(username);
|
|
276
309
|
|
|
277
310
|
if (username === "root") {
|
|
@@ -295,6 +328,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
295
328
|
* @returns True when user can run sudo.
|
|
296
329
|
*/
|
|
297
330
|
public isSudoer(username: string): boolean {
|
|
331
|
+
perf.mark("isSudoer");
|
|
298
332
|
return this.sudoers.has(username);
|
|
299
333
|
}
|
|
300
334
|
|
|
@@ -304,6 +338,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
304
338
|
* @param username Username to promote.
|
|
305
339
|
*/
|
|
306
340
|
public async addSudoer(username: string): Promise<void> {
|
|
341
|
+
perf.mark("addSudoer");
|
|
307
342
|
this.validateUsername(username);
|
|
308
343
|
if (!this.users.has(username)) {
|
|
309
344
|
throw new Error(`sudoers: user '${username}' does not exist`);
|
|
@@ -319,6 +354,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
319
354
|
* @param username Username to demote.
|
|
320
355
|
*/
|
|
321
356
|
public async removeSudoer(username: string): Promise<void> {
|
|
357
|
+
perf.mark("removeSudoer");
|
|
322
358
|
this.validateUsername(username);
|
|
323
359
|
if (username === "root") {
|
|
324
360
|
throw new Error("sudoers: cannot remove root");
|
|
@@ -339,6 +375,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
339
375
|
username: string,
|
|
340
376
|
remoteAddress: string,
|
|
341
377
|
): VirtualActiveSession {
|
|
378
|
+
perf.mark("registerSession");
|
|
342
379
|
const session: VirtualActiveSession = {
|
|
343
380
|
id: randomUUID(),
|
|
344
381
|
username,
|
|
@@ -361,6 +398,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
361
398
|
* @param sessionId Session identifier; ignored when nullish.
|
|
362
399
|
*/
|
|
363
400
|
public unregisterSession(sessionId: string | null | undefined): void {
|
|
401
|
+
perf.mark("unregisterSession");
|
|
364
402
|
if (!sessionId) {
|
|
365
403
|
return;
|
|
366
404
|
}
|
|
@@ -388,6 +426,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
388
426
|
username: string,
|
|
389
427
|
remoteAddress: string,
|
|
390
428
|
): void {
|
|
429
|
+
perf.mark("updateSession");
|
|
391
430
|
if (!sessionId) {
|
|
392
431
|
return;
|
|
393
432
|
}
|
|
@@ -410,6 +449,7 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
410
449
|
* @returns Snapshot of active session descriptors.
|
|
411
450
|
*/
|
|
412
451
|
public listActiveSessions(): VirtualActiveSession[] {
|
|
452
|
+
perf.mark("listActiveSessions");
|
|
413
453
|
return Array.from(this.activeSessions.values()).sort((left, right) =>
|
|
414
454
|
left.startedAt.localeCompare(right.startedAt),
|
|
415
455
|
);
|
|
@@ -488,47 +528,84 @@ export class VirtualUserManager extends EventEmitter {
|
|
|
488
528
|
this.vfs.mkdir(this.authDirPath, 0o700);
|
|
489
529
|
}
|
|
490
530
|
|
|
491
|
-
const
|
|
531
|
+
const authContent = Array.from(this.users.values())
|
|
492
532
|
.sort((left, right) => left.username.localeCompare(right.username))
|
|
493
533
|
.map((record) =>
|
|
494
534
|
[record.username, record.salt, record.passwordHash].join(":"),
|
|
495
535
|
)
|
|
496
536
|
.join("\n");
|
|
497
|
-
|
|
498
|
-
this.vfs.writeFile(
|
|
499
|
-
this.usersPath,
|
|
500
|
-
content.length > 0 ? `${content}\n` : "",
|
|
501
|
-
{ mode: 0o600 },
|
|
502
|
-
);
|
|
503
537
|
const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
|
|
504
|
-
this.vfs.writeFile(
|
|
505
|
-
this.sudoersPath,
|
|
506
|
-
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
507
|
-
{ mode: 0o600 },
|
|
508
|
-
);
|
|
509
538
|
const quotasContent = Array.from(this.quotas.entries())
|
|
510
539
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
511
540
|
.map(([username, maxBytes]) => `${username}:${maxBytes}`)
|
|
512
541
|
.join("\n");
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
542
|
+
|
|
543
|
+
let changed = false;
|
|
544
|
+
changed =
|
|
545
|
+
this.writeIfChanged(
|
|
546
|
+
this.usersPath,
|
|
547
|
+
authContent.length > 0 ? `${authContent}\n` : "",
|
|
548
|
+
0o600,
|
|
549
|
+
) || changed;
|
|
550
|
+
changed =
|
|
551
|
+
this.writeIfChanged(
|
|
552
|
+
this.sudoersPath,
|
|
553
|
+
sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
|
|
554
|
+
0o600,
|
|
555
|
+
) || changed;
|
|
556
|
+
changed =
|
|
557
|
+
this.writeIfChanged(
|
|
558
|
+
this.quotasPath,
|
|
559
|
+
quotasContent.length > 0 ? `${quotasContent}\n` : "",
|
|
560
|
+
0o600,
|
|
561
|
+
) || changed;
|
|
562
|
+
|
|
563
|
+
if (changed) {
|
|
564
|
+
await this.vfs.flushMirror();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private writeIfChanged(
|
|
569
|
+
targetPath: string,
|
|
570
|
+
content: string,
|
|
571
|
+
mode: number,
|
|
572
|
+
): boolean {
|
|
573
|
+
if (this.vfs.exists(targetPath)) {
|
|
574
|
+
const existing = this.vfs.readFile(targetPath);
|
|
575
|
+
if (existing === content) {
|
|
576
|
+
this.vfs.chmod(targetPath, mode);
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
this.vfs.writeFile(targetPath, content, { mode });
|
|
582
|
+
return true;
|
|
519
583
|
}
|
|
520
584
|
|
|
521
585
|
private createRecord(username: string, password: string): VirtualUserRecord {
|
|
586
|
+
const cacheKey = `${username}:${password}`;
|
|
587
|
+
const cached = VirtualUserManager.recordCache.get(cacheKey);
|
|
588
|
+
if (cached) {
|
|
589
|
+
return cached;
|
|
590
|
+
}
|
|
591
|
+
|
|
522
592
|
const salt = randomBytes(16).toString("hex");
|
|
523
|
-
|
|
593
|
+
const record = {
|
|
524
594
|
username,
|
|
525
595
|
salt,
|
|
526
596
|
passwordHash: this.hashPassword(password, salt),
|
|
527
597
|
};
|
|
598
|
+
|
|
599
|
+
VirtualUserManager.recordCache.set(cacheKey, record);
|
|
600
|
+
return record;
|
|
528
601
|
}
|
|
529
602
|
|
|
530
603
|
private hashPassword(password: string, salt: string): string {
|
|
531
|
-
|
|
604
|
+
if (VirtualUserManager.fastPasswordHash) {
|
|
605
|
+
return createHash("sha256").update(`${salt}:${password}`).digest("hex");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return scryptSync(password, salt, 32).toString("hex");
|
|
532
609
|
}
|
|
533
610
|
|
|
534
611
|
private validateUsername(username: string): void {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type PerfLogger = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
mark: (label: string) => void;
|
|
4
|
+
done: (label?: string) => void;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function isTruthyEnv(value: string | undefined): boolean {
|
|
8
|
+
return value === "1" || value === "true";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function nowMs(): number {
|
|
12
|
+
if (
|
|
13
|
+
typeof performance !== "undefined" &&
|
|
14
|
+
typeof performance.now === "function"
|
|
15
|
+
) {
|
|
16
|
+
return performance.now();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Date.now();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isPerfLoggingEnabled(): boolean {
|
|
23
|
+
return (
|
|
24
|
+
isTruthyEnv(process.env.DEV_MODE) || isTruthyEnv(process.env.RENDER_PERF)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPerfLogger(scope: string): PerfLogger {
|
|
29
|
+
const enabled = isPerfLoggingEnabled();
|
|
30
|
+
if (!enabled) {
|
|
31
|
+
return {
|
|
32
|
+
enabled,
|
|
33
|
+
mark: () => undefined,
|
|
34
|
+
done: () => undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const startedAt = nowMs();
|
|
39
|
+
|
|
40
|
+
const mark = (label: string): void => {
|
|
41
|
+
const elapsedMs = nowMs() - startedAt;
|
|
42
|
+
console.log(`[perf][${scope}] ${label}: ${elapsedMs.toFixed(1)}ms`);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const done = (label = "done"): void => {
|
|
46
|
+
mark(label);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
enabled,
|
|
51
|
+
mark,
|
|
52
|
+
done,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function withPerf<T>(
|
|
57
|
+
scope: string,
|
|
58
|
+
label: string,
|
|
59
|
+
work: () => Promise<T>,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const perf = createPerfLogger(scope);
|
|
62
|
+
if (!perf.enabled) {
|
|
63
|
+
return work();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
perf.mark(`${label}:start`);
|
|
67
|
+
try {
|
|
68
|
+
return await work();
|
|
69
|
+
} finally {
|
|
70
|
+
perf.done(`${label}:done`);
|
|
71
|
+
}
|
|
72
|
+
}
|