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.
- package/.claude/settings.local.json +22 -0
- package/.env.example +9 -0
- package/CLAUDE.md +532 -0
- package/README.md +94 -0
- package/dist/index.js +2813 -0
- package/package.json +38 -0
- package/src/commands/app.ts +394 -0
- package/src/commands/builds.ts +314 -0
- package/src/commands/config.ts +129 -0
- package/src/commands/connect.ts +197 -0
- package/src/commands/deploy.ts +227 -0
- package/src/commands/env.ts +174 -0
- package/src/commands/login.ts +120 -0
- package/src/commands/logs.ts +97 -0
- package/src/index.ts +43 -0
- package/src/lib/app-config.ts +95 -0
- package/src/lib/cluster.ts +428 -0
- package/src/lib/config.ts +137 -0
- package/src/lib/platform-auth.ts +20 -0
- package/src/lib/platform-client.ts +637 -0
- package/src/lib/platform.ts +87 -0
- package/src/lib/ssh-cert.ts +264 -0
- package/src/lib/uncloud-runner.ts +342 -0
- package/src/lib/uncloud.ts +149 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +17 -0
|
@@ -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
|
+
}
|