hackerrun 0.1.0 → 0.1.2
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 +1520 -392
- 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 +175 -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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
5
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
6
|
+
import {
|
|
7
|
+
isWireGuardInstalled,
|
|
8
|
+
isVPNUp,
|
|
9
|
+
getVPNStatus,
|
|
10
|
+
getOrCreateKeyPair,
|
|
11
|
+
getPublicKey,
|
|
12
|
+
writeWireGuardConfig,
|
|
13
|
+
vpnUp,
|
|
14
|
+
vpnDown,
|
|
15
|
+
testIPv6Connectivity,
|
|
16
|
+
VPNConfig,
|
|
17
|
+
} from '../lib/vpn.js';
|
|
18
|
+
|
|
19
|
+
// Default location for VPN gateway
|
|
20
|
+
const DEFAULT_LOCATION = 'eu-central-h1';
|
|
21
|
+
|
|
22
|
+
export function createVPNCommands() {
|
|
23
|
+
const vpnCmd = new Command('vpn');
|
|
24
|
+
vpnCmd.description('Manage VPN connection for IPv6 access to your apps');
|
|
25
|
+
|
|
26
|
+
// vpn status
|
|
27
|
+
vpnCmd
|
|
28
|
+
.command('status')
|
|
29
|
+
.description('Show VPN connection status')
|
|
30
|
+
.action(async () => {
|
|
31
|
+
try {
|
|
32
|
+
if (!isWireGuardInstalled()) {
|
|
33
|
+
console.log(chalk.yellow('\nWireGuard is not installed.\n'));
|
|
34
|
+
console.log('Install it with:');
|
|
35
|
+
console.log(' Ubuntu/Debian: sudo apt install wireguard');
|
|
36
|
+
console.log(' Fedora: sudo dnf install wireguard-tools');
|
|
37
|
+
console.log(' Arch: sudo pacman -S wireguard-tools');
|
|
38
|
+
console.log(' macOS: brew install wireguard-tools\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const status = getVPNStatus();
|
|
43
|
+
|
|
44
|
+
console.log(chalk.cyan('\nVPN Status\n'));
|
|
45
|
+
|
|
46
|
+
if (status.connected) {
|
|
47
|
+
console.log(` Status: ${chalk.green('Connected')}`);
|
|
48
|
+
console.log(` Interface: ${status.interface}`);
|
|
49
|
+
if (status.endpoint) {
|
|
50
|
+
console.log(` Endpoint: ${status.endpoint}`);
|
|
51
|
+
}
|
|
52
|
+
if (status.latestHandshake) {
|
|
53
|
+
console.log(` Handshake: ${status.latestHandshake}`);
|
|
54
|
+
}
|
|
55
|
+
if (status.transferRx && status.transferTx) {
|
|
56
|
+
console.log(` Transfer: ${status.transferRx} received, ${status.transferTx} sent`);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
console.log(` Status: ${chalk.yellow('Disconnected')}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Show local public key if available
|
|
63
|
+
const publicKey = getPublicKey();
|
|
64
|
+
if (publicKey) {
|
|
65
|
+
console.log(` Public Key: ${chalk.dim(publicKey.substring(0, 20) + '...')}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log();
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// vpn connect
|
|
76
|
+
vpnCmd
|
|
77
|
+
.command('connect')
|
|
78
|
+
.description('Establish VPN connection to gateway for IPv6 access')
|
|
79
|
+
.option('-l, --location <location>', 'Gateway location', DEFAULT_LOCATION)
|
|
80
|
+
.action(async (options) => {
|
|
81
|
+
try {
|
|
82
|
+
// Check WireGuard is installed
|
|
83
|
+
if (!isWireGuardInstalled()) {
|
|
84
|
+
console.error(chalk.red('\nWireGuard is not installed.\n'));
|
|
85
|
+
console.log('Install it with:');
|
|
86
|
+
console.log(' Ubuntu/Debian: sudo apt install wireguard');
|
|
87
|
+
console.log(' Fedora: sudo dnf install wireguard-tools');
|
|
88
|
+
console.log(' Arch: sudo pacman -S wireguard-tools');
|
|
89
|
+
console.log(' macOS: brew install wireguard-tools\n');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check if already connected
|
|
94
|
+
if (isVPNUp()) {
|
|
95
|
+
console.log(chalk.green('\nVPN is already connected.\n'));
|
|
96
|
+
const status = getVPNStatus();
|
|
97
|
+
if (status.endpoint) {
|
|
98
|
+
console.log(` Endpoint: ${status.endpoint}`);
|
|
99
|
+
}
|
|
100
|
+
console.log();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const platformToken = getPlatformToken();
|
|
105
|
+
const platformClient = new PlatformClient(platformToken);
|
|
106
|
+
|
|
107
|
+
console.log(chalk.cyan('\nEstablishing VPN connection...\n'));
|
|
108
|
+
|
|
109
|
+
// Get or create keypair
|
|
110
|
+
const spinner = ora('Preparing WireGuard keypair...').start();
|
|
111
|
+
const { publicKey, isNew } = getOrCreateKeyPair();
|
|
112
|
+
if (isNew) {
|
|
113
|
+
spinner.succeed('Generated new WireGuard keypair');
|
|
114
|
+
} else {
|
|
115
|
+
spinner.succeed('Using existing WireGuard keypair');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Register with platform and get config
|
|
119
|
+
spinner.start('Registering with gateway...');
|
|
120
|
+
const vpnConfigResponse = await platformClient.registerVPNPeer(publicKey, options.location);
|
|
121
|
+
spinner.succeed('Registered with gateway');
|
|
122
|
+
|
|
123
|
+
// Build full VPN config
|
|
124
|
+
const { privateKey } = getOrCreateKeyPair();
|
|
125
|
+
const vpnConfig: VPNConfig = {
|
|
126
|
+
privateKey,
|
|
127
|
+
publicKey,
|
|
128
|
+
address: vpnConfigResponse.address,
|
|
129
|
+
gatewayEndpoint: vpnConfigResponse.gatewayEndpoint,
|
|
130
|
+
gatewayPublicKey: vpnConfigResponse.gatewayPublicKey,
|
|
131
|
+
allowedIPs: vpnConfigResponse.allowedIPs,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Write config file
|
|
135
|
+
spinner.start('Writing WireGuard configuration...');
|
|
136
|
+
writeWireGuardConfig(vpnConfig);
|
|
137
|
+
spinner.succeed('WireGuard configuration written');
|
|
138
|
+
|
|
139
|
+
// Bring up VPN
|
|
140
|
+
spinner.start('Starting VPN tunnel (requires sudo)...');
|
|
141
|
+
spinner.stop();
|
|
142
|
+
console.log(chalk.dim(' Starting VPN tunnel (requires sudo)...'));
|
|
143
|
+
vpnUp();
|
|
144
|
+
console.log(chalk.green(' ✓ VPN tunnel started'));
|
|
145
|
+
|
|
146
|
+
// Verify connection
|
|
147
|
+
spinner.start('Verifying connection...');
|
|
148
|
+
// Give it a moment to establish
|
|
149
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
150
|
+
|
|
151
|
+
const status = getVPNStatus();
|
|
152
|
+
if (status.connected) {
|
|
153
|
+
spinner.succeed('VPN connected successfully');
|
|
154
|
+
} else {
|
|
155
|
+
spinner.warn('VPN interface is up but connection not verified');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(chalk.green('\n✓ VPN connected!\n'));
|
|
159
|
+
console.log(` Your IPv6: ${chalk.bold(vpnConfigResponse.address.split('/')[0])}`);
|
|
160
|
+
console.log(` Gateway: ${chalk.bold(vpnConfigResponse.gatewayEndpoint)}`);
|
|
161
|
+
console.log();
|
|
162
|
+
console.log(chalk.dim('You now have IPv6 connectivity to your app VMs.'));
|
|
163
|
+
console.log(chalk.dim(`Run ${chalk.bold('hackerrun vpn disconnect')} to disconnect.\n`));
|
|
164
|
+
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// vpn disconnect
|
|
172
|
+
vpnCmd
|
|
173
|
+
.command('disconnect')
|
|
174
|
+
.description('Disconnect VPN')
|
|
175
|
+
.action(async () => {
|
|
176
|
+
try {
|
|
177
|
+
if (!isVPNUp()) {
|
|
178
|
+
console.log(chalk.yellow('\nVPN is not connected.\n'));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(chalk.cyan('\nDisconnecting VPN...\n'));
|
|
183
|
+
console.log(chalk.dim(' Stopping VPN tunnel (requires sudo)...'));
|
|
184
|
+
vpnDown();
|
|
185
|
+
console.log(chalk.green(' ✓ VPN tunnel stopped'));
|
|
186
|
+
|
|
187
|
+
console.log(chalk.green('\n✓ VPN disconnected.\n'));
|
|
188
|
+
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// vpn test
|
|
196
|
+
vpnCmd
|
|
197
|
+
.command('test')
|
|
198
|
+
.description('Test IPv6 connectivity to an app VM')
|
|
199
|
+
.argument('<ipv6>', 'IPv6 address to test')
|
|
200
|
+
.action(async (ipv6: string) => {
|
|
201
|
+
try {
|
|
202
|
+
console.log(chalk.cyan(`\nTesting connectivity to ${ipv6}...\n`));
|
|
203
|
+
|
|
204
|
+
const spinner = ora('Testing direct IPv6...').start();
|
|
205
|
+
const directOk = testIPv6Connectivity(ipv6, 3);
|
|
206
|
+
|
|
207
|
+
if (directOk) {
|
|
208
|
+
spinner.succeed('Direct IPv6 connectivity works');
|
|
209
|
+
console.log(chalk.green('\n✓ You can reach this address directly.\n'));
|
|
210
|
+
} else {
|
|
211
|
+
spinner.fail('Direct IPv6 not available');
|
|
212
|
+
|
|
213
|
+
if (isVPNUp()) {
|
|
214
|
+
spinner.start('VPN is up, testing through VPN...');
|
|
215
|
+
const vpnOk = testIPv6Connectivity(ipv6, 5);
|
|
216
|
+
if (vpnOk) {
|
|
217
|
+
spinner.succeed('Connectivity works through VPN');
|
|
218
|
+
console.log(chalk.green('\n✓ You can reach this address via VPN.\n'));
|
|
219
|
+
} else {
|
|
220
|
+
spinner.fail('Cannot reach address even through VPN');
|
|
221
|
+
console.log(chalk.red('\n✗ Cannot reach this address.\n'));
|
|
222
|
+
console.log('Possible issues:');
|
|
223
|
+
console.log(' - The target VM may be down');
|
|
224
|
+
console.log(' - VPN routing may be misconfigured');
|
|
225
|
+
console.log(' - Firewall may be blocking traffic\n');
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
console.log(chalk.yellow('\n✗ Cannot reach this address directly.\n'));
|
|
229
|
+
console.log(`Run ${chalk.bold('hackerrun vpn connect')} to establish VPN and try again.\n`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return vpnCmd;
|
|
240
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { createLogsCommand } from './commands/logs.js';
|
|
|
7
7
|
import { createEnvCommand } from './commands/env.js';
|
|
8
8
|
import { createConnectCommand, createDisconnectCommand } from './commands/connect.js';
|
|
9
9
|
import { createBuildsCommand } from './commands/builds.js';
|
|
10
|
+
import { createVPNCommands } from './commands/vpn.js';
|
|
11
|
+
import { createScaleCommand } from './commands/scale.js';
|
|
10
12
|
|
|
11
13
|
const program = new Command();
|
|
12
14
|
|
|
@@ -31,6 +33,12 @@ program.addCommand(createDisconnectCommand());
|
|
|
31
33
|
program.addCommand(createEnvCommand());
|
|
32
34
|
program.addCommand(createBuildsCommand());
|
|
33
35
|
|
|
36
|
+
// Register VPN command
|
|
37
|
+
program.addCommand(createVPNCommands());
|
|
38
|
+
|
|
39
|
+
// Register scale command
|
|
40
|
+
program.addCommand(createScaleCommand());
|
|
41
|
+
|
|
34
42
|
const { appsCmd, nodesCmd, sshCmd, destroyCmd, linkCmd, renameCmd, domainCmd } = createAppCommands();
|
|
35
43
|
program.addCommand(appsCmd);
|
|
36
44
|
program.addCommand(nodesCmd);
|
package/src/lib/cluster.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AppCluster, VMNode, PlatformClient } from './platform-client.js';
|
|
2
|
-
import { SSHCertManager } from './ssh-cert.js';
|
|
2
|
+
import { SSHCertManager, testIPv6Connectivity } from './ssh-cert.js';
|
|
3
|
+
import { createTunnel, killTunnel, TunnelInfo } from './gateway-tunnel.js';
|
|
3
4
|
import { execSync } from 'child_process';
|
|
4
5
|
import ora from 'ora';
|
|
5
6
|
import chalk from 'chalk';
|
|
@@ -57,8 +58,8 @@ export class ClusterManager {
|
|
|
57
58
|
async initializeCluster(options: ClusterInitOptions): Promise<AppCluster> {
|
|
58
59
|
const { appName, location, vmSize, storageSize, bootImage } = options;
|
|
59
60
|
|
|
60
|
-
//
|
|
61
|
-
const vmName = `${appName}-
|
|
61
|
+
// First node is always appName-1
|
|
62
|
+
const vmName = `${appName}-1`;
|
|
62
63
|
|
|
63
64
|
let spinner = ora(`Creating VM '${vmName}' in ${location}...`).start();
|
|
64
65
|
|
|
@@ -125,12 +126,19 @@ export class ClusterManager {
|
|
|
125
126
|
spinner.text = 'Installing Docker and Uncloud...';
|
|
126
127
|
spinner.stop();
|
|
127
128
|
console.log(chalk.cyan('\nInitializing uncloud (this may take a few minutes)...'));
|
|
128
|
-
await this.initializeUncloud(vmWithIp.ip6!, appName);
|
|
129
|
+
await this.initializeUncloud(vmWithIp.ip6!, appName, gateway?.ipv4);
|
|
129
130
|
|
|
130
131
|
spinner = ora('Configuring Docker for NAT64...').start();
|
|
131
132
|
|
|
132
133
|
// Step 3: Configure Docker for NAT64
|
|
133
|
-
await this.configureDockerNAT64(vmWithIp.ip6
|
|
134
|
+
await this.configureDockerNAT64(vmWithIp.ip6!, gateway?.ipv4);
|
|
135
|
+
|
|
136
|
+
// Wait for uncloud services to be ready after Docker restart
|
|
137
|
+
spinner.text = 'Waiting for uncloud services to be ready...';
|
|
138
|
+
await this.waitForUncloudReady(vmWithIp.ip6!, gateway?.ipv4);
|
|
139
|
+
|
|
140
|
+
// Clean up SSH sessions from initialization to get fresh state for deploy
|
|
141
|
+
this.sshCertManager.cleanupAll();
|
|
134
142
|
|
|
135
143
|
spinner.succeed(chalk.green(`Cluster initialized successfully`));
|
|
136
144
|
|
|
@@ -150,16 +158,18 @@ export class ClusterManager {
|
|
|
150
158
|
throw new Error(`App '${appName}' not found`);
|
|
151
159
|
}
|
|
152
160
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
// Find any existing node to use as join target (all nodes are equal in uncloud)
|
|
162
|
+
const existingNode = cluster.nodes.find(n => n.ipv6);
|
|
163
|
+
if (!existingNode || !existingNode.ipv6) {
|
|
164
|
+
throw new Error(`No existing node with IPv6 address found to join`);
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
// Get gateway info to get the private subnet ID for NAT64 routing
|
|
159
168
|
const gateway = await this.platformClient.getGateway(cluster.location);
|
|
160
169
|
const privateSubnetId = gateway?.subnetId;
|
|
161
170
|
|
|
162
|
-
|
|
171
|
+
// Generate sequential node name: appName-1, appName-2, etc.
|
|
172
|
+
const vmName = this.generateNodeName(appName, cluster.nodes);
|
|
163
173
|
|
|
164
174
|
let spinner = ora(`Adding node '${vmName}' to cluster...`).start();
|
|
165
175
|
|
|
@@ -193,16 +203,16 @@ export class ClusterManager {
|
|
|
193
203
|
spinner.text = 'Configuring VM...';
|
|
194
204
|
await this.platformClient.setupVM(vmWithIp.ip6!, cluster.location, appName);
|
|
195
205
|
|
|
196
|
-
// Step 2: Get join token from
|
|
206
|
+
// Step 2: Get join token from existing node and join cluster
|
|
197
207
|
spinner.text = 'Joining uncloud cluster...';
|
|
198
208
|
spinner.stop();
|
|
199
209
|
console.log(chalk.cyan('\nJoining uncloud cluster...'));
|
|
200
|
-
await this.joinUncloudCluster(vmWithIp.ip6!,
|
|
210
|
+
await this.joinUncloudCluster(vmWithIp.ip6!, existingNode.ipv6, appName);
|
|
201
211
|
|
|
202
212
|
spinner = ora('Configuring Docker for NAT64...').start();
|
|
203
213
|
|
|
204
214
|
// Step 3: Configure Docker for NAT64
|
|
205
|
-
await this.configureDockerNAT64(vmWithIp.ip6
|
|
215
|
+
await this.configureDockerNAT64(vmWithIp.ip6!, gateway?.ipv4);
|
|
206
216
|
|
|
207
217
|
spinner.succeed(chalk.green(`Node '${vmName}' added successfully`));
|
|
208
218
|
|
|
@@ -231,20 +241,81 @@ export class ClusterManager {
|
|
|
231
241
|
* Before running uc, we get an SSH certificate from the platform
|
|
232
242
|
* and add it to the ssh-agent. This allows uc to authenticate
|
|
233
243
|
* since the VM only accepts platform SSH key or signed certificates.
|
|
244
|
+
*
|
|
245
|
+
* If direct IPv6 connectivity is not available, tunnels through the gateway.
|
|
234
246
|
*/
|
|
235
|
-
private async initializeUncloud(vmIp: string, contextName: string): Promise<void> {
|
|
247
|
+
private async initializeUncloud(vmIp: string, contextName: string, gatewayIp?: string): Promise<void> {
|
|
248
|
+
let tunnel: TunnelInfo | null = null;
|
|
249
|
+
let targetHost: string = vmIp;
|
|
250
|
+
|
|
236
251
|
try {
|
|
237
252
|
// Get SSH certificate and add to agent (required for auth)
|
|
238
253
|
// The VM was created with platform SSH key, so we need a certificate
|
|
239
254
|
await this.sshCertManager.getSession(contextName, vmIp);
|
|
240
255
|
|
|
256
|
+
// Test if we can reach the VM directly via IPv6
|
|
257
|
+
const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
|
|
258
|
+
|
|
259
|
+
if (!canConnectDirect) {
|
|
260
|
+
if (!gatewayIp) {
|
|
261
|
+
throw new Error('No direct IPv6 connectivity and no gateway available');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log(chalk.dim('No direct IPv6 connectivity, tunneling through gateway...'));
|
|
265
|
+
|
|
266
|
+
// Create tunnel through gateway
|
|
267
|
+
tunnel = await createTunnel(vmIp, gatewayIp);
|
|
268
|
+
targetHost = `localhost:${tunnel.localPort}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
241
271
|
// uc machine init connects to the VM and installs Docker + uncloud daemon
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
272
|
+
// Sometimes Caddy deployment fails with "machine is not ready" - we retry in that case
|
|
273
|
+
let initSucceeded = false;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
execSync(`uc machine init -c "${contextName}" --no-dns root@${targetHost}`, {
|
|
277
|
+
stdio: 'inherit',
|
|
278
|
+
timeout: 600000, // 10 min timeout
|
|
279
|
+
});
|
|
280
|
+
initSucceeded = true;
|
|
281
|
+
} catch (error: any) {
|
|
282
|
+
// uc machine init failed - but uncloud might still be installed, just Caddy failed
|
|
283
|
+
// We'll try to deploy Caddy separately below
|
|
284
|
+
console.log(chalk.yellow('\nInitial setup had errors, will attempt to recover...'));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// If init failed, retry Caddy deployment (uncloud daemon may need time to initialize)
|
|
288
|
+
if (!initSucceeded) {
|
|
289
|
+
const maxCaddyAttempts = 5;
|
|
290
|
+
|
|
291
|
+
for (let attempt = 1; attempt <= maxCaddyAttempts; attempt++) {
|
|
292
|
+
console.log(chalk.dim(`Waiting for machine to be ready (attempt ${attempt}/${maxCaddyAttempts})...`));
|
|
293
|
+
await new Promise(resolve => setTimeout(resolve, 10000));
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
execSync(`yes | uc -c "${contextName}" caddy deploy`, {
|
|
297
|
+
stdio: 'inherit',
|
|
298
|
+
timeout: 120000,
|
|
299
|
+
});
|
|
300
|
+
console.log(chalk.green('Caddy deployed successfully'));
|
|
301
|
+
break;
|
|
302
|
+
} catch (caddyError: any) {
|
|
303
|
+
if (attempt >= maxCaddyAttempts) {
|
|
304
|
+
throw new Error(`Caddy deployment failed after ${maxCaddyAttempts} attempts. The machine may need more time to initialize.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// If we got here without errors, uncloud is working
|
|
311
|
+
console.log(chalk.dim('Uncloud initialization complete.'));
|
|
246
312
|
} catch (error) {
|
|
247
313
|
throw new Error(`Failed to initialize uncloud: ${(error as Error).message}`);
|
|
314
|
+
} finally {
|
|
315
|
+
// Clean up tunnel if we created one
|
|
316
|
+
if (tunnel) {
|
|
317
|
+
killTunnel(tunnel);
|
|
318
|
+
}
|
|
248
319
|
}
|
|
249
320
|
}
|
|
250
321
|
|
|
@@ -288,8 +359,10 @@ export class ClusterManager {
|
|
|
288
359
|
* 1. Enable IPv6 on Docker networks so containers get IPv6 addresses
|
|
289
360
|
* 2. Block IPv4 forwarding from Docker to internet so IPv4 fails immediately
|
|
290
361
|
* 3. Applications then use IPv6 (NAT64) which works via the gateway
|
|
362
|
+
*
|
|
363
|
+
* If direct IPv6 connectivity is not available, tunnels through the gateway.
|
|
291
364
|
*/
|
|
292
|
-
private async configureDockerNAT64(vmIp: string): Promise<void> {
|
|
365
|
+
private async configureDockerNAT64(vmIp: string, gatewayIp?: string): Promise<void> {
|
|
293
366
|
const setupScript = `#!/bin/bash
|
|
294
367
|
set -e
|
|
295
368
|
|
|
@@ -355,10 +428,24 @@ systemctl start docker-ipv6-nat64
|
|
|
355
428
|
echo "Docker NAT64 configuration complete"
|
|
356
429
|
`;
|
|
357
430
|
|
|
431
|
+
let tunnel: TunnelInfo | null = null;
|
|
432
|
+
let sshHost: string = vmIp;
|
|
433
|
+
let sshPortArgs: string = '';
|
|
434
|
+
|
|
358
435
|
try {
|
|
436
|
+
// Test if we can reach the VM directly via IPv6
|
|
437
|
+
const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
|
|
438
|
+
|
|
439
|
+
if (!canConnectDirect && gatewayIp) {
|
|
440
|
+
// Create tunnel through gateway
|
|
441
|
+
tunnel = await createTunnel(vmIp, gatewayIp);
|
|
442
|
+
sshHost = 'localhost';
|
|
443
|
+
sshPortArgs = `-p ${tunnel.localPort}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
359
446
|
// Use SSH to run the script on the VM
|
|
360
447
|
// After platform setup, SSH certificate auth is available
|
|
361
|
-
execSync(`ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${
|
|
448
|
+
execSync(`ssh ${sshPortArgs} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null root@${sshHost} 'bash -s' << 'REMOTESCRIPT'
|
|
362
449
|
${setupScript}
|
|
363
450
|
REMOTESCRIPT`, {
|
|
364
451
|
stdio: 'inherit',
|
|
@@ -366,6 +453,10 @@ REMOTESCRIPT`, {
|
|
|
366
453
|
});
|
|
367
454
|
} catch (error) {
|
|
368
455
|
throw new Error(`Failed to configure Docker for NAT64: ${(error as Error).message}`);
|
|
456
|
+
} finally {
|
|
457
|
+
if (tunnel) {
|
|
458
|
+
killTunnel(tunnel);
|
|
459
|
+
}
|
|
369
460
|
}
|
|
370
461
|
}
|
|
371
462
|
|
|
@@ -413,7 +504,71 @@ REMOTESCRIPT`, {
|
|
|
413
504
|
}
|
|
414
505
|
|
|
415
506
|
/**
|
|
416
|
-
*
|
|
507
|
+
* Wait for uncloud services to be ready on the VM
|
|
508
|
+
* Specifically checks that unregistry (Docker registry at 10.210.0.1:5000) is accepting connections
|
|
509
|
+
* This is critical for first deploy - push will fail if unregistry isn't ready
|
|
510
|
+
*/
|
|
511
|
+
private async waitForUncloudReady(vmIp: string, gatewayIp?: string): Promise<void> {
|
|
512
|
+
const maxAttempts = 30; // 30 attempts * 2s = 60s max wait
|
|
513
|
+
const checkInterval = 2000; // 2 seconds between checks
|
|
514
|
+
|
|
515
|
+
// Build SSH command with ProxyJump if needed
|
|
516
|
+
const canConnectDirect = await testIPv6Connectivity(vmIp, 3000);
|
|
517
|
+
const proxyJump = (!canConnectDirect && gatewayIp) ? `-J root@${gatewayIp}` : '';
|
|
518
|
+
const sshBase = `ssh ${proxyJump} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 root@${vmIp}`;
|
|
519
|
+
|
|
520
|
+
console.log(chalk.dim(' Waiting for uncloud services...'));
|
|
521
|
+
|
|
522
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
523
|
+
try {
|
|
524
|
+
// Check that unregistry is accepting connections
|
|
525
|
+
// unregistry runs at 10.210.0.1:5000 (Docker gateway IP)
|
|
526
|
+
const result = execSync(
|
|
527
|
+
`${sshBase} "curl -sf --max-time 5 http://10.210.0.1:5000/v2/ >/dev/null 2>&1 && echo ready || echo notready"`,
|
|
528
|
+
{ encoding: 'utf-8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
529
|
+
).trim();
|
|
530
|
+
|
|
531
|
+
if (result.includes('ready')) {
|
|
532
|
+
console.log(chalk.dim(' Uncloud services ready'));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
} catch (error) {
|
|
536
|
+
// Ignore errors, will retry
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (attempt % 5 === 0) {
|
|
540
|
+
console.log(chalk.dim(` Still waiting for unregistry... (attempt ${attempt}/${maxAttempts})`));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (attempt < maxAttempts) {
|
|
544
|
+
await this.sleep(checkInterval);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// If we get here, services didn't become ready in time
|
|
549
|
+
console.log(chalk.yellow(' Warning: Timeout waiting for unregistry, continuing anyway...'));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Generate a sequential node name: appName-1, appName-2, etc.
|
|
554
|
+
* Finds the next available number by checking existing node names
|
|
555
|
+
*/
|
|
556
|
+
private generateNodeName(appName: string, existingNodes: VMNode[]): string {
|
|
557
|
+
// Extract existing numbers from node names
|
|
558
|
+
const existingNumbers = existingNodes
|
|
559
|
+
.map(n => {
|
|
560
|
+
const match = n.name.match(new RegExp(`^${appName}-(\\d+)$`));
|
|
561
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
562
|
+
})
|
|
563
|
+
.filter(n => n > 0);
|
|
564
|
+
|
|
565
|
+
// Find the next available number
|
|
566
|
+
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
|
|
567
|
+
return `${appName}-${maxNumber + 1}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Generate a random ID (used for legacy naming or fallback)
|
|
417
572
|
*/
|
|
418
573
|
private generateId(): string {
|
|
419
574
|
return Math.random().toString(36).substring(2, 9);
|