hackerrun 0.1.0 → 0.1.6

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.
@@ -2,10 +2,20 @@
2
2
  // Executes uncloud commands using SSH certificate authentication
3
3
  // Replaces the old `uc -c <context>` approach with `uc --connect ssh+cli://`
4
4
 
5
- import { execSync, spawnSync, spawn, ChildProcess } from 'child_process';
6
- import * as net from 'net';
5
+ import { execSync, spawnSync } from 'child_process';
7
6
  import { PlatformClient } from './platform-client.js';
8
7
  import { SSHCertManager, testIPv6Connectivity } from './ssh-cert.js';
8
+ import { TunnelManager, TunnelInfo } from './gateway-tunnel.js';
9
+ import {
10
+ VPNManager,
11
+ VPNConfig,
12
+ isWireGuardInstalled,
13
+ isVPNUp,
14
+ isRoutedViaVPN,
15
+ getOrCreateKeyPair,
16
+ testIPv6Connectivity as vpnTestIPv6,
17
+ getWireGuardInstallInstructions,
18
+ } from './vpn.js';
9
19
  import chalk from 'chalk';
10
20
 
11
21
  export interface UncloudRunnerOptions {
@@ -14,20 +24,18 @@ export interface UncloudRunnerOptions {
14
24
  timeout?: number;
15
25
  }
16
26
 
17
- interface TunnelInfo {
18
- process: ChildProcess;
19
- localPort: number;
20
- }
21
-
22
27
  /**
23
28
  * UncloudRunner - Execute uncloud commands against app VMs
24
29
  * Uses SSH certificates for authentication
30
+ * Uses WireGuard VPN for IPv6 connectivity when direct IPv6 is unavailable
25
31
  */
26
32
  export class UncloudRunner {
27
33
  private certManager: SSHCertManager;
28
34
  private gatewayCache: Map<string, { ipv4: string; ipv6: string } | null> = new Map();
29
- private activeTunnels: Map<string, TunnelInfo> = new Map();
35
+ private tunnelManager: TunnelManager = new TunnelManager();
36
+ private vpnManager: VPNManager = new VPNManager();
30
37
  private tempConfigPath: string | null = null;
38
+ private vpnEstablishedByUs: boolean = false;
31
39
 
32
40
  constructor(private platformClient: PlatformClient) {
33
41
  this.certManager = new SSHCertManager(platformClient);
@@ -35,12 +43,13 @@ export class UncloudRunner {
35
43
 
36
44
  /**
37
45
  * Get the connection URL for an app's primary VM
38
- * Handles IPv6 direct connection or gateway proxy fallback
46
+ * Handles IPv6 direct connection, VPN, or gateway SSH tunnel fallback
39
47
  */
40
48
  async getConnectionInfo(appName: string): Promise<{
41
49
  url: string;
42
50
  vmIp: string;
43
51
  viaGateway: boolean;
52
+ viaVPN: boolean;
44
53
  localPort?: number;
45
54
  }> {
46
55
  // Get app info
@@ -61,31 +70,97 @@ export class UncloudRunner {
61
70
 
62
71
  // Test direct IPv6 connectivity (3 second timeout)
63
72
  const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
64
- const viaGateway = !canConnectDirect;
65
73
 
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);
74
+ if (canConnectDirect) {
75
+ // Check if this connection goes through VPN (informational only)
76
+ const viaVPN = isRoutedViaVPN(vmIp);
70
77
 
71
- // Use ssh:// through the tunnel (Go SSH library with agent)
78
+ // Direct IPv6 connection using ssh:// (Go SSH library with agent support)
72
79
  return {
73
- url: `ssh://root@localhost:${tunnelInfo.localPort}`,
80
+ url: `ssh://root@${vmIp}`,
74
81
  vmIp,
75
- viaGateway: true,
76
- localPort: tunnelInfo.localPort,
82
+ viaGateway: false,
83
+ viaVPN,
77
84
  };
78
85
  }
79
86
 
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
87
+ // No direct IPv6 - try VPN first (more reliable for large transfers)
88
+ const vpnAvailable = isWireGuardInstalled();
89
+
90
+ if (vpnAvailable) {
91
+ console.log(chalk.yellow('Direct IPv6 not available, trying VPN...'));
92
+
93
+ try {
94
+ // Check if VPN is already up and working
95
+ if (isVPNUp() && vpnTestIPv6(vmIp, 3)) {
96
+ console.log(chalk.green('✓ VPN already connected'));
97
+ return {
98
+ url: `ssh://root@${vmIp}`,
99
+ vmIp,
100
+ viaGateway: false,
101
+ viaVPN: true,
102
+ };
103
+ }
104
+
105
+ // Try to establish VPN
106
+ this.vpnEstablishedByUs = await this.vpnManager.ensureConnected(
107
+ vmIp,
108
+ async () => this.getVPNConfig(app.location)
109
+ );
110
+
111
+ // If VPN is now up, use direct connection
112
+ if (vpnTestIPv6(vmIp, 5)) {
113
+ console.log(chalk.green('✓ Connected via VPN'));
114
+ return {
115
+ url: `ssh://root@${vmIp}`,
116
+ vmIp,
117
+ viaGateway: false,
118
+ viaVPN: true,
119
+ };
120
+ }
121
+ } catch (error) {
122
+ console.log(chalk.yellow(`VPN setup failed: ${(error as Error).message}`));
123
+ console.log(chalk.dim('Falling back to SSH tunnel...'));
124
+ }
125
+ } else {
126
+ // WireGuard not installed - show instructions
127
+ console.log(chalk.yellow('Direct IPv6 not available on your network.'));
128
+ console.log(chalk.dim('For faster deploys, install WireGuard:'));
129
+ console.log(chalk.dim(getWireGuardInstallInstructions()));
130
+ console.log();
131
+ }
132
+
133
+ // VPN not available or failed - fall back to SSH tunnel (less reliable)
134
+ console.log(chalk.yellow('Using SSH tunnel (may be slower for large transfers)...'));
135
+
136
+ const tunnelInfo = await this.ensureTunnel(appName, vmIp, app.location);
137
+
85
138
  return {
86
- url: `ssh://root@${vmIp}`,
139
+ url: `ssh://root@localhost:${tunnelInfo.localPort}`,
87
140
  vmIp,
88
- viaGateway: false,
141
+ viaGateway: true,
142
+ viaVPN: false,
143
+ localPort: tunnelInfo.localPort,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Get VPN configuration from platform
149
+ */
150
+ private async getVPNConfig(location: string): Promise<VPNConfig> {
151
+ // Get or create keypair
152
+ const { privateKey, publicKey } = getOrCreateKeyPair();
153
+
154
+ // Register with platform and get config
155
+ const vpnConfigResponse = await this.platformClient.registerVPNPeer(publicKey, location);
156
+
157
+ return {
158
+ privateKey,
159
+ publicKey,
160
+ address: vpnConfigResponse.address,
161
+ gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
162
+ gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
163
+ allowedIPs: vpnConfigResponse.allowedIPs,
89
164
  };
90
165
  }
91
166
 
@@ -110,89 +185,14 @@ export class UncloudRunner {
110
185
  * Ensure an SSH tunnel exists for gateway fallback
111
186
  */
112
187
  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
188
  // Get gateway
120
189
  const gateway = await this.getGateway(location);
121
190
  if (!gateway) {
122
191
  throw new Error(`No gateway found for location ${location}`);
123
192
  }
124
193
 
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}`);
194
+ // Use shared tunnel manager
195
+ return this.tunnelManager.ensureTunnel(appName, vmIp, gateway.ipv4);
196
196
  }
197
197
 
198
198
  /**
@@ -304,6 +304,34 @@ export class UncloudRunner {
304
304
  return execSync(`${sshCmd} "${command}"`, { encoding: 'utf-8' }).trim();
305
305
  }
306
306
 
307
+ /**
308
+ * Remove a node from the uncloud cluster
309
+ * Uses 'uc machine rm' to drain containers and remove from cluster
310
+ */
311
+ async removeNode(appName: string, nodeName: string): Promise<void> {
312
+ // Get connection info to primary node
313
+ const connInfo = await this.getConnectionInfo(appName);
314
+
315
+ // Run uc machine rm with --yes to skip confirmation
316
+ // The --no-reset flag is NOT used because we're deleting the VM anyway
317
+ const result = spawnSync('uc', ['--connect', connInfo.url, 'machine', 'rm', nodeName, '--yes'], {
318
+ stdio: 'inherit',
319
+ timeout: 300000, // 5 minute timeout for container drainage
320
+ });
321
+
322
+ if (result.status !== 0) {
323
+ throw new Error(`Failed to remove node '${nodeName}' from cluster (exit code ${result.status})`);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * List machines in the uncloud cluster
329
+ */
330
+ async listMachines(appName: string): Promise<string> {
331
+ const result = await this.run(appName, 'machine', ['ls'], { stdio: 'pipe' });
332
+ return result as string;
333
+ }
334
+
307
335
  /**
308
336
  * Get gateway info (cached)
309
337
  */
@@ -323,20 +351,19 @@ export class UncloudRunner {
323
351
  }
324
352
 
325
353
  /**
326
- * Clean up all SSH sessions and tunnels
354
+ * Clean up all SSH sessions, tunnels, and VPN
327
355
  */
328
356
  cleanup(): void {
329
357
  // 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();
358
+ this.tunnelManager.closeAll();
338
359
 
339
360
  // Clean up SSH certificate sessions
340
361
  this.certManager.cleanupAll();
362
+
363
+ // Disconnect VPN if we established it
364
+ if (this.vpnEstablishedByUs) {
365
+ this.vpnManager.disconnect();
366
+ this.vpnEstablishedByUs = false;
367
+ }
341
368
  }
342
369
  }
@@ -30,12 +30,21 @@ export class UncloudManager {
30
30
 
31
31
  /**
32
32
  * Check if uncloud is installed, offer to install if not
33
+ * @param options.nonInteractive - If true, fail immediately without prompting (for CI/CD)
33
34
  */
34
- static async ensureInstalled(): Promise<void> {
35
+ static async ensureInstalled(options?: { nonInteractive?: boolean }): Promise<void> {
35
36
  if (this.isInstalled()) {
36
37
  return;
37
38
  }
38
39
 
40
+ // In non-interactive mode (CI/CD), fail immediately with clear error
41
+ if (options?.nonInteractive) {
42
+ console.error(chalk.red('\n❌ Uncloud CLI (uc) not found\n'));
43
+ console.error('The uncloud CLI must be installed on the build VM.');
44
+ console.error('This is a build infrastructure issue - the uc binary should be pre-installed.\n');
45
+ process.exit(1);
46
+ }
47
+
39
48
  console.log(chalk.yellow('\n⚠️ Uncloud CLI not found\n'));
40
49
  console.log('Uncloud is required to deploy and manage your apps.');
41
50
  console.log('Learn more: https://uncloud.run\n');