@lensmcp/cluster 1.0.0 → 1.2.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.
@@ -35,14 +35,39 @@ const reply_from_1 = tslib_1.__importDefault(require("@fastify/reply-from"));
35
35
  const manifest_1 = require("./manifest");
36
36
  const eid = () => Date.now().toString(36) + (0, node_crypto_1.randomBytes)(6).toString('hex');
37
37
  const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
38
+ // Path-canonicalization guard (CWE-288): a reverse proxy and its backend must agree
39
+ // on the request path, or auth gets evaluated against a DIFFERENT path than the one
40
+ // served (AWS API Gateway trailing-slash bypass, IBM API Connect CVE-2025-13915,
41
+ // Kong/Envoy/NGINX normalization mismatch). We REJECT structurally-encoded paths
42
+ // (encoded dot/slash/backslash/null) outright, and return the DECODED path for auth
43
+ // matching so `/%61dmin` is matched as `/admin` — never skipped because it's encoded.
44
+ const ENCODED_STRUCTURE = /%2e|%2f|%5c|%00|\\/i; // encoded . / \ NUL, or a raw backslash
45
+ /** Canonical path for auth matching, or undefined if the path is unsafe (→ 400 / drop). */
46
+ const canonicalPath = (rawUrl) => {
47
+ const p = rawUrl.split('?')[0] ?? '/';
48
+ if (ENCODED_STRUCTURE.test(p))
49
+ return undefined;
50
+ let decoded;
51
+ try {
52
+ decoded = decodeURIComponent(p);
53
+ }
54
+ catch {
55
+ return undefined;
56
+ } // malformed %xx
57
+ if (decoded.includes('\0') || /(^|\/)\.\.(?=\/|$)/.test(decoded))
58
+ return undefined; // NUL or `..` traversal
59
+ // Strip fragment (#…) and per-segment matrix params (;…) so we match the path the
60
+ // BACKEND resolves to — defeats /admin;x=1 and /admin#.css permission-skip bypass (CWE-288).
61
+ return decoded.split('#')[0].split('/').map((s) => s.split(';')[0]).join('/') || '/';
62
+ };
38
63
  async function startProdGateway(opts) {
39
64
  const ports = opts.ports?.length ? opts.ports : [opts.tls ? 8443 : 8080];
40
- let warnedNoAuth = false;
65
+ // Default authenticator FAILS CLOSED: a jwt route with no authenticator wired is
66
+ // rejected, never passed through. Provide opts.authenticate (or a verifier in the
67
+ // entry) to actually validate tokens.
41
68
  const authenticate = opts.authenticate ?? ((mode) => {
42
- if (mode === 'jwt' && !warnedNoAuth) {
43
- warnedNoAuth = true;
44
- console.warn('[gateway] no JWT authenticator configured — jwt routes pass through. Wire opts.authenticate for production.');
45
- }
69
+ if (mode === 'jwt')
70
+ throw new Error('jwt route but no authenticator configured');
46
71
  });
47
72
  const emit = (severity, title, fingerprint, raw, ctx) => {
48
73
  if (!opts.emit)
@@ -64,11 +89,43 @@ async function startProdGateway(opts) {
64
89
  manifests = next;
65
90
  routes = (0, manifest_1.buildRouteTable)(next);
66
91
  manifestByService = new Map(next.map((m) => [m.service, m]));
92
+ if (opts.healthChecker) { // monitor every reachable upstream: direct manifest URLs + pooled endpoints
93
+ const urls = new Set();
94
+ for (const r of routes)
95
+ if (r.upstream)
96
+ urls.add(r.upstream);
97
+ for (const u of opts.pods?.endpoints?.() ?? [])
98
+ urls.add(u);
99
+ opts.healthChecker.track([...urls]);
100
+ }
67
101
  emit('info', `gateway routes: ${routes.length} on ${manifests.length} services`, 'gateway-up', { kind: 'gateway-up', routes: routes.map((r) => ({ host: r.host ?? '(default)', service: r.service, internal: !!r.internal })) });
68
102
  };
69
103
  const unwatch = opts.manifests.watch?.((next) => rebuild(next));
70
104
  const keyToProject = new Map(Object.entries(opts.serviceKeys ?? {}).map(([p, k]) => [k, p]));
71
105
  const versionHeader = (opts.versionHeader ?? 'x-lensmcp-version').toLowerCase();
106
+ const accessLog = !!opts.accessLog;
107
+ const clientIpHeader = opts.clientIpHeader?.toLowerCase();
108
+ // `clientIpHeader` is only trusted from a known front proxy. Default: loopback
109
+ // only (a co-located cloudflared/sidecar) — an off-proxy attacker hitting the
110
+ // origin directly cannot spoof the `ip` ABAC attribute.
111
+ const trustedProxies = opts.clientIpTrustedProxies ?? [];
112
+ const peerTrusted = (peer) => {
113
+ if (!peer)
114
+ return false;
115
+ const ip = peer.startsWith('::ffff:') ? peer.slice(7) : peer;
116
+ if (trustedProxies.length === 0)
117
+ return ip === '127.0.0.1' || peer === '::1';
118
+ return trustedProxies.some((c) => (0, manifest_1.matchCidr)(ip, c));
119
+ };
120
+ /** Resolve the client IP: the trusted front-proxy header if the peer is trusted, else the fallback. */
121
+ const clientIpOf = (req, fallback) => {
122
+ if (clientIpHeader && peerTrusted(req.socket?.remoteAddress ?? undefined)) {
123
+ const h = hdr(req.headers[clientIpHeader]);
124
+ if (h)
125
+ return h;
126
+ }
127
+ return fallback;
128
+ };
72
129
  // ---- targeted rollout: attributes + sticky device key (cookie → uid header) ----
73
130
  const attrHeaders = (opts.attributeHeaders ?? []).map((h) => h.toLowerCase());
74
131
  const uidHeader = opts.uidHeader?.toLowerCase();
@@ -124,11 +181,11 @@ async function startProdGateway(opts) {
124
181
  return { key: 'd_' + (0, node_crypto_1.randomBytes)(12).toString('base64url'), mint: true };
125
182
  return { mint: false };
126
183
  };
127
- /** Assemble the flat attribute bag a rule evaluates against. */
128
- const buildAttrs = (request, principal, key) => {
129
- const req = request.raw;
184
+ /** Assemble the flat attribute bag a rule evaluates against. Shared by the HTTP
185
+ * and WebSocket paths so ABAC sees an identical bag on both (no divergence). */
186
+ const buildAttrs = (req, fallbackIp, principal, key) => {
130
187
  const a = { ...(principal ?? {}) };
131
- a['ip'] = request.ip;
188
+ a['ip'] = clientIpOf(req, fallbackIp);
132
189
  a['method'] = (req.method ?? 'GET').toUpperCase();
133
190
  a['path'] = (req.url ?? '/').split('?')[0];
134
191
  if (key)
@@ -152,13 +209,71 @@ async function startProdGateway(opts) {
152
209
  e.errors += 1;
153
210
  edges.set(key, e);
154
211
  };
212
+ // ---- per-service health rollup (feeds /statusz + the Redis status fan-out) ----
213
+ // A ring of the last N flush windows, summed at read time → a rolling view that
214
+ // doesn't flap to "idle" the instant traffic pauses. Derived purely from the
215
+ // edges the gateway already records, so it's effectively free.
216
+ const statusWindowCount = Math.max(1, opts.statusWindows ?? 12);
217
+ const statusWindows = [];
218
+ const lastSeenAt = new Map();
219
+ const routeServices = () => [...new Set(routes.map((r) => r.service))];
220
+ const pushStatusWindow = () => {
221
+ const w = new Map();
222
+ for (const e of edges.values()) {
223
+ const s = w.get(e.service) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
224
+ s.requests += e.count;
225
+ s.errors += e.errors;
226
+ s.totalMs += e.totalMs;
227
+ if (e.version)
228
+ s.versions.add(e.version);
229
+ w.set(e.service, s);
230
+ }
231
+ const now = Date.now();
232
+ for (const [svc, s] of w)
233
+ if (s.requests > 0)
234
+ lastSeenAt.set(svc, now);
235
+ statusWindows.push(w);
236
+ while (statusWindows.length > statusWindowCount)
237
+ statusWindows.shift();
238
+ };
239
+ const buildStatusSnapshot = () => {
240
+ const agg = new Map();
241
+ for (const w of statusWindows)
242
+ for (const [svc, s] of w) {
243
+ const a = agg.get(svc) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
244
+ a.requests += s.requests;
245
+ a.errors += s.errors;
246
+ a.totalMs += s.totalMs;
247
+ for (const v of s.versions)
248
+ a.versions.add(v);
249
+ agg.set(svc, a);
250
+ }
251
+ return routeServices().map((svc) => {
252
+ const a = agg.get(svc) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
253
+ const hosts = [...new Set(routes.filter((r) => r.service === svc).map((r) => r.host ?? '(default)'))];
254
+ const er = a.requests ? a.errors / a.requests : 0;
255
+ let status = a.requests === 0 ? 'idle' : er >= 0.5 ? 'down' : er >= 0.05 ? 'degraded' : 'healthy';
256
+ // active liveness: if every known upstream for this service is failing probes, it's down (even with no traffic)
257
+ if (opts.healthChecker) {
258
+ const svcUrls = routes.filter((r) => r.service === svc && r.upstream).map((r) => r.upstream);
259
+ if (svcUrls.length && svcUrls.every((u) => !opts.healthChecker.isHealthy(u)))
260
+ status = 'down';
261
+ }
262
+ return { service: svc, hosts, status, requests: a.requests, errorRate: +er.toFixed(4), avgMs: a.requests ? Math.round(a.totalMs / a.requests) : 0, versions: [...a.versions], lastSeen: lastSeenAt.get(svc) ?? null };
263
+ });
264
+ };
155
265
  const edgeFlusher = setInterval(() => {
156
- if (!opts.emit) {
157
- edges.clear();
158
- return;
266
+ pushStatusWindow(); // always: roll the health window (independent of the lens)
267
+ if (opts.onStatus) {
268
+ try {
269
+ opts.onStatus(buildStatusSnapshot());
270
+ }
271
+ catch { /* never let a status sink break traffic */ }
159
272
  }
160
- for (const e of edges.values()) {
161
- emit(e.errors ? 'warning' : 'info', `traffic ${e.host} ${e.service}${e.version ? '@' + e.version : ''}: ${e.count} req`, `gateway-traffic:${e.host}->${e.service}${e.version ? '@' + e.version : ''}`, { kind: 'gateway-traffic', host: e.host, project: e.service, caller: e.caller, version: e.version, count: e.count, avgMs: Math.round(e.totalMs / e.count), errors: e.errors });
273
+ if (opts.emit) {
274
+ for (const e of edges.values()) {
275
+ emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.service}${e.version ? '@' + e.version : ''}: ${e.count} req`, `gateway-traffic:${e.host}->${e.service}${e.version ? '@' + e.version : ''}`, { kind: 'gateway-traffic', host: e.host, project: e.service, caller: e.caller, version: e.version, count: e.count, avgMs: Math.round(e.totalMs / e.count), errors: e.errors });
276
+ }
162
277
  }
163
278
  edges.clear();
164
279
  }, opts.trafficFlushMs ?? 5000);
@@ -190,11 +305,18 @@ async function startProdGateway(opts) {
190
305
  // The gateway OWNS these headers — drop any client-supplied copy at ingress so identity
191
306
  // can only originate from a verified token (closes header spoofing), then re-stamp on forward.
192
307
  const TRUST_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
308
+ // Also drop client-supplied hop-by-hop control headers: a `Connection: x-internal-token`
309
+ // would otherwise make undici strip the gateway's OWN injected trust headers before the
310
+ // upstream (CVE-2026-33805). The proxy manages its own upstream connection semantics.
311
+ const HOP_BY_HOP = ['connection', 'proxy-connection', 'keep-alive'];
193
312
  const stripTrust = (req) => { for (const h of TRUST_HEADERS)
313
+ delete req.headers[h]; for (const h of HOP_BY_HOP)
194
314
  delete req.headers[h]; };
195
315
  /** Stamp the verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
196
316
  const stampIdentity = (req, claims) => {
197
- const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
317
+ // Only stamp SCALAR claims an object/array sub/tid must never coerce to
318
+ // "[object Object]" / a CSV tenant header (claim type-confusion).
319
+ const set = (k, v) => { if ((typeof v === 'string' && v !== '') || typeof v === 'number')
198
320
  req.headers[k] = String(v); };
199
321
  set('x-user-id', claims['sub']);
200
322
  set('x-tenant-id', claims['tid']);
@@ -209,7 +331,10 @@ async function startProdGateway(opts) {
209
331
  const resolveUpstream = async (route, trace, pinned) => {
210
332
  if (route.upstream)
211
333
  return { url: route.upstream };
212
- const opt = pinned ? { version: pinned } : undefined;
334
+ const opt = {
335
+ ...(pinned ? { version: pinned } : {}),
336
+ ...(opts.healthChecker ? { isHealthy: (u) => opts.healthChecker.isHealthy(u) } : {}),
337
+ };
213
338
  let up = opts.pods?.pick(route.service, opt);
214
339
  if (!up && opts.pods?.ensureUp) {
215
340
  step(trace, 'ensure-up', { project: route.service });
@@ -219,10 +344,13 @@ async function startProdGateway(opts) {
219
344
  return up;
220
345
  };
221
346
  const reqState = new WeakMap();
347
+ const spanByReq = new WeakMap(); // OTel span per in-flight request (ended in onResponse)
222
348
  // ---- no-route fallbacks: CORS preflight, health, discovery, 404 ----
223
349
  const setCors = (reply, req) => {
350
+ // These are UNAUTHENTICATED gateway responses (404 / health / route discovery /
351
+ // preflight). Reflect the Origin for convenience but DO NOT allow credentials —
352
+ // a reflected origin + credentials would let any site read these cross-origin.
224
353
  reply.header('access-control-allow-origin', String(req.headers.origin ?? '*'));
225
- reply.header('access-control-allow-credentials', 'true');
226
354
  reply.header('vary', 'Origin');
227
355
  };
228
356
  const handleNoRoute = (request, reply, host, url) => {
@@ -254,12 +382,51 @@ async function startProdGateway(opts) {
254
382
  const url = req.url ?? '/';
255
383
  const trace = beginTrace(req);
256
384
  step(trace, 'received', { host, method: req.method, url });
385
+ // Reject HTTP/0.9 — a header-less simple request has no Host, defeating host-keyed routing/policy.
386
+ if (req.httpVersionMajor < 1) {
387
+ finish(trace, 'gateway', 400);
388
+ reply.code(400).type('text/plain').send('Bad Request');
389
+ return reply;
390
+ }
391
+ // Reject path-canonicalization attacks BEFORE routing/auth (proxy↔backend mismatch).
392
+ const cpath = canonicalPath(url);
393
+ if (cpath === undefined) {
394
+ step(trace, 'bad-path', {});
395
+ finish(trace, 'gateway', 400);
396
+ reply.code(400).type('text/plain').send('Bad Request');
397
+ return reply;
398
+ }
399
+ // Rate limit (opt-in) BEFORE routing/auth, per real client IP — defense-in-depth.
400
+ if (opts.rateLimiter) {
401
+ // Key on the trusted client IP (header only from a trusted peer) else the SOCKET ip —
402
+ // never request.ip, which is the spoofable X-Forwarded-For chain under trustProxy.
403
+ const d = await opts.rateLimiter.check(clientIpOf(req, req.socket?.remoteAddress ?? request.ip));
404
+ if (!d.allowed) {
405
+ reply.header('retry-after', String(Math.ceil(d.retryAfterMs / 1000)));
406
+ step(trace, 'rate-limited', {});
407
+ finish(trace, 'gateway', 429);
408
+ reply.code(429).type('text/plain').send('Too Many Requests');
409
+ return reply;
410
+ }
411
+ }
257
412
  const route = (0, manifest_1.matchRoute)(routes, host, url);
258
413
  if (!route) {
259
414
  handleNoRoute(request, reply, host, url);
260
415
  return reply;
261
416
  }
262
417
  step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
418
+ // OTel SERVER span (opt-in): continues the incoming traceparent. Stored now so
419
+ // onResponse ends it on ANY exit path (auth reject / 503 / forward). Context is
420
+ // injected into req.headers just before forwarding (below) so downstream parents to it.
421
+ const span = opts.tracing?.startSpan(req, `gateway ${route.service}`, {
422
+ 'http.request.method': (req.method ?? 'GET'),
423
+ 'url.path': url.split('?')[0],
424
+ 'server.address': host,
425
+ 'lensmcp.service': route.service,
426
+ ...(route.internal ? { 'lensmcp.internal': true } : {}),
427
+ });
428
+ if (span)
429
+ spanByReq.set(request, span);
263
430
  // Verify the end-user JWT ONCE (when a verifier is configured) — reused for both edge
264
431
  // authorization and rollout targeting. Internal east-west routes use x-api-key instead.
265
432
  const claims = route.internal ? undefined : opts.identify?.(req);
@@ -278,7 +445,7 @@ async function startProdGateway(opts) {
278
445
  }
279
446
  else {
280
447
  const m = manifestByService.get(route.service);
281
- const resolved = m ? (0, manifest_1.authRuleForPath)(m, url, req.method ?? 'GET', false) : { mode: route.auth };
448
+ const resolved = m ? (0, manifest_1.authRuleForPath)(m, cpath, req.method ?? 'GET', false) : { mode: route.auth };
282
449
  if (resolved.mode === 'jwt') {
283
450
  if (opts.identify) {
284
451
  if (!claims) {
@@ -300,7 +467,8 @@ async function startProdGateway(opts) {
300
467
  }
301
468
  }
302
469
  // edge ABAC: the required permission/rule must hold against the VERIFIED claims.
303
- if (resolved.rule && claims && !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(request, claims, undefined))) {
470
+ // A present rule MUST be satisfied by VERIFIED claims. No claims (no verifier) → cannot satisfy → DENY (fail closed).
471
+ if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, request.ip, claims, undefined)))) {
304
472
  step(trace, 'authz', { ok: false });
305
473
  finish(trace, route.service, 403);
306
474
  reply.code(403).type('text/plain').send('Forbidden: insufficient permission');
@@ -316,8 +484,12 @@ async function startProdGateway(opts) {
316
484
  }
317
485
  if (opts.serviceKeys?.[route.service])
318
486
  req.headers['x-internal-token'] = opts.serviceKeys[route.service];
319
- if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix))
320
- req.url = route.prependPrefix + (req.url ?? '/');
487
+ if (route.prependPrefix) {
488
+ const u = req.url ?? '/';
489
+ const pp = route.prependPrefix;
490
+ if (u !== pp && !u.startsWith(pp + '/'))
491
+ req.url = pp + u;
492
+ } // segment-boundary, not bare startsWith (/apidoc must not escape /api)
321
493
  // ── version selection: explicit pin → ABAC rule → sticky-key weighted → SWRR ──
322
494
  // (targeting/stickiness only for external routes; internal east-west has no "device")
323
495
  const explicit = hdr(req.headers[versionHeader]);
@@ -327,7 +499,7 @@ async function startProdGateway(opts) {
327
499
  const sk = resolveStickyKey(req);
328
500
  if (sk.mint)
329
501
  mintKey = sk.key;
330
- const attrs = buildAttrs(request, claims, sk.key);
502
+ const attrs = buildAttrs(req, request.ip, claims, sk.key);
331
503
  const targeted = opts.pods?.resolveVersion?.(route.service, attrs);
332
504
  if (targeted) {
333
505
  pinned = targeted.version;
@@ -356,6 +528,9 @@ async function startProdGateway(opts) {
356
528
  const upLabel = up.url;
357
529
  reqState.set(request, { route, caller, startedAt: Date.now(), trace, version: up.version, upLabel });
358
530
  step(trace, up.version ? 'rollout' : 'upstream', { upstream: upLabel, ...(up.version ? { version: up.version, ...(explicit ? { pinned: true } : {}) } : {}) });
531
+ // Propagate the gateway span's context downstream (W3C traceparent on the forwarded request).
532
+ if (span)
533
+ opts.tracing.inject(span, req.headers);
359
534
  // No source → reply-from forwards the (prefix-rewritten) req.url to `origin`.
360
535
  return reply.from(undefined, {
361
536
  getUpstream: () => origin,
@@ -366,6 +541,9 @@ async function startProdGateway(opts) {
366
541
  // The gateway owns the edge response: drop the upstream framework leak
367
542
  // (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
368
543
  delete headers['x-powered-by'];
544
+ delete headers['connection'];
545
+ delete headers['keep-alive'];
546
+ delete headers['proxy-connection']; // strip hop-by-hop from the upstream response
369
547
  headers['server'] = 'LensMCP';
370
548
  if (mintKey) { // first contact → stamp the device cookie (appended to any upstream Set-Cookie)
371
549
  const ex = headers['set-cookie'];
@@ -376,7 +554,8 @@ async function startProdGateway(opts) {
376
554
  },
377
555
  onError: (rep, { error }) => {
378
556
  opts.pods?.drop?.(route.service, up);
379
- rep.code(502).type('text/plain').send(`Upstream ${route.service} unavailable ${error.message}`);
557
+ void error; // don't reflect the raw upstream error (host:port / errno) to the client
558
+ rep.code(502).type('text/plain').send(`Upstream ${route.service} unavailable.`);
380
559
  },
381
560
  });
382
561
  };
@@ -384,6 +563,11 @@ async function startProdGateway(opts) {
384
563
  const upgrade = (req, socket, head) => {
385
564
  stripTrust(req);
386
565
  const host = (req.headers.host || '').split(':')[0];
566
+ const cpath = canonicalPath(req.url ?? '/');
567
+ if (cpath === undefined) {
568
+ socket.destroy();
569
+ return;
570
+ } // reject path-canonicalization attacks
387
571
  const route = (0, manifest_1.matchRoute)(routes, host, req.url ?? '/');
388
572
  if (!route) {
389
573
  socket.destroy();
@@ -400,7 +584,7 @@ async function startProdGateway(opts) {
400
584
  }
401
585
  else {
402
586
  const m = manifestByService.get(route.service);
403
- const resolved = m ? (0, manifest_1.authRuleForPath)(m, req.url ?? '/', req.method ?? 'GET', false) : { mode: route.auth };
587
+ const resolved = m ? (0, manifest_1.authRuleForPath)(m, cpath, req.method ?? 'GET', false) : { mode: route.auth };
404
588
  if (resolved.mode === 'jwt') {
405
589
  if (opts.identify) {
406
590
  if (!claims) {
@@ -417,14 +601,19 @@ async function startProdGateway(opts) {
417
601
  return;
418
602
  }
419
603
  }
420
- if (resolved.rule && claims && !(0, manifest_1.evalRule)(resolved.rule, { ...claims, ip: req.socket.remoteAddress ?? '', method: req.method ?? 'GET', path: (req.url ?? '/').split('?')[0] })) {
604
+ // Same fail-closed ABAC + identical attribute bag as the HTTP path (no divergence).
605
+ if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, req.socket.remoteAddress ?? '', claims, undefined)))) {
421
606
  socket.destroy();
422
607
  return;
423
608
  }
424
609
  }
425
610
  }
426
- if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix))
427
- req.url = route.prependPrefix + (req.url ?? '/');
611
+ if (route.prependPrefix) {
612
+ const u = req.url ?? '/';
613
+ const pp = route.prependPrefix;
614
+ if (u !== pp && !u.startsWith(pp + '/'))
615
+ req.url = pp + u;
616
+ } // segment-boundary, not bare startsWith (/apidoc must not escape /api)
428
617
  const pinned = hdr(req.headers[versionHeader]);
429
618
  void (async () => {
430
619
  const up = await resolveUpstream(route, undefined, pinned);
@@ -436,8 +625,10 @@ async function startProdGateway(opts) {
436
625
  const upstream = net.connect(Number(u.port) || (u.protocol === 'https:' ? 443 : 80), u.hostname, () => {
437
626
  // rebuild the upgrade request from rawHeaders (verbatim), minus the trust headers
438
627
  // we own (incl. client identity), plus the validated caller + target token + identity.
439
- const strip = new Set(['x-internal-token', 'x-api-key-id', 'x-api-key', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions']);
440
- const lines = [`${req.method} ${req.url} HTTP/1.1`];
628
+ // Strip the trust headers AND the client's hop-by-hop control (connection/keep-alive),
629
+ // so a `Connection: x-internal-token` can't strip the gateway's injected headers downstream.
630
+ const strip = new Set(['x-internal-token', 'x-api-key-id', 'x-api-key', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions', 'connection', 'proxy-connection', 'keep-alive']);
631
+ const lines = [`${req.method} ${req.url} HTTP/1.1`, 'Connection: Upgrade']; // gateway-owned, clean upgrade
441
632
  for (let i = 0; i < req.rawHeaders.length; i += 2) {
442
633
  const k = req.rawHeaders[i];
443
634
  if (!strip.has(k.toLowerCase()))
@@ -470,9 +661,43 @@ async function startProdGateway(opts) {
470
661
  socket.on('error', () => upstream.destroy());
471
662
  })();
472
663
  };
664
+ // ---- opt-in access log: one Cloud-Logging-shaped JSON line per response ----
665
+ // Covers EVERY response (incl. 401/404/503 that never reached an upstream),
666
+ // not just forwarded ones; `reply.elapsedTime` gives latency without our own timer.
667
+ const logAccess = (request, reply, st) => {
668
+ const req = request.raw;
669
+ const status = reply.statusCode ?? 0;
670
+ const host = (req.headers.host || '').split(':')[0];
671
+ const path = (req.url || '/').split('?')[0];
672
+ const ms = Math.round(reply.elapsedTime);
673
+ try {
674
+ console.log(JSON.stringify({
675
+ severity: status >= 500 ? 'ERROR' : status >= 400 ? 'WARNING' : 'INFO',
676
+ time: new Date().toISOString(),
677
+ message: `${req.method} ${host}${path} ${status} ${ms}ms`,
678
+ method: req.method, host, path, status, ms,
679
+ ...(st ? { service: st.route.service, upstream: st.upLabel } : {}),
680
+ ...(st?.version ? { version: st.version } : {}),
681
+ ...(st?.caller ? { caller: st.caller } : {}),
682
+ ...(req.headers['x-request-id'] ? { requestId: String(req.headers['x-request-id']) } : {}),
683
+ }));
684
+ }
685
+ catch { /* never let logging break the response */ }
686
+ };
473
687
  // ---- build one Fastify app per port (shared route table + providers) ----
474
688
  const buildApp = async () => {
475
- const app = (0, fastify_1.default)({ logger: false, ...(opts.trustProxyIp ? { trustProxy: true } : {}), ...(opts.tls ? { https: { key: opts.tls.key, cert: opts.tls.cert } } : {}) });
689
+ const keepAlive = opts.server?.keepAliveTimeout ?? 620_000; // > GCP external ALB 600s backend keep-alive
690
+ const app = (0, fastify_1.default)({
691
+ logger: false,
692
+ keepAliveTimeout: keepAlive,
693
+ requestTimeout: opts.server?.requestTimeout ?? 0, // 0 = no inbound request timeout (long-lived streaming)
694
+ ...(opts.server?.connectionTimeout !== undefined ? { connectionTimeout: opts.server.connectionTimeout } : {}),
695
+ ...(opts.trustProxyIp ? { trustProxy: true } : {}),
696
+ ...(opts.tls ? { https: { key: opts.tls.key, cert: opts.tls.cert } } : {}),
697
+ });
698
+ // headersTimeout must stay above keepAliveTimeout, else Node may close an idle
699
+ // keep-alive socket mid-request under load. Default: just above keepAlive.
700
+ app.server.headersTimeout = opts.server?.headersTimeout ?? keepAlive + 10_000;
476
701
  // Stream every body straight through — never parse/buffer (this is a proxy).
477
702
  app.removeAllContentTypeParsers();
478
703
  app.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
@@ -482,7 +707,15 @@ async function startProdGateway(opts) {
482
707
  connections: opts.undici?.connections ?? 256,
483
708
  pipelining: opts.undici?.pipelining ?? 1,
484
709
  keepAliveTimeout: opts.undici?.keepAliveTimeout ?? 60_000,
485
- connect: { rejectUnauthorized: false }, // upstreams may use the local CA / self-signed (mirrors http-proxy secure:false)
710
+ bodyTimeout: opts.undici?.bodyTimeout ?? 0, // 0 = unlimited never cut long-lived SSE / streaming MCP responses
711
+ headersTimeout: opts.undici?.headersTimeout ?? 60_000, // upstream must send response headers within 60s
712
+ ...(opts.undici?.allowH2 ? { allowH2: true } : {}), // multiplex many MCP streams over few h2 conns (Cloud Run speaks h2)
713
+ connect: {
714
+ rejectUnauthorized: opts.undici?.rejectUnauthorized ?? false, // local-CA/self-signed by default; set true for verified east-west on GCP
715
+ ...(opts.undici?.cert ? { cert: opts.undici.cert } : {}), // ── mTLS: present a client cert to upstreams ──
716
+ ...(opts.undici?.key ? { key: opts.undici.key } : {}),
717
+ ...(opts.undici?.ca ? { ca: opts.undici.ca } : {}), // trust a private CA for the server side
718
+ },
486
719
  },
487
720
  });
488
721
  // Host-agnostic operational probes (answered before routing).
@@ -491,6 +724,38 @@ async function startProdGateway(opts) {
491
724
  const ready = routes.length > 0;
492
725
  reply.code(ready ? 200 : 503).type('text/plain').send(ready ? 'ready' : 'no routes loaded');
493
726
  });
727
+ // Per-service health rollup (opt-in; token-guarded). For a status page that
728
+ // prefers HTTP over the Redis `status:*` fan-out. Off by default → 404 (don't
729
+ // leak topology on a public edge unless explicitly enabled).
730
+ app.get('/statusz', (request, reply) => {
731
+ // Fail closed: /statusz is served ONLY when both enabled AND a token is set
732
+ // (never expose topology/health unauthenticated). Constant-time token compare.
733
+ if (!opts.statusEndpoint || !opts.statusToken) {
734
+ reply.code(404).type('text/plain').send('not found');
735
+ return;
736
+ }
737
+ const tok = request.query?.token ?? hdr(request.raw.headers['x-status-token']) ?? '';
738
+ const a = Buffer.from(tok), b = Buffer.from(opts.statusToken);
739
+ if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b)) {
740
+ reply.code(401).type('text/plain').send('unauthorized');
741
+ return;
742
+ }
743
+ reply.type('application/json').send(JSON.stringify({ generatedAt: Date.now(), services: buildStatusSnapshot(), endpoints: opts.healthChecker?.snapshot() ?? [] }));
744
+ });
745
+ // Process self-metrics (event-loop lag / rss / cpu / req rates) — same token gate as /statusz.
746
+ app.get('/metricsz', (request, reply) => {
747
+ if (!opts.metrics || !opts.statusToken) {
748
+ reply.code(404).type('text/plain').send('not found');
749
+ return;
750
+ }
751
+ const tok = request.query?.token ?? hdr(request.raw.headers['x-status-token']) ?? '';
752
+ const a = Buffer.from(tok), b = Buffer.from(opts.statusToken);
753
+ if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b)) {
754
+ reply.code(401).type('text/plain').send('unauthorized');
755
+ return;
756
+ }
757
+ reply.type('application/json').send(JSON.stringify(opts.metrics.snapshot()));
758
+ });
494
759
  app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
495
760
  app.addHook('onResponse', (request, reply, done) => {
496
761
  const st = reqState.get(request);
@@ -499,6 +764,14 @@ async function startProdGateway(opts) {
499
764
  finish(st.trace, st.route.service, reply.statusCode ?? 0, { upstream: st.upLabel, ...(st.version ? { version: st.version } : {}), ...(st.caller ? { caller: st.caller } : {}) });
500
765
  reqState.delete(request);
501
766
  }
767
+ const sp = spanByReq.get(request);
768
+ if (sp) {
769
+ opts.tracing.end(sp, reply.statusCode ?? 0, { ...(st?.upLabel ? { 'upstream.url': st.upLabel } : {}), ...(st?.version ? { 'lensmcp.version': st.version } : {}) });
770
+ spanByReq.delete(request);
771
+ }
772
+ opts.metrics?.recordRequest(reply.statusCode ?? 0);
773
+ if (accessLog)
774
+ logAccess(request, reply, st);
502
775
  done();
503
776
  });
504
777
  app.all('/', handler);
@@ -518,16 +791,21 @@ async function startProdGateway(opts) {
518
791
  console.log(`[gateway] listening on ${opts.tls ? 'https' : 'http'}://0.0.0.0:${bound}`);
519
792
  apps.push(app);
520
793
  }
794
+ opts.healthChecker?.start(); // begin probing now that the route table (and tracked URLs) is warm
521
795
  let stopped = false;
522
796
  return {
523
797
  ports: boundPorts,
524
798
  routes: () => routes,
799
+ status: buildStatusSnapshot,
525
800
  stop: async () => {
526
801
  if (stopped)
527
802
  return;
528
803
  stopped = true;
529
804
  clearInterval(edgeFlusher);
530
805
  unwatch?.();
806
+ opts.healthChecker?.stop();
807
+ opts.rateLimiter?.stop();
808
+ opts.metrics?.stop();
531
809
  emit('info', 'gateway down', 'gateway-down', { kind: 'gateway-down' });
532
810
  await Promise.all(apps.map((a) => a.close())); // reply-from closes its undici pool on close
533
811
  },
@@ -1 +1 @@
1
- {"version":3,"file":"providers-prod.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/providers-prod.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,KAAK,aAAa,EAAE,KAAK,UAAU,EAChH,MAAM,YAAY,CAAC;AAEpB,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,aAAa,EAAE,CAAA;CAAE;AAC/E,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,WAAW,CAAC,CAAC;AAIzE,4DAA4D;AAC5D,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,gBAAgB,CAEnF;AAgBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CAAO,GAC7D,gBAAgB,GAAG;IAAE,IAAI,EAAE,MAAM,IAAI,CAAA;CAAE,CA+BzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,YAAY,GAClB,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;CAAE,CAkDxD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAClC,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,CAapE"}
1
+ {"version":3,"file":"providers-prod.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/providers-prod.ts"],"names":[],"mappings":"AAkBA,OAAO,EAEL,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAiB,KAAK,aAAa,EAAE,KAAK,UAAU,EAChH,MAAM,YAAY,CAAC;AAEpB,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,aAAa,EAAE,CAAA;CAAE;AAC/E,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,GAAG,WAAW,CAAC,CAAC;AAIzE,4DAA4D;AAC5D,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,gBAAgB,CAEnF;AAgBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CAAO,GAC7D,gBAAgB,GAAG;IAAE,IAAI,EAAE,MAAM,IAAI,CAAA;CAAE,CA+BzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,YAAY,GAClB,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAA;CAAE,CAwExD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,GAClC,WAAW,GAAG;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,KAAK,IAAI,CAAA;CAAE,CAapE"}
@@ -139,20 +139,53 @@ function rolloutPodProvider(table) {
139
139
  const st = svc.get(service);
140
140
  if (!st || st.cohorts.length === 0)
141
141
  return undefined;
142
- let ci = -1;
143
- if (opts?.version)
144
- ci = st.cohorts.findIndex((c) => c.version === opts.version && c.urls.length > 0);
145
- if (ci < 0) {
146
- ci = st.select();
147
- // chosen cohort drained/empty → fall back to any cohort with endpoints
148
- if (!st.cohorts[ci] || st.cohorts[ci].urls.length === 0)
149
- ci = firstNonEmpty(st);
150
- }
151
- const cohort = st.cohorts[ci];
152
- if (!cohort || cohort.urls.length === 0)
142
+ const isHealthy = opts?.isHealthy;
143
+ // round-robin the chosen cohort, preferring healthy endpoints; undefined if none pass.
144
+ const pickUrl = (ci, gate) => {
145
+ const cohort = st.cohorts[ci];
146
+ if (!cohort || cohort.urls.length === 0)
147
+ return undefined;
148
+ const n = cohort.urls.length;
149
+ for (let i = 0; i < n; i++) {
150
+ st.rr[ci] = (st.rr[ci] + 1) % n;
151
+ const u = cohort.urls[st.rr[ci]];
152
+ if (!gate || gate(u))
153
+ return u;
154
+ }
153
155
  return undefined;
154
- st.rr[ci] = (st.rr[ci] + 1) % cohort.urls.length;
155
- return { url: cohort.urls[st.rr[ci]], version: cohort.version };
156
+ };
157
+ const pinnedCi = opts?.version ? st.cohorts.findIndex((c) => c.version === opts.version && c.urls.length > 0) : -1;
158
+ if (pinnedCi >= 0) {
159
+ const url = pickUrl(pinnedCi, isHealthy) ?? pickUrl(pinnedCi); // fail-open WITHIN the pinned cohort
160
+ return url !== undefined ? { url, version: st.cohorts[pinnedCi].version } : undefined;
161
+ }
162
+ // not pinned (or unknown version) → weighted selection (unchanged contract)
163
+ let ci = st.select();
164
+ if (!st.cohorts[ci] || st.cohorts[ci].urls.length === 0)
165
+ ci = firstNonEmpty(st);
166
+ let url = pickUrl(ci, isHealthy);
167
+ // chosen cohort has no healthy endpoint → try any cohort that does
168
+ if (url === undefined && isHealthy) {
169
+ for (let i = 0; i < st.cohorts.length; i++) {
170
+ const u = pickUrl(i, isHealthy);
171
+ if (u !== undefined) {
172
+ ci = i;
173
+ url = u;
174
+ break;
175
+ }
176
+ }
177
+ }
178
+ if (url === undefined)
179
+ url = pickUrl(ci); // fail-open: everything looks down → still route
180
+ return url !== undefined ? { url, version: st.cohorts[ci].version } : undefined;
181
+ },
182
+ endpoints() {
183
+ const urls = new Set();
184
+ for (const st of svc.values())
185
+ for (const c of st.cohorts)
186
+ for (const u of c.urls)
187
+ urls.add(u);
188
+ return [...urls];
156
189
  },
157
190
  // first targeting rule that matches the request's attributes (sticky-by-attribute)
158
191
  resolveVersion(service, attrs) {