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.
@@ -0,0 +1,187 @@
1
+ // Gateway Tunnel Management
2
+ // Shared utility for SSH tunneling through gateway when direct IPv6 is unavailable
3
+
4
+ import { spawn, ChildProcess } from 'child_process';
5
+ import * as net from 'net';
6
+
7
+ export interface TunnelInfo {
8
+ process: ChildProcess;
9
+ localPort: number;
10
+ vmIp: string;
11
+ gatewayIp: string;
12
+ controlPath?: string; // SSH ControlMaster socket path
13
+ }
14
+
15
+ export interface GatewayInfo {
16
+ ipv4: string;
17
+ ipv6: string;
18
+ }
19
+
20
+ /**
21
+ * Find an available local port
22
+ */
23
+ export async function findAvailablePort(): Promise<number> {
24
+ return new Promise((resolve, reject) => {
25
+ const server = net.createServer();
26
+ server.listen(0, () => {
27
+ const addr = server.address();
28
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
29
+ server.close(() => resolve(port));
30
+ });
31
+ server.on('error', reject);
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Wait for tunnel to be established by testing TCP connectivity
37
+ */
38
+ export async function waitForTunnel(port: number, timeoutMs: number = 10000): Promise<void> {
39
+ const startTime = Date.now();
40
+
41
+ while (Date.now() - startTime < timeoutMs) {
42
+ try {
43
+ await new Promise<void>((resolve, reject) => {
44
+ const socket = net.createConnection(port, 'localhost', () => {
45
+ socket.destroy();
46
+ resolve();
47
+ });
48
+ socket.on('error', () => {
49
+ socket.destroy();
50
+ reject();
51
+ });
52
+ socket.setTimeout(500, () => {
53
+ socket.destroy();
54
+ reject();
55
+ });
56
+ });
57
+ return;
58
+ } catch {
59
+ await new Promise(r => setTimeout(r, 200));
60
+ }
61
+ }
62
+
63
+ throw new Error(`Tunnel failed to establish on port ${port}`);
64
+ }
65
+
66
+ /**
67
+ * Create an SSH tunnel through the gateway to reach a VM
68
+ * Returns tunnel info including local port to connect to
69
+ */
70
+ export async function createTunnel(
71
+ vmIp: string,
72
+ gatewayIp: string,
73
+ options: { timeoutMs?: number } = {}
74
+ ): Promise<TunnelInfo> {
75
+ const { timeoutMs = 15000 } = options;
76
+
77
+ // Find an available port
78
+ const localPort = await findAvailablePort();
79
+
80
+ // Start SSH tunnel in background
81
+ // The SSH agent will provide the certificate for authentication
82
+ const tunnelProcess = spawn('ssh', [
83
+ '-N', // No remote command
84
+ '-L', `${localPort}:[${vmIp}]:22`, // Local port forward
85
+ '-o', 'StrictHostKeyChecking=no',
86
+ '-o', 'UserKnownHostsFile=/dev/null',
87
+ '-o', 'LogLevel=ERROR',
88
+ '-o', 'ExitOnForwardFailure=yes',
89
+ '-o', 'BatchMode=yes', // No interactive prompts
90
+ '-o', 'ServerAliveInterval=5', // Send keepalive every 5s (more aggressive)
91
+ '-o', 'ServerAliveCountMax=12', // Allow 12 missed keepalives (60s) before disconnect
92
+ '-o', 'TCPKeepAlive=yes', // Enable TCP keepalive
93
+ '-o', 'Compression=no', // Disable compression for stability
94
+ '-o', 'IPQoS=throughput', // Optimize for throughput
95
+ '-o', 'ConnectTimeout=30', // Connection timeout
96
+ `root@${gatewayIp}`,
97
+ ], {
98
+ detached: true,
99
+ stdio: ['ignore', 'ignore', 'ignore'], // Ignore all stdio to prevent buffer issues
100
+ });
101
+
102
+ // Unref so the tunnel doesn't prevent the process from exiting
103
+ tunnelProcess.unref();
104
+
105
+ // Wait for tunnel to be established
106
+ await waitForTunnel(localPort, timeoutMs);
107
+
108
+ return {
109
+ process: tunnelProcess,
110
+ localPort,
111
+ vmIp,
112
+ gatewayIp,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Kill a tunnel process
118
+ */
119
+ export function killTunnel(tunnel: TunnelInfo): void {
120
+ try {
121
+ tunnel.process.kill();
122
+ } catch {
123
+ // Ignore errors during cleanup
124
+ }
125
+ }
126
+
127
+ /**
128
+ * TunnelManager - Manages multiple tunnels with caching
129
+ * Use this when you need to maintain tunnels across multiple operations
130
+ */
131
+ export class TunnelManager {
132
+ private activeTunnels: Map<string, TunnelInfo> = new Map();
133
+
134
+ /**
135
+ * Get or create a tunnel for a VM
136
+ * Tunnels are cached by a key (e.g., appName or vmIp)
137
+ */
138
+ async ensureTunnel(
139
+ key: string,
140
+ vmIp: string,
141
+ gatewayIp: string,
142
+ options: { timeoutMs?: number } = {}
143
+ ): Promise<TunnelInfo> {
144
+ // Check for existing valid tunnel
145
+ const existing = this.activeTunnels.get(key);
146
+ if (existing && !existing.process.killed) {
147
+ return existing;
148
+ }
149
+
150
+ // Create new tunnel
151
+ const tunnel = await createTunnel(vmIp, gatewayIp, options);
152
+ this.activeTunnels.set(key, tunnel);
153
+ return tunnel;
154
+ }
155
+
156
+ /**
157
+ * Get an existing tunnel if available
158
+ */
159
+ getTunnel(key: string): TunnelInfo | undefined {
160
+ const tunnel = this.activeTunnels.get(key);
161
+ if (tunnel && !tunnel.process.killed) {
162
+ return tunnel;
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * Close a specific tunnel
169
+ */
170
+ closeTunnel(key: string): void {
171
+ const tunnel = this.activeTunnels.get(key);
172
+ if (tunnel) {
173
+ killTunnel(tunnel);
174
+ this.activeTunnels.delete(key);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Close all tunnels
180
+ */
181
+ closeAll(): void {
182
+ for (const tunnel of this.activeTunnels.values()) {
183
+ killTunnel(tunnel);
184
+ }
185
+ this.activeTunnels.clear();
186
+ }
187
+ }
@@ -21,6 +21,37 @@ export interface AppCluster {
21
21
 
22
22
  const PLATFORM_API_URL = process.env.HACKERRUN_API_URL || 'http://localhost:3000';
23
23
 
24
+ // Default retry configuration
25
+ const DEFAULT_RETRIES = 3;
26
+ const RETRY_DELAY_MS = 2000;
27
+
28
+ /**
29
+ * Sleep for a given number of milliseconds
30
+ */
31
+ function sleep(ms: number): Promise<void> {
32
+ return new Promise(resolve => setTimeout(resolve, ms));
33
+ }
34
+
35
+ /**
36
+ * Check if an error is retryable (network errors, timeouts, 5xx errors)
37
+ */
38
+ function isRetryableError(error: any, status?: number): boolean {
39
+ // Network errors (fetch failed, connection reset, etc.)
40
+ if (error?.cause?.code === 'ECONNREFUSED' ||
41
+ error?.cause?.code === 'ECONNRESET' ||
42
+ error?.cause?.code === 'ETIMEDOUT' ||
43
+ error?.message?.includes('fetch failed') ||
44
+ error?.message?.includes('network') ||
45
+ error?.message?.includes('ECONNREFUSED')) {
46
+ return true;
47
+ }
48
+ // Server errors (5xx)
49
+ if (status && status >= 500) {
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+
24
55
  export interface CreateVMParams {
25
56
  name: string;
26
57
  location: string;
@@ -53,55 +84,69 @@ export class PlatformClient {
53
84
  private async request<T>(
54
85
  method: string,
55
86
  path: string,
56
- body?: any
87
+ body?: any,
88
+ options: { retries?: number; retryDelay?: number; operation?: string } = {}
57
89
  ): Promise<T> {
58
- const url = `${PLATFORM_API_URL}${path}`;
59
- const headers: Record<string, string> = {
60
- Authorization: `Bearer ${this.authToken}`,
61
- 'Content-Type': 'application/json',
62
- // FIX STALE CONNECTION: If retry logic below doesn't reliably fix
63
- // "SocketError: other side closed" errors, uncomment this line and
64
- // remove the retry logic in the catch block below.
65
- // 'Connection': 'close',
66
- };
67
-
68
- const doFetch = () => fetch(url, {
69
- method,
70
- headers,
71
- body: body ? JSON.stringify(body) : undefined,
72
- });
90
+ const {
91
+ retries = DEFAULT_RETRIES,
92
+ retryDelay = RETRY_DELAY_MS,
93
+ operation = `${method} ${path}`
94
+ } = options;
73
95
 
74
- let response: Response;
75
- try {
76
- response = await doFetch();
77
- } catch (error) {
78
- // STALE CONNECTION RETRY: Node.js fetch (undici) reuses TCP connections.
79
- // After spawnSync operations, connections may become stale (server closed them).
80
- // Retry once on UND_ERR_SOCKET errors to establish a fresh connection.
81
- // If this doesn't work reliably, use 'Connection: close' header instead.
82
- const isSocketError = error instanceof Error &&
83
- (error.cause as any)?.code === 'UND_ERR_SOCKET';
84
- if (isSocketError) {
85
- response = await doFetch();
86
- } else {
87
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
88
- throw new Error(`Failed to connect to platform API (${url}): ${errMsg}`);
89
- }
90
- }
96
+ const url = `${PLATFORM_API_URL}${path}`;
97
+ let lastError: Error | null = null;
91
98
 
92
- if (!response.ok) {
93
- const errorText = await response.text();
94
- let errorMessage: string;
99
+ for (let attempt = 1; attempt <= retries; attempt++) {
95
100
  try {
96
- const errorJson = JSON.parse(errorText);
97
- errorMessage = errorJson.error || response.statusText;
98
- } catch {
99
- errorMessage = errorText || response.statusText;
101
+ const response = await fetch(url, {
102
+ method,
103
+ headers: {
104
+ Authorization: `Bearer ${this.authToken}`,
105
+ 'Content-Type': 'application/json',
106
+ },
107
+ body: body ? JSON.stringify(body) : undefined,
108
+ });
109
+
110
+ if (!response.ok) {
111
+ const errorText = await response.text();
112
+ let errorMessage: string;
113
+ try {
114
+ const errorJson = JSON.parse(errorText);
115
+ errorMessage = errorJson.error || response.statusText;
116
+ } catch {
117
+ errorMessage = errorText || response.statusText;
118
+ }
119
+
120
+ const error = new Error(`API error (${response.status}): ${errorMessage}`);
121
+
122
+ // Retry on server errors
123
+ if (isRetryableError(error, response.status) && attempt < retries) {
124
+ lastError = error;
125
+ await sleep(retryDelay * attempt); // Exponential backoff
126
+ continue;
127
+ }
128
+
129
+ throw error;
130
+ }
131
+
132
+ return response.json();
133
+ } catch (error) {
134
+ lastError = error as Error;
135
+
136
+ // Check if this is a retryable error
137
+ if (isRetryableError(error) && attempt < retries) {
138
+ await sleep(retryDelay * attempt); // Exponential backoff
139
+ continue;
140
+ }
141
+
142
+ // Not retryable or max retries reached
143
+ const errorMessage = (error as Error).message || 'Unknown error';
144
+ throw new Error(`${operation} failed: ${errorMessage}`);
100
145
  }
101
- throw new Error(`API error (${response.status}): ${errorMessage}`);
102
146
  }
103
147
 
104
- return response.json();
148
+ // Should not reach here, but just in case
149
+ throw lastError || new Error(`${operation} failed after ${retries} attempts`);
105
150
  }
106
151
 
107
152
  // ==================== App State Management ====================
@@ -119,7 +164,12 @@ export class PlatformClient {
119
164
  */
120
165
  async getApp(appName: string): Promise<AppCluster | null> {
121
166
  try {
122
- const { app } = await this.request<{ app: AppCluster }>('GET', `/api/apps/${appName}`);
167
+ const { app } = await this.request<{ app: AppCluster }>(
168
+ 'GET',
169
+ `/api/apps/${appName}`,
170
+ undefined,
171
+ { operation: `Get app '${appName}'` }
172
+ );
123
173
  return app;
124
174
  } catch (error: any) {
125
175
  if (error.message.includes('404') || error.message.includes('not found')) {
@@ -134,18 +184,23 @@ export class PlatformClient {
134
184
  * Returns the app with auto-generated domainName
135
185
  */
136
186
  async saveApp(cluster: AppCluster): Promise<AppCluster> {
137
- const { app } = await this.request<{ app: AppCluster }>('POST', '/api/apps', {
138
- appName: cluster.appName,
139
- location: cluster.location,
140
- uncloudContext: cluster.uncloudContext,
141
- nodes: cluster.nodes.map(node => ({
142
- name: node.name,
143
- id: node.id,
144
- ipv4: node.ipv4,
145
- ipv6: node.ipv6,
146
- isPrimary: node.isPrimary,
147
- })),
148
- });
187
+ const { app } = await this.request<{ app: AppCluster }>(
188
+ 'POST',
189
+ '/api/apps',
190
+ {
191
+ appName: cluster.appName,
192
+ location: cluster.location,
193
+ uncloudContext: cluster.uncloudContext,
194
+ nodes: cluster.nodes.map(node => ({
195
+ name: node.name,
196
+ id: node.id,
197
+ ipv4: node.ipv4,
198
+ ipv6: node.ipv6,
199
+ isPrimary: node.isPrimary,
200
+ })),
201
+ },
202
+ { operation: `Save app '${cluster.appName}'` }
203
+ );
149
204
  return app;
150
205
  }
151
206
 
@@ -155,7 +210,7 @@ export class PlatformClient {
155
210
  async updateLastDeployed(appName: string): Promise<void> {
156
211
  await this.request('PATCH', `/api/apps/${appName}`, {
157
212
  lastDeployedAt: new Date().toISOString(),
158
- });
213
+ }, { operation: `Update last deployed for '${appName}'` });
159
214
  }
160
215
 
161
216
  /**
@@ -182,7 +237,7 @@ export class PlatformClient {
182
237
  * Delete an app
183
238
  */
184
239
  async deleteApp(appName: string): Promise<void> {
185
- await this.request('DELETE', `/api/apps/${appName}`);
240
+ await this.request('DELETE', `/api/apps/${appName}`, undefined, { operation: `Delete app '${appName}'` });
186
241
  }
187
242
 
188
243
  /**
@@ -215,7 +270,7 @@ export class PlatformClient {
215
270
  * Create a new VM
216
271
  */
217
272
  async createVM(params: CreateVMParams): Promise<UbicloudVM> {
218
- const { vm } = await this.request<{ vm: UbicloudVM }>('POST', '/api/vms', params);
273
+ const { vm } = await this.request<{ vm: UbicloudVM }>('POST', '/api/vms', params, { operation: `Create VM '${params.name}'` });
219
274
  return vm;
220
275
  }
221
276
 
@@ -225,7 +280,9 @@ export class PlatformClient {
225
280
  async getVM(location: string, vmName: string): Promise<UbicloudVM> {
226
281
  const { vm } = await this.request<{ vm: UbicloudVM }>(
227
282
  'GET',
228
- `/api/vms/${location}/${vmName}`
283
+ `/api/vms/${location}/${vmName}`,
284
+ undefined,
285
+ { operation: `Get VM '${vmName}'` }
229
286
  );
230
287
  return vm;
231
288
  }
@@ -234,7 +291,7 @@ export class PlatformClient {
234
291
  * Delete a VM
235
292
  */
236
293
  async deleteVM(location: string, vmName: string): Promise<void> {
237
- await this.request('DELETE', `/api/vms/${location}/${vmName}`);
294
+ await this.request('DELETE', `/api/vms/${location}/${vmName}`, undefined, { operation: `Delete VM '${vmName}'` });
238
295
  }
239
296
 
240
297
  // ==================== Uncloud Config Management ====================
@@ -292,7 +349,7 @@ export class PlatformClient {
292
349
  tunnelId: string;
293
350
  location: string;
294
351
  };
295
- }>('GET', `/api/gateway?location=${encodeURIComponent(location)}`);
352
+ }>('GET', `/api/gateway?location=${encodeURIComponent(location)}`, undefined, { operation: `Get gateway for '${location}'` });
296
353
  return gateway;
297
354
  } catch (error: any) {
298
355
  if (error.message.includes('404') || error.message.includes('not found')) {
@@ -317,7 +374,7 @@ export class PlatformClient {
317
374
  appName,
318
375
  backendIpv6,
319
376
  backendPort,
320
- });
377
+ }, { operation: `Register route for '${appName}'` });
321
378
  return route;
322
379
  }
323
380
 
@@ -338,7 +395,7 @@ export class PlatformClient {
338
395
  const result = await this.request<{
339
396
  success: boolean;
340
397
  routeCount: number;
341
- }>('POST', '/api/gateway/sync', { location });
398
+ }>('POST', '/api/gateway/sync', { location }, { operation: `Sync gateway routes for '${location}'` });
342
399
  return { routeCount: result.routeCount };
343
400
  }
344
401
 
@@ -354,7 +411,7 @@ export class PlatformClient {
354
411
  return this.request<{
355
412
  caPublicKey: string;
356
413
  platformPublicKey: string;
357
- }>('GET', '/api/platform/ssh-keys');
414
+ }>('GET', '/api/platform/ssh-keys', undefined, { operation: 'Get platform SSH keys' });
358
415
  }
359
416
 
360
417
  /**
@@ -382,7 +439,7 @@ export class PlatformClient {
382
439
  return this.request<{
383
440
  certificate: string;
384
441
  validityMinutes: number;
385
- }>('POST', `/api/apps/${appName}/ssh-certificate`, { publicKey });
442
+ }>('POST', `/api/apps/${appName}/ssh-certificate`, { publicKey }, { operation: `Request SSH certificate for '${appName}'` });
386
443
  }
387
444
 
388
445
  // ==================== VM Setup ====================
@@ -396,7 +453,7 @@ export class PlatformClient {
396
453
  vmIp,
397
454
  location,
398
455
  appName,
399
- });
456
+ }, { operation: `Setup VM for '${appName}'`, retries: 5, retryDelay: 3000 });
400
457
  }
401
458
 
402
459
  // ==================== Environment Variables ====================
@@ -419,10 +476,10 @@ export class PlatformClient {
419
476
  * List environment variables (values are masked)
420
477
  */
421
478
  async listEnvVars(appName: string): Promise<Array<{ key: string; valueLength: number }>> {
422
- const { vars } = await this.request<{
423
- vars: Array<{ key: string; valueLength: number }>;
479
+ const { envVars } = await this.request<{
480
+ envVars: Array<{ key: string; valueLength: number }>;
424
481
  }>('GET', `/api/apps/${appName}/env`);
425
- return vars;
482
+ return envVars;
426
483
  }
427
484
 
428
485
  /**
@@ -489,6 +546,14 @@ export class PlatformClient {
489
546
  }
490
547
  }
491
548
 
549
+ /**
550
+ * Delete current user's GitHub App installation record
551
+ * Used when the installation becomes stale/invalid
552
+ */
553
+ async deleteGitHubInstallation(): Promise<void> {
554
+ await this.request('DELETE', '/api/github/installation');
555
+ }
556
+
492
557
  /**
493
558
  * List repositories accessible via GitHub App installation
494
559
  */
@@ -634,4 +699,61 @@ export class PlatformClient {
634
699
  }>('GET', url);
635
700
  return events;
636
701
  }
702
+
703
+ // ==================== VPN Management ====================
704
+
705
+ /**
706
+ * Register VPN peer with the platform
707
+ * This is idempotent - if already registered, returns existing config
708
+ */
709
+ async registerVPNPeer(publicKey: string, location: string): Promise<{
710
+ address: string; // User's assigned IPv6 address
711
+ gatewayEndpoint: string; // Gateway WireGuard endpoint (ip:port)
712
+ gatewayPublicKey: string; // Gateway's WireGuard public key
713
+ allowedIPs: string; // IPs to route through VPN
714
+ }> {
715
+ const { vpnConfig } = await this.request<{
716
+ vpnConfig: {
717
+ address: string;
718
+ gatewayEndpoint: string;
719
+ gatewayPublicKey: string;
720
+ allowedIPs: string;
721
+ };
722
+ }>('POST', '/api/vpn/register', { publicKey, location }, { operation: 'Register VPN peer' });
723
+ return vpnConfig;
724
+ }
725
+
726
+ /**
727
+ * Get existing VPN config if registered
728
+ */
729
+ async getVPNConfig(location: string): Promise<{
730
+ address: string;
731
+ gatewayEndpoint: string;
732
+ gatewayPublicKey: string;
733
+ allowedIPs: string;
734
+ } | null> {
735
+ try {
736
+ const { vpnConfig } = await this.request<{
737
+ vpnConfig: {
738
+ address: string;
739
+ gatewayEndpoint: string;
740
+ gatewayPublicKey: string;
741
+ allowedIPs: string;
742
+ } | null;
743
+ }>('GET', `/api/vpn/config?location=${encodeURIComponent(location)}`);
744
+ return vpnConfig;
745
+ } catch (error: any) {
746
+ if (error.message.includes('404') || error.message.includes('not found')) {
747
+ return null;
748
+ }
749
+ throw error;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Unregister VPN peer
755
+ */
756
+ async unregisterVPNPeer(): Promise<void> {
757
+ await this.request('DELETE', '/api/vpn/unregister');
758
+ }
637
759
  }