@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.
@@ -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";