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.
- package/CLAUDE.md +138 -0
- package/dist/index.js +1529 -395
- package/package.json +1 -1
- package/src/commands/app.ts +30 -6
- package/src/commands/connect.ts +53 -1
- package/src/commands/deploy.ts +88 -18
- package/src/commands/scale.ts +231 -0
- package/src/commands/vpn.ts +240 -0
- package/src/index.ts +8 -0
- package/src/lib/cluster.ts +181 -20
- package/src/lib/gateway-tunnel.ts +187 -0
- package/src/lib/platform-client.ts +191 -69
- package/src/lib/uncloud-runner.ts +138 -111
- package/src/lib/uncloud.ts +10 -1
- package/src/lib/vpn.ts +487 -0
|
@@ -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
|
|
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
|
|
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
|
|
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 (
|
|
67
|
-
//
|
|
68
|
-
|
|
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
|
-
//
|
|
78
|
+
// Direct IPv6 connection using ssh:// (Go SSH library with agent support)
|
|
72
79
|
return {
|
|
73
|
-
url: `ssh://root
|
|
80
|
+
url: `ssh://root@${vmIp}`,
|
|
74
81
|
vmIp,
|
|
75
|
-
viaGateway:
|
|
76
|
-
|
|
82
|
+
viaGateway: false,
|
|
83
|
+
viaVPN,
|
|
77
84
|
};
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
139
|
+
url: `ssh://root@localhost:${tunnelInfo.localPort}`,
|
|
87
140
|
vmIp,
|
|
88
|
-
viaGateway:
|
|
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
|
-
//
|
|
126
|
-
|
|
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
|
|
354
|
+
* Clean up all SSH sessions, tunnels, and VPN
|
|
327
355
|
*/
|
|
328
356
|
cleanup(): void {
|
|
329
357
|
// Kill all active tunnels
|
|
330
|
-
|
|
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
|
}
|
package/src/lib/uncloud.ts
CHANGED
|
@@ -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');
|