@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.
- package/executors/gateway/gateway.lib.d.ts +32 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +147 -1
- package/executors/gateway/health-check.d.ts +53 -0
- package/executors/gateway/health-check.d.ts.map +1 -0
- package/executors/gateway/health-check.js +66 -0
- package/executors/gateway/main.prod-gateway.js +219 -8
- package/executors/gateway/manifest.d.ts +7 -2
- package/executors/gateway/manifest.d.ts.map +1 -1
- package/executors/gateway/manifest.js +22 -9
- package/executors/gateway/metrics.d.ts +37 -0
- package/executors/gateway/metrics.d.ts.map +1 -0
- package/executors/gateway/metrics.js +56 -0
- package/executors/gateway/otel-tracing.d.ts +47 -0
- package/executors/gateway/otel-tracing.d.ts.map +1 -0
- package/executors/gateway/otel-tracing.js +73 -0
- package/executors/gateway/prod-gateway.lib.d.ts +87 -1
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +309 -31
- package/executors/gateway/providers-prod.d.ts.map +1 -1
- package/executors/gateway/providers-prod.js +46 -13
- package/executors/gateway/rate-limit.d.ts +66 -0
- package/executors/gateway/rate-limit.d.ts.map +1 -0
- package/executors/gateway/rate-limit.js +91 -0
- package/executors/gateway/schema.d.ts +8 -0
- package/executors/gateway/schema.json +19 -0
- package/executors.json +8 -8
- package/main.devserver.js +5 -4
- package/package.json +23 -2
|
@@ -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
|
-
|
|
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'
|
|
43
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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'] =
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
427
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
143
|
-
if
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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) {
|