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
package/src/lib/vpn.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// WireGuard VPN management for IPv6 connectivity through gateway
|
|
2
|
+
import { execSync, spawnSync } from 'child_process';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
4
|
+
import { homedir, platform } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect the current operating system and distribution
|
|
10
|
+
*/
|
|
11
|
+
function detectOS(): { type: 'linux' | 'macos' | 'windows' | 'unknown'; distro?: string } {
|
|
12
|
+
const os = platform();
|
|
13
|
+
|
|
14
|
+
if (os === 'darwin') {
|
|
15
|
+
return { type: 'macos' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (os === 'win32') {
|
|
19
|
+
return { type: 'windows' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (os === 'linux') {
|
|
23
|
+
// Try to detect Linux distribution from /etc/os-release
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync('/etc/os-release')) {
|
|
26
|
+
const osRelease = readFileSync('/etc/os-release', 'utf-8');
|
|
27
|
+
const idMatch = osRelease.match(/^ID=(.*)$/m);
|
|
28
|
+
if (idMatch) {
|
|
29
|
+
const distro = idMatch[1].replace(/"/g, '').toLowerCase();
|
|
30
|
+
return { type: 'linux', distro };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Ignore errors, fall through to generic linux
|
|
35
|
+
}
|
|
36
|
+
return { type: 'linux' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { type: 'unknown' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get WireGuard installation instructions for the current OS
|
|
44
|
+
*/
|
|
45
|
+
export function getWireGuardInstallInstructions(): string {
|
|
46
|
+
const os = detectOS();
|
|
47
|
+
|
|
48
|
+
switch (os.type) {
|
|
49
|
+
case 'macos':
|
|
50
|
+
return ` ${chalk.cyan('macOS:')} brew install wireguard-tools`;
|
|
51
|
+
|
|
52
|
+
case 'windows':
|
|
53
|
+
return ` ${chalk.cyan('Windows:')} Download from https://www.wireguard.com/install/\n` +
|
|
54
|
+
` Or using winget: winget install WireGuard.WireGuard`;
|
|
55
|
+
|
|
56
|
+
case 'linux':
|
|
57
|
+
// Provide specific instructions based on distro
|
|
58
|
+
switch (os.distro) {
|
|
59
|
+
case 'arch':
|
|
60
|
+
case 'manjaro':
|
|
61
|
+
case 'endeavouros':
|
|
62
|
+
case 'artix':
|
|
63
|
+
return ` ${chalk.cyan('Arch Linux:')} sudo pacman -S wireguard-tools`;
|
|
64
|
+
|
|
65
|
+
case 'ubuntu':
|
|
66
|
+
case 'debian':
|
|
67
|
+
case 'linuxmint':
|
|
68
|
+
case 'pop':
|
|
69
|
+
case 'elementary':
|
|
70
|
+
case 'zorin':
|
|
71
|
+
return ` ${chalk.cyan('Ubuntu/Debian:')} sudo apt install wireguard`;
|
|
72
|
+
|
|
73
|
+
case 'fedora':
|
|
74
|
+
return ` ${chalk.cyan('Fedora:')} sudo dnf install wireguard-tools`;
|
|
75
|
+
|
|
76
|
+
case 'rhel':
|
|
77
|
+
case 'centos':
|
|
78
|
+
case 'rocky':
|
|
79
|
+
case 'almalinux':
|
|
80
|
+
return ` ${chalk.cyan('RHEL/CentOS:')} sudo dnf install wireguard-tools\n` +
|
|
81
|
+
` (may need EPEL: sudo dnf install epel-release)`;
|
|
82
|
+
|
|
83
|
+
case 'opensuse':
|
|
84
|
+
case 'opensuse-leap':
|
|
85
|
+
case 'opensuse-tumbleweed':
|
|
86
|
+
return ` ${chalk.cyan('openSUSE:')} sudo zypper install wireguard-tools`;
|
|
87
|
+
|
|
88
|
+
case 'gentoo':
|
|
89
|
+
return ` ${chalk.cyan('Gentoo:')} sudo emerge net-vpn/wireguard-tools`;
|
|
90
|
+
|
|
91
|
+
case 'void':
|
|
92
|
+
return ` ${chalk.cyan('Void Linux:')} sudo xbps-install wireguard-tools`;
|
|
93
|
+
|
|
94
|
+
case 'alpine':
|
|
95
|
+
return ` ${chalk.cyan('Alpine:')} sudo apk add wireguard-tools`;
|
|
96
|
+
|
|
97
|
+
case 'nixos':
|
|
98
|
+
return ` ${chalk.cyan('NixOS:')} Add wireguard-tools to environment.systemPackages`;
|
|
99
|
+
|
|
100
|
+
default:
|
|
101
|
+
// Generic Linux instructions
|
|
102
|
+
return ` ${chalk.cyan('Linux:')} Install wireguard-tools using your package manager:\n` +
|
|
103
|
+
` Arch: sudo pacman -S wireguard-tools\n` +
|
|
104
|
+
` Ubuntu/Debian: sudo apt install wireguard\n` +
|
|
105
|
+
` Fedora: sudo dnf install wireguard-tools\n` +
|
|
106
|
+
` openSUSE: sudo zypper install wireguard-tools`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
default:
|
|
110
|
+
return ` Visit https://www.wireguard.com/install/ for installation instructions`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const CONFIG_DIR = join(homedir(), '.config', 'hackerrun');
|
|
115
|
+
const PRIVATE_KEY_FILE = join(CONFIG_DIR, 'wg-private-key');
|
|
116
|
+
const WG_INTERFACE = 'hackerrun';
|
|
117
|
+
const WG_CONFIG_PATH = `/etc/wireguard/${WG_INTERFACE}.conf`;
|
|
118
|
+
|
|
119
|
+
export interface VPNConfig {
|
|
120
|
+
privateKey: string;
|
|
121
|
+
publicKey: string;
|
|
122
|
+
address: string; // User's assigned IPv6 address (e.g., fd00:hackerrun:user:1::1/64)
|
|
123
|
+
gatewayEndpoint: string; // Gateway WireGuard endpoint (e.g., 46.4.244.246:51820)
|
|
124
|
+
gatewayPublicKey: string; // Gateway's WireGuard public key
|
|
125
|
+
allowedIPs: string; // IPs to route through VPN (app VM IPv6 ranges)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if WireGuard tools are installed
|
|
130
|
+
*/
|
|
131
|
+
export function isWireGuardInstalled(): boolean {
|
|
132
|
+
try {
|
|
133
|
+
execSync('which wg', { stdio: 'ignore' });
|
|
134
|
+
execSync('which wg-quick', { stdio: 'ignore' });
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Ensure config directory exists
|
|
143
|
+
*/
|
|
144
|
+
function ensureConfigDir(): void {
|
|
145
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
146
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Generate a new WireGuard keypair
|
|
152
|
+
*/
|
|
153
|
+
export function generateKeyPair(): { privateKey: string; publicKey: string } {
|
|
154
|
+
// Generate private key
|
|
155
|
+
const privateKey = execSync('wg genkey', { encoding: 'utf-8' }).trim();
|
|
156
|
+
|
|
157
|
+
// Derive public key from private key
|
|
158
|
+
const publicKey = execSync('wg pubkey', {
|
|
159
|
+
input: privateKey,
|
|
160
|
+
encoding: 'utf-8',
|
|
161
|
+
}).trim();
|
|
162
|
+
|
|
163
|
+
return { privateKey, publicKey };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get or create WireGuard keypair
|
|
168
|
+
* Returns existing keypair if available, otherwise generates new one
|
|
169
|
+
*/
|
|
170
|
+
export function getOrCreateKeyPair(): { privateKey: string; publicKey: string; isNew: boolean } {
|
|
171
|
+
ensureConfigDir();
|
|
172
|
+
|
|
173
|
+
if (existsSync(PRIVATE_KEY_FILE)) {
|
|
174
|
+
// Load existing private key
|
|
175
|
+
const privateKey = readFileSync(PRIVATE_KEY_FILE, 'utf-8').trim();
|
|
176
|
+
|
|
177
|
+
// Derive public key
|
|
178
|
+
const publicKey = execSync('wg pubkey', {
|
|
179
|
+
input: privateKey,
|
|
180
|
+
encoding: 'utf-8',
|
|
181
|
+
}).trim();
|
|
182
|
+
|
|
183
|
+
return { privateKey, publicKey, isNew: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Generate new keypair
|
|
187
|
+
const { privateKey, publicKey } = generateKeyPair();
|
|
188
|
+
|
|
189
|
+
// Save private key with restricted permissions
|
|
190
|
+
writeFileSync(PRIVATE_KEY_FILE, privateKey + '\n', { mode: 0o600 });
|
|
191
|
+
|
|
192
|
+
return { privateKey, publicKey, isNew: true };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get public key without exposing private key
|
|
197
|
+
*/
|
|
198
|
+
export function getPublicKey(): string | null {
|
|
199
|
+
if (!existsSync(PRIVATE_KEY_FILE)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const privateKey = readFileSync(PRIVATE_KEY_FILE, 'utf-8').trim();
|
|
204
|
+
return execSync('wg pubkey', {
|
|
205
|
+
input: privateKey,
|
|
206
|
+
encoding: 'utf-8',
|
|
207
|
+
}).trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if VPN interface is currently up
|
|
212
|
+
*/
|
|
213
|
+
export function isVPNUp(): boolean {
|
|
214
|
+
try {
|
|
215
|
+
// Check if interface exists and is configured
|
|
216
|
+
const result = execSync(`ip link show ${WG_INTERFACE}`, {
|
|
217
|
+
encoding: 'utf-8',
|
|
218
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
219
|
+
});
|
|
220
|
+
return result.includes(WG_INTERFACE);
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if traffic to a given IPv6 address would go through the VPN interface
|
|
228
|
+
*/
|
|
229
|
+
export function isRoutedViaVPN(ipv6Address: string): boolean {
|
|
230
|
+
if (!isVPNUp()) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// Use ip route get to check which interface would be used
|
|
236
|
+
const result = execSync(`ip -6 route get ${ipv6Address}`, {
|
|
237
|
+
encoding: 'utf-8',
|
|
238
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
239
|
+
});
|
|
240
|
+
return result.includes(`dev ${WG_INTERFACE}`);
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get VPN connection status
|
|
248
|
+
*/
|
|
249
|
+
export function getVPNStatus(): {
|
|
250
|
+
connected: boolean;
|
|
251
|
+
interface?: string;
|
|
252
|
+
endpoint?: string;
|
|
253
|
+
latestHandshake?: string;
|
|
254
|
+
transferRx?: string;
|
|
255
|
+
transferTx?: string;
|
|
256
|
+
} {
|
|
257
|
+
if (!isVPNUp()) {
|
|
258
|
+
return { connected: false };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const output = execSync(`sudo wg show ${WG_INTERFACE}`, {
|
|
263
|
+
encoding: 'utf-8',
|
|
264
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const lines = output.split('\n');
|
|
268
|
+
let endpoint: string | undefined;
|
|
269
|
+
let latestHandshake: string | undefined;
|
|
270
|
+
let transferRx: string | undefined;
|
|
271
|
+
let transferTx: string | undefined;
|
|
272
|
+
|
|
273
|
+
for (const line of lines) {
|
|
274
|
+
if (line.includes('endpoint:')) {
|
|
275
|
+
endpoint = line.split('endpoint:')[1]?.trim();
|
|
276
|
+
}
|
|
277
|
+
if (line.includes('latest handshake:')) {
|
|
278
|
+
latestHandshake = line.split('latest handshake:')[1]?.trim();
|
|
279
|
+
}
|
|
280
|
+
if (line.includes('transfer:')) {
|
|
281
|
+
const transfer = line.split('transfer:')[1]?.trim();
|
|
282
|
+
const parts = transfer?.split(',');
|
|
283
|
+
transferRx = parts?.[0]?.trim();
|
|
284
|
+
transferTx = parts?.[1]?.trim();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
connected: true,
|
|
290
|
+
interface: WG_INTERFACE,
|
|
291
|
+
endpoint,
|
|
292
|
+
latestHandshake,
|
|
293
|
+
transferRx,
|
|
294
|
+
transferTx,
|
|
295
|
+
};
|
|
296
|
+
} catch {
|
|
297
|
+
return { connected: isVPNUp(), interface: WG_INTERFACE };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Generate WireGuard config file content
|
|
303
|
+
*/
|
|
304
|
+
export function generateWireGuardConfig(config: VPNConfig): string {
|
|
305
|
+
return `# HackerRun VPN - Auto-generated
|
|
306
|
+
# Do not edit manually
|
|
307
|
+
|
|
308
|
+
[Interface]
|
|
309
|
+
PrivateKey = ${config.privateKey}
|
|
310
|
+
Address = ${config.address}
|
|
311
|
+
|
|
312
|
+
[Peer]
|
|
313
|
+
PublicKey = ${config.gatewayPublicKey}
|
|
314
|
+
Endpoint = ${config.gatewayEndpoint}
|
|
315
|
+
AllowedIPs = ${config.allowedIPs}
|
|
316
|
+
PersistentKeepalive = 25
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Write WireGuard config file (requires sudo)
|
|
322
|
+
*/
|
|
323
|
+
export function writeWireGuardConfig(config: VPNConfig): void {
|
|
324
|
+
const configContent = generateWireGuardConfig(config);
|
|
325
|
+
|
|
326
|
+
// Write to temp file first, then sudo mv to /etc/wireguard
|
|
327
|
+
const tempFile = join(CONFIG_DIR, 'wg-temp.conf');
|
|
328
|
+
writeFileSync(tempFile, configContent, { mode: 0o600 });
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
// Ensure /etc/wireguard exists
|
|
332
|
+
execSync('sudo mkdir -p /etc/wireguard', { stdio: 'inherit' });
|
|
333
|
+
|
|
334
|
+
// Move config file with proper permissions
|
|
335
|
+
execSync(`sudo mv ${tempFile} ${WG_CONFIG_PATH}`, { stdio: 'inherit' });
|
|
336
|
+
execSync(`sudo chmod 600 ${WG_CONFIG_PATH}`, { stdio: 'inherit' });
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// Clean up temp file on failure
|
|
339
|
+
if (existsSync(tempFile)) {
|
|
340
|
+
unlinkSync(tempFile);
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Bring VPN interface up (requires sudo)
|
|
348
|
+
*/
|
|
349
|
+
export function vpnUp(): void {
|
|
350
|
+
if (isVPNUp()) {
|
|
351
|
+
// Already up, nothing to do
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const result = spawnSync('sudo', ['wg-quick', 'up', WG_INTERFACE], {
|
|
356
|
+
stdio: 'inherit',
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (result.status !== 0) {
|
|
360
|
+
throw new Error(`Failed to bring up VPN interface (exit code ${result.status})`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Bring VPN interface down (requires sudo)
|
|
366
|
+
*/
|
|
367
|
+
export function vpnDown(): void {
|
|
368
|
+
if (!isVPNUp()) {
|
|
369
|
+
// Already down, nothing to do
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = spawnSync('sudo', ['wg-quick', 'down', WG_INTERFACE], {
|
|
374
|
+
stdio: 'inherit',
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (result.status !== 0) {
|
|
378
|
+
throw new Error(`Failed to bring down VPN interface (exit code ${result.status})`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Test IPv6 connectivity to a specific address
|
|
384
|
+
*/
|
|
385
|
+
export function testIPv6Connectivity(ipv6Address: string, timeoutSeconds: number = 3): boolean {
|
|
386
|
+
try {
|
|
387
|
+
// Use ping6 to test connectivity
|
|
388
|
+
execSync(`ping -6 -c 1 -W ${timeoutSeconds} ${ipv6Address}`, {
|
|
389
|
+
stdio: 'ignore',
|
|
390
|
+
timeout: (timeoutSeconds + 2) * 1000,
|
|
391
|
+
});
|
|
392
|
+
return true;
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* VPN Manager class for managing VPN lifecycle during operations
|
|
400
|
+
*/
|
|
401
|
+
export class VPNManager {
|
|
402
|
+
private wasConnectedByUs: boolean = false;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Ensure VPN is connected if IPv6 is not available
|
|
406
|
+
* Returns true if VPN was established (and should be torn down later)
|
|
407
|
+
*/
|
|
408
|
+
async ensureConnected(
|
|
409
|
+
targetIPv6: string,
|
|
410
|
+
getVPNConfig: () => Promise<VPNConfig>
|
|
411
|
+
): Promise<boolean> {
|
|
412
|
+
// First, test direct IPv6 connectivity
|
|
413
|
+
if (testIPv6Connectivity(targetIPv6)) {
|
|
414
|
+
console.log(chalk.green('✓ Direct IPv6 connectivity available'));
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
console.log(chalk.yellow('IPv6 not available, checking VPN...'));
|
|
419
|
+
|
|
420
|
+
// Check if VPN is already up
|
|
421
|
+
if (isVPNUp()) {
|
|
422
|
+
// VPN is up, test connectivity again through VPN
|
|
423
|
+
if (testIPv6Connectivity(targetIPv6)) {
|
|
424
|
+
console.log(chalk.green('✓ VPN already connected'));
|
|
425
|
+
return false; // Don't tear down - we didn't establish it
|
|
426
|
+
}
|
|
427
|
+
// VPN is up but can't reach target - might be misconfigured
|
|
428
|
+
console.log(chalk.yellow('VPN is up but cannot reach target, reconnecting...'));
|
|
429
|
+
vpnDown();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check WireGuard is installed
|
|
433
|
+
if (!isWireGuardInstalled()) {
|
|
434
|
+
const instructions = getWireGuardInstallInstructions();
|
|
435
|
+
throw new Error(
|
|
436
|
+
'WireGuard is not installed.\n\n' +
|
|
437
|
+
'WireGuard is needed for IPv6 connectivity to your app VMs.\n' +
|
|
438
|
+
'Please install it and try again:\n\n' +
|
|
439
|
+
instructions
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
console.log(chalk.cyan('Establishing VPN tunnel (requires sudo)...'));
|
|
444
|
+
|
|
445
|
+
// Get or create keypair
|
|
446
|
+
const { publicKey, isNew } = getOrCreateKeyPair();
|
|
447
|
+
if (isNew) {
|
|
448
|
+
console.log(chalk.dim('Generated new WireGuard keypair'));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Get VPN config from platform (this registers our public key if needed)
|
|
452
|
+
const vpnConfig = await getVPNConfig();
|
|
453
|
+
|
|
454
|
+
// Write WireGuard config
|
|
455
|
+
writeWireGuardConfig(vpnConfig);
|
|
456
|
+
|
|
457
|
+
// Bring up VPN
|
|
458
|
+
vpnUp();
|
|
459
|
+
|
|
460
|
+
// Verify connectivity
|
|
461
|
+
if (!testIPv6Connectivity(targetIPv6)) {
|
|
462
|
+
// Clean up on failure
|
|
463
|
+
vpnDown();
|
|
464
|
+
throw new Error('VPN established but cannot reach target. Please try again.');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log(chalk.green('✓ VPN connected'));
|
|
468
|
+
this.wasConnectedByUs = true;
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Disconnect VPN if we established it
|
|
474
|
+
*/
|
|
475
|
+
disconnect(): void {
|
|
476
|
+
if (this.wasConnectedByUs && isVPNUp()) {
|
|
477
|
+
console.log(chalk.dim('Disconnecting VPN...'));
|
|
478
|
+
try {
|
|
479
|
+
vpnDown();
|
|
480
|
+
console.log(chalk.green('✓ VPN disconnected'));
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.log(chalk.yellow(`Warning: Failed to disconnect VPN: ${(error as Error).message}`));
|
|
483
|
+
}
|
|
484
|
+
this.wasConnectedByUs = false;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|