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/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
+ }