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.
Files changed (60) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +50 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.yml +31 -0
  3. package/.github/dependabot.yml +27 -0
  4. package/.github/pull_request_template.md +21 -0
  5. package/.github/workflows/create-pull-request.yml +83 -0
  6. package/.github/workflows/test-battery.yml +57 -0
  7. package/CHANGELOG.md +27 -0
  8. package/CODE_OF_CONDUCT.md +39 -0
  9. package/CONTRIBUTING.md +59 -0
  10. package/LICENSE +21 -0
  11. package/README.md +1283 -0
  12. package/SECURITY.md +33 -0
  13. package/biome.json +20 -0
  14. package/bun.lock +99 -0
  15. package/package.json +38 -0
  16. package/src/SSHMimic/client.ts +248 -0
  17. package/src/SSHMimic/commands/adduser.ts +22 -0
  18. package/src/SSHMimic/commands/cat.ts +16 -0
  19. package/src/SSHMimic/commands/cd.ts +20 -0
  20. package/src/SSHMimic/commands/clear.ts +7 -0
  21. package/src/SSHMimic/commands/curl.ts +27 -0
  22. package/src/SSHMimic/commands/deluser.ts +19 -0
  23. package/src/SSHMimic/commands/exit.ts +7 -0
  24. package/src/SSHMimic/commands/help.ts +9 -0
  25. package/src/SSHMimic/commands/helpers.ts +137 -0
  26. package/src/SSHMimic/commands/hostname.ts +7 -0
  27. package/src/SSHMimic/commands/htop.ts +13 -0
  28. package/src/SSHMimic/commands/index.ts +120 -0
  29. package/src/SSHMimic/commands/ls.ts +14 -0
  30. package/src/SSHMimic/commands/mkdir.ts +17 -0
  31. package/src/SSHMimic/commands/nano.ts +30 -0
  32. package/src/SSHMimic/commands/pwd.ts +7 -0
  33. package/src/SSHMimic/commands/rm.ts +26 -0
  34. package/src/SSHMimic/commands/su.ts +31 -0
  35. package/src/SSHMimic/commands/sudo.ts +90 -0
  36. package/src/SSHMimic/commands/touch.ts +20 -0
  37. package/src/SSHMimic/commands/tree.ts +11 -0
  38. package/src/SSHMimic/commands/wget.ts +33 -0
  39. package/src/SSHMimic/commands/who.ts +18 -0
  40. package/src/SSHMimic/commands/whoami.ts +7 -0
  41. package/src/SSHMimic/exec.ts +37 -0
  42. package/src/SSHMimic/hostKey.ts +21 -0
  43. package/src/SSHMimic/index.ts +203 -0
  44. package/src/SSHMimic/loginFormat.ts +10 -0
  45. package/src/SSHMimic/prompt.ts +14 -0
  46. package/src/SSHMimic/shell.ts +740 -0
  47. package/src/SSHMimic/users.ts +336 -0
  48. package/src/VirtualFileSystem.ts +420 -0
  49. package/src/index.ts +34 -0
  50. package/src/standalone.ts +14 -0
  51. package/src/types/commands.ts +98 -0
  52. package/src/types/streams.ts +32 -0
  53. package/src/types/tar-stream.d.ts +38 -0
  54. package/src/types/vfs.ts +81 -0
  55. package/src/vfs/archive.ts +74 -0
  56. package/src/vfs/internalTypes.ts +19 -0
  57. package/src/vfs/path.ts +74 -0
  58. package/src/vfs/snapshot.ts +84 -0
  59. package/src/vfs/tree.ts +34 -0
  60. package/tsconfig.json +31 -0
@@ -0,0 +1,336 @@
1
+ import { randomBytes, randomUUID, scryptSync } from "node:crypto";
2
+ import type VirtualFileSystem from "../VirtualFileSystem";
3
+
4
+ /** Persisted virtual user credential record. */
5
+ export interface VirtualUserRecord {
6
+ /** Unique login name. */
7
+ username: string;
8
+ /** Per-user random salt used for password hashing. */
9
+ salt: string;
10
+ /** Scrypt-derived password hash in hex encoding. */
11
+ passwordHash: string;
12
+ }
13
+
14
+ /** Runtime representation of authenticated SSH session. */
15
+ export interface VirtualActiveSession {
16
+ /** Stable session identifier (UUID). */
17
+ id: string;
18
+ /** Username bound to session. */
19
+ username: string;
20
+ /** Virtual terminal identifier (pts/*). */
21
+ tty: string;
22
+ /** Remote client IP or host label. */
23
+ remoteAddress: string;
24
+ /** ISO-8601 start timestamp. */
25
+ startedAt: string;
26
+ }
27
+
28
+ /**
29
+ * User, sudoers, and active session manager for SSH mimic runtime.
30
+ */
31
+ export class VirtualUserManager {
32
+ private readonly usersPath = "/virtual-env-js/.auth/htpasswd";
33
+ private readonly sudoersPath = "/virtual-env-js/.auth/sudoers";
34
+ private readonly users = new Map<string, VirtualUserRecord>();
35
+ private readonly sudoers = new Set<string>();
36
+ private readonly activeSessions = new Map<string, VirtualActiveSession>();
37
+ private nextTty = 0;
38
+
39
+ /**
40
+ * Creates user manager instance.
41
+ *
42
+ * @param vfs Backing virtual filesystem used for persistence.
43
+ * @param defaultRootPassword Initial root password used when root missing.
44
+ */
45
+ constructor(
46
+ private readonly vfs: VirtualFileSystem,
47
+ private readonly defaultRootPassword: string = "root",
48
+ ) {}
49
+
50
+ /**
51
+ * Loads users/sudoers from disk and ensures root account exists.
52
+ */
53
+ public async initialize(): Promise<void> {
54
+ this.loadFromVfs();
55
+ this.loadSudoersFromVfs();
56
+
57
+ if (!this.users.has("root")) {
58
+ this.users.set(
59
+ "root",
60
+ this.createRecord("root", this.defaultRootPassword),
61
+ );
62
+ }
63
+
64
+ this.sudoers.add("root");
65
+
66
+ await this.persist();
67
+ }
68
+
69
+ /**
70
+ * Verifies plaintext password against stored record.
71
+ *
72
+ * @param username User login name.
73
+ * @param password Plaintext password candidate.
74
+ * @returns True when credentials are valid.
75
+ */
76
+ public verifyPassword(username: string, password: string): boolean {
77
+ const record = this.users.get(username);
78
+ if (!record) {
79
+ return false;
80
+ }
81
+
82
+ return this.hashPassword(password, record.salt) === record.passwordHash;
83
+ }
84
+
85
+ /**
86
+ * Creates user, home directory, and sudo access entry.
87
+ *
88
+ * @param username New username.
89
+ * @param password Initial plaintext password.
90
+ */
91
+ public async addUser(username: string, password: string): Promise<void> {
92
+ this.validateUsername(username);
93
+ this.validatePassword(password);
94
+
95
+ if (this.users.has(username)) {
96
+ throw new Error(`adduser: user '${username}' already exists`);
97
+ }
98
+
99
+ this.users.set(username, this.createRecord(username, password));
100
+ this.sudoers.add(username);
101
+ const homePath = `/home/${username}`;
102
+ if (!this.vfs.exists(homePath)) {
103
+ this.vfs.mkdir(homePath, 0o755);
104
+ this.vfs.writeFile(
105
+ `${homePath}/README.txt`,
106
+ `Welcome to the virtual environment, ${username}`,
107
+ );
108
+ }
109
+ await this.persist();
110
+ }
111
+
112
+ /**
113
+ * Deletes existing non-root user account.
114
+ *
115
+ * @param username Username to remove.
116
+ */
117
+ public async deleteUser(username: string): Promise<void> {
118
+ this.validateUsername(username);
119
+
120
+ if (username === "root") {
121
+ throw new Error("deluser: cannot delete root");
122
+ }
123
+
124
+ if (!this.users.delete(username)) {
125
+ throw new Error(`deluser: user '${username}' does not exist`);
126
+ }
127
+
128
+ this.sudoers.delete(username);
129
+
130
+ await this.persist();
131
+ }
132
+
133
+ /**
134
+ * Checks whether user is member of sudoers set.
135
+ *
136
+ * @param username Username to test.
137
+ * @returns True when user can run sudo.
138
+ */
139
+ public isSudoer(username: string): boolean {
140
+ return this.sudoers.has(username);
141
+ }
142
+
143
+ /**
144
+ * Grants sudo access to existing user.
145
+ *
146
+ * @param username Username to promote.
147
+ */
148
+ public async addSudoer(username: string): Promise<void> {
149
+ this.validateUsername(username);
150
+ if (!this.users.has(username)) {
151
+ throw new Error(`sudoers: user '${username}' does not exist`);
152
+ }
153
+
154
+ this.sudoers.add(username);
155
+ await this.persist();
156
+ }
157
+
158
+ /**
159
+ * Revokes sudo access from user.
160
+ *
161
+ * @param username Username to demote.
162
+ */
163
+ public async removeSudoer(username: string): Promise<void> {
164
+ this.validateUsername(username);
165
+ if (username === "root") {
166
+ throw new Error("sudoers: cannot remove root");
167
+ }
168
+
169
+ this.sudoers.delete(username);
170
+ await this.persist();
171
+ }
172
+
173
+ /**
174
+ * Registers active session and allocates tty id.
175
+ *
176
+ * @param username Session username.
177
+ * @param remoteAddress Session source address.
178
+ * @returns Registered session descriptor.
179
+ */
180
+ public registerSession(
181
+ username: string,
182
+ remoteAddress: string,
183
+ ): VirtualActiveSession {
184
+ const session: VirtualActiveSession = {
185
+ id: randomUUID(),
186
+ username,
187
+ tty: `pts/${this.nextTty++}`,
188
+ remoteAddress,
189
+ startedAt: new Date().toISOString(),
190
+ };
191
+
192
+ this.activeSessions.set(session.id, session);
193
+ return session;
194
+ }
195
+
196
+ /**
197
+ * Unregisters active session when connection closes.
198
+ *
199
+ * @param sessionId Session identifier; ignored when nullish.
200
+ */
201
+ public unregisterSession(sessionId: string | null | undefined): void {
202
+ if (!sessionId) {
203
+ return;
204
+ }
205
+
206
+ this.activeSessions.delete(sessionId);
207
+ }
208
+
209
+ /**
210
+ * Updates username/address metadata for existing session.
211
+ *
212
+ * @param sessionId Session identifier; ignored when nullish.
213
+ * @param username New username value.
214
+ * @param remoteAddress New remote address value.
215
+ */
216
+ public updateSession(
217
+ sessionId: string | null | undefined,
218
+ username: string,
219
+ remoteAddress: string,
220
+ ): void {
221
+ if (!sessionId) {
222
+ return;
223
+ }
224
+
225
+ const session = this.activeSessions.get(sessionId);
226
+ if (!session) {
227
+ return;
228
+ }
229
+
230
+ this.activeSessions.set(sessionId, {
231
+ ...session,
232
+ username,
233
+ remoteAddress,
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Lists active sessions sorted by start time.
239
+ *
240
+ * @returns Snapshot of active session descriptors.
241
+ */
242
+ public listActiveSessions(): VirtualActiveSession[] {
243
+ return Array.from(this.activeSessions.values()).sort((left, right) =>
244
+ left.startedAt.localeCompare(right.startedAt),
245
+ );
246
+ }
247
+
248
+ private loadFromVfs(): void {
249
+ this.users.clear();
250
+
251
+ if (!this.vfs.exists(this.usersPath)) {
252
+ return;
253
+ }
254
+
255
+ const raw = this.vfs.readFile(this.usersPath);
256
+ for (const line of raw.split("\n")) {
257
+ const trimmed = line.trim();
258
+ if (trimmed.length === 0) {
259
+ continue;
260
+ }
261
+
262
+ const parts = trimmed.split(":");
263
+ if (parts.length < 3) {
264
+ continue;
265
+ }
266
+
267
+ const [username, salt, passwordHash] = parts;
268
+ if (!username || !salt || !passwordHash) {
269
+ continue;
270
+ }
271
+
272
+ this.users.set(username, { username, salt, passwordHash });
273
+ }
274
+ }
275
+
276
+ private loadSudoersFromVfs(): void {
277
+ this.sudoers.clear();
278
+
279
+ if (!this.vfs.exists(this.sudoersPath)) {
280
+ return;
281
+ }
282
+
283
+ const raw = this.vfs.readFile(this.sudoersPath);
284
+ for (const line of raw.split("\n")) {
285
+ const username = line.trim();
286
+ if (username.length > 0) {
287
+ this.sudoers.add(username);
288
+ }
289
+ }
290
+ }
291
+
292
+ private async persist(): Promise<void> {
293
+ const content = Array.from(this.users.values())
294
+ .sort((left, right) => left.username.localeCompare(right.username))
295
+ .map((record) =>
296
+ [record.username, record.salt, record.passwordHash].join(":"),
297
+ )
298
+ .join("\n");
299
+
300
+ this.vfs.writeFile(
301
+ this.usersPath,
302
+ content.length > 0 ? `${content}\n` : "",
303
+ );
304
+ const sudoersContent = Array.from(this.sudoers.values()).sort().join("\n");
305
+ this.vfs.writeFile(
306
+ this.sudoersPath,
307
+ sudoersContent.length > 0 ? `${sudoersContent}\n` : "",
308
+ );
309
+ await this.vfs.flushMirror();
310
+ }
311
+
312
+ private createRecord(username: string, password: string): VirtualUserRecord {
313
+ const salt = randomBytes(16).toString("hex");
314
+ return {
315
+ username,
316
+ salt,
317
+ passwordHash: this.hashPassword(password, salt),
318
+ };
319
+ }
320
+
321
+ private hashPassword(password: string, salt: string): string {
322
+ return scryptSync(password, salt, 64).toString("hex");
323
+ }
324
+
325
+ private validateUsername(username: string): void {
326
+ if (!username || username.trim() === "") {
327
+ throw new Error("invalid username");
328
+ }
329
+ }
330
+
331
+ private validatePassword(password: string): void {
332
+ if (!password || password.trim() === "") {
333
+ throw new Error("invalid password");
334
+ }
335
+ }
336
+ }