Skill Tracer
Skills › gstack › browse › tunnel.ts

tunnel.ts

62 lines · browse/src/routes/tunnel.ts at 3f79d81

1/**
2 * POST /tunnel/start — start the ngrok tunnel on demand (root-only).
3 *
4 * Dual-listener model: binds a SECOND Bun.serve listener on an ephemeral
5 * 127.0.0.1 port dedicated to tunnel traffic, then points ngrok.forward() at
6 * THAT port. The local listener (which serves /extension-token,
7 * /cookie-picker, /inspector/*, welcome, etc.) is never exposed to ngrok.
8 * Hard fail if the tunnel listener bind fails — NEVER fall back to the local
9 * port, which would silently defeat the whole security property.
10 */
12import { json, jsonError, type RouteEntry } from './table';
13import { isPairAgentEnabled } from '../config';
15export const tunnelRoutes: RouteEntry[] = [
16 {
17 method: 'POST', path: '/tunnel/start', auth: 'root-token', surfaces: ['local'],
18 handler: async (_req, _r, ctx) => {
19 if (!isPairAgentEnabled()) {
20 // Consent-on-first-use: the /pair-agent skill asks once and sets the
21 // key; a direct API caller gets the same hint instead of a tunnel.
22 return jsonError(403, 'pair-agent is off (tunnel exposes this browser beyond the machine)', {
23 hint: 'enable once with: gstack-config set pair_agent on — or run /pair-agent, which asks for consent and sets it',
24 });
25 }
26 const tunnel = ctx.tunnel.state();
27 if (tunnel.active && tunnel.url && tunnel.hasListener) {
28 // Verify tunnel is still alive before returning cached URL.
29 // Probe GET /connect (the only unauth-reachable path on the tunnel
30 // surface); /health is NOT tunnel-reachable under dual-listener.
31 try {
32 const probe = await fetch(`${tunnel.url}/connect`, {
33 method: 'GET',
34 headers: { 'ngrok-skip-browser-warning': 'true' },
35 signal: AbortSignal.timeout(5000),
36 });
37 if (probe.ok) return json({ url: tunnel.url, already_active: true });
38 } catch {}
39 // Tunnel is dead — tear down cleanly before restarting
40 console.warn('[browse] Cached tunnel is dead, restarting...');
41 await ctx.tunnel.close();
42 }
44 // 1) Resolve ngrok authtoken from env / .gstack / native config
45 const authtoken = ctx.tunnel.resolveAuthtoken();
46 if (!authtoken) {
47 return jsonError(400, 'No ngrok authtoken found', { hint: 'Run: ngrok config add-authtoken YOUR_TOKEN' });
48 }
50 // 2) Bind the tunnel listener + open ngrok via the shared startTunnel
51 // helper (hard-fails the bind, cleans up both ngrok and the Bun
52 // listener on any post-bind failure).
53 const started = await ctx.tunnel.start(authtoken);
54 if (!started.ok) {
55 return jsonError(500, started.stage === 'bind'
56 ? `Failed to bind tunnel listener: ${started.error.message}`
57 : `Failed to open ngrok tunnel: ${started.error.message}`);
58 }
59 return json({ url: started.url });
60 },
61 },
62];