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
|
@@ -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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
93
|
-
const errorText = await response.text();
|
|
94
|
-
let errorMessage: string;
|
|
99
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
95
100
|
try {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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 }>(
|
|
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 }>(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 {
|
|
423
|
-
|
|
479
|
+
const { envVars } = await this.request<{
|
|
480
|
+
envVars: Array<{ key: string; valueLength: number }>;
|
|
424
481
|
}>('GET', `/api/apps/${appName}/env`);
|
|
425
|
-
return
|
|
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
|
}
|