typescript-virtual-container 1.2.4 → 1.2.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/README.md +868 -1245
- package/benchmark-results.txt +21 -21
- package/dist/SSHMimic/index.d.ts +19 -2
- package/dist/SSHMimic/index.d.ts.map +1 -1
- package/dist/SSHMimic/index.js +116 -20
- package/dist/VirtualFileSystem/index.d.ts +115 -88
- package/dist/VirtualFileSystem/index.d.ts.map +1 -1
- package/dist/VirtualFileSystem/index.js +406 -258
- package/dist/VirtualShell/index.d.ts +3 -4
- package/dist/VirtualShell/index.d.ts.map +1 -1
- package/dist/VirtualShell/index.js +4 -6
- package/dist/VirtualUserManager/index.d.ts +25 -0
- package/dist/VirtualUserManager/index.d.ts.map +1 -1
- package/dist/VirtualUserManager/index.js +33 -0
- package/dist/commands/chmod.d.ts +3 -0
- package/dist/commands/chmod.d.ts.map +1 -0
- package/dist/commands/chmod.js +31 -0
- package/dist/commands/cp.d.ts +3 -0
- package/dist/commands/cp.d.ts.map +1 -0
- package/dist/commands/cp.js +68 -0
- package/dist/commands/find.d.ts +3 -0
- package/dist/commands/find.d.ts.map +1 -0
- package/dist/commands/find.js +48 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +61 -35
- package/dist/commands/head.d.ts +3 -0
- package/dist/commands/head.d.ts.map +1 -0
- package/dist/commands/head.js +30 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +25 -35
- package/dist/commands/ln.d.ts +3 -0
- package/dist/commands/ln.d.ts.map +1 -0
- package/dist/commands/ln.js +42 -0
- package/dist/commands/mv.d.ts +3 -0
- package/dist/commands/mv.d.ts.map +1 -0
- package/dist/commands/mv.js +35 -0
- package/dist/commands/tail.d.ts +3 -0
- package/dist/commands/tail.d.ts.map +1 -0
- package/dist/commands/tail.js +33 -0
- package/dist/commands/wc.d.ts +3 -0
- package/dist/commands/wc.d.ts.map +1 -0
- package/dist/commands/wc.js +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +5 -2
- package/scripts/publish-package.sh +70 -0
- package/src/SSHMimic/index.ts +143 -28
- package/src/VirtualFileSystem/index.ts +500 -280
- package/src/VirtualShell/index.ts +4 -6
- package/src/VirtualUserManager/index.ts +41 -0
- package/src/commands/chmod.ts +33 -0
- package/src/commands/cp.ts +76 -0
- package/src/commands/find.ts +61 -0
- package/src/commands/grep.ts +54 -38
- package/src/commands/head.ts +35 -0
- package/src/commands/index.ts +25 -43
- package/src/commands/ln.ts +47 -0
- package/src/commands/mv.ts +43 -0
- package/src/commands/tail.ts +37 -0
- package/src/commands/wc.ts +48 -0
- package/src/index.ts +1 -0
- package/standalone.js +62 -52
- package/standalone.js.map +4 -4
- package/tests/bun-test-shim.ts +1 -0
- package/tests/sftp.test.ts +115 -191
- package/tests/users.test.ts +66 -83
package/src/SSHMimic/index.ts
CHANGED
|
@@ -11,14 +11,31 @@ import { loadOrCreateHostKey } from "./hostKey";
|
|
|
11
11
|
* This class is exported as `VirtualSshServer` for public API compatibility.
|
|
12
12
|
* Create an instance, call {@link SshMimic.start}, and stop it with
|
|
13
13
|
* {@link SshMimic.stop} when your process exits.
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Password authentication
|
|
17
|
+
* - Public-key authentication
|
|
18
|
+
* - Per-IP rate limiting / lockout for brute-force protection
|
|
19
|
+
* - Interactive shell sessions
|
|
20
|
+
* - Non-interactive exec sessions
|
|
14
21
|
*/
|
|
15
22
|
const perf: PerfLogger = createPerfLogger("SshMimic");
|
|
16
23
|
|
|
24
|
+
interface RateLimitEntry {
|
|
25
|
+
attempts: number;
|
|
26
|
+
lockedUntil: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
class SshMimic extends EventEmitter {
|
|
18
30
|
port: number;
|
|
19
31
|
server: SshServer | null;
|
|
20
32
|
private shell: VirtualShell;
|
|
21
|
-
|
|
33
|
+
|
|
34
|
+
/** Max failed auth attempts before an IP is temporarily locked. */
|
|
35
|
+
private readonly maxAuthAttempts: number;
|
|
36
|
+
/** How long (ms) a locked IP must wait before retrying. */
|
|
37
|
+
private readonly lockoutDurationMs: number;
|
|
38
|
+
private readonly authAttempts = new Map<string, RateLimitEntry>();
|
|
22
39
|
|
|
23
40
|
/**
|
|
24
41
|
* Creates a new SSH mimic server instance.
|
|
@@ -26,24 +43,73 @@ class SshMimic extends EventEmitter {
|
|
|
26
43
|
* @param port TCP port to bind on localhost.
|
|
27
44
|
* @param hostname Virtual hostname used for the SSH ident and default shell label.
|
|
28
45
|
* @param shell Optional preconfigured virtual shell instance to reuse.
|
|
46
|
+
* @param maxAuthAttempts Max failed attempts per IP before lockout (default: 5).
|
|
47
|
+
* @param lockoutDurationMs Lockout window in ms after exceeding attempts (default: 60 000).
|
|
29
48
|
*/
|
|
30
49
|
constructor({
|
|
31
50
|
port,
|
|
32
51
|
hostname = "typescript-vm",
|
|
33
52
|
shell = new VirtualShell(hostname),
|
|
53
|
+
maxAuthAttempts = 5,
|
|
54
|
+
lockoutDurationMs = 60_000,
|
|
34
55
|
}: {
|
|
35
56
|
port: number;
|
|
36
57
|
hostname?: string;
|
|
37
58
|
shell?: VirtualShell;
|
|
59
|
+
maxAuthAttempts?: number;
|
|
60
|
+
lockoutDurationMs?: number;
|
|
38
61
|
}) {
|
|
39
62
|
super();
|
|
40
63
|
perf.mark("constructor");
|
|
41
64
|
this.port = port;
|
|
42
|
-
this.shellHostname = hostname;
|
|
43
65
|
this.server = null;
|
|
44
66
|
this.shell = shell;
|
|
67
|
+
this.maxAuthAttempts = maxAuthAttempts;
|
|
68
|
+
this.lockoutDurationMs = lockoutDurationMs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Rate limiting ────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
private isLockedOut(ip: string): boolean {
|
|
74
|
+
const entry = this.authAttempts.get(ip);
|
|
75
|
+
if (!entry) return false;
|
|
76
|
+
if (Date.now() < entry.lockedUntil) return true;
|
|
77
|
+
if (entry.lockedUntil > 0) {
|
|
78
|
+
this.authAttempts.delete(ip);
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private recordFailure(ip: string): void {
|
|
84
|
+
const entry = this.authAttempts.get(ip) ?? { attempts: 0, lockedUntil: 0 };
|
|
85
|
+
entry.attempts += 1;
|
|
86
|
+
if (entry.attempts >= this.maxAuthAttempts) {
|
|
87
|
+
entry.lockedUntil = Date.now() + this.lockoutDurationMs;
|
|
88
|
+
this.emit("auth:lockout", { ip, until: new Date(entry.lockedUntil) });
|
|
89
|
+
}
|
|
90
|
+
this.authAttempts.set(ip, entry);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private recordSuccess(ip: string): void {
|
|
94
|
+
this.authAttempts.delete(ip);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Home directory bootstrap ─────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
private ensureHomeDir(authUser: string): void {
|
|
100
|
+
const homePath = `/home/${authUser}`;
|
|
101
|
+
if (!this.shell.vfs.exists(homePath)) {
|
|
102
|
+
this.shell.vfs.mkdir(homePath, 0o755);
|
|
103
|
+
this.shell.vfs.writeFile(
|
|
104
|
+
`${homePath}/README.txt`,
|
|
105
|
+
`Welcome to ${this.shell.hostname}\n`,
|
|
106
|
+
);
|
|
107
|
+
void this.shell.vfs.flushMirror();
|
|
108
|
+
}
|
|
45
109
|
}
|
|
46
110
|
|
|
111
|
+
// ── Server lifecycle ─────────────────────────────────────────────────────
|
|
112
|
+
|
|
47
113
|
/**
|
|
48
114
|
* Starts server and initializes virtual filesystem, users, and handlers.
|
|
49
115
|
*
|
|
@@ -54,7 +120,6 @@ class SshMimic extends EventEmitter {
|
|
|
54
120
|
const shell = this.shell;
|
|
55
121
|
const privateKey = loadOrCreateHostKey();
|
|
56
122
|
|
|
57
|
-
// Ensure VirtualShell is fully initialized before accepting connections
|
|
58
123
|
await shell.ensureInitialized();
|
|
59
124
|
|
|
60
125
|
this.server = new SshServer(
|
|
@@ -70,11 +135,22 @@ class SshMimic extends EventEmitter {
|
|
|
70
135
|
this.emit("client:connect");
|
|
71
136
|
|
|
72
137
|
client.on("authentication", (ctx) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
138
|
+
const candidateUser = ctx.username || "root";
|
|
139
|
+
remoteAddress = (ctx as { ip?: string }).ip ?? remoteAddress;
|
|
140
|
+
|
|
141
|
+
// Rate-limit check
|
|
142
|
+
if (this.isLockedOut(remoteAddress)) {
|
|
143
|
+
this.emit("auth:failure", {
|
|
144
|
+
username: candidateUser,
|
|
145
|
+
remoteAddress,
|
|
146
|
+
reason: "lockout",
|
|
147
|
+
});
|
|
148
|
+
ctx.reject();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
77
151
|
|
|
152
|
+
// ── Password auth ──────────────────────────────────────
|
|
153
|
+
if (ctx.method === "password") {
|
|
78
154
|
if (!shell.users.hasPassword(candidateUser)) {
|
|
79
155
|
console.log(
|
|
80
156
|
`User ${candidateUser} has no password set, allowing login without verification`,
|
|
@@ -84,18 +160,9 @@ class SshMimic extends EventEmitter {
|
|
|
84
160
|
authUser,
|
|
85
161
|
remoteAddress,
|
|
86
162
|
).id;
|
|
163
|
+
this.recordSuccess(remoteAddress);
|
|
87
164
|
this.emit("auth:success", { username: authUser, remoteAddress });
|
|
88
|
-
|
|
89
|
-
const homePath = `/home/${authUser}`;
|
|
90
|
-
if (!shell.vfs.exists(homePath)) {
|
|
91
|
-
shell.vfs.mkdir(homePath, 0o755);
|
|
92
|
-
shell.vfs.writeFile(
|
|
93
|
-
`${homePath}/README.txt`,
|
|
94
|
-
`Welcome to ${shell?.hostname ?? this.shellHostname}`,
|
|
95
|
-
);
|
|
96
|
-
void shell.vfs.flushMirror();
|
|
97
|
-
}
|
|
98
|
-
|
|
165
|
+
this.ensureHomeDir(authUser);
|
|
99
166
|
ctx.accept();
|
|
100
167
|
return;
|
|
101
168
|
}
|
|
@@ -105,6 +172,7 @@ class SshMimic extends EventEmitter {
|
|
|
105
172
|
ctx.password === "" ||
|
|
106
173
|
!shell.users.verifyPassword(candidateUser, ctx.password)
|
|
107
174
|
) {
|
|
175
|
+
this.recordFailure(remoteAddress);
|
|
108
176
|
this.emit("auth:failure", {
|
|
109
177
|
username: candidateUser,
|
|
110
178
|
remoteAddress,
|
|
@@ -115,23 +183,62 @@ class SshMimic extends EventEmitter {
|
|
|
115
183
|
|
|
116
184
|
authUser = candidateUser;
|
|
117
185
|
sessionId = shell.users.registerSession(authUser, remoteAddress).id;
|
|
186
|
+
this.recordSuccess(remoteAddress);
|
|
118
187
|
this.emit("auth:success", { username: authUser, remoteAddress });
|
|
188
|
+
this.ensureHomeDir(authUser);
|
|
189
|
+
ctx.accept();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
119
192
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
void shell.vfs.flushMirror();
|
|
193
|
+
// ── Public-key auth ────────────────────────────────────
|
|
194
|
+
if (ctx.method === "publickey") {
|
|
195
|
+
const authorizedKeys = shell.users.getAuthorizedKeys(candidateUser);
|
|
196
|
+
if (authorizedKeys.length === 0) {
|
|
197
|
+
// No keys configured — reject cleanly
|
|
198
|
+
ctx.reject();
|
|
199
|
+
return;
|
|
128
200
|
}
|
|
129
201
|
|
|
130
|
-
ctx.
|
|
202
|
+
const incomingKey = ctx.key;
|
|
203
|
+
const keyMatches = authorizedKeys.some(
|
|
204
|
+
(k) =>
|
|
205
|
+
k.algo === incomingKey.algo && k.data.equals(incomingKey.data),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (!keyMatches) {
|
|
209
|
+
this.recordFailure(remoteAddress);
|
|
210
|
+
this.emit("auth:failure", {
|
|
211
|
+
username: candidateUser,
|
|
212
|
+
remoteAddress,
|
|
213
|
+
method: "publickey",
|
|
214
|
+
});
|
|
215
|
+
ctx.reject();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Key matched — if this is a signature check step, accept
|
|
220
|
+
if (ctx.signature) {
|
|
221
|
+
authUser = candidateUser;
|
|
222
|
+
sessionId = shell.users.registerSession(
|
|
223
|
+
authUser,
|
|
224
|
+
remoteAddress,
|
|
225
|
+
).id;
|
|
226
|
+
this.recordSuccess(remoteAddress);
|
|
227
|
+
this.emit("auth:success", {
|
|
228
|
+
username: authUser,
|
|
229
|
+
remoteAddress,
|
|
230
|
+
method: "publickey",
|
|
231
|
+
});
|
|
232
|
+
this.ensureHomeDir(authUser);
|
|
233
|
+
ctx.accept();
|
|
234
|
+
} else {
|
|
235
|
+
// Key exists but no signature yet — ssh2 will call again with signature
|
|
236
|
+
ctx.accept();
|
|
237
|
+
}
|
|
131
238
|
return;
|
|
132
239
|
}
|
|
133
240
|
|
|
134
|
-
ctx.reject();
|
|
241
|
+
ctx.reject(["password", "publickey"]);
|
|
135
242
|
});
|
|
136
243
|
|
|
137
244
|
client.on("close", () => {
|
|
@@ -209,6 +316,14 @@ class SshMimic extends EventEmitter {
|
|
|
209
316
|
});
|
|
210
317
|
}
|
|
211
318
|
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Manually clears the rate-limit record for an IP address.
|
|
322
|
+
* Useful in tests or admin tooling.
|
|
323
|
+
*/
|
|
324
|
+
public clearLockout(ip: string): void {
|
|
325
|
+
this.authAttempts.delete(ip);
|
|
326
|
+
}
|
|
212
327
|
}
|
|
213
328
|
|
|
214
329
|
export { SftpMimic } from "./sftp";
|