@debugg-ai/debugg-ai-mcp 3.9.3 → 4.0.0
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/CHANGELOG.md +42 -0
- package/README.md +37 -1
- package/dist/handlers/probePageHandler.js +252 -139
- package/dist/handlers/runTestSuiteHandler.js +70 -51
- package/dist/handlers/testPageChangesHandler.js +32 -1
- package/dist/handlers/triggerCrawlHandler.js +26 -1
- package/dist/services/caddy/caddyProxy.js +611 -0
- package/dist/services/caddy/portLock.js +321 -0
- package/dist/services/ngrok/tunnelManager.js +526 -625
- package/dist/services/ngrok/tunnelRegistry.js +57 -70
- package/dist/services/verdictAdapter.js +2 -0
- package/dist/utils/telemetry.js +16 -0
- package/dist/utils/tunnelContext.js +52 -15
- package/dist/utils/tunnelDisposition.js +9 -17
- package/package.json +3 -1
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tunnel Management Service
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* ONE ngrok tunnel per SESSION KEY (§2.1 of
|
|
5
|
+
* docs/local-tunnel-multiplexer-architecture-2026-07-31.md) — not one per
|
|
6
|
+
* local port. A session's tunnel dials a local Caddy instance
|
|
7
|
+
* (services/caddy/caddyProxy.ts) that holds exactly one dynamic upstream,
|
|
8
|
+
* repointed via Caddy's admin API immediately before each tool dispatch
|
|
9
|
+
* under a per-session PortLock (services/caddy/portLock.ts) that serializes
|
|
10
|
+
* different-port calls but not same-port ones.
|
|
5
11
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* This retires the entire cross-process "borrow another MCP's tunnel"
|
|
13
|
+
* mechanism the previous per-port design needed (registry-mediated adoption,
|
|
14
|
+
* PID-liveness/freshness checks, re-adoption from the local ngrok agent) —
|
|
15
|
+
* see §4's "Same-machine multi-process / multi-session sharing" decision:
|
|
16
|
+
* "No sharing, ever, at any granularity." Every tunnel this process holds is
|
|
17
|
+
* one it created; `services/ngrok/tunnelRegistry.ts` is now write-mostly
|
|
18
|
+
* observability, not a correctness dependency.
|
|
10
19
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
20
|
+
* Session identity (§2.1): stdio has exactly one session key for its whole
|
|
21
|
+
* process life. HTTP transport derives a distinct key per caller from the
|
|
22
|
+
* request-scoped bearer token (utils/requestContext.ts), because a bare
|
|
23
|
+
* module singleton serving many HTTP callers on one process must not let two
|
|
24
|
+
* different callers share one Caddy route — that would be a cross-tenant
|
|
25
|
+
* correctness bug, not just a cost one.
|
|
26
|
+
*
|
|
27
|
+
* A single, permanent, named exception: `run_test_suite` is fire-and-forget
|
|
28
|
+
* (no poll loop, no bounded window this process can hold a lock over), so it
|
|
29
|
+
* gets its own dedicated per-call tunnel via `acquireDedicatedTunnel()` that
|
|
30
|
+
* bypasses Caddy/PortLock entirely — see §2.3.
|
|
18
31
|
*/
|
|
19
32
|
import { Logger } from '../../utils/logger.js';
|
|
20
33
|
import { Telemetry, TelemetryEvents } from '../../utils/telemetry.js';
|
|
21
|
-
import {
|
|
34
|
+
import { extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js';
|
|
35
|
+
import { currentApiKey } from '../../utils/requestContext.js';
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
22
37
|
import { v4 as uuidv4 } from 'uuid';
|
|
23
38
|
import { FaultInjector, TunnelTrace, getFaultModeFromEnv } from './tunnelFaultInjection.js';
|
|
24
39
|
import { getDefaultRegistry, } from './tunnelRegistry.js';
|
|
25
40
|
import { startAgentSession } from './ngrokAgentSession.js';
|
|
26
|
-
import {
|
|
41
|
+
import { createCaddyProxy, isDockerEnv, } from '../caddy/caddyProxy.js';
|
|
42
|
+
import { PortLock } from '../caddy/portLock.js';
|
|
27
43
|
let ngrokModule = null;
|
|
28
44
|
async function getNgrok() {
|
|
29
45
|
if (!ngrokModule) {
|
|
@@ -38,63 +54,71 @@ async function getNgrok() {
|
|
|
38
54
|
}
|
|
39
55
|
/**
|
|
40
56
|
* Reset the cached ngrok module so the next connect() bootstraps a fresh agent.
|
|
41
|
-
* Called when the last
|
|
57
|
+
* Called when the last tunnel is disconnected and the agent process may have died.
|
|
42
58
|
*/
|
|
43
59
|
function resetNgrokModule() {
|
|
44
60
|
ngrokModule = null;
|
|
45
61
|
}
|
|
46
62
|
const logger = new Logger({ module: 'tunnelManager' });
|
|
63
|
+
// ── Session identity (§2.1) ────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* Derives this call's session key. stdio: `currentApiKey()` is always
|
|
66
|
+
* unset (nothing on the stdio path ever calls
|
|
67
|
+
* `utils/requestContext.ts`'s `runWithApiKey`), so every stdio call
|
|
68
|
+
* legitimately collapses onto the fixed `'stdio'` key — that IS "one
|
|
69
|
+
* process = one caller for its whole life" (§2.1), not a fallback failure.
|
|
70
|
+
*
|
|
71
|
+
* HTTP: every request MUST carry a bearer token by the time it reaches tunnel
|
|
72
|
+
* logic — `httpServer.ts` 401s on a missing token before ever calling
|
|
73
|
+
* `runWithApiKey` — so `currentApiKey()` returning undefined while
|
|
74
|
+
* `DEBUGGAI_MCP_TRANSPORT=http` is genuinely anomalous: it means two
|
|
75
|
+
* different HTTP callers could collapse onto the same session key and get
|
|
76
|
+
* routed into each other's local dev server. That specific case is logged
|
|
77
|
+
* loudly so it surfaces in practice.
|
|
78
|
+
*
|
|
79
|
+
* NOTE — this deliberately deviates from the architecture doc's §2.1
|
|
80
|
+
* pseudocode, which logs `logger.error` on EVERY `!apiKey` fallback with no
|
|
81
|
+
* way to tell "expected stdio call" apart from "HTTP call with isolation
|
|
82
|
+
* broken" (both read as `currentApiKey() === undefined`). Following the doc
|
|
83
|
+
* literally would fire an ERROR log on every single stdio tool call — a
|
|
84
|
+
* regression, not a safety net. `DEBUGGAI_MCP_TRANSPORT` (already read by
|
|
85
|
+
* index.ts to choose stdio vs HTTP at startup) is the signal that lets the
|
|
86
|
+
* two cases be told apart; using it here is a bug fix over the doc's literal
|
|
87
|
+
* text, not a simplification of its intent (§6's "no-API-key fallback"
|
|
88
|
+
* finding is still tracked — the loud log now actually only fires for the
|
|
89
|
+
* case it was meant to catch).
|
|
90
|
+
*/
|
|
91
|
+
export function getSessionKey() {
|
|
92
|
+
const apiKey = currentApiKey();
|
|
93
|
+
if (apiKey) {
|
|
94
|
+
return `http:${createHash('sha256').update(apiKey).digest('hex').slice(0, 16)}`;
|
|
95
|
+
}
|
|
96
|
+
const transportMode = (process.env.DEBUGGAI_MCP_TRANSPORT || 'stdio').toLowerCase();
|
|
97
|
+
if (transportMode === 'http') {
|
|
98
|
+
logger.error('getSessionKey(): HTTP transport reached tunnel logic with no API key in request context — ' +
|
|
99
|
+
'falling back to a shared key. This MUST NOT be reachable on an authenticated HTTP path; ' +
|
|
100
|
+
'if it fires, tunnel isolation between callers is broken.');
|
|
101
|
+
}
|
|
102
|
+
return 'stdio';
|
|
103
|
+
}
|
|
47
104
|
// ── TunnelManager ─────────────────────────────────────────────────────────────
|
|
48
105
|
class TunnelManager {
|
|
49
106
|
reg;
|
|
50
107
|
activeTunnels = new Map();
|
|
51
|
-
|
|
108
|
+
directTunnels = new Map();
|
|
109
|
+
/** sessionKey -> tunnelId, for the fast "already have a tunnel" path. */
|
|
110
|
+
sessionTunnels = new Map();
|
|
111
|
+
/** sessionKey -> in-flight creation, so concurrent first calls for a fresh
|
|
112
|
+
* session key join one creation instead of each minting their own (§2.3's
|
|
113
|
+
* cold-start TOCTOU fix — see ensureSessionTunnel()). */
|
|
114
|
+
pendingSessionTunnels = new Map();
|
|
52
115
|
initialized = false;
|
|
53
116
|
/**
|
|
54
|
-
* Idle window before
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* when they answered it differently they cost real money (bead y7x6).
|
|
58
|
-
*
|
|
59
|
-
* Public so timer tests can run in milliseconds instead of 55 minutes,
|
|
60
|
-
* matching the `connectBackoffMs` / `agentSessionStarter` precedent.
|
|
117
|
+
* Idle window before a tunnel auto-shuts-off. Public so timer tests can run
|
|
118
|
+
* in milliseconds instead of 55 minutes, matching the `connectBackoffMs` /
|
|
119
|
+
* `agentSessionStarter` precedent.
|
|
61
120
|
*/
|
|
62
121
|
idleTimeoutMs = 55 * 60 * 1000;
|
|
63
|
-
/**
|
|
64
|
-
* Bead `3th`: registry-entry freshness window. An entry not touched within
|
|
65
|
-
* this many ms is treated as stale even if its owner PID is alive — defends
|
|
66
|
-
* against PID-reuse (OS reassigns dead-owner's PID to a different process).
|
|
67
|
-
*
|
|
68
|
-
* Bead `y7x6`: this was a hard-coded 30 minutes while tunnels lived for 55,
|
|
69
|
-
* so between T+30 and T+55 an entry was judged unusable while the tunnel it
|
|
70
|
-
* named was alive and billing. The next request provisioned a duplicate and
|
|
71
|
-
* OVERWROTE the entry, orphaning the original: a systematic double-bill on a
|
|
72
|
-
* 25-minute-wide window. Deriving it from the idle timeout is the fix, and
|
|
73
|
-
* it is the derivation — not the number — that matters, because it makes the
|
|
74
|
-
* two impossible to drift apart again.
|
|
75
|
-
*
|
|
76
|
-
* No guard band is subtracted. A band would just re-open a narrower version
|
|
77
|
-
* of the same window, and the T+55 boundary is already handled: a borrower
|
|
78
|
-
* writes `lastAccessedAt` before the owner's timer fires, and the owner then
|
|
79
|
-
* extends rather than shutting down.
|
|
80
|
-
*/
|
|
81
|
-
get registryFreshnessTtlMs() {
|
|
82
|
-
return this.idleTimeoutMs;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Bead `mdp`: prune-on-startup eviction window. Entries older than this OR
|
|
86
|
-
* with dead owner PID get swept out when TunnelManager initializes.
|
|
87
|
-
*
|
|
88
|
-
* Also derived, for the same reason: an entry older than the idle timeout
|
|
89
|
-
* names a tunnel that has already auto-shut-off, and one that has NOT is
|
|
90
|
-
* recovered by re-adoption (bead lc62) rather than by keeping a longer
|
|
91
|
-
* threshold here. Pruning something `isEntryUsable` already rejects costs
|
|
92
|
-
* nothing; the danger was never prune's window, it was that nothing put a
|
|
93
|
-
* live tunnel back.
|
|
94
|
-
*/
|
|
95
|
-
get registryPruneThresholdMs() {
|
|
96
|
-
return this.idleTimeoutMs;
|
|
97
|
-
}
|
|
98
122
|
/**
|
|
99
123
|
* Backoff schedule (ms) between ngrok.connect() retry attempts. Bead ixh.
|
|
100
124
|
* Exposed on the class so tests can override with short delays without
|
|
@@ -115,30 +139,23 @@ class TunnelManager {
|
|
|
115
139
|
*/
|
|
116
140
|
agentSessionTimeoutMs = 5000;
|
|
117
141
|
/**
|
|
118
|
-
*
|
|
119
|
-
* Overridable so tests drive a fake
|
|
142
|
+
* §2.2/§2.3: one fresh `CaddyProxy` instance PER SESSION KEY, never a
|
|
143
|
+
* process-wide singleton. Overridable so tests drive a fake proxy instead
|
|
144
|
+
* of spawning a real `caddy` process.
|
|
120
145
|
*/
|
|
121
|
-
|
|
146
|
+
caddyFactory = createCaddyProxy;
|
|
122
147
|
/** Whether the ngrok agent's client session is established (bead pqgj). */
|
|
123
148
|
agentSessionReady = false;
|
|
124
149
|
/** In-flight session bootstrap, so concurrent tunnels wait on one spawn. */
|
|
125
150
|
agentSessionPromise = null;
|
|
126
|
-
/** Memoized one-shot reconcile against the local ngrok agents (bead lc62). */
|
|
127
|
-
reconcilePromise = null;
|
|
128
151
|
constructor(reg = getDefaultRegistry()) {
|
|
129
152
|
this.reg = reg;
|
|
130
|
-
// Bead `mdp`: sweep
|
|
131
|
-
//
|
|
132
|
-
// (SIGKILL / crash).
|
|
133
|
-
//
|
|
134
|
-
// Bead lc62: this sweep deletes map keys, which cannot stop a tunnel, so a
|
|
135
|
-
// pruned-but-live tunnel becomes an invisible billing line. Making prune
|
|
136
|
-
// tear tunnels down would be far worse — two billed hours for every idle
|
|
137
|
-
// gap, on exactly the days-long sessions this design exists to serve — so
|
|
138
|
-
// recovery is handled the other way round, by reconcileWithLocalAgents()
|
|
139
|
-
// putting live tunnels BACK. Prune stays cheap, synchronous, and harmless.
|
|
153
|
+
// Bead `mdp`: sweep dead-owner entries on startup so the (now purely
|
|
154
|
+
// diagnostic — §4) registry doesn't grow unboundedly across MCP
|
|
155
|
+
// processes that exited without stopAllTunnels (SIGKILL / crash).
|
|
156
|
+
// Best-effort — no-op registries don't actually prune.
|
|
140
157
|
try {
|
|
141
|
-
const result = this.reg.prune(
|
|
158
|
+
const result = this.reg.prune();
|
|
142
159
|
if (result.pruned > 0) {
|
|
143
160
|
logger.info(`Pruned ${result.pruned} stale registry entries on startup (${result.remaining} remaining)`);
|
|
144
161
|
}
|
|
@@ -147,145 +164,191 @@ class TunnelManager {
|
|
|
147
164
|
logger.warn(`Registry prune-on-startup failed (non-fatal): ${err}`);
|
|
148
165
|
}
|
|
149
166
|
}
|
|
167
|
+
// ── Public API — session tunnels ───────────────────────────────────────────
|
|
150
168
|
/**
|
|
151
|
-
*
|
|
152
|
-
*
|
|
169
|
+
* The single entry point for "get me the tunnel for my session," replacing
|
|
170
|
+
* `processUrl()`/`processPerPort()`. Idempotent per session key: the first
|
|
171
|
+
* caller creates, everyone else — concurrent or sequential — reuses.
|
|
172
|
+
*
|
|
173
|
+
* §2.3's cold-start TOCTOU fix: the read (`sessionTunnels.get`) and the
|
|
174
|
+
* eventual write (`sessionTunnels.set`) are separated by several `await`
|
|
175
|
+
* points (spawning Caddy, connecting ngrok). Two near-simultaneous first
|
|
176
|
+
* calls for the same fresh session key — exactly what an orchestrating
|
|
177
|
+
* agent produces (an initial navigate fired alongside an initial probe) —
|
|
178
|
+
* would otherwise both observe a miss and each mint their own tunnel,
|
|
179
|
+
* silently defeating "one tunnel per session" at the moment most likely to
|
|
180
|
+
* have concurrent calls. `pendingSessionTunnels` closes that window: the
|
|
181
|
+
* claim (steps 2-3 below) is entirely synchronous relative to each other,
|
|
182
|
+
* so whichever call runs its synchronous prefix first wins the map slot,
|
|
183
|
+
* and the other necessarily observes it on its own synchronous prefix.
|
|
153
184
|
*/
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
async ensureSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
|
|
186
|
+
// 1. Fast path: a fully-created tunnel already exists for this session key.
|
|
187
|
+
const existingId = this.sessionTunnels.get(sessionKey);
|
|
188
|
+
if (existingId) {
|
|
189
|
+
const info = this.activeTunnels.get(existingId);
|
|
190
|
+
if (info) {
|
|
191
|
+
this.touchTunnel(info.tunnelId);
|
|
192
|
+
return info;
|
|
193
|
+
}
|
|
162
194
|
}
|
|
195
|
+
// 2. A creation is already in flight for this session key — join it
|
|
196
|
+
// rather than starting a second one.
|
|
197
|
+
const inFlight = this.pendingSessionTunnels.get(sessionKey);
|
|
198
|
+
if (inFlight)
|
|
199
|
+
return inFlight;
|
|
200
|
+
// 3. First caller for this session key: claim the slot BEFORE any await.
|
|
201
|
+
const creation = this.createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey)
|
|
202
|
+
.finally(() => { this.pendingSessionTunnels.delete(sessionKey); });
|
|
203
|
+
this.pendingSessionTunnels.set(sessionKey, creation);
|
|
204
|
+
return creation;
|
|
205
|
+
}
|
|
206
|
+
/** Cheap peek: an already-created session tunnel, or undefined. Never
|
|
207
|
+
* provisions anything — used by callers that want to skip a backend key
|
|
208
|
+
* provision step when reuse is possible (utils/tunnelContext.ts's
|
|
209
|
+
* findExistingTunnel, mirroring the old getTunnelForPort's role). */
|
|
210
|
+
getSessionTunnelInfo(sessionKey) {
|
|
211
|
+
const tunnelId = this.sessionTunnels.get(sessionKey);
|
|
212
|
+
return tunnelId ? this.activeTunnels.get(tunnelId) : undefined;
|
|
213
|
+
}
|
|
214
|
+
getTunnelInfo(tunnelId) {
|
|
215
|
+
return this.activeTunnels.get(tunnelId);
|
|
216
|
+
}
|
|
217
|
+
getActiveTunnels() {
|
|
218
|
+
return Array.from(this.activeTunnels.values());
|
|
219
|
+
}
|
|
220
|
+
// ── Public API — the run_test_suite exception (§2.3) ───────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Used ONLY by runTestSuiteHandler.ts. Bypasses Caddy/PortLock entirely —
|
|
223
|
+
* dials ngrok straight at the app, exactly like today's per-port
|
|
224
|
+
* createTunnel(). Governed by the same idleTimeoutMs auto-shutoff as any
|
|
225
|
+
* other tunnel. This is a deliberate, scoped exception to "one tunnel per
|
|
226
|
+
* session" (§2.3) — not a smuggled-in legacy fallback — forced by
|
|
227
|
+
* run_test_suite's async execution model: it is fire-and-forget (no poll
|
|
228
|
+
* loop, no bounded window this process can hold a lock over), so holding
|
|
229
|
+
* the shared session lock for "the whole call" would give it no protection
|
|
230
|
+
* at all — the lock would release back to contention seconds after
|
|
231
|
+
* triggering a suite that goes on to use the port for possibly many more
|
|
232
|
+
* minutes.
|
|
233
|
+
*
|
|
234
|
+
* A session that calls both a Caddy-routed tool AND run_test_suite pays for
|
|
235
|
+
* 2 tunnels for that session — honest and bounded, flagged in §6.
|
|
236
|
+
*/
|
|
237
|
+
async acquireDedicatedTunnel(url, authToken, keyId, revokeKey) {
|
|
238
|
+
await this.ensureInitialized();
|
|
163
239
|
const port = extractLocalhostPort(url);
|
|
164
240
|
if (!port) {
|
|
165
|
-
throw new Error(`
|
|
241
|
+
throw new Error(`acquireDedicatedTunnel: could not extract port from localhost URL: ${url}`);
|
|
242
|
+
}
|
|
243
|
+
const tunnelId = uuidv4();
|
|
244
|
+
const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`;
|
|
245
|
+
const isHttpsLocal = url.startsWith('https:');
|
|
246
|
+
const inDocker = isDockerEnv();
|
|
247
|
+
// NOTE: this intentionally does NOT reuse caddyProxy.ts's
|
|
248
|
+
// resolveDialAddress() — that function builds Caddy's JSON `dial` field,
|
|
249
|
+
// which is always a bare `host:port` (Caddy conveys TLS-ness via its
|
|
250
|
+
// separate `transport` field, never a URL scheme in `dial`). ngrok's
|
|
251
|
+
// own `connect({ addr })` option is a different consumer with a
|
|
252
|
+
// different format: it DOES need a `https://` scheme prefix for an
|
|
253
|
+
// HTTPS local target (see the original tunnelManager.ts:696-701, which
|
|
254
|
+
// this path preserves byte-for-byte since it dials the app directly,
|
|
255
|
+
// exactly like the pre-cutover per-port createTunnel()). Reusing
|
|
256
|
+
// resolveDialAddress() here would silently drop that scheme and break
|
|
257
|
+
// HTTPS dedicated tunnels.
|
|
258
|
+
const dockerHost = 'host.docker.internal';
|
|
259
|
+
let localAddr;
|
|
260
|
+
if (isHttpsLocal) {
|
|
261
|
+
localAddr = inDocker ? `https://${dockerHost}:${port}` : `https://localhost:${port}`;
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
localAddr = inDocker ? `${dockerHost}:${port}` : `127.0.0.1:${port}`;
|
|
265
|
+
}
|
|
266
|
+
logger.info(`Creating dedicated tunnel for localhost:${port} (domain: ${tunnelDomain}) — ` +
|
|
267
|
+
'run_test_suite exception, bypasses Caddy');
|
|
268
|
+
const faultMode = getFaultModeFromEnv();
|
|
269
|
+
const faults = new FaultInjector(faultMode);
|
|
270
|
+
const trace = new TunnelTrace();
|
|
271
|
+
trace.emit('acquireDedicatedTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
|
|
272
|
+
try {
|
|
273
|
+
const tunnelUrl = await this.connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults);
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const info = {
|
|
276
|
+
tunnelId, tunnelUrl, createdAt: now, lastAccessedAt: now, keyId, revokeKey,
|
|
277
|
+
};
|
|
278
|
+
this.directTunnels.set(tunnelId, info);
|
|
279
|
+
this.writeRegistryEntry(tunnelId, `dedicated:${tunnelId}`, tunnelUrl, -1);
|
|
280
|
+
this.armIdleTimer(info);
|
|
281
|
+
trace.emit('acquireDedicatedTunnel.success', { tunnelId, tunnelUrl });
|
|
282
|
+
logger.info(`Dedicated tunnel created: ${tunnelUrl} -> localhost:${port}`);
|
|
283
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created-dedicated' });
|
|
284
|
+
return { url: generateTunnelUrl(url, tunnelId), tunnelId };
|
|
166
285
|
}
|
|
167
|
-
|
|
168
|
-
|
|
286
|
+
catch (error) {
|
|
287
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
288
|
+
trace.emit('acquireDedicatedTunnel.fail', { message: msg.slice(0, 200) });
|
|
289
|
+
logger.warn(`Tunnel lifecycle trace (fail path):\n${trace.format()}`);
|
|
290
|
+
if (msg.includes('authtoken')) {
|
|
291
|
+
throw new Error(`Failed to create tunnel: invalid auth token. ${msg}`);
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`Failed to create tunnel: ${msg}`);
|
|
169
294
|
}
|
|
170
|
-
const tunnelId = specificTunnelId || uuidv4();
|
|
171
|
-
return this.processPerPort(url, port, authToken, tunnelId, keyId, revokeKey);
|
|
172
295
|
}
|
|
296
|
+
// ── Public API — lifecycle / teardown ──────────────────────────────────────
|
|
173
297
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
298
|
+
* Evict a tunnel that a health probe PROVED dead (e.g. ERR_NGROK_3200) —
|
|
299
|
+
* simplifies to a plain delegate now that every tunnel is created (never
|
|
300
|
+
* borrowed) by this process: there is no shared-registry adoption record
|
|
301
|
+
* to also evict (bead k34o's second half retired along with borrowing,
|
|
302
|
+
* §4). Drops the `port` parameter — eviction is no longer port-scoped.
|
|
176
303
|
*/
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (!existing)
|
|
180
|
-
return undefined;
|
|
181
|
-
if (!existing.isOwned) {
|
|
182
|
-
// Verify the owning process is still alive AND the entry is fresh
|
|
183
|
-
// (lastAccessedAt within registryFreshnessTtlMs — defends against
|
|
184
|
-
// PID-reuse per bead 3th).
|
|
185
|
-
const entry = this.reg.read()[String(port)];
|
|
186
|
-
if (!entry || !this.isEntryUsable(entry)) {
|
|
187
|
-
this.activeTunnels.delete(existing.tunnelId);
|
|
188
|
-
const reason = !entry
|
|
189
|
-
? 'no registry entry'
|
|
190
|
-
: !this.reg.isPidAlive(entry.ownerPid)
|
|
191
|
-
? `owner PID ${entry.ownerPid} dead`
|
|
192
|
-
: `entry stale (last accessed ${Math.round((Date.now() - entry.lastAccessedAt) / 1000)}s ago)`;
|
|
193
|
-
logger.info(`Evicted stale borrowed tunnel ${existing.tunnelId} (${reason})`);
|
|
194
|
-
return undefined;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return existing;
|
|
304
|
+
async markTunnelDead(tunnelId) {
|
|
305
|
+
await this.stopTunnel(tunnelId);
|
|
198
306
|
}
|
|
199
307
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* failure
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* for the same port is never removed. Best-effort, never throws.
|
|
308
|
+
* `stopTunnel()`'s ordering is a load-bearing contract, not an
|
|
309
|
+
* implementation detail (§2.3): map removal is UNCONDITIONAL and happens
|
|
310
|
+
* before any cleanup I/O, so a downstream cleanup failure (ngrok
|
|
311
|
+
* disconnect, `caddy.stop()`, key revoke) can never leave a
|
|
312
|
+
* live-looking-but-actually-dead `TunnelInfo` behind for the next call to
|
|
313
|
+
* find — the exact failure mode the `onPortChanged`-triggered eviction
|
|
314
|
+
* path (see createSessionTunnel) exists to avoid re-creating. Because this
|
|
315
|
+
* never throws (failures are caught inside `Promise.allSettled`, not
|
|
316
|
+
* propagated), callers — including a queued lock waiter promoted against a
|
|
317
|
+
* Caddy instance mid-teardown — never need a defensive `.catch()` of their
|
|
318
|
+
* own.
|
|
212
319
|
*/
|
|
213
|
-
async
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
await this.
|
|
320
|
+
async stopTunnel(tunnelId) {
|
|
321
|
+
const info = this.activeTunnels.get(tunnelId);
|
|
322
|
+
if (info) {
|
|
323
|
+
await this.stopSessionTunnel(info);
|
|
217
324
|
return;
|
|
218
325
|
}
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
const registry = this.reg.read();
|
|
225
|
-
if (registry[String(port)]?.tunnelId === tunnelId) {
|
|
226
|
-
delete registry[String(port)];
|
|
227
|
-
this.reg.write(registry);
|
|
228
|
-
logger.info(`Evicted dead borrowed tunnel ${tunnelId} for port ${port} from shared registry`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
catch {
|
|
232
|
-
// best-effort — a failed eviction just means the next call re-probes and re-evicts
|
|
326
|
+
const direct = this.directTunnels.get(tunnelId);
|
|
327
|
+
if (direct) {
|
|
328
|
+
await this.stopDirectTunnel(direct);
|
|
329
|
+
return;
|
|
233
330
|
}
|
|
331
|
+
logger.warn(`Tunnel ${tunnelId} not found for cleanup`);
|
|
234
332
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
*
|
|
242
|
-
* entry held our port, so using tunnel A kept tunnel B's entry alive after
|
|
243
|
-
* B had replaced A on that port.
|
|
244
|
-
* 2. If we OWN the tunnel and the entry has gone missing, we put it back.
|
|
245
|
-
* Nothing did this before: prune (or a registry the process could not see,
|
|
246
|
-
* bead fcbm) deleted the entry, and since the in-process reuse path never
|
|
247
|
-
* wrote to the registry, the tunnel stayed live, stayed billing, and
|
|
248
|
-
* stayed permanently invisible to every other MCP on the machine. One file
|
|
249
|
-
* write makes that self-heal, and it cannot churn a tunnel because it only
|
|
250
|
-
* ever ADDS the entry for a tunnel this process is holding open.
|
|
251
|
-
*
|
|
252
|
-
* A foreign entry — same port, different tunnelId — is left completely alone.
|
|
253
|
-
* That is another process's live tunnel; overwriting it would displace it.
|
|
254
|
-
*/
|
|
333
|
+
async stopAllTunnels() {
|
|
334
|
+
const ids = [...this.activeTunnels.keys(), ...this.directTunnels.keys()];
|
|
335
|
+
await Promise.all(ids.map((id) => this.stopTunnel(id).catch((err) => logger.error(`Failed to stop tunnel ${id}:`, err))));
|
|
336
|
+
logger.info(`Stopped ${ids.length} tunnel(s)`);
|
|
337
|
+
}
|
|
338
|
+
/** Refresh a tunnel's idle timer (and its diagnostic registry row) —
|
|
339
|
+
* called on every reuse so an in-use tunnel never auto-shuts-off. */
|
|
255
340
|
touchTunnel(tunnelId) {
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
341
|
+
const info = this.activeTunnels.get(tunnelId);
|
|
342
|
+
if (info) {
|
|
343
|
+
this.touchRegistryEntry(tunnelId);
|
|
344
|
+
this.armIdleTimer(info);
|
|
258
345
|
return;
|
|
259
|
-
try {
|
|
260
|
-
const registry = this.reg.read();
|
|
261
|
-
const key = String(tunnelInfo.port);
|
|
262
|
-
const entry = registry[key];
|
|
263
|
-
if (entry?.tunnelId === tunnelInfo.tunnelId) {
|
|
264
|
-
entry.lastAccessedAt = Date.now();
|
|
265
|
-
this.reg.write(registry);
|
|
266
|
-
}
|
|
267
|
-
else if (!entry && tunnelInfo.isOwned) {
|
|
268
|
-
registry[key] = this.registryEntryFor(tunnelInfo);
|
|
269
|
-
this.reg.write(registry);
|
|
270
|
-
logger.info(`Re-registered owned tunnel ${tunnelInfo.tunnelId} for port ${tunnelInfo.port} — ` +
|
|
271
|
-
'its registry entry had gone missing while the tunnel was still live (bead lc62).');
|
|
272
|
-
}
|
|
273
346
|
}
|
|
274
|
-
|
|
275
|
-
|
|
347
|
+
const direct = this.directTunnels.get(tunnelId);
|
|
348
|
+
if (direct) {
|
|
349
|
+
this.touchRegistryEntry(tunnelId);
|
|
350
|
+
this.armIdleTimer(direct);
|
|
276
351
|
}
|
|
277
|
-
this.resetTunnelTimer(tunnelInfo);
|
|
278
|
-
}
|
|
279
|
-
/** The shared-registry view of a tunnel this process owns. */
|
|
280
|
-
registryEntryFor(tunnelInfo) {
|
|
281
|
-
return {
|
|
282
|
-
tunnelId: tunnelInfo.tunnelId,
|
|
283
|
-
publicUrl: tunnelInfo.publicUrl,
|
|
284
|
-
tunnelUrl: tunnelInfo.tunnelUrl,
|
|
285
|
-
port: tunnelInfo.port,
|
|
286
|
-
ownerPid: process.pid,
|
|
287
|
-
lastAccessedAt: Date.now(),
|
|
288
|
-
};
|
|
289
352
|
}
|
|
290
353
|
touchTunnelByUrl(url) {
|
|
291
354
|
const tunnelId = this.extractTunnelId(url);
|
|
@@ -300,70 +363,6 @@ class TunnelManager {
|
|
|
300
363
|
const match = url.match(/https?:\/\/([^.]+)\.ngrok\.debugg\.ai/);
|
|
301
364
|
return match ? match[1] : null;
|
|
302
365
|
}
|
|
303
|
-
getTunnelInfo(tunnelId) {
|
|
304
|
-
return this.activeTunnels.get(tunnelId);
|
|
305
|
-
}
|
|
306
|
-
getActiveTunnels() {
|
|
307
|
-
return Array.from(this.activeTunnels.values());
|
|
308
|
-
}
|
|
309
|
-
async stopTunnel(tunnelId) {
|
|
310
|
-
const tunnelInfo = this.activeTunnels.get(tunnelId);
|
|
311
|
-
if (!tunnelInfo) {
|
|
312
|
-
logger.warn(`Tunnel ${tunnelId} not found for cleanup`);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
if (tunnelInfo.autoShutoffTimer) {
|
|
316
|
-
clearTimeout(tunnelInfo.autoShutoffTimer);
|
|
317
|
-
}
|
|
318
|
-
this.activeTunnels.delete(tunnelId);
|
|
319
|
-
if (!tunnelInfo.isOwned) {
|
|
320
|
-
// Borrowed — just drop the local reference; owner manages the real tunnel
|
|
321
|
-
logger.info(`Released borrowed tunnel reference: ${tunnelInfo.publicUrl}`);
|
|
322
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'released', isOwned: false });
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
// Owned — remove from shared registry, then disconnect + revoke.
|
|
326
|
-
// Guarded by tunnelId (bead lc62, same reasoning as the auto-shutoff check
|
|
327
|
-
// and markTunnelDead): if a replacement already holds this port's entry,
|
|
328
|
-
// deleting it would strand ITS live tunnel and buy the next caller a
|
|
329
|
-
// duplicate. Only ever remove the entry that names the tunnel we are
|
|
330
|
-
// actually stopping.
|
|
331
|
-
try {
|
|
332
|
-
const registry = this.reg.read();
|
|
333
|
-
const key = String(tunnelInfo.port);
|
|
334
|
-
if (registry[key]?.tunnelId === tunnelInfo.tunnelId) {
|
|
335
|
-
delete registry[key];
|
|
336
|
-
this.reg.write(registry);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
catch {
|
|
340
|
-
// best-effort
|
|
341
|
-
}
|
|
342
|
-
try {
|
|
343
|
-
const ngrok = await getNgrok();
|
|
344
|
-
await ngrok.disconnect(tunnelInfo.tunnelUrl);
|
|
345
|
-
logger.info(`Cleaned up tunnel: ${tunnelInfo.publicUrl}`);
|
|
346
|
-
}
|
|
347
|
-
catch (error) {
|
|
348
|
-
logger.warn(`ngrok.disconnect failed for tunnel ${tunnelId} (already cleaned up):`, error);
|
|
349
|
-
}
|
|
350
|
-
// If no owned tunnels remain, the ngrok agent process may have exited.
|
|
351
|
-
// Reset module + init state so the next connect() bootstraps a fresh agent.
|
|
352
|
-
const hasOwnedTunnels = Array.from(this.activeTunnels.values()).some(t => t.isOwned);
|
|
353
|
-
if (!hasOwnedTunnels) {
|
|
354
|
-
logger.info('No owned tunnels remain — resetting ngrok module for fresh init on next request');
|
|
355
|
-
resetNgrokModule();
|
|
356
|
-
this.initialized = false;
|
|
357
|
-
}
|
|
358
|
-
if (tunnelInfo.revokeKey) {
|
|
359
|
-
tunnelInfo.revokeKey().catch((err) => logger.warn(`Failed to revoke key for tunnel ${tunnelId}:`, err));
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
async stopAllTunnels() {
|
|
363
|
-
const ids = Array.from(this.activeTunnels.keys());
|
|
364
|
-
await Promise.all(ids.map((id) => this.stopTunnel(id).catch((err) => logger.error(`Failed to stop tunnel ${id}:`, err))));
|
|
365
|
-
logger.info(`Stopped ${ids.length} tunnel(s)`);
|
|
366
|
-
}
|
|
367
366
|
getTunnelStatus(tunnelId) {
|
|
368
367
|
const tunnel = this.activeTunnels.get(tunnelId);
|
|
369
368
|
if (!tunnel)
|
|
@@ -385,363 +384,301 @@ class TunnelManager {
|
|
|
385
384
|
}
|
|
386
385
|
return statuses;
|
|
387
386
|
}
|
|
388
|
-
// ──
|
|
389
|
-
async
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'reused' });
|
|
403
|
-
return { url: url_, tunnelId: existing.tunnelId, isLocalhost: true };
|
|
404
|
-
}
|
|
405
|
-
// 2. Deduplicate concurrent creation requests for the same port
|
|
406
|
-
const pending = this.pendingTunnels.get(port);
|
|
407
|
-
if (pending) {
|
|
408
|
-
// Bead 7qh Finding 2: our minted tunnelKey/keyId are now redundant — the
|
|
409
|
-
// in-flight call owns the tunnel for this port. Revoke our key up-front
|
|
410
|
-
// so it doesn't orphan on the backend. Failures are swallowed: we can't
|
|
411
|
-
// let cleanup break the join.
|
|
412
|
-
if (revokeKey) {
|
|
413
|
-
revokeKey().catch((err) => logger.warn(`Failed to revoke redundant key while joining pending tunnel for port ${port}:`, err));
|
|
414
|
-
}
|
|
415
|
-
const info = await pending;
|
|
416
|
-
// Bead zmc9: retarget to THIS caller's path, not the in-flight creator's.
|
|
417
|
-
return { url: retargetTunnelUrl(info.tunnelUrl, url), tunnelId: info.tunnelId, isLocalhost: true };
|
|
418
|
-
}
|
|
419
|
-
// 3. Check cross-process registry — another MCP instance may own a tunnel.
|
|
420
|
-
// Borrow only if the entry is fresh (PID alive AND touched within
|
|
421
|
-
// registryFreshnessTtlMs — defends against PID-reuse, bead 3th).
|
|
422
|
-
const registry = this.reg.read();
|
|
423
|
-
const regEntry = registry[String(port)];
|
|
424
|
-
if (regEntry && this.isEntryUsable(regEntry)) {
|
|
425
|
-
const borrowed = this.borrowRegistryEntry(regEntry, url, registry);
|
|
426
|
-
// Bead zmc9: retarget to THIS caller's path; regEntry.publicUrl carries the
|
|
427
|
-
// owning PID's creating-call path — replaying it is the cross-session poison.
|
|
428
|
-
return { url: retargetTunnelUrl(borrowed.tunnelUrl, url), tunnelId: borrowed.tunnelId, isLocalhost: true };
|
|
429
|
-
}
|
|
430
|
-
// 4. Nothing to reuse. Publish the pending promise SYNCHRONOUSLY — every
|
|
431
|
-
// check above is synchronous precisely so a second caller arriving in
|
|
432
|
-
// this same tick joins us rather than buying a second hour — and do the
|
|
433
|
-
// slow work (agent reconcile, then connect) inside it.
|
|
434
|
-
const creationPromise = this.adoptOrCreateTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
|
|
435
|
-
this.pendingTunnels.set(port, creationPromise);
|
|
436
|
-
let tunnelInfo;
|
|
437
|
-
try {
|
|
438
|
-
tunnelInfo = await creationPromise;
|
|
439
|
-
}
|
|
440
|
-
finally {
|
|
441
|
-
this.pendingTunnels.delete(port);
|
|
442
|
-
}
|
|
443
|
-
// A tunnel we just created carries this caller's path in publicUrl; an
|
|
444
|
-
// ADOPTED one carries someone else's, so retarget (bead zmc9).
|
|
445
|
-
const resolvedUrl = tunnelInfo.isOwned
|
|
446
|
-
? tunnelInfo.publicUrl
|
|
447
|
-
: retargetTunnelUrl(tunnelInfo.tunnelUrl, url);
|
|
448
|
-
return { url: resolvedUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true };
|
|
449
|
-
}
|
|
450
|
-
/**
|
|
451
|
-
* Take a live registry entry into this process as a BORROWED tunnel, and
|
|
452
|
-
* stamp it as touched so its owner does not auto-shut-off underneath us.
|
|
453
|
-
*/
|
|
454
|
-
borrowRegistryEntry(entry, url, registry) {
|
|
455
|
-
logger.info(`Borrowing tunnel from PID ${entry.ownerPid} for port ${entry.port}: ${entry.publicUrl}`);
|
|
456
|
-
const now = Date.now();
|
|
457
|
-
const borrowed = {
|
|
458
|
-
tunnelId: entry.tunnelId,
|
|
459
|
-
originalUrl: url,
|
|
460
|
-
tunnelUrl: entry.tunnelUrl,
|
|
461
|
-
publicUrl: entry.publicUrl,
|
|
462
|
-
port: entry.port,
|
|
463
|
-
createdAt: now,
|
|
464
|
-
lastAccessedAt: now,
|
|
465
|
-
isOwned: false,
|
|
466
|
-
};
|
|
467
|
-
this.activeTunnels.set(entry.tunnelId, borrowed);
|
|
468
|
-
entry.lastAccessedAt = now;
|
|
387
|
+
// ── Session tunnel creation ─────────────────────────────────────────────────
|
|
388
|
+
async createSessionTunnel(sessionKey, authToken, specificTunnelId, keyId, revokeKey) {
|
|
389
|
+
await this.ensureInitialized();
|
|
390
|
+
const tunnelId = specificTunnelId ?? uuidv4();
|
|
391
|
+
const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`; // UNCHANGED scheme, minted ONCE per session now
|
|
392
|
+
const caddy = this.caddyFactory(); // NEW instance per session key — never a process-wide singleton
|
|
393
|
+
const { localOrigin, adminPort } = await caddy.ensureStarted();
|
|
394
|
+
logger.info(`Creating session tunnel (domain: ${tunnelDomain}, session: ${sessionKey})`);
|
|
395
|
+
// Bead 42g: fault injection + trace. Only active when NODE_ENV !== 'production'
|
|
396
|
+
// AND DEBUGG_TUNNEL_FAULT_MODE env var is set. Zero overhead when disabled.
|
|
397
|
+
const faultMode = getFaultModeFromEnv();
|
|
398
|
+
const faults = new FaultInjector(faultMode);
|
|
399
|
+
const trace = new TunnelTrace();
|
|
400
|
+
trace.emit('createSessionTunnel.start', { tunnelId, sessionKey, hasFaultMode: !!faultMode });
|
|
469
401
|
try {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
//
|
|
402
|
+
// ngrok's own dial target is now always plain loopback HTTP to Caddy —
|
|
403
|
+
// no HTTPS/Docker complexity on this leg at all (Caddy runs in the same
|
|
404
|
+
// host/container as the MCP server). That matrix moved entirely into
|
|
405
|
+
// caddyProxy.setUpstream(), invoked per-dispatch, not per-tunnel-creation.
|
|
406
|
+
const tunnelUrl = await this.connectWithRetry(localOrigin, tunnelDomain, authToken, trace, faults);
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
const portLock = new PortLock((t) => caddy.setUpstream(t));
|
|
409
|
+
caddy.onPortChanged(() => {
|
|
410
|
+
// A crash-triggered respawn landed on a DIFFERENT local proxy port
|
|
411
|
+
// (sticky-port reclaim failed). The existing ngrok tunnel is now
|
|
412
|
+
// dialing a dead port — nothing Caddy-internal can fix this; the
|
|
413
|
+
// whole session tunnel must be torn down and recreated on the next
|
|
414
|
+
// call. stopTunnel() never throws (its unconditional-removal
|
|
415
|
+
// contract, above), so this needs no defensive .catch() of its own.
|
|
416
|
+
logger.error(`Caddy proxy port changed under session ${sessionKey} — evicting tunnel ${tunnelId}`);
|
|
417
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_EVICTED_PORT_CHANGED, { tunnelId, sessionKey });
|
|
418
|
+
void this.stopTunnel(tunnelId);
|
|
419
|
+
});
|
|
420
|
+
const info = {
|
|
421
|
+
tunnelId, sessionKey, tunnelUrl, createdAt: now, lastAccessedAt: now,
|
|
422
|
+
keyId, revokeKey, caddy, portLock,
|
|
423
|
+
};
|
|
424
|
+
this.activeTunnels.set(tunnelId, info);
|
|
425
|
+
this.sessionTunnels.set(sessionKey, tunnelId);
|
|
426
|
+
this.writeRegistryEntry(tunnelId, sessionKey, tunnelUrl, adminPort);
|
|
427
|
+
this.armIdleTimer(info);
|
|
428
|
+
trace.emit('createSessionTunnel.success', { tunnelId, tunnelUrl });
|
|
429
|
+
logger.info(`Session tunnel created: ${tunnelUrl} (session ${sessionKey})`);
|
|
430
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { tunnelId, how: 'created' });
|
|
431
|
+
return info;
|
|
474
432
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
*
|
|
490
|
-
* Reaping orphans is deliberately NOT done here. Once they can be re-adopted
|
|
491
|
-
* an orphan pointing at a live port is an asset, and killing it only
|
|
492
|
-
* guarantees we buy that hour again later.
|
|
493
|
-
*/
|
|
494
|
-
async adoptOrCreateTunnel(url, port, tunnelId, authToken, keyId, revokeKey) {
|
|
495
|
-
await this.reconcileWithLocalAgents();
|
|
496
|
-
const registry = this.reg.read();
|
|
497
|
-
const entry = registry[String(port)];
|
|
498
|
-
if (entry && this.isEntryUsable(entry)) {
|
|
499
|
-
logger.info(`Adopted live tunnel ${entry.tunnelId} for port ${port} instead of provisioning a new one`);
|
|
500
|
-
return this.borrowRegistryEntry(entry, url, registry);
|
|
433
|
+
catch (error) {
|
|
434
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
435
|
+
trace.emit('createSessionTunnel.fail', { message: msg.slice(0, 200) });
|
|
436
|
+
// Bead 42g: when the trace captured meaningful timing info, log it at
|
|
437
|
+
// WARN so operators can post-mortem. Keeping it out of the thrown error
|
|
438
|
+
// text so we don't leak internals to users.
|
|
439
|
+
logger.warn(`Tunnel lifecycle trace (fail path):\n${trace.format()}`);
|
|
440
|
+
// Never leak a Caddy process on connect failure — nothing else will
|
|
441
|
+
// ever stop() an instance that never made it into activeTunnels.
|
|
442
|
+
await caddy.stop().catch(() => { });
|
|
443
|
+
if (msg.includes('authtoken')) {
|
|
444
|
+
throw new Error(`Failed to create tunnel: invalid auth token. ${msg}`);
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`Failed to create tunnel: ${msg}`);
|
|
501
447
|
}
|
|
502
|
-
return this.createTunnel(url, port, tunnelId, authToken, keyId, revokeKey);
|
|
503
448
|
}
|
|
449
|
+
// ── Shared connect-retry ladder (KEPT byte-for-byte — bead ixh/pqgj/42g/fhg) ─
|
|
504
450
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
451
|
+
* Bead ixh: 3-attempt retry for ngrok.connect transient failures.
|
|
452
|
+
* - Attempt 1: fresh connect
|
|
453
|
+
* - Attempt 2: after 500ms backoff, reset the ngrok agent module and retry
|
|
454
|
+
* (existing "agent died" recovery path)
|
|
455
|
+
* - Attempt 3: after 1500ms backoff, retry with the already-reset agent
|
|
456
|
+
* Auth-token errors short-circuit at any attempt — no point looping.
|
|
509
457
|
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
* A usable entry is never disturbed, even by a live tunnel claiming the same
|
|
517
|
-
* port: that entry is somebody's working tunnel and displacing it would
|
|
518
|
-
* strand a paid-for session.
|
|
458
|
+
* Parameterized by `localAddr` rather than computing it internally: the
|
|
459
|
+
* session-tunnel path (createSessionTunnel) always dials Caddy's fixed
|
|
460
|
+
* local origin; the dedicated-tunnel path (acquireDedicatedTunnel) dials
|
|
461
|
+
* the app directly via the isHttpsLocal/inDocker matrix. Both need the
|
|
462
|
+
* IDENTICAL retry/backoff/fault-injection/agent-prewarm behavior, so it
|
|
463
|
+
* lives here once.
|
|
519
464
|
*/
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
465
|
+
async connectWithRetry(localAddr, tunnelDomain, authToken, trace, faults) {
|
|
466
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
467
|
+
const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
|
|
468
|
+
const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
|
|
469
|
+
const connectOpts = {
|
|
470
|
+
proto: 'http',
|
|
471
|
+
addr: localAddr,
|
|
472
|
+
hostname: tunnelDomain,
|
|
473
|
+
authtoken: authToken,
|
|
474
|
+
};
|
|
475
|
+
// Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
|
|
476
|
+
// agent's ~293ms not-ready window (which poisons the tunnel name via
|
|
477
|
+
// ngrok's own name-reusing internal retry and surfaces as
|
|
478
|
+
// "invalid tunnel configuration").
|
|
479
|
+
await this.ensureAgentSession(authToken, trace);
|
|
480
|
+
let lastError;
|
|
481
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
482
|
+
trace.emit('connect.attempt.start', { attempt });
|
|
483
|
+
// Optional fault-injected delay before each attempt.
|
|
484
|
+
const delayMs = faults.delayMsForAttempt();
|
|
485
|
+
if (delayMs > 0) {
|
|
486
|
+
trace.emit('connect.fault.delay', { attempt, delayMs });
|
|
487
|
+
await sleep(delayMs);
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const ngrok = await getNgrok();
|
|
491
|
+
// Fault-inject a synthetic failure BEFORE ngrok.connect runs so we
|
|
492
|
+
// can simulate connect-layer failures without hitting the real API.
|
|
493
|
+
if (faults.shouldFailConnect()) {
|
|
494
|
+
trace.emit('connect.fault.inject', { attempt, mode: 'fail-connect-N' });
|
|
495
|
+
throw new Error(`[fault-inject] synthetic connect failure (attempt ${attempt})`);
|
|
547
496
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
497
|
+
const url = faults.shouldReturnEmptyUrl() ? '' : await ngrok.connect(connectOpts);
|
|
498
|
+
if (!url) {
|
|
499
|
+
trace.emit('connect.attempt.empty-url', { attempt });
|
|
500
|
+
throw new Error(`ngrok.connect() returned empty URL (attempt ${attempt})`);
|
|
552
501
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}
|
|
561
|
-
findTunnelByPort(port) {
|
|
562
|
-
for (const tunnel of this.activeTunnels.values()) {
|
|
563
|
-
if (tunnel.port === port)
|
|
564
|
-
return tunnel;
|
|
565
|
-
}
|
|
566
|
-
return undefined;
|
|
567
|
-
}
|
|
568
|
-
async createTunnel(originalUrl, port, tunnelId, authToken, keyId, revokeKey) {
|
|
569
|
-
await this.ensureInitialized();
|
|
570
|
-
const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`;
|
|
571
|
-
logger.info(`Creating tunnel for localhost:${port} (domain: ${tunnelDomain})`);
|
|
572
|
-
const isHttpsLocal = originalUrl.startsWith('https:');
|
|
573
|
-
const inDocker = process.env.DOCKER_CONTAINER === 'true';
|
|
574
|
-
const dockerHost = 'host.docker.internal';
|
|
575
|
-
// Bead fhg: force IPv4 loopback when running against localhost. ngrok's
|
|
576
|
-
// default resolution of a bare port or "localhost" can pick IPv6 [::1]
|
|
577
|
-
// first on macOS/modern OSes, but most dev servers (Next.js, Vite) bind
|
|
578
|
-
// only to 127.0.0.1 — resulting in ngrok connect:refused + ERR_NGROK_8012
|
|
579
|
-
// on the browser side with no actionable error back to the MCP caller.
|
|
580
|
-
let localAddr;
|
|
581
|
-
if (isHttpsLocal) {
|
|
582
|
-
localAddr = inDocker ? `https://${dockerHost}:${port}` : `https://localhost:${port}`;
|
|
583
|
-
}
|
|
584
|
-
else {
|
|
585
|
-
localAddr = inDocker ? `${dockerHost}:${port}` : `127.0.0.1:${port}`;
|
|
586
|
-
}
|
|
587
|
-
// Bead ixh: 3-attempt retry for ngrok.connect transient failures. Previously
|
|
588
|
-
// only retried ONCE (with agent reset), which is insufficient against real
|
|
589
|
-
// ngrok / network flakes (client-reported incident 2026-04-24).
|
|
590
|
-
// - Attempt 1: fresh connect
|
|
591
|
-
// - Attempt 2: after 500ms backoff, reset the ngrok agent module and retry
|
|
592
|
-
// (existing "agent died" recovery path)
|
|
593
|
-
// - Attempt 3: after 1500ms backoff, retry with the already-reset agent
|
|
594
|
-
// Auth-token errors short-circuit at any attempt — no point looping.
|
|
595
|
-
// Bead 42g: fault injection + trace. Only active when NODE_ENV !== 'production'
|
|
596
|
-
// AND DEBUGG_TUNNEL_FAULT_MODE env var is set. Zero overhead when disabled.
|
|
597
|
-
const faultMode = getFaultModeFromEnv();
|
|
598
|
-
const faults = new FaultInjector(faultMode);
|
|
599
|
-
const trace = new TunnelTrace();
|
|
600
|
-
trace.emit('createTunnel.start', { port, tunnelId, hasFaultMode: !!faultMode });
|
|
601
|
-
const connectWithRetry = async () => {
|
|
602
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
603
|
-
const BACKOFF_MS = this.connectBackoffMs; // bead ixh: test-overridable
|
|
604
|
-
const MAX_ATTEMPTS = BACKOFF_MS.length + 1; // N sleeps between N+1 attempts
|
|
605
|
-
const connectOpts = {
|
|
606
|
-
proto: 'http',
|
|
607
|
-
addr: localAddr,
|
|
608
|
-
hostname: tunnelDomain,
|
|
609
|
-
authtoken: authToken,
|
|
610
|
-
};
|
|
611
|
-
// Bead pqgj: pre-warm the agent session so attempt 1 doesn't race the
|
|
612
|
-
// agent's ~293ms not-ready window (which poisons the tunnel name via
|
|
613
|
-
// ngrok's own name-reusing internal retry and surfaces as
|
|
614
|
-
// "invalid tunnel configuration").
|
|
615
|
-
await this.ensureAgentSession(authToken, trace);
|
|
616
|
-
let lastError;
|
|
617
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
618
|
-
trace.emit('connect.attempt.start', { attempt });
|
|
619
|
-
// Optional fault-injected delay before each attempt.
|
|
620
|
-
const delayMs = faults.delayMsForAttempt();
|
|
621
|
-
if (delayMs > 0) {
|
|
622
|
-
trace.emit('connect.fault.delay', { attempt, delayMs });
|
|
623
|
-
await sleep(delayMs);
|
|
624
|
-
}
|
|
625
|
-
try {
|
|
626
|
-
const ngrok = await getNgrok();
|
|
627
|
-
// Fault-inject a synthetic failure BEFORE ngrok.connect runs so we
|
|
628
|
-
// can simulate connect-layer failures without hitting the real API.
|
|
629
|
-
if (faults.shouldFailConnect()) {
|
|
630
|
-
trace.emit('connect.fault.inject', { attempt, mode: 'fail-connect-N' });
|
|
631
|
-
throw new Error(`[fault-inject] synthetic connect failure (attempt ${attempt})`);
|
|
632
|
-
}
|
|
633
|
-
const url = faults.shouldReturnEmptyUrl() ? '' : await ngrok.connect(connectOpts);
|
|
634
|
-
if (!url) {
|
|
635
|
-
trace.emit('connect.attempt.empty-url', { attempt });
|
|
636
|
-
throw new Error(`ngrok.connect() returned empty URL (attempt ${attempt})`);
|
|
637
|
-
}
|
|
638
|
-
trace.emit('connect.attempt.success', { attempt });
|
|
639
|
-
if (attempt > 1) {
|
|
640
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
641
|
-
attempt,
|
|
642
|
-
outcome: 'success',
|
|
643
|
-
stage: 'ngrok_connect',
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
return url;
|
|
502
|
+
trace.emit('connect.attempt.success', { attempt });
|
|
503
|
+
if (attempt > 1) {
|
|
504
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
505
|
+
attempt,
|
|
506
|
+
outcome: 'success',
|
|
507
|
+
stage: 'ngrok_connect',
|
|
508
|
+
});
|
|
647
509
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
outcome: 'giving-up',
|
|
659
|
-
stage: 'ngrok_connect',
|
|
660
|
-
reason: 'auth-error',
|
|
661
|
-
});
|
|
662
|
-
throw err;
|
|
663
|
-
}
|
|
664
|
-
const isLastAttempt = attempt >= MAX_ATTEMPTS;
|
|
510
|
+
return url;
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
lastError = err;
|
|
514
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
515
|
+
trace.emit('connect.attempt.fail', { attempt, message: msg.slice(0, 200) });
|
|
516
|
+
// Auth-class errors are non-retryable — retrying with the same token
|
|
517
|
+
// would loop. Let the outer catch classify the message.
|
|
518
|
+
if (/authtoken|unauthorized|\b401\b|\b403\b/i.test(msg)) {
|
|
519
|
+
trace.emit('connect.giving-up', { reason: 'auth-error' });
|
|
665
520
|
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
666
521
|
attempt,
|
|
667
|
-
outcome:
|
|
522
|
+
outcome: 'giving-up',
|
|
668
523
|
stage: 'ngrok_connect',
|
|
524
|
+
reason: 'auth-error',
|
|
669
525
|
});
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
this.initialized = false;
|
|
682
|
-
await this.ensureInitialized();
|
|
683
|
-
}
|
|
684
|
-
else {
|
|
685
|
-
logger.warn(`ngrok.connect() failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
|
|
686
|
-
}
|
|
687
|
-
const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
|
|
688
|
-
trace.emit('connect.backoff', { attempt, backoffMs });
|
|
689
|
-
await sleep(backoffMs);
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
const isLastAttempt = attempt >= MAX_ATTEMPTS;
|
|
529
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISION_RETRY, {
|
|
530
|
+
attempt,
|
|
531
|
+
outcome: isLastAttempt ? 'giving-up' : 'will-retry',
|
|
532
|
+
stage: 'ngrok_connect',
|
|
533
|
+
});
|
|
534
|
+
if (isLastAttempt) {
|
|
535
|
+
trace.emit('connect.giving-up', { reason: 'max-attempts' });
|
|
536
|
+
throw err;
|
|
690
537
|
}
|
|
538
|
+
// Between attempt 1→2, do an agent-reset (covers the "agent died"
|
|
539
|
+
// failure mode that used to be the only retried case). Between 2→3,
|
|
540
|
+
// just wait — the reset already happened.
|
|
541
|
+
if (attempt === 1) {
|
|
542
|
+
logger.warn(`ngrok.connect() failed (attempt 1/${MAX_ATTEMPTS}), resetting agent: ${msg}`);
|
|
543
|
+
trace.emit('agent.reset');
|
|
544
|
+
resetNgrokModule();
|
|
545
|
+
this.initialized = false;
|
|
546
|
+
await this.ensureInitialized();
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
logger.warn(`ngrok.connect() failed (attempt ${attempt}/${MAX_ATTEMPTS}), will retry: ${msg}`);
|
|
550
|
+
}
|
|
551
|
+
const backoffMs = BACKOFF_MS[attempt - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
|
|
552
|
+
trace.emit('connect.backoff', { attempt, backoffMs });
|
|
553
|
+
await sleep(backoffMs);
|
|
691
554
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
555
|
+
}
|
|
556
|
+
// Unreachable (loop always returns or throws), but satisfy TS
|
|
557
|
+
throw lastError ?? new Error('connectWithRetry: exhausted attempts without error');
|
|
558
|
+
}
|
|
559
|
+
// ── Teardown internals ──────────────────────────────────────────────────────
|
|
560
|
+
async stopSessionTunnel(info) {
|
|
561
|
+
// Unconditional, synchronous, BEFORE any cleanup I/O. A partial failure
|
|
562
|
+
// below can never leave stale-but-discoverable state — the next call for
|
|
563
|
+
// this session key always sees a clean miss and rebuilds from scratch.
|
|
564
|
+
this.activeTunnels.delete(info.tunnelId);
|
|
565
|
+
if (this.sessionTunnels.get(info.sessionKey) === info.tunnelId) {
|
|
566
|
+
this.sessionTunnels.delete(info.sessionKey);
|
|
567
|
+
}
|
|
568
|
+
if (info.autoShutoffTimer)
|
|
569
|
+
clearTimeout(info.autoShutoffTimer);
|
|
570
|
+
this.removeRegistryEntry(info.tunnelId);
|
|
571
|
+
const results = await Promise.allSettled([
|
|
572
|
+
this.disconnectNgrok(info.tunnelUrl),
|
|
573
|
+
info.caddy.stop(),
|
|
574
|
+
info.revokeKey ? info.revokeKey() : Promise.resolve(),
|
|
575
|
+
]);
|
|
576
|
+
results.forEach((r, i) => {
|
|
577
|
+
if (r.status === 'rejected') {
|
|
578
|
+
// Logged and telemetered, never rethrown and never blocks/reverts the
|
|
579
|
+
// removal above — state is already gone by the time this runs.
|
|
580
|
+
logger.warn(`stopTunnel(${info.tunnelId}) cleanup step ${i} failed (state already removed): ${r.reason}`);
|
|
581
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
this.maybeResetNgrokModule();
|
|
585
|
+
logger.info(`Cleaned up session tunnel: ${info.tunnelUrl}`);
|
|
586
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: info.tunnelId, reason: 'stopped' });
|
|
587
|
+
}
|
|
588
|
+
async stopDirectTunnel(info) {
|
|
589
|
+
// Same unconditional-removal contract as stopSessionTunnel, above.
|
|
590
|
+
this.directTunnels.delete(info.tunnelId);
|
|
591
|
+
if (info.autoShutoffTimer)
|
|
592
|
+
clearTimeout(info.autoShutoffTimer);
|
|
593
|
+
this.removeRegistryEntry(info.tunnelId);
|
|
594
|
+
const results = await Promise.allSettled([
|
|
595
|
+
this.disconnectNgrok(info.tunnelUrl),
|
|
596
|
+
info.revokeKey ? info.revokeKey() : Promise.resolve(),
|
|
597
|
+
]);
|
|
598
|
+
results.forEach((r, i) => {
|
|
599
|
+
if (r.status === 'rejected') {
|
|
600
|
+
logger.warn(`stopTunnel(${info.tunnelId}) dedicated-tunnel cleanup step ${i} failed (state already removed): ${r.reason}`);
|
|
601
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_TEARDOWN_PARTIAL_FAILURE, { tunnelId: info.tunnelId, step: i });
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
this.maybeResetNgrokModule();
|
|
605
|
+
logger.info(`Cleaned up dedicated tunnel: ${info.tunnelUrl}`);
|
|
606
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: info.tunnelId, reason: 'stopped' });
|
|
607
|
+
}
|
|
608
|
+
async disconnectNgrok(tunnelUrl) {
|
|
609
|
+
const ngrok = await getNgrok();
|
|
610
|
+
await ngrok.disconnect(tunnelUrl);
|
|
611
|
+
}
|
|
612
|
+
/** If no tunnels of any kind remain, the ngrok agent process may have
|
|
613
|
+
* exited. Reset module + init state so the next connect() bootstraps a
|
|
614
|
+
* fresh agent. */
|
|
615
|
+
maybeResetNgrokModule() {
|
|
616
|
+
if (this.activeTunnels.size === 0 && this.directTunnels.size === 0) {
|
|
617
|
+
logger.info('No tunnels remain — resetting ngrok module for fresh init on next request');
|
|
618
|
+
resetNgrokModule();
|
|
619
|
+
this.initialized = false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// ── Idle timer (KEPT — minus the retired cross-process extension branch) ───
|
|
623
|
+
/**
|
|
624
|
+
* Bead y7x6/lc62's cross-process "another process touched the registry
|
|
625
|
+
* entry, so extend instead of shutting down" branch is retired along with
|
|
626
|
+
* borrowing (§4/§5.1): every tunnel now has exactly one process that could
|
|
627
|
+
* ever be using it, so there is nothing else to check for before shutting
|
|
628
|
+
* an idle one down. The core mechanism — arm a timer, clear+rearm on
|
|
629
|
+
* touch, stop on expiry — is otherwise unchanged.
|
|
630
|
+
*/
|
|
631
|
+
armIdleTimer(entry) {
|
|
632
|
+
if (entry.autoShutoffTimer)
|
|
633
|
+
clearTimeout(entry.autoShutoffTimer);
|
|
634
|
+
entry.lastAccessedAt = Date.now();
|
|
635
|
+
entry.autoShutoffTimer = setTimeout(async () => {
|
|
636
|
+
logger.info(`Auto-shutting down tunnel ${entry.tunnelId} after inactivity`);
|
|
637
|
+
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { tunnelId: entry.tunnelId, reason: 'auto-shutoff' });
|
|
638
|
+
await this.stopTunnel(entry.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${entry.tunnelId}:`, err));
|
|
639
|
+
}, this.idleTimeoutMs);
|
|
640
|
+
}
|
|
641
|
+
// ── Registry writes (§4: write-mostly observability, best-effort) ──────────
|
|
642
|
+
writeRegistryEntry(tunnelId, sessionKey, tunnelUrl, caddyAdminPort) {
|
|
695
643
|
try {
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
const now = Date.now();
|
|
699
|
-
const tunnelInfo = {
|
|
644
|
+
const registry = this.reg.read();
|
|
645
|
+
registry[tunnelId] = {
|
|
700
646
|
tunnelId,
|
|
701
|
-
|
|
647
|
+
sessionKey,
|
|
648
|
+
publicUrl: tunnelUrl,
|
|
702
649
|
tunnelUrl,
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
lastAccessedAt: now,
|
|
707
|
-
isOwned: true,
|
|
708
|
-
keyId,
|
|
709
|
-
revokeKey,
|
|
650
|
+
caddyAdminPort,
|
|
651
|
+
ownerPid: process.pid,
|
|
652
|
+
lastAccessedAt: Date.now(),
|
|
710
653
|
};
|
|
711
|
-
this.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
};
|
|
654
|
+
this.reg.write(registry);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// best-effort — nothing reads this for correctness anymore
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
touchRegistryEntry(tunnelId) {
|
|
661
|
+
try {
|
|
662
|
+
const registry = this.reg.read();
|
|
663
|
+
if (registry[tunnelId]) {
|
|
664
|
+
registry[tunnelId].lastAccessedAt = Date.now();
|
|
723
665
|
this.reg.write(registry);
|
|
724
666
|
}
|
|
725
|
-
catch {
|
|
726
|
-
// best-effort
|
|
727
|
-
}
|
|
728
|
-
this.resetTunnelTimer(tunnelInfo);
|
|
729
|
-
trace.emit('createTunnel.success', { tunnelId, publicUrl });
|
|
730
|
-
logger.info(`Tunnel created: ${publicUrl} → localhost:${port}`);
|
|
731
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_PROVISIONED, { port, how: 'created' });
|
|
732
|
-
return tunnelInfo;
|
|
733
667
|
}
|
|
734
|
-
catch
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if (
|
|
742
|
-
|
|
668
|
+
catch {
|
|
669
|
+
// best-effort
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
removeRegistryEntry(tunnelId) {
|
|
673
|
+
try {
|
|
674
|
+
const registry = this.reg.read();
|
|
675
|
+
if (registry[tunnelId]) {
|
|
676
|
+
delete registry[tunnelId];
|
|
677
|
+
this.reg.write(registry);
|
|
743
678
|
}
|
|
744
|
-
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// best-effort
|
|
745
682
|
}
|
|
746
683
|
}
|
|
747
684
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -812,42 +749,6 @@ class TunnelManager {
|
|
|
812
749
|
this.initialized = true;
|
|
813
750
|
}
|
|
814
751
|
}
|
|
815
|
-
resetTunnelTimer(tunnelInfo) {
|
|
816
|
-
if (tunnelInfo.autoShutoffTimer)
|
|
817
|
-
clearTimeout(tunnelInfo.autoShutoffTimer);
|
|
818
|
-
tunnelInfo.lastAccessedAt = Date.now();
|
|
819
|
-
tunnelInfo.autoShutoffTimer = setTimeout(async () => {
|
|
820
|
-
// For owned tunnels: if another process recently touched the registry entry,
|
|
821
|
-
// reset the timer rather than disconnecting — that process is still using it.
|
|
822
|
-
//
|
|
823
|
-
// Bead lc62: the entry has to be OURS. This lookup is keyed by port, and
|
|
824
|
-
// the check used to stop at the timestamp, so once a replacement tunnel
|
|
825
|
-
// took over the port the displaced tunnel read the replacement's activity
|
|
826
|
-
// as its own and extended itself — forever, since every extension found
|
|
827
|
-
// the entry fresh again. That is what turned a 55-minute mistake into a
|
|
828
|
-
// multi-day one: an orphan nobody could reach, billing indefinitely.
|
|
829
|
-
// Comparing tunnelId makes an orphan simply time out, 55 idle minutes
|
|
830
|
-
// after the last time anyone actually used IT.
|
|
831
|
-
if (tunnelInfo.isOwned) {
|
|
832
|
-
try {
|
|
833
|
-
const entry = this.reg.read()[String(tunnelInfo.port)];
|
|
834
|
-
if (entry &&
|
|
835
|
-
entry.tunnelId === tunnelInfo.tunnelId &&
|
|
836
|
-
Date.now() - entry.lastAccessedAt < this.idleTimeoutMs) {
|
|
837
|
-
logger.info(`Tunnel ${tunnelInfo.tunnelId} accessed by another process — extending lifetime`);
|
|
838
|
-
this.resetTunnelTimer(tunnelInfo);
|
|
839
|
-
return;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
catch {
|
|
843
|
-
// best-effort; proceed with shutoff
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after inactivity`);
|
|
847
|
-
Telemetry.capture(TelemetryEvents.TUNNEL_STOPPED, { port: tunnelInfo.port, reason: 'auto-shutoff', isOwned: tunnelInfo.isOwned });
|
|
848
|
-
await this.stopTunnel(tunnelInfo.tunnelId).catch((err) => logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, err));
|
|
849
|
-
}, this.idleTimeoutMs);
|
|
850
|
-
}
|
|
851
752
|
}
|
|
852
753
|
const tunnelManager = new TunnelManager();
|
|
853
754
|
export { tunnelManager };
|