hackerrun 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.
@@ -0,0 +1,87 @@
1
+ import { platform } from 'os';
2
+ import { existsSync } from 'fs';
3
+ import chalk from 'chalk';
4
+
5
+ export class PlatformDetector {
6
+ /**
7
+ * Check if running on Windows
8
+ */
9
+ static isWindows(): boolean {
10
+ return platform() === 'win32';
11
+ }
12
+
13
+ /**
14
+ * Check if running inside WSL (Windows Subsystem for Linux)
15
+ */
16
+ static isWSL(): boolean {
17
+ if (!this.isWindows()) {
18
+ // Not Windows, check if we're Linux running under WSL
19
+ if (platform() === 'linux') {
20
+ // WSL has /proc/version with "Microsoft" or "WSL"
21
+ try {
22
+ const fs = require('fs');
23
+ const procVersion = fs.readFileSync('/proc/version', 'utf8').toLowerCase();
24
+ return procVersion.includes('microsoft') || procVersion.includes('wsl');
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ /**
35
+ * Check if platform is supported for hackerrun
36
+ */
37
+ static isSupported(): boolean {
38
+ return platform() === 'darwin' || platform() === 'linux' || this.isWSL();
39
+ }
40
+
41
+ /**
42
+ * Get platform name for display
43
+ */
44
+ static getPlatformName(): string {
45
+ if (this.isWSL()) return 'WSL (Windows Subsystem for Linux)';
46
+ if (platform() === 'win32') return 'Windows';
47
+ if (platform() === 'darwin') return 'macOS';
48
+ if (platform() === 'linux') return 'Linux';
49
+ return platform();
50
+ }
51
+
52
+ /**
53
+ * Ensure platform is supported, exit with helpful message if not
54
+ */
55
+ static ensureSupported(): void {
56
+ if (this.isWindows() && !this.isWSL()) {
57
+ console.log(chalk.yellow('\n⚠️ Windows detected\n'));
58
+ console.log('Hackerrun requires WSL (Windows Subsystem for Linux) to run.');
59
+ console.log('Uncloud and SSH tools work best in a Linux environment.\n');
60
+
61
+ console.log(chalk.cyan('How to set up WSL:\n'));
62
+ console.log('1. Open PowerShell as Administrator and run:');
63
+ console.log(chalk.bold(' wsl --install\n'));
64
+
65
+ console.log('2. Restart your computer\n');
66
+
67
+ console.log('3. Install Ubuntu from Microsoft Store (or use default Linux)\n');
68
+
69
+ console.log('4. Open WSL terminal and install hackerrun:');
70
+ console.log(chalk.bold(' npm install -g hackerrun\n'));
71
+
72
+ console.log('Learn more: https://docs.microsoft.com/en-us/windows/wsl/install\n');
73
+
74
+ console.log(chalk.red('Please install WSL and run hackerrun from WSL terminal.\n'));
75
+ process.exit(1);
76
+ }
77
+
78
+ if (!this.isSupported()) {
79
+ console.log(chalk.red(`\n❌ Platform '${platform()}' is not supported\n`));
80
+ console.log('Hackerrun supports:');
81
+ console.log(' - macOS');
82
+ console.log(' - Linux');
83
+ console.log(' - Windows (via WSL)\n');
84
+ process.exit(1);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,264 @@
1
+ // SSH Certificate Management for CLI
2
+ // Handles temporary keypair generation, certificate requests, and SSH agent management
3
+
4
+ import { execSync, spawnSync } from 'child_process';
5
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, chmodSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { tmpdir } from 'os';
8
+ import * as net from 'net';
9
+ import { PlatformClient } from './platform-client.js';
10
+
11
+ export interface SSHCertSession {
12
+ keyPath: string; // Path to private key
13
+ publicKey: string; // Public key content
14
+ certificate: string; // Signed certificate
15
+ certPath: string; // Path to certificate file
16
+ vmIp: string; // VM IPv6 address
17
+ cleanup: () => void; // Cleanup function
18
+ }
19
+
20
+ /**
21
+ * SSH Certificate Manager
22
+ * Handles temporary certificates for SSH access to app VMs
23
+ */
24
+ export class SSHCertManager {
25
+ private sessions: Map<string, SSHCertSession> = new Map();
26
+ private sshAgentStarted = false;
27
+
28
+ constructor(private platformClient: PlatformClient) {}
29
+
30
+ /**
31
+ * Get or create an SSH session for an app
32
+ * Generates temp keypair, gets certificate, adds to SSH agent
33
+ */
34
+ async getSession(appName: string, vmIp: string): Promise<SSHCertSession> {
35
+ // Check for existing valid session
36
+ const existingSession = this.sessions.get(appName);
37
+ if (existingSession && this.isSessionValid(existingSession)) {
38
+ return existingSession;
39
+ }
40
+
41
+ // Create new session
42
+ const session = await this.createSession(appName, vmIp);
43
+ this.sessions.set(appName, session);
44
+ return session;
45
+ }
46
+
47
+ /**
48
+ * Create a new SSH session with certificate
49
+ */
50
+ private async createSession(appName: string, vmIp: string): Promise<SSHCertSession> {
51
+ // Create temp directory for keys
52
+ const tmpDir = mkdtempSync(join(tmpdir(), 'hackerrun-ssh-'));
53
+ const keyPath = join(tmpDir, 'key');
54
+
55
+ try {
56
+ // Generate ED25519 keypair
57
+ execSync(`ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "hackerrun-temp"`, {
58
+ stdio: 'pipe',
59
+ });
60
+
61
+ const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
62
+
63
+ // Request certificate from platform
64
+ const { certificate } = await this.platformClient.requestSSHCertificate(appName, publicKey);
65
+
66
+ // Write certificate to file
67
+ const certPath = `${keyPath}-cert.pub`;
68
+ writeFileSync(certPath, certificate);
69
+
70
+ // Ensure private key has correct permissions
71
+ chmodSync(keyPath, 0o600);
72
+
73
+ // Add key+cert to SSH agent
74
+ await this.addToSSHAgent(keyPath);
75
+
76
+ const session: SSHCertSession = {
77
+ keyPath,
78
+ publicKey,
79
+ certificate,
80
+ certPath,
81
+ vmIp,
82
+ cleanup: () => {
83
+ // Remove from SSH agent
84
+ try {
85
+ execSync(`ssh-add -d "${keyPath}" 2>/dev/null`, { stdio: 'pipe' });
86
+ } catch {
87
+ // Ignore errors during cleanup
88
+ }
89
+ // Remove temp files
90
+ rmSync(tmpDir, { recursive: true, force: true });
91
+ this.sessions.delete(appName);
92
+ },
93
+ };
94
+
95
+ return session;
96
+ } catch (error) {
97
+ // Cleanup on error
98
+ rmSync(tmpDir, { recursive: true, force: true });
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Check if an SSH agent is running, start one if needed
105
+ */
106
+ private ensureSSHAgent(): void {
107
+ if (this.sshAgentStarted) return;
108
+
109
+ const agentPid = process.env.SSH_AGENT_PID;
110
+ const authSock = process.env.SSH_AUTH_SOCK;
111
+
112
+ if (agentPid && authSock && existsSync(authSock)) {
113
+ // Agent already running
114
+ this.sshAgentStarted = true;
115
+ return;
116
+ }
117
+
118
+ // Start SSH agent
119
+ try {
120
+ const result = execSync('ssh-agent -s', { encoding: 'utf-8' });
121
+
122
+ // Parse and set environment variables
123
+ const pidMatch = result.match(/SSH_AGENT_PID=(\d+)/);
124
+ const sockMatch = result.match(/SSH_AUTH_SOCK=([^;]+)/);
125
+
126
+ if (pidMatch && sockMatch) {
127
+ process.env.SSH_AGENT_PID = pidMatch[1];
128
+ process.env.SSH_AUTH_SOCK = sockMatch[1];
129
+ this.sshAgentStarted = true;
130
+ }
131
+ } catch {
132
+ // Agent might already be running in parent shell
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Add key and certificate to SSH agent
138
+ */
139
+ private async addToSSHAgent(keyPath: string): Promise<void> {
140
+ this.ensureSSHAgent();
141
+
142
+ try {
143
+ // Add key with certificate (SSH agent picks up the cert automatically if -cert.pub exists)
144
+ execSync(`ssh-add "${keyPath}"`, { stdio: 'pipe' });
145
+ } catch (error) {
146
+ throw new Error(`Failed to add key to SSH agent: ${(error as Error).message}`);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Check if a session is still valid (certificate not expired)
152
+ * Certificates have 5-minute TTL
153
+ */
154
+ private isSessionValid(session: SSHCertSession): boolean {
155
+ try {
156
+ // Check if files still exist
157
+ if (!existsSync(session.keyPath) || !existsSync(session.certPath)) {
158
+ return false;
159
+ }
160
+
161
+ // Use ssh-keygen to check certificate validity
162
+ const result = execSync(`ssh-keygen -L -f "${session.certPath}"`, { encoding: 'utf-8' });
163
+
164
+ // Check if certificate has expired by looking at Valid: line
165
+ const validMatch = result.match(/Valid: from .* to (.+)/);
166
+ if (!validMatch) return false;
167
+
168
+ // Parse expiry time
169
+ const expiryStr = validMatch[1].trim();
170
+ const expiry = new Date(expiryStr);
171
+
172
+ // Add 30 second buffer before expiry
173
+ const now = new Date();
174
+ now.setSeconds(now.getSeconds() + 30);
175
+
176
+ return expiry > now;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Get SSH command options for connecting with certificate
184
+ * Returns options that work with ssh+cli:// connector
185
+ */
186
+ getSSHOptions(session: SSHCertSession): string[] {
187
+ return [
188
+ '-o', 'StrictHostKeyChecking=no',
189
+ '-o', 'UserKnownHostsFile=/dev/null',
190
+ '-o', `IdentityFile=${session.keyPath}`,
191
+ '-o', `CertificateFile=${session.certPath}`,
192
+ ];
193
+ }
194
+
195
+ /**
196
+ * Format VM IP for uncloud --connect ssh+cli:// URL
197
+ * Note: Don't use brackets - uncloud passes this to SSH which expects raw addresses
198
+ */
199
+ formatConnectionURL(vmIp: string): string {
200
+ return `ssh+cli://root@${vmIp}`;
201
+ }
202
+
203
+ /**
204
+ * Clean up all sessions
205
+ */
206
+ cleanupAll(): void {
207
+ for (const session of this.sessions.values()) {
208
+ session.cleanup();
209
+ }
210
+ this.sessions.clear();
211
+ }
212
+ }
213
+
214
+ // Cache IPv6 connectivity results for the session
215
+ const ipv6ConnectivityCache = new Map<string, boolean>();
216
+
217
+ /**
218
+ * Test IPv6 connectivity with timeout
219
+ * Returns true if direct IPv6 connection is possible
220
+ * Results are cached for the session
221
+ */
222
+ export async function testIPv6Connectivity(vmIp: string, timeoutMs: number = 2000): Promise<boolean> {
223
+ // Check cache first
224
+ if (ipv6ConnectivityCache.has(vmIp)) {
225
+ return ipv6ConnectivityCache.get(vmIp)!;
226
+ }
227
+
228
+ // Quick TCP connect test (faster than SSH)
229
+ const result = await new Promise<boolean>((resolve) => {
230
+ const socket = new net.Socket();
231
+
232
+ const cleanup = () => {
233
+ socket.destroy();
234
+ };
235
+
236
+ socket.setTimeout(timeoutMs);
237
+ socket.on('connect', () => {
238
+ cleanup();
239
+ resolve(true);
240
+ });
241
+ socket.on('timeout', () => {
242
+ cleanup();
243
+ resolve(false);
244
+ });
245
+ socket.on('error', () => {
246
+ cleanup();
247
+ resolve(false);
248
+ });
249
+
250
+ socket.connect(22, vmIp);
251
+ });
252
+
253
+ ipv6ConnectivityCache.set(vmIp, result);
254
+ return result;
255
+ }
256
+
257
+ /**
258
+ * Get SSH proxy command for routing through gateway
259
+ * Used when direct IPv6 connectivity is not available
260
+ */
261
+ export function getGatewayProxyCommand(gatewayIp: string, vmIp: string): string {
262
+ // Use -W for netcat mode to proxy through gateway
263
+ return `ssh -W [${vmIp}]:22 root@${gatewayIp}`;
264
+ }
@@ -0,0 +1,342 @@
1
+ // Uncloud Command Runner
2
+ // Executes uncloud commands using SSH certificate authentication
3
+ // Replaces the old `uc -c <context>` approach with `uc --connect ssh+cli://`
4
+
5
+ import { execSync, spawnSync, spawn, ChildProcess } from 'child_process';
6
+ import * as net from 'net';
7
+ import { PlatformClient } from './platform-client.js';
8
+ import { SSHCertManager, testIPv6Connectivity } from './ssh-cert.js';
9
+ import chalk from 'chalk';
10
+
11
+ export interface UncloudRunnerOptions {
12
+ cwd?: string;
13
+ stdio?: 'inherit' | 'pipe';
14
+ timeout?: number;
15
+ }
16
+
17
+ interface TunnelInfo {
18
+ process: ChildProcess;
19
+ localPort: number;
20
+ }
21
+
22
+ /**
23
+ * UncloudRunner - Execute uncloud commands against app VMs
24
+ * Uses SSH certificates for authentication
25
+ */
26
+ export class UncloudRunner {
27
+ private certManager: SSHCertManager;
28
+ private gatewayCache: Map<string, { ipv4: string; ipv6: string } | null> = new Map();
29
+ private activeTunnels: Map<string, TunnelInfo> = new Map();
30
+ private tempConfigPath: string | null = null;
31
+
32
+ constructor(private platformClient: PlatformClient) {
33
+ this.certManager = new SSHCertManager(platformClient);
34
+ }
35
+
36
+ /**
37
+ * Get the connection URL for an app's primary VM
38
+ * Handles IPv6 direct connection or gateway proxy fallback
39
+ */
40
+ async getConnectionInfo(appName: string): Promise<{
41
+ url: string;
42
+ vmIp: string;
43
+ viaGateway: boolean;
44
+ localPort?: number;
45
+ }> {
46
+ // Get app info
47
+ const app = await this.platformClient.getApp(appName);
48
+ if (!app) {
49
+ throw new Error(`App '${appName}' not found`);
50
+ }
51
+
52
+ const primaryNode = app.nodes.find(n => n.isPrimary);
53
+ if (!primaryNode?.ipv6) {
54
+ throw new Error(`App '${appName}' has no primary node with IPv6 address`);
55
+ }
56
+
57
+ const vmIp = primaryNode.ipv6;
58
+
59
+ // Get or create SSH session with certificate (adds to agent)
60
+ await this.certManager.getSession(appName, vmIp);
61
+
62
+ // Test direct IPv6 connectivity (3 second timeout)
63
+ const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
64
+ const viaGateway = !canConnectDirect;
65
+
66
+ if (viaGateway) {
67
+ // Need to tunnel through gateway
68
+ // Start a local SSH tunnel: ssh -L localport:[vmip]:22 root@gateway
69
+ const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
70
+
71
+ // Use ssh:// through the tunnel (Go SSH library with agent)
72
+ return {
73
+ url: `ssh://root@localhost:${tunnelInfo.localPort}`,
74
+ vmIp,
75
+ viaGateway: true,
76
+ localPort: tunnelInfo.localPort,
77
+ };
78
+ }
79
+
80
+ // Direct IPv6 connection using ssh:// (Go SSH library with agent support)
81
+ // This is faster than ssh+cli:// because:
82
+ // - One persistent SSH connection (no per-operation process spawning)
83
+ // - Go's SSH library uses ssh-agent which has our temp certificate
84
+ // - Dialer() works for image push to unregistry
85
+ return {
86
+ url: `ssh://root@${vmIp}`,
87
+ vmIp,
88
+ viaGateway: false,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Pre-accept a host's SSH key by running ssh-keyscan
94
+ * This prevents "Host key verification failed" errors from uncloud
95
+ */
96
+ private preAcceptHostKey(host: string, port?: number): void {
97
+ try {
98
+ const portArg = port ? `-p ${port}` : '';
99
+ // Scan the host key and add to known_hosts
100
+ execSync(
101
+ `ssh-keyscan ${portArg} -H ${host} >> ~/.ssh/known_hosts 2>/dev/null`,
102
+ { stdio: 'pipe', timeout: 10000 }
103
+ );
104
+ } catch {
105
+ // Ignore errors - host might already be in known_hosts
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Ensure an SSH tunnel exists for gateway fallback
111
+ */
112
+ private async ensureTunnel(appName: string, vmIp: string, location: string): Promise<TunnelInfo> {
113
+ // Check for existing tunnel
114
+ const existing = this.activeTunnels.get(appName);
115
+ if (existing && !existing.process.killed) {
116
+ return existing;
117
+ }
118
+
119
+ // Get gateway
120
+ const gateway = await this.getGateway(location);
121
+ if (!gateway) {
122
+ throw new Error(`No gateway found for location ${location}`);
123
+ }
124
+
125
+ // Find an available port
126
+ const localPort = await this.findAvailablePort();
127
+
128
+ // Start SSH tunnel in background
129
+ // The SSH agent will provide the certificate for authentication
130
+ const tunnelProcess = spawn('ssh', [
131
+ '-N', // No remote command
132
+ '-L', `${localPort}:[${vmIp}]:22`, // Local port forward
133
+ '-o', 'StrictHostKeyChecking=no',
134
+ '-o', 'UserKnownHostsFile=/dev/null',
135
+ '-o', 'LogLevel=ERROR',
136
+ '-o', 'ExitOnForwardFailure=yes',
137
+ '-o', 'ServerAliveInterval=30',
138
+ `root@${gateway.ipv4}`,
139
+ ], {
140
+ detached: true,
141
+ stdio: 'pipe',
142
+ });
143
+
144
+ // Wait for tunnel to be established
145
+ await this.waitForTunnel(localPort);
146
+
147
+ const tunnelInfo: TunnelInfo = { process: tunnelProcess, localPort };
148
+ this.activeTunnels.set(appName, tunnelInfo);
149
+
150
+ return tunnelInfo;
151
+ }
152
+
153
+ /**
154
+ * Find an available local port
155
+ */
156
+ private async findAvailablePort(): Promise<number> {
157
+ return new Promise((resolve, reject) => {
158
+ const server = net.createServer();
159
+ server.listen(0, () => {
160
+ const port = server.address().port;
161
+ server.close(() => resolve(port));
162
+ });
163
+ server.on('error', reject);
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Wait for tunnel to be established
169
+ */
170
+ private async waitForTunnel(port: number, timeoutMs: number = 10000): Promise<void> {
171
+ const startTime = Date.now();
172
+
173
+ while (Date.now() - startTime < timeoutMs) {
174
+ try {
175
+ await new Promise<void>((resolve, reject) => {
176
+ const socket = net.createConnection(port, 'localhost', () => {
177
+ socket.destroy();
178
+ resolve();
179
+ });
180
+ socket.on('error', () => {
181
+ socket.destroy();
182
+ reject();
183
+ });
184
+ socket.setTimeout(500, () => {
185
+ socket.destroy();
186
+ reject();
187
+ });
188
+ });
189
+ return;
190
+ } catch {
191
+ await new Promise(r => setTimeout(r, 200));
192
+ }
193
+ }
194
+
195
+ throw new Error(`Tunnel failed to establish on port ${port}`);
196
+ }
197
+
198
+ /**
199
+ * Run an uncloud command on the app's VM
200
+ */
201
+ async run(
202
+ appName: string,
203
+ command: string,
204
+ args: string[] = [],
205
+ options: UncloudRunnerOptions = {}
206
+ ): Promise<string | void> {
207
+ // Get connection info (handles certificate and gateway tunnel if needed)
208
+ const connInfo = await this.getConnectionInfo(appName);
209
+
210
+ if (connInfo.viaGateway) {
211
+ console.log(chalk.dim(`Connecting via gateway...`));
212
+ }
213
+
214
+ // Build the uc command
215
+ const fullArgs = ['--connect', connInfo.url, command, ...args];
216
+
217
+ if (options.stdio === 'inherit') {
218
+ // For interactive commands, spawn with inherited stdio
219
+ const result = spawnSync('uc', fullArgs, {
220
+ cwd: options.cwd,
221
+ stdio: 'inherit',
222
+ timeout: options.timeout,
223
+ });
224
+
225
+ if (result.status !== 0) {
226
+ throw new Error(`Uncloud command failed with exit code ${result.status}`);
227
+ }
228
+ } else {
229
+ // For pipe mode, return output
230
+ const result = execSync(`uc ${fullArgs.map(a => `"${a}"`).join(' ')}`, {
231
+ cwd: options.cwd,
232
+ encoding: 'utf-8',
233
+ timeout: options.timeout,
234
+ });
235
+ return result;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Run 'uc deploy' for an app
241
+ */
242
+ async deploy(appName: string, cwd: string): Promise<void> {
243
+ await this.run(appName, 'deploy', ['--yes'], { cwd, stdio: 'inherit' });
244
+ }
245
+
246
+ /**
247
+ * Run 'uc service logs' for an app
248
+ */
249
+ async logs(
250
+ appName: string,
251
+ serviceName: string,
252
+ options: { follow?: boolean; tail?: number } = {}
253
+ ): Promise<void> {
254
+ const args = [serviceName];
255
+ if (options.follow) args.push('-f');
256
+ if (options.tail) args.push('--tail', String(options.tail));
257
+
258
+ await this.run(appName, 'service', ['logs', ...args], { stdio: 'inherit' });
259
+ }
260
+
261
+ /**
262
+ * Run 'uc service ls' for an app
263
+ */
264
+ async serviceList(appName: string): Promise<string> {
265
+ const result = await this.run(appName, 'service', ['ls'], { stdio: 'pipe' });
266
+ return result as string;
267
+ }
268
+
269
+ /**
270
+ * Run 'uc machine token' on remote VM via SSH
271
+ * This is different - it runs on the VM directly, not via uncloud connector
272
+ */
273
+ async getMachineToken(appName: string): Promise<string> {
274
+ // Get connection info (sets up certificate and tunnel if needed)
275
+ const connInfo = await this.getConnectionInfo(appName);
276
+
277
+ // For getting machine token, we need to SSH directly to the VM
278
+ let sshCmd: string;
279
+ if (connInfo.viaGateway && connInfo.localPort) {
280
+ // Use the tunnel
281
+ sshCmd = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
282
+ } else {
283
+ // Direct connection
284
+ sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
285
+ }
286
+
287
+ const token = execSync(`${sshCmd} "uc machine token"`, { encoding: 'utf-8' }).trim();
288
+ return token;
289
+ }
290
+
291
+ /**
292
+ * Execute an SSH command on the VM
293
+ */
294
+ async sshExec(appName: string, command: string): Promise<string> {
295
+ const connInfo = await this.getConnectionInfo(appName);
296
+
297
+ let sshCmd: string;
298
+ if (connInfo.viaGateway && connInfo.localPort) {
299
+ sshCmd = `ssh -p ${connInfo.localPort} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@localhost`;
300
+ } else {
301
+ sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@${connInfo.vmIp}`;
302
+ }
303
+
304
+ return execSync(`${sshCmd} "${command}"`, { encoding: 'utf-8' }).trim();
305
+ }
306
+
307
+ /**
308
+ * Get gateway info (cached)
309
+ */
310
+ private async getGateway(location: string): Promise<{ ipv4: string; ipv6: string } | null> {
311
+ if (this.gatewayCache.has(location)) {
312
+ return this.gatewayCache.get(location) || null;
313
+ }
314
+
315
+ const gateway = await this.platformClient.getGateway(location);
316
+ if (gateway) {
317
+ this.gatewayCache.set(location, { ipv4: gateway.ipv4, ipv6: gateway.ipv6 });
318
+ return { ipv4: gateway.ipv4, ipv6: gateway.ipv6 };
319
+ }
320
+
321
+ this.gatewayCache.set(location, null);
322
+ return null;
323
+ }
324
+
325
+ /**
326
+ * Clean up all SSH sessions and tunnels
327
+ */
328
+ cleanup(): void {
329
+ // Kill all active tunnels
330
+ for (const [appName, tunnel] of this.activeTunnels) {
331
+ try {
332
+ tunnel.process.kill();
333
+ } catch {
334
+ // Ignore errors during cleanup
335
+ }
336
+ }
337
+ this.activeTunnels.clear();
338
+
339
+ // Clean up SSH certificate sessions
340
+ this.certManager.cleanupAll();
341
+ }
342
+ }