@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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH fingerprint utilities with validation and recovery
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SSHOptions } from "./types.js";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
import { readFile } from "fs/promises";
|
|
9
|
+
|
|
10
|
+
const execAsync = promisify(require("child_process").exec);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get SSH fingerprint from remote server
|
|
14
|
+
* @param options - SSH connection options
|
|
15
|
+
* @returns SSH fingerprint or null
|
|
16
|
+
*/
|
|
17
|
+
export async function getSSHFingerprint(
|
|
18
|
+
options: SSHOptions,
|
|
19
|
+
): Promise<string | null> {
|
|
20
|
+
const { host, port = 22 } = options;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const proc = Bun.spawn(["ssh-keyscan", "-p", port.toString(), `${host}`], {
|
|
24
|
+
stdout: "pipe",
|
|
25
|
+
stderr: "pipe",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const output = await new Response(proc.stdout).text();
|
|
29
|
+
await proc.exited;
|
|
30
|
+
|
|
31
|
+
// Parse fingerprint from output
|
|
32
|
+
const lines = output.split("\n");
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
if (line.includes(host)) {
|
|
35
|
+
const parts = line.split(" ");
|
|
36
|
+
if (parts.length >= 3) {
|
|
37
|
+
return parts[2]; // Return the fingerprint
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get SSH fingerprint from a local private key file
|
|
49
|
+
* @param keyPath - Path to the private key file
|
|
50
|
+
* @returns SSH fingerprint (SHA256 format) or null
|
|
51
|
+
*/
|
|
52
|
+
export async function getLocalKeyFingerprint(
|
|
53
|
+
keyPath: string,
|
|
54
|
+
): Promise<string | null> {
|
|
55
|
+
try {
|
|
56
|
+
// Try ssh-keygen first (most reliable)
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await execAsync(`ssh-keygen -lf "${keyPath}"`);
|
|
59
|
+
const match = stdout.match(/SHA256:(\S+)/i);
|
|
60
|
+
if (match) {
|
|
61
|
+
return match[1];
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// ssh-keygen might not be available, try fallback
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fallback: Try to extract from the key file directly
|
|
68
|
+
const keyContent = await readFile(keyPath, "utf-8");
|
|
69
|
+
|
|
70
|
+
// For ED25519 keys, the public key is embedded
|
|
71
|
+
if (keyContent.includes("OPENSSH") && keyContent.includes("ssh-ed25519")) {
|
|
72
|
+
// Extract the public key part
|
|
73
|
+
const lines = keyContent.split("\n");
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (line.startsWith("ssh-ed25519")) {
|
|
76
|
+
// Calculate fingerprint from public key
|
|
77
|
+
const { stdout } = await execAsync(`echo '${line}' | ssh-keygen -lf -`);
|
|
78
|
+
const match = stdout.match(/SHA256:(\S+)/i);
|
|
79
|
+
if (match) {
|
|
80
|
+
return match[1];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error("[Fingerprint] Failed to get local key fingerprint:", error);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert MD5 fingerprint format to SHA256 format (for comparison)
|
|
95
|
+
* Hetzner returns MD5 like "29:cd:c1:c3:84:eb:ca:31:a4:1f:94:69:0c:84:b3:56"
|
|
96
|
+
* We need to handle both formats
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeFingerprint(fingerprint: string): string {
|
|
99
|
+
// Remove colons from MD5 format for easier comparison
|
|
100
|
+
return fingerprint.replace(/:/g, "").toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate that a local SSH key matches what's on a remote server
|
|
105
|
+
* @param host - Server hostname or IP
|
|
106
|
+
* @param keyPath - Path to local private key
|
|
107
|
+
* @returns Validation result
|
|
108
|
+
*/
|
|
109
|
+
export async function validateSSHKeyMatch(
|
|
110
|
+
host: string,
|
|
111
|
+
keyPath: string,
|
|
112
|
+
): Promise<{
|
|
113
|
+
valid: boolean;
|
|
114
|
+
localFingerprint?: string;
|
|
115
|
+
remoteFingerprint?: string;
|
|
116
|
+
error?: string;
|
|
117
|
+
}> {
|
|
118
|
+
try {
|
|
119
|
+
// Get local fingerprint
|
|
120
|
+
const localFingerprint = await getLocalKeyFingerprint(keyPath);
|
|
121
|
+
if (!localFingerprint) {
|
|
122
|
+
return {
|
|
123
|
+
valid: false,
|
|
124
|
+
error: "Could not read local SSH key fingerprint",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Get remote fingerprint
|
|
129
|
+
const remoteFingerprint = await getSSHFingerprint({ host });
|
|
130
|
+
if (!remoteFingerprint) {
|
|
131
|
+
return {
|
|
132
|
+
valid: false,
|
|
133
|
+
error: "Could not get remote SSH fingerprint",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Normalize both for comparison
|
|
138
|
+
const localNormalized = normalizeFingerprint(localFingerprint);
|
|
139
|
+
const remoteNormalized = normalizeFingerprint(remoteFingerprint);
|
|
140
|
+
|
|
141
|
+
const match = localNormalized === remoteNormalized ||
|
|
142
|
+
localFingerprint === remoteFingerprint ||
|
|
143
|
+
localFingerprint === remoteFingerprint.replace(/:/g, "");
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
valid: match,
|
|
147
|
+
localFingerprint: localFingerprint,
|
|
148
|
+
remoteFingerprint: remoteFingerprint,
|
|
149
|
+
error: match ? undefined : "Fingerprints do not match",
|
|
150
|
+
};
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return {
|
|
153
|
+
valid: false,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if we can SSH to a server with a given key
|
|
161
|
+
* @param host - Server hostname or IP
|
|
162
|
+
* @param keyPath - Path to SSH private key
|
|
163
|
+
* @returns true if SSH works
|
|
164
|
+
*/
|
|
165
|
+
export async function testSSHKeyConnection(
|
|
166
|
+
host: string,
|
|
167
|
+
keyPath: string,
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
try {
|
|
170
|
+
await execAsync(
|
|
171
|
+
`ssh -F /dev/null -i "${keyPath}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 ${host} "echo ok"`,
|
|
172
|
+
{ timeout: 10000 }
|
|
173
|
+
);
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* SSH Key Mismatch Error with recovery suggestions
|
|
182
|
+
*/
|
|
183
|
+
export class SSHKeyMismatchError extends Error {
|
|
184
|
+
constructor(
|
|
185
|
+
public host: string,
|
|
186
|
+
public localFingerprint: string,
|
|
187
|
+
public hetznerFingerprint: string,
|
|
188
|
+
public keyPath: string,
|
|
189
|
+
) {
|
|
190
|
+
const hetznerShort = hetznerFingerprint.split(":").slice(0, 4).join(":");
|
|
191
|
+
const localShort = localFingerprint.slice(0, 16);
|
|
192
|
+
|
|
193
|
+
super(
|
|
194
|
+
`SSH key mismatch for ${host}\n` +
|
|
195
|
+
` Local key (${keyPath}): ${localShort}...\n` +
|
|
196
|
+
` Hetzner key: ${hetznerShort}...\n\n` +
|
|
197
|
+
`The local private key doesn't match the public key on Hetzner.\n\n` +
|
|
198
|
+
`RECOVERY OPTIONS:\n` +
|
|
199
|
+
`1. Create a new SSH key and upload to Hetzner\n` +
|
|
200
|
+
`2. Regenerate local key to match Hetzner's key\n` +
|
|
201
|
+
`3. Use the correct key path for this server`
|
|
202
|
+
);
|
|
203
|
+
this.name = "SSHKeyMismatchError";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Comprehensive SSH key validation for server creation
|
|
209
|
+
* @param host - Server hostname or IP
|
|
210
|
+
* @param keyPath - Path to local SSH key
|
|
211
|
+
* @param hetznerKeyId - SSH key ID on Hetzner (for comparison)
|
|
212
|
+
* @returns Validation result with recovery suggestions
|
|
213
|
+
*/
|
|
214
|
+
export async function validateSSHKeyForServer(
|
|
215
|
+
host: string,
|
|
216
|
+
keyPath: string,
|
|
217
|
+
hetznerKeyId?: string,
|
|
218
|
+
): Promise<{
|
|
219
|
+
canConnect: boolean;
|
|
220
|
+
fingerprintMatch: boolean;
|
|
221
|
+
localFingerprint?: string;
|
|
222
|
+
remoteFingerprint?: string;
|
|
223
|
+
error?: string;
|
|
224
|
+
recovery?: string[];
|
|
225
|
+
}> {
|
|
226
|
+
const result: Awaited<ReturnType<typeof validateSSHKeyForServer>> = {
|
|
227
|
+
canConnect: false,
|
|
228
|
+
fingerprintMatch: false,
|
|
229
|
+
recovery: [],
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Test if SSH works at all
|
|
233
|
+
result.canConnect = await testSSHKeyConnection(host, keyPath);
|
|
234
|
+
|
|
235
|
+
// Get fingerprints for comparison
|
|
236
|
+
const validation = await validateSSHKeyMatch(host, keyPath);
|
|
237
|
+
result.localFingerprint = validation.localFingerprint;
|
|
238
|
+
result.remoteFingerprint = validation.remoteFingerprint;
|
|
239
|
+
result.fingerprintMatch = validation.valid;
|
|
240
|
+
|
|
241
|
+
if (!result.canConnect) {
|
|
242
|
+
result.error = "Cannot connect to server with this key";
|
|
243
|
+
result.recovery = [
|
|
244
|
+
"1. Check if the server is fully initialized (may still be booting)",
|
|
245
|
+
"2. Verify the SSH key was added to the server's ~/.ssh/authorized_keys",
|
|
246
|
+
"3. Try a different SSH key or regenerate the key pair",
|
|
247
|
+
];
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!result.fingerprintMatch) {
|
|
252
|
+
result.error = "SSH key fingerprint mismatch";
|
|
253
|
+
result.recovery = [
|
|
254
|
+
"1. Generate a new SSH key pair: `ssh-keygen -t ed25519 -f ~/.ssh/hetzner-new`",
|
|
255
|
+
"2. Upload public key to Hetzner (via GUI or API)",
|
|
256
|
+
"3. Update the server's metadata with the new key path",
|
|
257
|
+
`4. Alternatively: The Hetzner key ID ${hetznerKeyId} may need key regeneration`,
|
|
258
|
+
];
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return result;
|
|
263
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH utility library - modular entry point
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Tmux Session Manager (multi-node tmux management)
|
|
6
|
+
export {
|
|
7
|
+
TmuxSessionManager,
|
|
8
|
+
getTmuxManager,
|
|
9
|
+
resetTmuxManager,
|
|
10
|
+
type Node,
|
|
11
|
+
type TmuxSession,
|
|
12
|
+
type DetailedTmuxSession,
|
|
13
|
+
type BatchOperationResult,
|
|
14
|
+
type CreateSessionOptions,
|
|
15
|
+
type BatchCommandOptions,
|
|
16
|
+
type SessionQueryOptions,
|
|
17
|
+
} from "./tmux-manager.js";
|
|
18
|
+
|
|
19
|
+
// Types
|
|
20
|
+
export type { SSHOptions, SCPOptions } from "./types.js";
|
|
21
|
+
|
|
22
|
+
// Error
|
|
23
|
+
export { SSHError } from "./error.js";
|
|
24
|
+
|
|
25
|
+
// Core client
|
|
26
|
+
export { execSSH } from "./client.js";
|
|
27
|
+
|
|
28
|
+
// Command execution
|
|
29
|
+
export { execSSHParallel, testSSHConnection } from "./exec.js";
|
|
30
|
+
|
|
31
|
+
// Tmux-based command execution (consolidates SSH connections)
|
|
32
|
+
export { execViaTmux, execViaTmuxParallel } from "./tmux-exec.js";
|
|
33
|
+
|
|
34
|
+
// SCP operations
|
|
35
|
+
export { scpUpload, scpDownload } from "./scp.js";
|
|
36
|
+
|
|
37
|
+
// File operations
|
|
38
|
+
export {
|
|
39
|
+
listFiles,
|
|
40
|
+
previewFile,
|
|
41
|
+
sanitizePath,
|
|
42
|
+
PathTraversalError,
|
|
43
|
+
getSecurityEvents,
|
|
44
|
+
clearSecurityEvents,
|
|
45
|
+
type FileType,
|
|
46
|
+
type RemoteFile,
|
|
47
|
+
type PreviewType,
|
|
48
|
+
type FilePreview,
|
|
49
|
+
type SanitizePathOptions,
|
|
50
|
+
} from "./files.js";
|
|
51
|
+
|
|
52
|
+
// Fingerprint utilities
|
|
53
|
+
export { getSSHFingerprint, getLocalKeyFingerprint, normalizeFingerprint, validateSSHKeyMatch, testSSHKeyConnection, validateSSHKeyForServer, SSHKeyMismatchError } from "./fingerprint.js";
|
|
54
|
+
|
|
55
|
+
// PTY (interactive terminal) operations
|
|
56
|
+
export {
|
|
57
|
+
createPTYSession,
|
|
58
|
+
writeToPTY,
|
|
59
|
+
setPTYSize,
|
|
60
|
+
readFromPTY,
|
|
61
|
+
closePTYSession,
|
|
62
|
+
getPTYSession,
|
|
63
|
+
getActivePTYSessions,
|
|
64
|
+
} from "./pty.js";
|
|
65
|
+
|
|
66
|
+
// Connection pool management
|
|
67
|
+
export {
|
|
68
|
+
getSSHPool,
|
|
69
|
+
closeGlobalSSHPool,
|
|
70
|
+
getActiveSSHConnections,
|
|
71
|
+
SSHConnectionPool,
|
|
72
|
+
} from "./pool.js";
|
|
73
|
+
|
|
74
|
+
// Terminal session management
|
|
75
|
+
export {
|
|
76
|
+
closeSession,
|
|
77
|
+
cleanupStaleSessions,
|
|
78
|
+
getOrCreateSession,
|
|
79
|
+
getSession,
|
|
80
|
+
getAllSessions,
|
|
81
|
+
getAllSessionInfo,
|
|
82
|
+
getSessionInfo,
|
|
83
|
+
getSessionCount,
|
|
84
|
+
getSessionsByHost,
|
|
85
|
+
attachWebSocket,
|
|
86
|
+
writeToSession,
|
|
87
|
+
resizeSession,
|
|
88
|
+
detachWebSocket,
|
|
89
|
+
} from "./sessions.js";
|
|
90
|
+
|
|
91
|
+
export type { TerminalSession, SessionInfo } from "./sessions.js";
|
|
92
|
+
|
|
93
|
+
// Tmux session management (remote tmux on servers)
|
|
94
|
+
export {
|
|
95
|
+
generateSessionName,
|
|
96
|
+
isTmuxInstalled,
|
|
97
|
+
installTmux,
|
|
98
|
+
ensureTmux,
|
|
99
|
+
listTmuxSessions,
|
|
100
|
+
hasTmuxSession,
|
|
101
|
+
createOrAttachTmuxSession,
|
|
102
|
+
killTmuxSession,
|
|
103
|
+
getTmuxSessionInfo,
|
|
104
|
+
cleanupOldTmuxSessions,
|
|
105
|
+
getTmuxResourceUsage,
|
|
106
|
+
sendCommandToPane,
|
|
107
|
+
splitPane,
|
|
108
|
+
listSessionWindows,
|
|
109
|
+
listWindowPanes,
|
|
110
|
+
capturePane,
|
|
111
|
+
getPaneHistory,
|
|
112
|
+
switchWindow,
|
|
113
|
+
switchPane,
|
|
114
|
+
renameWindow,
|
|
115
|
+
killPane,
|
|
116
|
+
getDetailedSessionInfo,
|
|
117
|
+
} from "./tmux.js";
|
|
118
|
+
|
|
119
|
+
// Local tmux session management (tmux on local machine with persistent SSH)
|
|
120
|
+
export {
|
|
121
|
+
generateLocalSessionName,
|
|
122
|
+
isLocalTmuxInstalled,
|
|
123
|
+
listLocalSessions,
|
|
124
|
+
hasLocalSession,
|
|
125
|
+
createLocalTmuxSSHSession,
|
|
126
|
+
sendCommandToLocalSession,
|
|
127
|
+
captureLocalPane,
|
|
128
|
+
getLocalPaneHistory,
|
|
129
|
+
killLocalSession,
|
|
130
|
+
getLocalSessionInfo,
|
|
131
|
+
listLocalSessionWindows,
|
|
132
|
+
listLocalWindowPanes,
|
|
133
|
+
splitLocalPane,
|
|
134
|
+
cleanupOldLocalSessions,
|
|
135
|
+
getLocalTmuxResourceUsage,
|
|
136
|
+
waitForTextInPane,
|
|
137
|
+
switchLocalWindow,
|
|
138
|
+
switchLocalPane,
|
|
139
|
+
renameLocalWindow,
|
|
140
|
+
killLocalPane,
|
|
141
|
+
getDetailedLocalSessionInfo,
|
|
142
|
+
type LocalTmuxSessionOptions,
|
|
143
|
+
type LocalTmuxSessionResult,
|
|
144
|
+
} from "./tmux-local.js";
|