@ebowwa/terminal 0.2.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.
- package/dist/client.d.ts +15 -0
- package/dist/client.js +45 -0
- package/dist/error.d.ts +8 -0
- package/dist/error.js +12 -0
- package/dist/exec.d.ts +47 -0
- package/dist/exec.js +107 -0
- package/dist/files.d.ts +124 -0
- package/dist/files.js +436 -0
- package/dist/fingerprint.d.ts +67 -0
- package/dist/index.d.ts +17 -0
- package/dist/pool.d.ts +143 -0
- package/dist/pool.js +554 -0
- package/dist/pty.d.ts +59 -0
- package/dist/scp.d.ts +30 -0
- package/dist/scp.js +74 -0
- package/dist/sessions.d.ts +98 -0
- package/dist/tmux-exec.d.ts +50 -0
- package/dist/tmux.d.ts +213 -0
- package/dist/tmux.js +528 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +5 -0
- package/ebowwa-terminal-0.2.0.tgz +0 -0
- package/mcp/README.md +181 -0
- package/mcp/package.json +34 -0
- package/mcp/test-fix.sh +273 -0
- package/package.json +118 -0
- package/src/api.ts +752 -0
- package/src/client.ts +55 -0
- package/src/config.ts +489 -0
- package/src/error.ts +13 -0
- package/src/exec.ts +128 -0
- package/src/files.ts +636 -0
- package/src/fingerprint.ts +263 -0
- package/src/index.ts +144 -0
- package/src/manager.ts +319 -0
- package/src/mcp/index.ts +467 -0
- package/src/mcp/stdio.ts +708 -0
- package/src/network-error-detector.ts +121 -0
- package/src/pool.ts +662 -0
- package/src/pty.ts +285 -0
- package/src/scp.ts +109 -0
- package/src/sessions.ts +861 -0
- package/src/tmux-exec.ts +96 -0
- package/src/tmux-local.ts +839 -0
- package/src/tmux-manager.ts +962 -0
- package/src/tmux.ts +711 -0
- package/src/types.ts +19 -0
package/dist/files.js
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote file operations via SSH
|
|
3
|
+
*/
|
|
4
|
+
import { execSSH } from "./client.js";
|
|
5
|
+
import { SSHError } from "./error.js";
|
|
6
|
+
/**
|
|
7
|
+
* Path traversal security error
|
|
8
|
+
*/
|
|
9
|
+
export class PathTraversalError extends SSHError {
|
|
10
|
+
attemptedPath;
|
|
11
|
+
reason;
|
|
12
|
+
constructor(message, attemptedPath, reason) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.attemptedPath = attemptedPath;
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.name = "PathTraversalError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const securityEvents = [];
|
|
20
|
+
const MAX_SECURITY_EVENTS = 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Log a security event
|
|
23
|
+
*/
|
|
24
|
+
function logSecurityEvent(attemptedPath, reason, severity) {
|
|
25
|
+
const event = {
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
attemptedPath,
|
|
28
|
+
reason,
|
|
29
|
+
severity,
|
|
30
|
+
};
|
|
31
|
+
securityEvents.push(event);
|
|
32
|
+
// Keep only recent events
|
|
33
|
+
if (securityEvents.length > MAX_SECURITY_EVENTS) {
|
|
34
|
+
securityEvents.shift();
|
|
35
|
+
}
|
|
36
|
+
// Log to console with appropriate severity
|
|
37
|
+
const logPrefix = {
|
|
38
|
+
blocked: "[SECURITY BLOCKED]",
|
|
39
|
+
suspicious: "[SECURITY SUSPICIOUS]",
|
|
40
|
+
warning: "[SECURITY WARNING]",
|
|
41
|
+
}[severity];
|
|
42
|
+
console.error(`${logPrefix} Path traversal attempt detected:`, JSON.stringify(event));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Get recent security events for monitoring
|
|
46
|
+
*/
|
|
47
|
+
export function getSecurityEvents(limit = 50) {
|
|
48
|
+
return securityEvents.slice(-limit);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Clear old security events (for maintenance)
|
|
52
|
+
*/
|
|
53
|
+
export function clearSecurityEvents(olderThanMs = 24 * 60 * 60 * 1000) {
|
|
54
|
+
const cutoff = Date.now() - olderThanMs;
|
|
55
|
+
const initialLength = securityEvents.length;
|
|
56
|
+
for (let i = securityEvents.length - 1; i >= 0; i--) {
|
|
57
|
+
const eventTime = new Date(securityEvents[i].timestamp).getTime();
|
|
58
|
+
if (eventTime < cutoff) {
|
|
59
|
+
securityEvents.splice(i, 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return initialLength - securityEvents.length;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Normalize a path by resolving . and removing redundant slashes
|
|
66
|
+
* Does NOT resolve .. (those are checked separately)
|
|
67
|
+
*/
|
|
68
|
+
function normalizePath(path) {
|
|
69
|
+
// Remove redundant slashes
|
|
70
|
+
let normalized = path.replace(/\/+/g, "/");
|
|
71
|
+
// Remove trailing slash (unless it's just "/")
|
|
72
|
+
if (normalized.length > 1 && normalized.endsWith("/")) {
|
|
73
|
+
normalized = normalized.slice(0, -1);
|
|
74
|
+
}
|
|
75
|
+
// Resolve single dots (current directory)
|
|
76
|
+
normalized = normalized.replace(/\/\.\//g, "/").replace(/\/\.$/, "");
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Check if a path contains parent directory references
|
|
81
|
+
*/
|
|
82
|
+
function hasParentDirReference(path) {
|
|
83
|
+
// Check for .. in path components
|
|
84
|
+
const parts = path.split("/");
|
|
85
|
+
return parts.some((part) => part === ".." || part.includes("\\.."));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Check if path attempts to escape using null bytes
|
|
89
|
+
*/
|
|
90
|
+
function hasNullByte(path) {
|
|
91
|
+
return path.includes("\0");
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Calculate path depth (number of directories)
|
|
95
|
+
*/
|
|
96
|
+
function calculatePathDepth(path) {
|
|
97
|
+
return path.split("/").filter((p) => p.length > 0).length;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Validate that a resolved path stays within allowed base directories
|
|
101
|
+
*/
|
|
102
|
+
function validatePathInAllowedDirs(resolvedPath, allowedDirs) {
|
|
103
|
+
// Normalize all paths for comparison
|
|
104
|
+
const normalizedResolved = normalizePath(resolvedPath);
|
|
105
|
+
const normalizedAllowed = allowedDirs.map(normalizePath);
|
|
106
|
+
// Check if resolved path starts with any allowed directory
|
|
107
|
+
return normalizedAllowed.some((allowedDir) => {
|
|
108
|
+
// Ensure allowed directory ends with / for proper prefix matching
|
|
109
|
+
const prefix = allowedDir.endsWith("/") ? allowedDir : allowedDir + "/";
|
|
110
|
+
return (normalizedResolved === allowedDir || normalizedResolved.startsWith(prefix));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Resolve a path relative to a base directory
|
|
115
|
+
* Throws if the result would escape the base directory
|
|
116
|
+
*/
|
|
117
|
+
function resolveRelativePath(baseDir, inputPath) {
|
|
118
|
+
const normalizedBase = normalizePath(baseDir);
|
|
119
|
+
const normalizedInput = normalizePath(inputPath);
|
|
120
|
+
// If input is absolute, reject (unless allowed by options)
|
|
121
|
+
if (normalizedInput.startsWith("/")) {
|
|
122
|
+
throw new Error("Absolute paths are not allowed");
|
|
123
|
+
}
|
|
124
|
+
// Build full path
|
|
125
|
+
let fullPath = normalizedBase + "/" + normalizedInput;
|
|
126
|
+
fullPath = normalizePath(fullPath);
|
|
127
|
+
// Check for parent directory references in the full path
|
|
128
|
+
if (hasParentDirReference(fullPath)) {
|
|
129
|
+
throw new Error("Path contains parent directory references");
|
|
130
|
+
}
|
|
131
|
+
// Verify the path is still within base directory
|
|
132
|
+
if (!fullPath.startsWith(normalizedBase + "/") && fullPath !== normalizedBase) {
|
|
133
|
+
throw new Error("Path escapes allowed directory");
|
|
134
|
+
}
|
|
135
|
+
return fullPath;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Sanitize and validate a file path for security
|
|
139
|
+
*
|
|
140
|
+
* This function prevents path traversal attacks by:
|
|
141
|
+
* 1. Rejecting paths with .. components
|
|
142
|
+
* 2. Validating against allowed base directories
|
|
143
|
+
* 3. Normalizing paths to remove . and redundant /
|
|
144
|
+
* 4. Checking for null bytes and other escape sequences
|
|
145
|
+
* 5. Limiting path depth to prevent deep traversal
|
|
146
|
+
*
|
|
147
|
+
* @param inputPath - The user-provided path to sanitize
|
|
148
|
+
* @param options - Sanitization options
|
|
149
|
+
* @returns Sanitized absolute path
|
|
150
|
+
* @throws PathTraversalError if path is suspicious or invalid
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* // Safe: within /root
|
|
155
|
+
* sanitizePath("project/file.txt", { user: "root" })
|
|
156
|
+
* // Returns: "/root/project/file.txt"
|
|
157
|
+
*
|
|
158
|
+
* // BLOCKED: attempts to escape
|
|
159
|
+
* sanitizePath("../../../etc/passwd", { user: "root" })
|
|
160
|
+
* // Throws: PathTraversalError
|
|
161
|
+
*
|
|
162
|
+
* // BLOCKED: null byte injection
|
|
163
|
+
* sanitizePath("file.txt\0../../../etc/passwd", { user: "root" })
|
|
164
|
+
* // Throws: PathTraversalError
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export function sanitizePath(inputPath, options = {}) {
|
|
168
|
+
const { allowedBaseDirs, user = "root", allowAbsolutePaths = false, maxDepth = 20, logSuspicious = true, } = options;
|
|
169
|
+
// Determine default allowed base directories based on user
|
|
170
|
+
const defaultAllowedDirs = user === "root"
|
|
171
|
+
? ["/root"]
|
|
172
|
+
: [`/home/${user}`, "/tmp"];
|
|
173
|
+
const finalAllowedDirs = allowedBaseDirs || defaultAllowedDirs;
|
|
174
|
+
// ===== SECURITY CHECKS =====
|
|
175
|
+
// 1. Check for null byte injection (CWE-158)
|
|
176
|
+
if (hasNullByte(inputPath)) {
|
|
177
|
+
const error = "Path contains null byte (possible injection attempt)";
|
|
178
|
+
if (logSuspicious) {
|
|
179
|
+
logSecurityEvent(inputPath, "Null byte injection detected", "blocked");
|
|
180
|
+
}
|
|
181
|
+
throw new PathTraversalError("Invalid path: contains null byte", inputPath, error);
|
|
182
|
+
}
|
|
183
|
+
// 2. Check for parent directory references (CWE-22)
|
|
184
|
+
if (hasParentDirReference(inputPath)) {
|
|
185
|
+
const error = "Path contains parent directory reference (..)";
|
|
186
|
+
if (logSuspicious) {
|
|
187
|
+
logSecurityEvent(inputPath, "Path traversal attempt detected", "blocked");
|
|
188
|
+
}
|
|
189
|
+
throw new PathTraversalError("Path traversal blocked: parent directory references not allowed", inputPath, error);
|
|
190
|
+
}
|
|
191
|
+
// 3. Check for backslashes (Windows path separator, potential escape)
|
|
192
|
+
if (inputPath.includes("\\")) {
|
|
193
|
+
const error = "Path contains backslashes";
|
|
194
|
+
if (logSuspicious) {
|
|
195
|
+
logSecurityEvent(inputPath, "Backslash in path detected", "suspicious");
|
|
196
|
+
}
|
|
197
|
+
throw new PathTraversalError("Invalid path: backslashes not allowed", inputPath, error);
|
|
198
|
+
}
|
|
199
|
+
// 4. Check for URL-encoded characters (possible bypass attempt)
|
|
200
|
+
if (/%2e|%2f|%5c/i.test(inputPath)) {
|
|
201
|
+
const error = "Path contains URL-encoded characters";
|
|
202
|
+
if (logSuspicious) {
|
|
203
|
+
logSecurityEvent(inputPath, "URL encoding detected in path", "suspicious");
|
|
204
|
+
}
|
|
205
|
+
throw new PathTraversalError("Invalid path: URL-encoded characters not allowed", inputPath, error);
|
|
206
|
+
}
|
|
207
|
+
// 5. Check path depth
|
|
208
|
+
const pathDepth = calculatePathDepth(inputPath);
|
|
209
|
+
if (pathDepth > maxDepth) {
|
|
210
|
+
const error = `Path depth ${pathDepth} exceeds maximum ${maxDepth}`;
|
|
211
|
+
if (logSuspicious) {
|
|
212
|
+
logSecurityEvent(inputPath, error, "suspicious");
|
|
213
|
+
}
|
|
214
|
+
throw new PathTraversalError("Path too deep: possible traversal attempt", inputPath, error);
|
|
215
|
+
}
|
|
216
|
+
// 6. Check for suspicious patterns
|
|
217
|
+
const suspiciousPatterns = [
|
|
218
|
+
/\.\.[\/\\]/, // ../ or ..\
|
|
219
|
+
/\/\.\./, // /..
|
|
220
|
+
/\.\.$/, // ends with ..
|
|
221
|
+
/^\.\./, // starts with ..
|
|
222
|
+
];
|
|
223
|
+
for (const pattern of suspiciousPatterns) {
|
|
224
|
+
if (pattern.test(inputPath)) {
|
|
225
|
+
const error = `Path matches suspicious pattern: ${pattern}`;
|
|
226
|
+
if (logSuspicious) {
|
|
227
|
+
logSecurityEvent(inputPath, error, "blocked");
|
|
228
|
+
}
|
|
229
|
+
throw new PathTraversalError("Path blocked: matches suspicious pattern", inputPath, error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// ===== PATH RESOLUTION =====
|
|
233
|
+
let sanitizedPath;
|
|
234
|
+
if (inputPath.startsWith("/")) {
|
|
235
|
+
// Handle absolute paths
|
|
236
|
+
if (!allowAbsolutePaths) {
|
|
237
|
+
const error = "Absolute paths are not allowed";
|
|
238
|
+
if (logSuspicious) {
|
|
239
|
+
logSecurityEvent(inputPath, error, "blocked");
|
|
240
|
+
}
|
|
241
|
+
throw new PathTraversalError(error + ": use relative paths only", inputPath, error);
|
|
242
|
+
}
|
|
243
|
+
// Normalize the absolute path
|
|
244
|
+
sanitizedPath = normalizePath(inputPath);
|
|
245
|
+
// Validate against allowed directories
|
|
246
|
+
if (!validatePathInAllowedDirs(sanitizedPath, finalAllowedDirs)) {
|
|
247
|
+
const error = `Absolute path not within allowed directories: ${finalAllowedDirs.join(", ")}`;
|
|
248
|
+
if (logSuspicious) {
|
|
249
|
+
logSecurityEvent(inputPath, error, "blocked");
|
|
250
|
+
}
|
|
251
|
+
throw new PathTraversalError("Access denied: path outside allowed directories", inputPath, error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
// Handle relative paths - resolve against each allowed base directory
|
|
256
|
+
// Use the first valid resolution
|
|
257
|
+
let resolved = false;
|
|
258
|
+
for (const baseDir of finalAllowedDirs) {
|
|
259
|
+
try {
|
|
260
|
+
sanitizedPath = resolveRelativePath(baseDir, inputPath);
|
|
261
|
+
resolved = true;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Try next base directory
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!resolved) {
|
|
270
|
+
const error = `Path cannot be resolved within allowed directories: ${finalAllowedDirs.join(", ")}`;
|
|
271
|
+
if (logSuspicious) {
|
|
272
|
+
logSecurityEvent(inputPath, error, "blocked");
|
|
273
|
+
}
|
|
274
|
+
throw new PathTraversalError("Access denied: path outside allowed directories", inputPath, error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Final validation
|
|
278
|
+
if (!sanitizedPath || sanitizedPath.length === 0) {
|
|
279
|
+
throw new PathTraversalError("Invalid path: resulted in empty path", inputPath, "Empty path after sanitization");
|
|
280
|
+
}
|
|
281
|
+
return sanitizedPath;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* List files in a directory on remote server
|
|
285
|
+
* @param path - Directory path to list (default: .)
|
|
286
|
+
* @param options - SSH connection options
|
|
287
|
+
* @returns List of files with metadata
|
|
288
|
+
* @throws PathTraversalError if path attempts to escape allowed directories
|
|
289
|
+
* @throws SSHError if SSH command fails
|
|
290
|
+
*/
|
|
291
|
+
export async function listFiles(path = ".", options) {
|
|
292
|
+
const { host, user = "root", timeout = 5 } = options;
|
|
293
|
+
// SECURITY: Sanitize path to prevent directory traversal attacks
|
|
294
|
+
let sanitizedPath;
|
|
295
|
+
try {
|
|
296
|
+
sanitizedPath = sanitizePath(path, {
|
|
297
|
+
user,
|
|
298
|
+
allowAbsolutePaths: false,
|
|
299
|
+
logSuspicious: true,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (error instanceof PathTraversalError) {
|
|
304
|
+
// Re-throw path traversal errors with security context
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
throw new SSHError(`Failed to sanitize path: ${path}`, error);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
// Use sanitized path in command
|
|
311
|
+
const command = `ls -la "${sanitizedPath}" 2>/dev/null || echo "FAILED"`;
|
|
312
|
+
const output = await execSSH(command, { host, user, timeout });
|
|
313
|
+
if (output === "FAILED" || output === "0") {
|
|
314
|
+
throw new SSHError(`Failed to list directory: ${sanitizedPath} (original: ${path})`);
|
|
315
|
+
}
|
|
316
|
+
const lines = output.split("\n").slice(1); // Skip first line (total)
|
|
317
|
+
const files = [];
|
|
318
|
+
for (const line of lines) {
|
|
319
|
+
if (!line.trim())
|
|
320
|
+
continue;
|
|
321
|
+
const parts = line.split(/\s+/);
|
|
322
|
+
if (parts.length < 8)
|
|
323
|
+
continue;
|
|
324
|
+
const permissions = parts[0];
|
|
325
|
+
const isDir = permissions.startsWith("d");
|
|
326
|
+
const fileName = parts[8]?.split(" ->")[0] || parts[8]; // Handle symlinks
|
|
327
|
+
// Build file path based on sanitized base path
|
|
328
|
+
const filePath = `${sanitizedPath}/${fileName}`.replace(/\/\//g, "/");
|
|
329
|
+
files.push({
|
|
330
|
+
name: fileName,
|
|
331
|
+
path: filePath,
|
|
332
|
+
size: isDir ? "-" : parts[4],
|
|
333
|
+
modified: parts[5] +
|
|
334
|
+
" " +
|
|
335
|
+
parts[6] +
|
|
336
|
+
" " +
|
|
337
|
+
parts[7]?.split(".").slice(0, 2).join(":") || "",
|
|
338
|
+
type: isDir ? "directory" : "file",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return files;
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
throw new SSHError(`Failed to list files: ${path}`, error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Preview a file's content from remote server
|
|
349
|
+
* @param filePath - Path to the file to preview
|
|
350
|
+
* @param options - SSH connection options
|
|
351
|
+
* @returns File content for preview
|
|
352
|
+
* @throws PathTraversalError if path attempts to escape allowed directories
|
|
353
|
+
* @throws SSHError if SSH command fails
|
|
354
|
+
*/
|
|
355
|
+
export async function previewFile(filePath, options) {
|
|
356
|
+
const { host, user = "root", timeout = 10 } = options;
|
|
357
|
+
// SECURITY: Sanitize path to prevent directory traversal attacks
|
|
358
|
+
let absolutePath;
|
|
359
|
+
try {
|
|
360
|
+
absolutePath = sanitizePath(filePath, {
|
|
361
|
+
user,
|
|
362
|
+
allowAbsolutePaths: false,
|
|
363
|
+
logSuspicious: true,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (error instanceof PathTraversalError) {
|
|
368
|
+
// Re-throw path traversal errors with security context
|
|
369
|
+
return {
|
|
370
|
+
type: "error",
|
|
371
|
+
error: "Access denied: path outside allowed directories",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return { type: "error", error: "Failed to validate file path" };
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
// Check if file exists (using sanitized path)
|
|
378
|
+
const checkCmd = `test -f "${absolutePath}" && echo "EXISTS" || echo "NOFILE"`;
|
|
379
|
+
const checkOutput = await execSSH(checkCmd, { host, user, timeout });
|
|
380
|
+
if (checkOutput !== "EXISTS") {
|
|
381
|
+
return { type: "error", error: "File not found" };
|
|
382
|
+
}
|
|
383
|
+
// Try to read file content to determine if it's text or binary
|
|
384
|
+
const readCmd = `cat "${absolutePath}" 2>/dev/null || echo "READFAIL"`;
|
|
385
|
+
const content = await execSSH(readCmd, { host, user, timeout: 15 });
|
|
386
|
+
if (content === "READFAIL" || content === "0") {
|
|
387
|
+
return { type: "error", error: "Failed to read file content" };
|
|
388
|
+
}
|
|
389
|
+
// Determine if content is text or binary by checking for null bytes and non-printable characters
|
|
390
|
+
const hasNullBytes = content.includes("\u0000");
|
|
391
|
+
// Count non-printable ASCII characters (excluding whitespace and common text chars)
|
|
392
|
+
const nonPrintableCount = (content.match(/[\x00-\x08\x0E-\x1F]/g) || [])
|
|
393
|
+
.length;
|
|
394
|
+
const isLikelyBinary = hasNullBytes ||
|
|
395
|
+
(content.length > 0 && nonPrintableCount / content.length > 0.3);
|
|
396
|
+
// Check for image file extensions
|
|
397
|
+
const imageExtensions = [
|
|
398
|
+
".png",
|
|
399
|
+
".jpg",
|
|
400
|
+
".jpeg",
|
|
401
|
+
".gif",
|
|
402
|
+
".bmp",
|
|
403
|
+
".svg",
|
|
404
|
+
".webp",
|
|
405
|
+
".ico",
|
|
406
|
+
];
|
|
407
|
+
const isImage = imageExtensions.some((ext) => absolutePath.toLowerCase().endsWith(ext));
|
|
408
|
+
if (isImage) {
|
|
409
|
+
return { type: "image", content: "[Image file - download to view]" };
|
|
410
|
+
}
|
|
411
|
+
else if (isLikelyBinary) {
|
|
412
|
+
return { type: "binary", content: "[Binary file - download to view]" };
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// Text file - limit content size for preview
|
|
416
|
+
const maxPreviewSize = 10000; // 10KB max for preview
|
|
417
|
+
const trimmedContent = content.length > maxPreviewSize
|
|
418
|
+
? content.slice(0, maxPreviewSize) + "\n\n... (truncated)"
|
|
419
|
+
: content;
|
|
420
|
+
return {
|
|
421
|
+
type: "text",
|
|
422
|
+
content: trimmedContent,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
// Log the error for security auditing
|
|
428
|
+
console.error(`[File Preview Error] path="${filePath}", sanitized="${absolutePath}", error=${error}`);
|
|
429
|
+
// Return error without exposing internal details
|
|
430
|
+
if (error instanceof SSHError || error instanceof PathTraversalError) {
|
|
431
|
+
return { type: "error", error: "Access denied" };
|
|
432
|
+
}
|
|
433
|
+
return { type: "error", error: "Failed to preview file" };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH fingerprint utilities with validation and recovery
|
|
3
|
+
*/
|
|
4
|
+
import type { SSHOptions } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Get SSH fingerprint from remote server
|
|
7
|
+
* @param options - SSH connection options
|
|
8
|
+
* @returns SSH fingerprint or null
|
|
9
|
+
*/
|
|
10
|
+
export declare function getSSHFingerprint(options: SSHOptions): Promise<string | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Get SSH fingerprint from a local private key file
|
|
13
|
+
* @param keyPath - Path to the private key file
|
|
14
|
+
* @returns SSH fingerprint (SHA256 format) or null
|
|
15
|
+
*/
|
|
16
|
+
export declare function getLocalKeyFingerprint(keyPath: string): Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Convert MD5 fingerprint format to SHA256 format (for comparison)
|
|
19
|
+
* Hetzner returns MD5 like "29:cd:c1:c3:84:eb:ca:31:a4:1f:94:69:0c:84:b3:56"
|
|
20
|
+
* We need to handle both formats
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeFingerprint(fingerprint: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Validate that a local SSH key matches what's on a remote server
|
|
25
|
+
* @param host - Server hostname or IP
|
|
26
|
+
* @param keyPath - Path to local private key
|
|
27
|
+
* @returns Validation result
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateSSHKeyMatch(host: string, keyPath: string): Promise<{
|
|
30
|
+
valid: boolean;
|
|
31
|
+
localFingerprint?: string;
|
|
32
|
+
remoteFingerprint?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Check if we can SSH to a server with a given key
|
|
37
|
+
* @param host - Server hostname or IP
|
|
38
|
+
* @param keyPath - Path to SSH private key
|
|
39
|
+
* @returns true if SSH works
|
|
40
|
+
*/
|
|
41
|
+
export declare function testSSHKeyConnection(host: string, keyPath: string): Promise<boolean>;
|
|
42
|
+
/**
|
|
43
|
+
* SSH Key Mismatch Error with recovery suggestions
|
|
44
|
+
*/
|
|
45
|
+
export declare class SSHKeyMismatchError extends Error {
|
|
46
|
+
host: string;
|
|
47
|
+
localFingerprint: string;
|
|
48
|
+
hetznerFingerprint: string;
|
|
49
|
+
keyPath: string;
|
|
50
|
+
constructor(host: string, localFingerprint: string, hetznerFingerprint: string, keyPath: string);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Comprehensive SSH key validation for server creation
|
|
54
|
+
* @param host - Server hostname or IP
|
|
55
|
+
* @param keyPath - Path to local SSH key
|
|
56
|
+
* @param hetznerKeyId - SSH key ID on Hetzner (for comparison)
|
|
57
|
+
* @returns Validation result with recovery suggestions
|
|
58
|
+
*/
|
|
59
|
+
export declare function validateSSHKeyForServer(host: string, keyPath: string, hetznerKeyId?: string): Promise<{
|
|
60
|
+
canConnect: boolean;
|
|
61
|
+
fingerprintMatch: boolean;
|
|
62
|
+
localFingerprint?: string;
|
|
63
|
+
remoteFingerprint?: string;
|
|
64
|
+
error?: string;
|
|
65
|
+
recovery?: string[];
|
|
66
|
+
}>;
|
|
67
|
+
//# sourceMappingURL=fingerprint.d.ts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH utility library - modular entry point
|
|
3
|
+
*/
|
|
4
|
+
export type { SSHOptions, SCPOptions } from "./types.js";
|
|
5
|
+
export { SSHError } from "./error.js";
|
|
6
|
+
export { execSSH } from "./client.js";
|
|
7
|
+
export { execSSHParallel, testSSHConnection } from "./exec.js";
|
|
8
|
+
export { execViaTmux, execViaTmuxParallel } from "./tmux-exec.js";
|
|
9
|
+
export { scpUpload, scpDownload } from "./scp.js";
|
|
10
|
+
export { listFiles, previewFile, sanitizePath, PathTraversalError, getSecurityEvents, clearSecurityEvents, type FileType, type RemoteFile, type PreviewType, type FilePreview, type SanitizePathOptions, } from "./files.js";
|
|
11
|
+
export { getSSHFingerprint, getLocalKeyFingerprint, normalizeFingerprint, validateSSHKeyMatch, testSSHKeyConnection, validateSSHKeyForServer, SSHKeyMismatchError } from "./fingerprint.js";
|
|
12
|
+
export { createPTYSession, writeToPTY, setPTYSize, readFromPTY, closePTYSession, getPTYSession, getActivePTYSessions, } from "./pty.js";
|
|
13
|
+
export { getSSHPool, closeGlobalSSHPool, getActiveSSHConnections, SSHConnectionPool, } from "./pool.js";
|
|
14
|
+
export { closeSession, cleanupStaleSessions, getOrCreateSession, getSession, getAllSessions, getAllSessionInfo, getSessionInfo, getSessionCount, getSessionsByHost, attachWebSocket, writeToSession, resizeSession, detachWebSocket, } from "./sessions.js";
|
|
15
|
+
export type { TerminalSession, SessionInfo } from "./sessions.js";
|
|
16
|
+
export { generateSessionName, isTmuxInstalled, installTmux, ensureTmux, listTmuxSessions, hasTmuxSession, createOrAttachTmuxSession, killTmuxSession, getTmuxSessionInfo, cleanupOldTmuxSessions, getTmuxResourceUsage, sendCommandToPane, splitPane, listSessionWindows, listWindowPanes, capturePane, getPaneHistory, switchWindow, switchPane, renameWindow, killPane, getDetailedSessionInfo, } from "./tmux.js";
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Connection Pool Manager
|
|
3
|
+
* Maintains persistent SSH connections for reuse across commands
|
|
4
|
+
*/
|
|
5
|
+
import { NodeSSH } from 'node-ssh';
|
|
6
|
+
import type { SSHOptions } from './types.js';
|
|
7
|
+
/**
|
|
8
|
+
* Connection pool configuration
|
|
9
|
+
*/
|
|
10
|
+
interface PoolConfig {
|
|
11
|
+
/** Maximum number of connections to keep alive across all hosts */
|
|
12
|
+
maxConnections: number;
|
|
13
|
+
/** Maximum number of connections per host (for parallel execution) */
|
|
14
|
+
maxConnectionsPerHost: number;
|
|
15
|
+
/** Idle timeout in milliseconds (default: 5 minutes) */
|
|
16
|
+
idleTimeout: number;
|
|
17
|
+
/** Connection timeout in milliseconds (default: 10 seconds) */
|
|
18
|
+
connectionTimeout: number;
|
|
19
|
+
/** Keep alive interval in milliseconds (default: 30 seconds) */
|
|
20
|
+
keepAliveInterval: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* SSH Connection Pool Class
|
|
24
|
+
*/
|
|
25
|
+
export declare class SSHConnectionPool {
|
|
26
|
+
private connections;
|
|
27
|
+
private config;
|
|
28
|
+
private cleanupInterval;
|
|
29
|
+
private nextId;
|
|
30
|
+
constructor(config?: Partial<PoolConfig>);
|
|
31
|
+
/**
|
|
32
|
+
* Generate a unique key for the connection (host-based)
|
|
33
|
+
*/
|
|
34
|
+
private getKey;
|
|
35
|
+
/**
|
|
36
|
+
* Get all connections for a given host
|
|
37
|
+
*/
|
|
38
|
+
private getConnectionsList;
|
|
39
|
+
/**
|
|
40
|
+
* Get or create a connection (returns least recently used connection)
|
|
41
|
+
*/
|
|
42
|
+
getConnection(options: SSHOptions): Promise<NodeSSH>;
|
|
43
|
+
/**
|
|
44
|
+
* Get or create a connection using password authentication
|
|
45
|
+
*/
|
|
46
|
+
getConnectionWithPassword(host: string, user: string, password: string, port?: number): Promise<NodeSSH>;
|
|
47
|
+
/**
|
|
48
|
+
* Get or create multiple connections for parallel execution
|
|
49
|
+
* @param options - SSH connection options
|
|
50
|
+
* @param count - Number of connections to retrieve
|
|
51
|
+
* @returns Array of SSH connections
|
|
52
|
+
*/
|
|
53
|
+
getConnections(options: SSHOptions, count: number): Promise<NodeSSH[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Create a new SSH connection
|
|
56
|
+
* Tries key-based auth first, then password auth, then SSH agent
|
|
57
|
+
*/
|
|
58
|
+
private createConnection;
|
|
59
|
+
/**
|
|
60
|
+
* Get total number of connections across all hosts
|
|
61
|
+
*/
|
|
62
|
+
private getTotalConnectionCount;
|
|
63
|
+
/**
|
|
64
|
+
* Execute a command using a pooled connection
|
|
65
|
+
*
|
|
66
|
+
* ERROR HANDLING BEHAVIOR:
|
|
67
|
+
* =========================
|
|
68
|
+
* If result.stderr exists AND result.stdout is empty, we throw an error.
|
|
69
|
+
* This is intentional - commands that fail should return fallback values
|
|
70
|
+
* via shell redirection (e.g., `|| echo "0"` or `2>/dev/null`).
|
|
71
|
+
*
|
|
72
|
+
* Example of proper fallback handling:
|
|
73
|
+
* `type nvidia-smi 2>/dev/null && nvidia-smi ... || echo NOGPU`
|
|
74
|
+
*
|
|
75
|
+
* This ensures commands don't silently fail - they must handle their own
|
|
76
|
+
* error cases and return sensible defaults.
|
|
77
|
+
*/
|
|
78
|
+
exec(command: string, options: SSHOptions): Promise<string>;
|
|
79
|
+
/**
|
|
80
|
+
* Check if a connection exists and is alive for a given host
|
|
81
|
+
*/
|
|
82
|
+
hasConnection(options: SSHOptions): Promise<boolean>;
|
|
83
|
+
/**
|
|
84
|
+
* Close a specific connection by SSH instance
|
|
85
|
+
*/
|
|
86
|
+
private closeConnectionInstance;
|
|
87
|
+
/**
|
|
88
|
+
* Close all connections for a specific host
|
|
89
|
+
*/
|
|
90
|
+
closeConnection(options: SSHOptions): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Evict the oldest connection from the pool
|
|
93
|
+
*/
|
|
94
|
+
private evictOldest;
|
|
95
|
+
/**
|
|
96
|
+
* Clean up idle connections
|
|
97
|
+
*/
|
|
98
|
+
private cleanupIdle;
|
|
99
|
+
/**
|
|
100
|
+
* Start periodic cleanup
|
|
101
|
+
*/
|
|
102
|
+
private startCleanup;
|
|
103
|
+
/**
|
|
104
|
+
* Stop cleanup interval
|
|
105
|
+
*/
|
|
106
|
+
private stopCleanup;
|
|
107
|
+
/**
|
|
108
|
+
* Close all connections and stop cleanup
|
|
109
|
+
*/
|
|
110
|
+
closeAll(): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Get pool statistics
|
|
113
|
+
*/
|
|
114
|
+
getStats(): {
|
|
115
|
+
totalConnections: number;
|
|
116
|
+
connections: Array<{
|
|
117
|
+
host: string;
|
|
118
|
+
port: number;
|
|
119
|
+
user: string;
|
|
120
|
+
lastUsed: Date;
|
|
121
|
+
idleMs: number;
|
|
122
|
+
id: string;
|
|
123
|
+
}>;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Check if a host has an active connection
|
|
127
|
+
*/
|
|
128
|
+
isConnected(host: string, user?: string, port?: number): boolean;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get the global connection pool instance
|
|
132
|
+
*/
|
|
133
|
+
export declare function getSSHPool(config?: Partial<PoolConfig>): SSHConnectionPool;
|
|
134
|
+
/**
|
|
135
|
+
* Close the global pool (for cleanup/shutdown)
|
|
136
|
+
*/
|
|
137
|
+
export declare function closeGlobalSSHPool(): Promise<void>;
|
|
138
|
+
/**
|
|
139
|
+
* Get active SSH connections (for monitoring)
|
|
140
|
+
*/
|
|
141
|
+
export declare function getActiveSSHConnections(): ReturnType<SSHConnectionPool['getStats']>;
|
|
142
|
+
export {};
|
|
143
|
+
//# sourceMappingURL=pool.d.ts.map
|