@lensmcp/cluster 1.12.0 → 1.13.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 +15 -167
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +24 -1041
- package/executors/gateway/main.prod-gateway.js +74 -2
- package/executors/gateway/prod-gateway.lib.d.ts +19 -151
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +16 -863
- package/executors/gateway/prod-runtime/access-log.d.ts +9 -0
- package/executors/gateway/prod-runtime/access-log.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/access-log.js +24 -0
- package/executors/gateway/prod-runtime/app.d.ts +23 -0
- package/executors/gateway/prod-runtime/app.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/app.js +123 -0
- package/executors/gateway/prod-runtime/auth.d.ts +30 -0
- package/executors/gateway/prod-runtime/auth.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/auth.js +51 -0
- package/executors/gateway/prod-runtime/cors.d.ts +19 -0
- package/executors/gateway/prod-runtime/cors.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/cors.js +40 -0
- package/executors/gateway/prod-runtime/edge.d.ts +16 -0
- package/executors/gateway/prod-runtime/edge.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/edge.js +65 -0
- package/executors/gateway/prod-runtime/handler.d.ts +32 -0
- package/executors/gateway/prod-runtime/handler.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/handler.js +226 -0
- package/executors/gateway/prod-runtime/hooks.d.ts +20 -0
- package/executors/gateway/prod-runtime/hooks.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/hooks.js +42 -0
- package/executors/gateway/prod-runtime/observability.d.ts +23 -0
- package/executors/gateway/prod-runtime/observability.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/observability.js +125 -0
- package/executors/gateway/prod-runtime/rollout.d.ts +18 -0
- package/executors/gateway/prod-runtime/rollout.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/rollout.js +103 -0
- package/executors/gateway/prod-runtime/routing.d.ts +4 -0
- package/executors/gateway/prod-runtime/routing.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/routing.js +40 -0
- package/executors/gateway/prod-runtime/server.d.ts +3 -0
- package/executors/gateway/prod-runtime/server.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/server.js +79 -0
- package/executors/gateway/prod-runtime/trust.d.ts +12 -0
- package/executors/gateway/prod-runtime/trust.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/trust.js +32 -0
- package/executors/gateway/prod-runtime/types.d.ts +241 -0
- package/executors/gateway/prod-runtime/types.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/types.js +2 -0
- package/executors/gateway/prod-runtime/upgrade.d.ts +21 -0
- package/executors/gateway/prod-runtime/upgrade.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/upgrade.js +116 -0
- package/executors/gateway/prod-runtime/upstream.d.ts +13 -0
- package/executors/gateway/prod-runtime/upstream.d.ts.map +1 -0
- package/executors/gateway/prod-runtime/upstream.js +21 -0
- package/executors/gateway/runtime/auth.d.ts +8 -0
- package/executors/gateway/runtime/auth.d.ts.map +1 -0
- package/executors/gateway/runtime/auth.js +60 -0
- package/executors/gateway/runtime/dev-auth.d.ts +9 -0
- package/executors/gateway/runtime/dev-auth.d.ts.map +1 -0
- package/executors/gateway/runtime/dev-auth.js +79 -0
- package/executors/gateway/runtime/discovery.d.ts +17 -0
- package/executors/gateway/runtime/discovery.d.ts.map +1 -0
- package/executors/gateway/runtime/discovery.js +111 -0
- package/executors/gateway/runtime/edge.d.ts +31 -0
- package/executors/gateway/runtime/edge.d.ts.map +1 -0
- package/executors/gateway/runtime/edge.js +47 -0
- package/executors/gateway/runtime/handler.d.ts +24 -0
- package/executors/gateway/runtime/handler.d.ts.map +1 -0
- package/executors/gateway/runtime/handler.js +165 -0
- package/executors/gateway/runtime/hooks.d.ts +23 -0
- package/executors/gateway/runtime/hooks.d.ts.map +1 -0
- package/executors/gateway/runtime/hooks.js +55 -0
- package/executors/gateway/runtime/lens-children.d.ts +13 -0
- package/executors/gateway/runtime/lens-children.d.ts.map +1 -0
- package/executors/gateway/runtime/lens-children.js +136 -0
- package/executors/gateway/runtime/lifecycle.d.ts +13 -0
- package/executors/gateway/runtime/lifecycle.d.ts.map +1 -0
- package/executors/gateway/runtime/lifecycle.js +179 -0
- package/executors/gateway/runtime/observability.d.ts +17 -0
- package/executors/gateway/runtime/observability.d.ts.map +1 -0
- package/executors/gateway/runtime/observability.js +107 -0
- package/executors/gateway/runtime/proxy.d.ts +20 -0
- package/executors/gateway/runtime/proxy.d.ts.map +1 -0
- package/executors/gateway/runtime/proxy.js +113 -0
- package/executors/gateway/runtime/scope.d.ts +16 -0
- package/executors/gateway/runtime/scope.d.ts.map +1 -0
- package/executors/gateway/runtime/scope.js +68 -0
- package/executors/gateway/runtime/server.d.ts +3 -0
- package/executors/gateway/runtime/server.d.ts.map +1 -0
- package/executors/gateway/runtime/server.js +168 -0
- package/executors/gateway/runtime/service-keys.d.ts +11 -0
- package/executors/gateway/runtime/service-keys.d.ts.map +1 -0
- package/executors/gateway/runtime/service-keys.js +49 -0
- package/executors/gateway/runtime/types.d.ts +234 -0
- package/executors/gateway/runtime/types.d.ts.map +1 -0
- package/executors/gateway/runtime/types.js +2 -0
- package/executors/gateway/runtime/upgrade.d.ts +22 -0
- package/executors/gateway/runtime/upgrade.d.ts.map +1 -0
- package/executors/gateway/runtime/upgrade.js +63 -0
- package/package.json +3 -3
|
@@ -1,869 +1,22 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.startProdGateway =
|
|
4
|
-
const tslib_1 = require("tslib");
|
|
3
|
+
exports.startProdGateway = void 0;
|
|
5
4
|
/**
|
|
6
|
-
*
|
|
5
|
+
* Public facade for the PRODUCTION gateway runtime (Fastify + @fastify/reply-from).
|
|
7
6
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* The implementation now lives in `./prod-runtime/*` as composable LAYERS,
|
|
8
|
+
* mirroring the dev gateway's `./runtime/*` — see `prod-runtime/server.ts` for the
|
|
9
|
+
* composition root (`startProdGateway`), `prod-runtime/types.ts` for the shared
|
|
10
|
+
* types + the customer **hooks** API, and the sibling modules for each layer
|
|
11
|
+
* (routing, observability, rollout, upstream, auth, cors, trust, handler, upgrade,
|
|
12
|
+
* app, access-log, hooks). This barrel preserves the original module's public
|
|
13
|
+
* surface so the entry (`main.prod-gateway.ts`), the `@lensmcp/cluster` re-export
|
|
14
|
+
* (`gateway.ts`), the e2e suite, and the pentest/perf suites import from one stable
|
|
15
|
+
* path, and additionally exposes the hooks types for customer-authored layers.
|
|
14
16
|
*
|
|
15
|
-
* The
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* behind a stable seam.
|
|
19
|
-
*
|
|
20
|
-
* It keeps the dev trust boundary verbatim — strip client-supplied trust
|
|
21
|
-
* headers, validate `x-api-key` on internal routes, inject the target's
|
|
22
|
-
* `x-internal-token` — and the header-gated `gateway-request` flow trace, so
|
|
23
|
-
* the lens (when ENABLED via opts.emit; off by default) sees identical events.
|
|
24
|
-
* Per-request upstream selection (rollout cohort + sticky version) rides on
|
|
25
|
-
* reply-from's `getUpstream` hook. WebSocket upgrades are proxied raw (a
|
|
26
|
-
* net-level pipe) with the same trust model — no http-proxy in the prod path.
|
|
27
|
-
*
|
|
28
|
-
* No autoscale / idle-kill / process management here — prod pods are
|
|
29
|
-
* always-on (k8s keeps them up); that machinery is the dev PodProvider's.
|
|
30
|
-
*/
|
|
31
|
-
const node_crypto_1 = require("node:crypto");
|
|
32
|
-
const net = tslib_1.__importStar(require("node:net"));
|
|
33
|
-
const fastify_1 = tslib_1.__importDefault(require("fastify"));
|
|
34
|
-
const reply_from_1 = tslib_1.__importDefault(require("@fastify/reply-from"));
|
|
35
|
-
const manifest_1 = require("./manifest");
|
|
36
|
-
const gateway_errors_1 = require("./gateway-errors");
|
|
37
|
-
const eid = () => Date.now().toString(36) + (0, node_crypto_1.randomBytes)(6).toString('hex');
|
|
38
|
-
const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
|
|
39
|
-
/**
|
|
40
|
-
* Send a gateway-generated edge error as the JSON envelope `{ key, status, traceId, message? }` (NOT
|
|
41
|
-
* `text/plain`), stamping `x-request-id` so the trace id is on the response. The SAME `{ key, status }`
|
|
42
|
-
* localized contract a service returns, so a browser app renders `t(key)` and surfaces the trace id.
|
|
43
|
-
* `detail` is the dev-facing hint (the old plain-text reason), never shown to end users.
|
|
17
|
+
* The same routing brain as dev (shared `matchRoute` / `buildRouteTable` / manifest
|
|
18
|
+
* schema); the HTTP transport differs on purpose (dev: node:http + http-proxy over
|
|
19
|
+
* unix-socket pods; prod: Fastify + reply-from's undici pool for the high-RPS edge).
|
|
44
20
|
*/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return reply
|
|
48
|
-
.code(status)
|
|
49
|
-
.header('x-request-id', traceId)
|
|
50
|
-
.type(gateway_errors_1.GATEWAY_ERROR_CONTENT_TYPE)
|
|
51
|
-
.send((0, gateway_errors_1.gatewayErrorBody)(status, reason, traceId, detail));
|
|
52
|
-
};
|
|
53
|
-
// Path-canonicalization guard (CWE-288): a reverse proxy and its backend must agree
|
|
54
|
-
// on the request path, or auth gets evaluated against a DIFFERENT path than the one
|
|
55
|
-
// served (AWS API Gateway trailing-slash bypass, IBM API Connect CVE-2025-13915,
|
|
56
|
-
// Kong/Envoy/NGINX normalization mismatch). We REJECT structurally-encoded paths
|
|
57
|
-
// (encoded dot/slash/backslash/null) outright, and return the DECODED path for auth
|
|
58
|
-
// matching so `/%61dmin` is matched as `/admin` — never skipped because it's encoded.
|
|
59
|
-
const ENCODED_STRUCTURE = /%2e|%2f|%5c|%00|\\/i; // encoded . / \ NUL, or a raw backslash
|
|
60
|
-
/** Canonical path for auth matching, or undefined if the path is unsafe (→ 400 / drop). */
|
|
61
|
-
const canonicalPath = (rawUrl) => {
|
|
62
|
-
const p = rawUrl.split('?')[0] ?? '/';
|
|
63
|
-
if (ENCODED_STRUCTURE.test(p))
|
|
64
|
-
return undefined;
|
|
65
|
-
let decoded;
|
|
66
|
-
try {
|
|
67
|
-
decoded = decodeURIComponent(p);
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return undefined;
|
|
71
|
-
} // malformed %xx
|
|
72
|
-
if (decoded.includes('\0') || /(^|\/)\.\.(?=\/|$)/.test(decoded))
|
|
73
|
-
return undefined; // NUL or `..` traversal
|
|
74
|
-
// Strip fragment (#…) and per-segment matrix params (;…) so we match the path the
|
|
75
|
-
// BACKEND resolves to — defeats /admin;x=1 and /admin#.css permission-skip bypass (CWE-288).
|
|
76
|
-
return decoded.split('#')[0].split('/').map((s) => s.split(';')[0]).join('/') || '/';
|
|
77
|
-
};
|
|
78
|
-
async function startProdGateway(opts) {
|
|
79
|
-
const ports = opts.ports?.length ? opts.ports : [opts.tls ? 8443 : 8080];
|
|
80
|
-
// Default authenticator FAILS CLOSED: a jwt route with no authenticator wired is
|
|
81
|
-
// rejected, never passed through. Provide opts.authenticate (or a verifier in the
|
|
82
|
-
// entry) to actually validate tokens.
|
|
83
|
-
const authenticate = opts.authenticate ?? ((mode) => {
|
|
84
|
-
if (mode === 'jwt')
|
|
85
|
-
throw new Error('jwt route but no authenticator configured');
|
|
86
|
-
});
|
|
87
|
-
const emit = (severity, title, fingerprint, raw, ctx) => {
|
|
88
|
-
if (!opts.emit)
|
|
89
|
-
return; // lens off by default — no work, no allocation beyond the call
|
|
90
|
-
try {
|
|
91
|
-
opts.emit({
|
|
92
|
-
id: eid(), sessionId: 'pending', timestamp: Date.now(),
|
|
93
|
-
source: 'gateway', category: 'cluster', severity,
|
|
94
|
-
context: { sessionId: 'pending', ...ctx }, fingerprint, title, raw,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
catch { /* never let the bus break traffic */ }
|
|
98
|
-
};
|
|
99
|
-
// ---- route table (rebuilt atomically from the manifest source) ----
|
|
100
|
-
let manifests = opts.manifests.list();
|
|
101
|
-
let routes = (0, manifest_1.buildRouteTable)(manifests);
|
|
102
|
-
let manifestByService = new Map(manifests.map((m) => [m.service, m]));
|
|
103
|
-
const rebuild = (next) => {
|
|
104
|
-
manifests = next;
|
|
105
|
-
routes = (0, manifest_1.buildRouteTable)(next);
|
|
106
|
-
manifestByService = new Map(next.map((m) => [m.service, m]));
|
|
107
|
-
if (opts.healthChecker) { // monitor every reachable upstream: direct manifest URLs + pooled endpoints
|
|
108
|
-
const urls = new Set();
|
|
109
|
-
for (const r of routes)
|
|
110
|
-
if (r.upstream)
|
|
111
|
-
urls.add(r.upstream);
|
|
112
|
-
for (const u of opts.pods?.endpoints?.() ?? [])
|
|
113
|
-
urls.add(u);
|
|
114
|
-
opts.healthChecker.track([...urls]);
|
|
115
|
-
}
|
|
116
|
-
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 })) });
|
|
117
|
-
};
|
|
118
|
-
const unwatch = opts.manifests.watch?.((next) => rebuild(next));
|
|
119
|
-
const keyToProject = new Map(Object.entries(opts.serviceKeys ?? {}).map(([p, k]) => [k, p]));
|
|
120
|
-
const versionHeader = (opts.versionHeader ?? 'x-lensmcp-version').toLowerCase();
|
|
121
|
-
const accessLog = !!opts.accessLog;
|
|
122
|
-
// Origins allowed to make CREDENTIALED CORS requests (cookies). Exact-matched, lower-cased.
|
|
123
|
-
const corsCredentialOrigins = new Set((opts.corsCredentialOrigins ?? []).map((o) => o.toLowerCase()));
|
|
124
|
-
const clientIpHeader = opts.clientIpHeader?.toLowerCase();
|
|
125
|
-
// `clientIpHeader` is only trusted from a known front proxy. Default: loopback
|
|
126
|
-
// only (a co-located cloudflared/sidecar) — an off-proxy attacker hitting the
|
|
127
|
-
// origin directly cannot spoof the `ip` ABAC attribute.
|
|
128
|
-
const trustedProxies = opts.clientIpTrustedProxies ?? [];
|
|
129
|
-
const peerTrusted = (peer) => {
|
|
130
|
-
if (!peer)
|
|
131
|
-
return false;
|
|
132
|
-
const ip = peer.startsWith('::ffff:') ? peer.slice(7) : peer;
|
|
133
|
-
if (trustedProxies.length === 0)
|
|
134
|
-
return ip === '127.0.0.1' || peer === '::1';
|
|
135
|
-
return trustedProxies.some((c) => (0, manifest_1.matchCidr)(ip, c));
|
|
136
|
-
};
|
|
137
|
-
/** Resolve the client IP: the trusted front-proxy header if the peer is trusted, else the fallback. */
|
|
138
|
-
const clientIpOf = (req, fallback) => {
|
|
139
|
-
if (clientIpHeader && peerTrusted(req.socket?.remoteAddress ?? undefined)) {
|
|
140
|
-
const h = hdr(req.headers[clientIpHeader]);
|
|
141
|
-
if (h)
|
|
142
|
-
return h;
|
|
143
|
-
}
|
|
144
|
-
return fallback;
|
|
145
|
-
};
|
|
146
|
-
// ---- targeted rollout: attributes + sticky device key (cookie → uid header) ----
|
|
147
|
-
const attrHeaders = (opts.attributeHeaders ?? []).map((h) => h.toLowerCase());
|
|
148
|
-
const uidHeader = opts.uidHeader?.toLowerCase();
|
|
149
|
-
const cookieOn = !!opts.cookie;
|
|
150
|
-
const cookieName = opts.cookie?.name ?? 'lensmcp_did';
|
|
151
|
-
const cookieSecret = opts.cookie?.secret;
|
|
152
|
-
const cookieDomain = opts.cookie?.domain;
|
|
153
|
-
const cookieMaxAge = opts.cookie?.maxAge ?? 31_536_000; // 1y
|
|
154
|
-
if (cookieOn && !cookieSecret)
|
|
155
|
-
console.warn('[gateway] device cookie has no secret — values are UNSIGNED (set cookie.secret to prevent bucket-grinding).');
|
|
156
|
-
const signId = (id) => (0, node_crypto_1.createHmac)('sha256', cookieSecret).update(id).digest('base64url');
|
|
157
|
-
const signedCookie = (id) => (cookieSecret ? `${id}.${signId(id)}` : id);
|
|
158
|
-
const verifyCookie = (val) => {
|
|
159
|
-
if (!cookieSecret)
|
|
160
|
-
return val || undefined; // unsigned mode
|
|
161
|
-
const i = val.lastIndexOf('.');
|
|
162
|
-
if (i < 0)
|
|
163
|
-
return undefined;
|
|
164
|
-
const id = val.slice(0, i);
|
|
165
|
-
const a = Buffer.from(val.slice(i + 1)), b = Buffer.from(signId(id));
|
|
166
|
-
return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b) ? id : undefined;
|
|
167
|
-
};
|
|
168
|
-
const parseCookie = (header, name) => {
|
|
169
|
-
if (!header)
|
|
170
|
-
return undefined;
|
|
171
|
-
for (const part of header.split(';')) {
|
|
172
|
-
const eq = part.indexOf('=');
|
|
173
|
-
if (eq > 0 && part.slice(0, eq).trim() === name)
|
|
174
|
-
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
175
|
-
}
|
|
176
|
-
return undefined;
|
|
177
|
-
};
|
|
178
|
-
const mintCookieHeader = (id) => {
|
|
179
|
-
let c = `${cookieName}=${encodeURIComponent(signedCookie(id))}; Path=/; Max-Age=${cookieMaxAge}; HttpOnly; SameSite=Lax; Secure`;
|
|
180
|
-
if (cookieDomain)
|
|
181
|
-
c += `; Domain=${cookieDomain}`;
|
|
182
|
-
return c;
|
|
183
|
-
};
|
|
184
|
-
/** Resolve the sticky key: valid cookie → UID header → freshly minted (cookie clients). */
|
|
185
|
-
const resolveStickyKey = (req) => {
|
|
186
|
-
if (cookieOn) {
|
|
187
|
-
const c = parseCookie(req.headers.cookie, cookieName);
|
|
188
|
-
const id = c ? verifyCookie(c) : undefined;
|
|
189
|
-
if (id)
|
|
190
|
-
return { key: id, mint: false };
|
|
191
|
-
}
|
|
192
|
-
if (uidHeader) {
|
|
193
|
-
const u = hdr(req.headers[uidHeader]);
|
|
194
|
-
if (u)
|
|
195
|
-
return { key: u, mint: false };
|
|
196
|
-
}
|
|
197
|
-
if (cookieOn)
|
|
198
|
-
return { key: 'd_' + (0, node_crypto_1.randomBytes)(12).toString('base64url'), mint: true };
|
|
199
|
-
return { mint: false };
|
|
200
|
-
};
|
|
201
|
-
/** Assemble the flat attribute bag a rule evaluates against. Shared by the HTTP
|
|
202
|
-
* and WebSocket paths so ABAC sees an identical bag on both (no divergence). */
|
|
203
|
-
const buildAttrs = (req, fallbackIp, principal, key) => {
|
|
204
|
-
const a = { ...(principal ?? {}) };
|
|
205
|
-
a['ip'] = clientIpOf(req, fallbackIp);
|
|
206
|
-
a['method'] = (req.method ?? 'GET').toUpperCase();
|
|
207
|
-
a['path'] = (req.url ?? '/').split('?')[0];
|
|
208
|
-
if (key)
|
|
209
|
-
a['device'] = key;
|
|
210
|
-
for (const h of attrHeaders) {
|
|
211
|
-
const v = hdr(req.headers[h]);
|
|
212
|
-
if (v !== undefined)
|
|
213
|
-
a['header.' + h] = v;
|
|
214
|
-
}
|
|
215
|
-
return a;
|
|
216
|
-
};
|
|
217
|
-
// ---- traffic edges (host→service[@version]), flushed to the lens periodically ----
|
|
218
|
-
const edges = new Map();
|
|
219
|
-
const recordEdge = (route, ms, status, caller, version) => {
|
|
220
|
-
const host = route.host ?? '(default)';
|
|
221
|
-
const key = (caller ? caller + '⇒' : '') + host + '→' + route.service + (version ? '@' + version : '');
|
|
222
|
-
const e = edges.get(key) ?? { host, service: route.service, caller, version, count: 0, totalMs: 0, errors: 0 };
|
|
223
|
-
e.count += 1;
|
|
224
|
-
e.totalMs += ms;
|
|
225
|
-
if (status >= 500 || status === 0)
|
|
226
|
-
e.errors += 1;
|
|
227
|
-
edges.set(key, e);
|
|
228
|
-
};
|
|
229
|
-
// ---- per-service health rollup (feeds /statusz + the Redis status fan-out) ----
|
|
230
|
-
// A ring of the last N flush windows, summed at read time → a rolling view that
|
|
231
|
-
// doesn't flap to "idle" the instant traffic pauses. Derived purely from the
|
|
232
|
-
// edges the gateway already records, so it's effectively free.
|
|
233
|
-
const statusWindowCount = Math.max(1, opts.statusWindows ?? 12);
|
|
234
|
-
const statusWindows = [];
|
|
235
|
-
const lastSeenAt = new Map();
|
|
236
|
-
const routeServices = () => [...new Set(routes.map((r) => r.service))];
|
|
237
|
-
const pushStatusWindow = () => {
|
|
238
|
-
const w = new Map();
|
|
239
|
-
for (const e of edges.values()) {
|
|
240
|
-
const s = w.get(e.service) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
|
|
241
|
-
s.requests += e.count;
|
|
242
|
-
s.errors += e.errors;
|
|
243
|
-
s.totalMs += e.totalMs;
|
|
244
|
-
if (e.version)
|
|
245
|
-
s.versions.add(e.version);
|
|
246
|
-
w.set(e.service, s);
|
|
247
|
-
}
|
|
248
|
-
const now = Date.now();
|
|
249
|
-
for (const [svc, s] of w)
|
|
250
|
-
if (s.requests > 0)
|
|
251
|
-
lastSeenAt.set(svc, now);
|
|
252
|
-
statusWindows.push(w);
|
|
253
|
-
while (statusWindows.length > statusWindowCount)
|
|
254
|
-
statusWindows.shift();
|
|
255
|
-
};
|
|
256
|
-
const buildStatusSnapshot = () => {
|
|
257
|
-
const agg = new Map();
|
|
258
|
-
for (const w of statusWindows)
|
|
259
|
-
for (const [svc, s] of w) {
|
|
260
|
-
const a = agg.get(svc) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
|
|
261
|
-
a.requests += s.requests;
|
|
262
|
-
a.errors += s.errors;
|
|
263
|
-
a.totalMs += s.totalMs;
|
|
264
|
-
for (const v of s.versions)
|
|
265
|
-
a.versions.add(v);
|
|
266
|
-
agg.set(svc, a);
|
|
267
|
-
}
|
|
268
|
-
return routeServices().map((svc) => {
|
|
269
|
-
const a = agg.get(svc) ?? { requests: 0, errors: 0, totalMs: 0, versions: new Set() };
|
|
270
|
-
const hosts = [...new Set(routes.filter((r) => r.service === svc).map((r) => r.host ?? '(default)'))];
|
|
271
|
-
const er = a.requests ? a.errors / a.requests : 0;
|
|
272
|
-
let status = a.requests === 0 ? 'idle' : er >= 0.5 ? 'down' : er >= 0.05 ? 'degraded' : 'healthy';
|
|
273
|
-
// active liveness: if every known upstream for this service is failing probes, it's down (even with no traffic)
|
|
274
|
-
if (opts.healthChecker) {
|
|
275
|
-
const svcUrls = routes.filter((r) => r.service === svc && r.upstream).map((r) => r.upstream);
|
|
276
|
-
if (svcUrls.length && svcUrls.every((u) => !opts.healthChecker.isHealthy(u)))
|
|
277
|
-
status = 'down';
|
|
278
|
-
}
|
|
279
|
-
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 };
|
|
280
|
-
});
|
|
281
|
-
};
|
|
282
|
-
const edgeFlusher = setInterval(() => {
|
|
283
|
-
pushStatusWindow(); // always: roll the health window (independent of the lens)
|
|
284
|
-
if (opts.onStatus) {
|
|
285
|
-
try {
|
|
286
|
-
opts.onStatus(buildStatusSnapshot());
|
|
287
|
-
}
|
|
288
|
-
catch { /* never let a status sink break traffic */ }
|
|
289
|
-
}
|
|
290
|
-
if (opts.emit) {
|
|
291
|
-
for (const e of edges.values()) {
|
|
292
|
-
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 });
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
edges.clear();
|
|
296
|
-
}, opts.trafficFlushMs ?? 5000);
|
|
297
|
-
edgeFlusher.unref?.();
|
|
298
|
-
// ---- per-request flow trace (only for flow-header-carrying requests) ----
|
|
299
|
-
const beginTrace = (req) => {
|
|
300
|
-
if (!opts.emit)
|
|
301
|
-
return undefined; // no lens → no trace overhead
|
|
302
|
-
const flowId = hdr(req.headers['x-lensmcp-flow-id']);
|
|
303
|
-
let requestId = hdr(req.headers['x-request-id']);
|
|
304
|
-
if (!flowId && !requestId && !hdr(req.headers['traceparent']))
|
|
305
|
-
return undefined;
|
|
306
|
-
if (!requestId) {
|
|
307
|
-
requestId = eid();
|
|
308
|
-
req.headers['x-request-id'] = requestId;
|
|
309
|
-
}
|
|
310
|
-
return { startedAt: Date.now(), method: (req.method ?? 'GET').toUpperCase(), host: (req.headers.host || '').split(':')[0], url: req.url ?? '/', steps: [], ...(flowId ? { flowId } : {}), ...(requestId ? { requestId } : {}) };
|
|
311
|
-
};
|
|
312
|
-
const step = (t, name, info) => { if (t)
|
|
313
|
-
t.steps.push({ step: name, atMs: Date.now() - t.startedAt, ...info }); };
|
|
314
|
-
const finish = (t, service, status, extra) => {
|
|
315
|
-
if (!t || t.done)
|
|
316
|
-
return;
|
|
317
|
-
t.done = true;
|
|
318
|
-
emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${Date.now() - t.startedAt}ms)`, `gateway-request:${service}`, { kind: 'gateway-request', project: service, host: t.host, method: t.method, url: t.url, status, durationMs: Date.now() - t.startedAt, startedAt: t.startedAt, steps: t.steps, ...extra }, { ...(t.flowId ? { flowId: t.flowId } : {}), ...(t.requestId ? { requestId: t.requestId } : {}) });
|
|
319
|
-
};
|
|
320
|
-
rebuild(manifests); // emit initial gateway-up
|
|
321
|
-
// ---- trust boundary (verbatim dev model) ----
|
|
322
|
-
// The gateway OWNS these headers — drop any client-supplied copy at ingress so identity
|
|
323
|
-
// can only originate from a verified token (closes header spoofing), then re-stamp on forward.
|
|
324
|
-
const TRUST_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
|
|
325
|
-
// Also drop client-supplied hop-by-hop control headers: a `Connection: x-internal-token`
|
|
326
|
-
// would otherwise make undici strip the gateway's OWN injected trust headers before the
|
|
327
|
-
// upstream (CVE-2026-33805). The proxy manages its own upstream connection semantics.
|
|
328
|
-
const HOP_BY_HOP = ['connection', 'proxy-connection', 'keep-alive'];
|
|
329
|
-
const stripTrust = (req) => { for (const h of TRUST_HEADERS)
|
|
330
|
-
delete req.headers[h]; for (const h of HOP_BY_HOP)
|
|
331
|
-
delete req.headers[h]; };
|
|
332
|
-
/** Stamp the verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
|
|
333
|
-
const stampIdentity = (req, claims) => {
|
|
334
|
-
// Only stamp SCALAR claims — an object/array sub/tid must never coerce to
|
|
335
|
-
// "[object Object]" / a CSV tenant header (claim type-confusion).
|
|
336
|
-
const set = (k, v) => { if ((typeof v === 'string' && v !== '') || typeof v === 'number')
|
|
337
|
-
req.headers[k] = String(v); };
|
|
338
|
-
set('x-user-id', claims['sub']);
|
|
339
|
-
set('x-tenant-id', claims['tid']);
|
|
340
|
-
const roles = claims['roles'];
|
|
341
|
-
if (Array.isArray(roles) && roles.length)
|
|
342
|
-
req.headers['x-user-roles'] = roles.map(String).join(',');
|
|
343
|
-
const perms = claims['perms'];
|
|
344
|
-
if (Array.isArray(perms) && perms.length)
|
|
345
|
-
req.headers['x-user-permissions'] = perms.map(String).join(',');
|
|
346
|
-
};
|
|
347
|
-
// ---- per-request upstream resolution (direct upstream OR pod pick + sticky version) ----
|
|
348
|
-
const resolveUpstream = async (route, trace, pinned) => {
|
|
349
|
-
if (route.upstream)
|
|
350
|
-
return { url: route.upstream };
|
|
351
|
-
const opt = {
|
|
352
|
-
...(pinned ? { version: pinned } : {}),
|
|
353
|
-
...(opts.healthChecker ? { isHealthy: (u) => opts.healthChecker.isHealthy(u) } : {}),
|
|
354
|
-
};
|
|
355
|
-
let up = opts.pods?.pick(route.service, opt);
|
|
356
|
-
if (!up && opts.pods?.ensureUp) {
|
|
357
|
-
step(trace, 'ensure-up', { project: route.service });
|
|
358
|
-
if (await opts.pods.ensureUp(route.service))
|
|
359
|
-
up = opts.pods.pick(route.service, opt);
|
|
360
|
-
}
|
|
361
|
-
return up;
|
|
362
|
-
};
|
|
363
|
-
const reqState = new WeakMap();
|
|
364
|
-
const spanByReq = new WeakMap(); // OTel span per in-flight request (ended in onResponse)
|
|
365
|
-
// ---- no-route fallbacks: CORS preflight, health, discovery, 404 ----
|
|
366
|
-
const setCors = (reply, req) => {
|
|
367
|
-
const origin = req.headers.origin;
|
|
368
|
-
// Credentialed allowlist: echo the EXACT origin + allow credentials (cookies). NEVER `*` with
|
|
369
|
-
// credentials (forbidden by the Fetch spec; a cross-site read hole). Needed for a first-party
|
|
370
|
-
// browser app that POSTs to a cookie-setting routed service (e.g. an IdP SSO cookie).
|
|
371
|
-
if (origin && corsCredentialOrigins.has(origin.toLowerCase())) {
|
|
372
|
-
reply.header('access-control-allow-origin', origin);
|
|
373
|
-
reply.header('access-control-allow-credentials', 'true');
|
|
374
|
-
reply.header('vary', 'Origin');
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
// Otherwise — UNAUTHENTICATED gateway responses (404 / health / route discovery / preflight):
|
|
378
|
-
// reflect the Origin for convenience but DO NOT allow credentials — a reflected origin +
|
|
379
|
-
// credentials would let any site read these cross-origin.
|
|
380
|
-
reply.header('access-control-allow-origin', String(origin ?? '*'));
|
|
381
|
-
reply.header('vary', 'Origin');
|
|
382
|
-
};
|
|
383
|
-
const handleNoRoute = (request, reply, host, url) => {
|
|
384
|
-
const req = request.raw;
|
|
385
|
-
setCors(reply, req);
|
|
386
|
-
if (req.method === 'OPTIONS') {
|
|
387
|
-
reply.code(204)
|
|
388
|
-
.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
|
|
389
|
-
.header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
|
|
390
|
-
.send();
|
|
391
|
-
return;
|
|
392
|
-
}
|
|
393
|
-
const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
|
|
394
|
-
const p = url.split('?')[0];
|
|
395
|
-
if (mounted.length > 0 && (p === '/health' || p === '/healthz')) {
|
|
396
|
-
reply.code(200).type('application/json').send(JSON.stringify({ status: 'ok', host, services: mounted.map((r) => ({ service: r.service, prefix: r.prefix })) }));
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (p === '/.well-known/lensmcp-routes') {
|
|
400
|
-
reply.code(200).type('application/json').send(JSON.stringify({ host, routes: routes.filter((r) => !r.host || (0, manifest_1.hostMatches)(r.host, host)).map((r) => ({ host: r.host ?? '(default)', service: r.service, prefix: r.prefix, internal: !!r.internal, auth: r.auth })) }));
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
sendError(reply, req, 404, 'not_found', mounted.length > 0 ? `No service mounted at ${url} on ${host}.` : 'No gateway route for this hostname.');
|
|
404
|
-
};
|
|
405
|
-
// ---- the request handler (catch-all): route → trust → auth → forward ----
|
|
406
|
-
const handler = async (request, reply) => {
|
|
407
|
-
const req = request.raw;
|
|
408
|
-
const host = (req.headers.host || '').split(':')[0];
|
|
409
|
-
const url = req.url ?? '/';
|
|
410
|
-
const trace = beginTrace(req);
|
|
411
|
-
// Mint a trace id for EVERY request (not just lens-traced ones): it's forwarded to the upstream (the
|
|
412
|
-
// pod adopts it as its traceId) and returned on the response — so any request is traceable end-to-end.
|
|
413
|
-
(0, gateway_errors_1.ensureRequestId)(req);
|
|
414
|
-
step(trace, 'received', { host, method: req.method, url });
|
|
415
|
-
// Reject HTTP/0.9 — a header-less simple request has no Host, defeating host-keyed routing/policy.
|
|
416
|
-
if (req.httpVersionMajor < 1) {
|
|
417
|
-
finish(trace, 'gateway', 400);
|
|
418
|
-
return sendError(reply, req, 400, 'bad_request', 'Bad Request');
|
|
419
|
-
}
|
|
420
|
-
// Reject path-canonicalization attacks BEFORE routing/auth (proxy↔backend mismatch).
|
|
421
|
-
const cpath = canonicalPath(url);
|
|
422
|
-
if (cpath === undefined) {
|
|
423
|
-
step(trace, 'bad-path', {});
|
|
424
|
-
finish(trace, 'gateway', 400);
|
|
425
|
-
return sendError(reply, req, 400, 'bad_request', 'Bad Request');
|
|
426
|
-
}
|
|
427
|
-
// Rate limit (opt-in) BEFORE routing/auth, per real client IP — defense-in-depth.
|
|
428
|
-
if (opts.rateLimiter) {
|
|
429
|
-
// Key on the trusted client IP (header only from a trusted peer) else the SOCKET ip —
|
|
430
|
-
// never request.ip, which is the spoofable X-Forwarded-For chain under trustProxy.
|
|
431
|
-
const d = await opts.rateLimiter.check(clientIpOf(req, req.socket?.remoteAddress ?? request.ip));
|
|
432
|
-
if (!d.allowed) {
|
|
433
|
-
reply.header('retry-after', String(Math.ceil(d.retryAfterMs / 1000)));
|
|
434
|
-
step(trace, 'rate-limited', {});
|
|
435
|
-
finish(trace, 'gateway', 429);
|
|
436
|
-
return sendError(reply, req, 429, 'rate_limited', 'Too Many Requests');
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
const route = (0, manifest_1.matchRoute)(routes, host, url);
|
|
440
|
-
if (!route) {
|
|
441
|
-
handleNoRoute(request, reply, host, url);
|
|
442
|
-
return reply;
|
|
443
|
-
}
|
|
444
|
-
step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
|
|
445
|
-
// CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries no
|
|
446
|
-
// token by spec, so the auth below would 401 it and the browser's CORS check fails. The real request
|
|
447
|
-
// still authenticates. (handleNoRoute covers UNROUTED preflights; this covers routed ones.)
|
|
448
|
-
if (req.method === 'OPTIONS') {
|
|
449
|
-
setCors(reply, req);
|
|
450
|
-
reply.code(204)
|
|
451
|
-
.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
|
|
452
|
-
.header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
|
|
453
|
-
.send();
|
|
454
|
-
return reply;
|
|
455
|
-
}
|
|
456
|
-
// OTel SERVER span (opt-in): continues the incoming traceparent. Stored now so
|
|
457
|
-
// onResponse ends it on ANY exit path (auth reject / 503 / forward). Context is
|
|
458
|
-
// injected into req.headers just before forwarding (below) so downstream parents to it.
|
|
459
|
-
const span = opts.tracing?.startSpan(req, `gateway ${route.service}`, {
|
|
460
|
-
'http.request.method': (req.method ?? 'GET'),
|
|
461
|
-
'url.path': url.split('?')[0],
|
|
462
|
-
'server.address': host,
|
|
463
|
-
'lensmcp.service': route.service,
|
|
464
|
-
...(route.internal ? { 'lensmcp.internal': true } : {}),
|
|
465
|
-
});
|
|
466
|
-
if (span)
|
|
467
|
-
spanByReq.set(request, span);
|
|
468
|
-
// Verify the end-user JWT ONCE (when a verifier is configured) — reused for both edge
|
|
469
|
-
// authorization and rollout targeting. Internal east-west routes use x-api-key instead.
|
|
470
|
-
const claims = route.internal ? undefined : opts.identify?.(req);
|
|
471
|
-
let caller;
|
|
472
|
-
if (route.internal) {
|
|
473
|
-
caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
|
|
474
|
-
if (!caller) {
|
|
475
|
-
step(trace, 'auth', { mode: 'x-api-key', ok: false });
|
|
476
|
-
finish(trace, route.service, 401);
|
|
477
|
-
return sendError(reply, req, 401, 'unauthorized', 'Invalid or missing x-api-key for internal route.');
|
|
478
|
-
}
|
|
479
|
-
req.headers['x-api-key-id'] = caller;
|
|
480
|
-
delete req.headers['x-api-key'];
|
|
481
|
-
step(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
|
|
482
|
-
}
|
|
483
|
-
else {
|
|
484
|
-
const m = manifestByService.get(route.service);
|
|
485
|
-
const resolved = m ? (0, manifest_1.authRuleForPath)(m, cpath, req.method ?? 'GET', false) : { mode: route.auth };
|
|
486
|
-
if (resolved.mode === 'jwt') {
|
|
487
|
-
if (opts.identify) {
|
|
488
|
-
if (!claims) {
|
|
489
|
-
step(trace, 'auth', { mode: 'jwt', ok: false });
|
|
490
|
-
finish(trace, route.service, 401);
|
|
491
|
-
return sendError(reply, req, 401, 'unauthorized', 'Unauthorized: invalid or missing token');
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
else {
|
|
495
|
-
try {
|
|
496
|
-
authenticate('jwt', req);
|
|
497
|
-
}
|
|
498
|
-
catch (e) {
|
|
499
|
-
step(trace, 'auth', { mode: 'jwt', ok: false });
|
|
500
|
-
finish(trace, route.service, 401);
|
|
501
|
-
return sendError(reply, req, 401, 'unauthorized', `Unauthorized: ${e.message}`);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
// edge ABAC: the required permission/rule must hold against the VERIFIED claims.
|
|
505
|
-
// A present rule MUST be satisfied by VERIFIED claims. No claims (no verifier) → cannot satisfy → DENY (fail closed).
|
|
506
|
-
if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, request.ip, claims, undefined)))) {
|
|
507
|
-
step(trace, 'authz', { ok: false });
|
|
508
|
-
finish(trace, route.service, 403);
|
|
509
|
-
return sendError(reply, req, 403, 'forbidden', 'Forbidden: insufficient permission');
|
|
510
|
-
}
|
|
511
|
-
step(trace, 'auth', { mode: 'jwt', ok: true });
|
|
512
|
-
if (claims)
|
|
513
|
-
stampIdentity(req, claims);
|
|
514
|
-
}
|
|
515
|
-
else {
|
|
516
|
-
step(trace, 'auth', { mode: resolved.mode });
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
if (opts.serviceKeys?.[route.service])
|
|
520
|
-
req.headers['x-internal-token'] = opts.serviceKeys[route.service];
|
|
521
|
-
if (route.prependPrefix) {
|
|
522
|
-
const u = req.url ?? '/';
|
|
523
|
-
const pp = route.prependPrefix;
|
|
524
|
-
if (u !== pp && !u.startsWith(pp + '/'))
|
|
525
|
-
req.url = pp + u;
|
|
526
|
-
} // segment-boundary, not bare startsWith (/apidoc must not escape /api)
|
|
527
|
-
// ── version selection: explicit pin → ABAC rule → sticky-key weighted → SWRR ──
|
|
528
|
-
// (targeting/stickiness only for external routes; internal east-west has no "device")
|
|
529
|
-
const explicit = hdr(req.headers[versionHeader]);
|
|
530
|
-
let pinned = explicit;
|
|
531
|
-
let mintKey; // set → append Set-Cookie on the response
|
|
532
|
-
if (!route.internal && !explicit) {
|
|
533
|
-
const sk = resolveStickyKey(req);
|
|
534
|
-
if (sk.mint)
|
|
535
|
-
mintKey = sk.key;
|
|
536
|
-
const attrs = buildAttrs(req, request.ip, claims, sk.key);
|
|
537
|
-
const targeted = opts.pods?.resolveVersion?.(route.service, attrs);
|
|
538
|
-
if (targeted) {
|
|
539
|
-
pinned = targeted.version;
|
|
540
|
-
step(trace, 'targeting', { rule: targeted.rule, version: targeted.version });
|
|
541
|
-
}
|
|
542
|
-
else if (sk.key) {
|
|
543
|
-
const b = opts.pods?.bucket?.(route.service, sk.key);
|
|
544
|
-
if (b) {
|
|
545
|
-
pinned = b.version;
|
|
546
|
-
step(trace, 'targeting', { mode: 'sticky', version: b.version });
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
const up = await resolveUpstream(route, trace, pinned);
|
|
551
|
-
if (!up) {
|
|
552
|
-
finish(trace, route.service, 503);
|
|
553
|
-
return sendError(reply, req, 503, 'unavailable', `Service ${route.service} has no available upstream.`);
|
|
554
|
-
}
|
|
555
|
-
if (!('url' in up)) {
|
|
556
|
-
finish(trace, route.service, 502);
|
|
557
|
-
return sendError(reply, req, 502, 'unavailable', `Service ${route.service}: socket upstreams are not supported by the prod gateway.`);
|
|
558
|
-
}
|
|
559
|
-
const origin = new URL(up.url).origin;
|
|
560
|
-
const upLabel = up.url;
|
|
561
|
-
reqState.set(request, { route, caller, startedAt: Date.now(), trace, version: up.version, upLabel });
|
|
562
|
-
step(trace, up.version ? 'rollout' : 'upstream', { upstream: upLabel, ...(up.version ? { version: up.version, ...(explicit ? { pinned: true } : {}) } : {}) });
|
|
563
|
-
// Propagate the gateway span's context downstream (W3C traceparent on the forwarded request).
|
|
564
|
-
if (span)
|
|
565
|
-
opts.tracing.inject(span, req.headers);
|
|
566
|
-
// No source → reply-from forwards the (prefix-rewritten) req.url to `origin`.
|
|
567
|
-
return reply.from(undefined, {
|
|
568
|
-
getUpstream: () => origin,
|
|
569
|
-
rewriteHeaders: (headers) => {
|
|
570
|
-
headers['x-lensmcp-upstream'] = upLabel;
|
|
571
|
-
if (!headers['x-request-id'])
|
|
572
|
-
headers['x-request-id'] = (0, gateway_errors_1.ensureRequestId)(req); // trace id on success too (if the upstream didn't echo it)
|
|
573
|
-
if (up.version)
|
|
574
|
-
headers['x-lensmcp-version'] = up.version; // which rollout cohort served
|
|
575
|
-
// The gateway owns the edge response: drop the upstream framework leak
|
|
576
|
-
// (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
|
|
577
|
-
delete headers['x-powered-by'];
|
|
578
|
-
delete headers['connection'];
|
|
579
|
-
delete headers['keep-alive'];
|
|
580
|
-
delete headers['proxy-connection']; // strip hop-by-hop from the upstream response
|
|
581
|
-
headers['server'] = 'LensMCP';
|
|
582
|
-
if (mintKey) { // first contact → stamp the device cookie (appended to any upstream Set-Cookie)
|
|
583
|
-
const ex = headers['set-cookie'];
|
|
584
|
-
const list = Array.isArray(ex) ? ex : ex ? [ex] : [];
|
|
585
|
-
headers['set-cookie'] = [...list, mintCookieHeader(mintKey)];
|
|
586
|
-
}
|
|
587
|
-
return headers;
|
|
588
|
-
},
|
|
589
|
-
onError: (rep, { error }) => {
|
|
590
|
-
opts.pods?.drop?.(route.service, up);
|
|
591
|
-
void error; // don't reflect the raw upstream error (host:port / errno) to the client
|
|
592
|
-
sendError(rep, req, 502, 'unavailable', `Upstream ${route.service} unavailable.`);
|
|
593
|
-
},
|
|
594
|
-
});
|
|
595
|
-
};
|
|
596
|
-
// ---- raw WebSocket upgrade proxy (same trust model; net-level pipe) ----
|
|
597
|
-
const upgrade = (req, socket, head) => {
|
|
598
|
-
stripTrust(req);
|
|
599
|
-
const host = (req.headers.host || '').split(':')[0];
|
|
600
|
-
const cpath = canonicalPath(req.url ?? '/');
|
|
601
|
-
if (cpath === undefined) {
|
|
602
|
-
socket.destroy();
|
|
603
|
-
return;
|
|
604
|
-
} // reject path-canonicalization attacks
|
|
605
|
-
const route = (0, manifest_1.matchRoute)(routes, host, req.url ?? '/');
|
|
606
|
-
if (!route) {
|
|
607
|
-
socket.destroy();
|
|
608
|
-
return;
|
|
609
|
-
}
|
|
610
|
-
const claims = route.internal ? undefined : opts.identify?.(req);
|
|
611
|
-
let caller;
|
|
612
|
-
if (route.internal) {
|
|
613
|
-
caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
|
|
614
|
-
if (!caller) {
|
|
615
|
-
socket.destroy();
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
else {
|
|
620
|
-
const m = manifestByService.get(route.service);
|
|
621
|
-
const resolved = m ? (0, manifest_1.authRuleForPath)(m, cpath, req.method ?? 'GET', false) : { mode: route.auth };
|
|
622
|
-
if (resolved.mode === 'jwt') {
|
|
623
|
-
if (opts.identify) {
|
|
624
|
-
if (!claims) {
|
|
625
|
-
socket.destroy();
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
else {
|
|
630
|
-
try {
|
|
631
|
-
authenticate('jwt', req);
|
|
632
|
-
}
|
|
633
|
-
catch {
|
|
634
|
-
socket.destroy();
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
// Same fail-closed ABAC + identical attribute bag as the HTTP path (no divergence).
|
|
639
|
-
if (resolved.rule && (!claims || !(0, manifest_1.evalRule)(resolved.rule, buildAttrs(req, req.socket.remoteAddress ?? '', claims, undefined)))) {
|
|
640
|
-
socket.destroy();
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
if (route.prependPrefix) {
|
|
646
|
-
const u = req.url ?? '/';
|
|
647
|
-
const pp = route.prependPrefix;
|
|
648
|
-
if (u !== pp && !u.startsWith(pp + '/'))
|
|
649
|
-
req.url = pp + u;
|
|
650
|
-
} // segment-boundary, not bare startsWith (/apidoc must not escape /api)
|
|
651
|
-
const pinned = hdr(req.headers[versionHeader]);
|
|
652
|
-
void (async () => {
|
|
653
|
-
const up = await resolveUpstream(route, undefined, pinned);
|
|
654
|
-
if (!up || !('url' in up)) {
|
|
655
|
-
socket.destroy();
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
const u = new URL(up.url);
|
|
659
|
-
const upstream = net.connect(Number(u.port) || (u.protocol === 'https:' ? 443 : 80), u.hostname, () => {
|
|
660
|
-
// rebuild the upgrade request from rawHeaders (verbatim), minus the trust headers
|
|
661
|
-
// we own (incl. client identity), plus the validated caller + target token + identity.
|
|
662
|
-
// Strip the trust headers AND the client's hop-by-hop control (connection/keep-alive),
|
|
663
|
-
// so a `Connection: x-internal-token` can't strip the gateway's injected headers downstream.
|
|
664
|
-
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']);
|
|
665
|
-
const lines = [`${req.method} ${req.url} HTTP/1.1`, 'Connection: Upgrade']; // gateway-owned, clean upgrade
|
|
666
|
-
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
667
|
-
const k = req.rawHeaders[i];
|
|
668
|
-
if (!strip.has(k.toLowerCase()))
|
|
669
|
-
lines.push(`${k}: ${req.rawHeaders[i + 1]}`);
|
|
670
|
-
}
|
|
671
|
-
if (caller)
|
|
672
|
-
lines.push(`x-api-key-id: ${caller}`);
|
|
673
|
-
const token = opts.serviceKeys?.[route.service];
|
|
674
|
-
if (token)
|
|
675
|
-
lines.push(`x-internal-token: ${token}`);
|
|
676
|
-
if (claims) { // stamp the verified identity (mirrors the HTTP path's stampIdentity)
|
|
677
|
-
if (claims['sub'])
|
|
678
|
-
lines.push(`x-user-id: ${String(claims['sub'])}`);
|
|
679
|
-
if (claims['tid'])
|
|
680
|
-
lines.push(`x-tenant-id: ${String(claims['tid'])}`);
|
|
681
|
-
const roles = claims['roles'];
|
|
682
|
-
if (Array.isArray(roles) && roles.length)
|
|
683
|
-
lines.push(`x-user-roles: ${roles.map(String).join(',')}`);
|
|
684
|
-
const perms = claims['perms'];
|
|
685
|
-
if (Array.isArray(perms) && perms.length)
|
|
686
|
-
lines.push(`x-user-permissions: ${perms.map(String).join(',')}`);
|
|
687
|
-
}
|
|
688
|
-
upstream.write(lines.join('\r\n') + '\r\n\r\n');
|
|
689
|
-
if (head?.length)
|
|
690
|
-
upstream.write(head);
|
|
691
|
-
socket.pipe(upstream);
|
|
692
|
-
upstream.pipe(socket);
|
|
693
|
-
});
|
|
694
|
-
upstream.on('error', () => socket.destroy());
|
|
695
|
-
socket.on('error', () => upstream.destroy());
|
|
696
|
-
})();
|
|
697
|
-
};
|
|
698
|
-
// ---- opt-in access log: one Cloud-Logging-shaped JSON line per response ----
|
|
699
|
-
// Covers EVERY response (incl. 401/404/503 that never reached an upstream),
|
|
700
|
-
// not just forwarded ones; `reply.elapsedTime` gives latency without our own timer.
|
|
701
|
-
const logAccess = (request, reply, st) => {
|
|
702
|
-
const req = request.raw;
|
|
703
|
-
const status = reply.statusCode ?? 0;
|
|
704
|
-
const host = (req.headers.host || '').split(':')[0];
|
|
705
|
-
const path = (req.url || '/').split('?')[0];
|
|
706
|
-
const ms = Math.round(reply.elapsedTime);
|
|
707
|
-
try {
|
|
708
|
-
console.log(JSON.stringify({
|
|
709
|
-
severity: status >= 500 ? 'ERROR' : status >= 400 ? 'WARNING' : 'INFO',
|
|
710
|
-
time: new Date().toISOString(),
|
|
711
|
-
message: `${req.method} ${host}${path} ${status} ${ms}ms`,
|
|
712
|
-
method: req.method, host, path, status, ms,
|
|
713
|
-
...(st ? { service: st.route.service, upstream: st.upLabel } : {}),
|
|
714
|
-
...(st?.version ? { version: st.version } : {}),
|
|
715
|
-
...(st?.caller ? { caller: st.caller } : {}),
|
|
716
|
-
...(req.headers['x-request-id'] ? { requestId: String(req.headers['x-request-id']) } : {}),
|
|
717
|
-
}));
|
|
718
|
-
}
|
|
719
|
-
catch { /* never let logging break the response */ }
|
|
720
|
-
};
|
|
721
|
-
// ---- build one Fastify app per port (shared route table + providers) ----
|
|
722
|
-
const buildApp = async () => {
|
|
723
|
-
const keepAlive = opts.server?.keepAliveTimeout ?? 620_000; // > GCP external ALB 600s backend keep-alive
|
|
724
|
-
const app = (0, fastify_1.default)({
|
|
725
|
-
logger: false,
|
|
726
|
-
keepAliveTimeout: keepAlive,
|
|
727
|
-
requestTimeout: opts.server?.requestTimeout ?? 0, // 0 = no inbound request timeout (long-lived streaming)
|
|
728
|
-
...(opts.server?.connectionTimeout !== undefined ? { connectionTimeout: opts.server.connectionTimeout } : {}),
|
|
729
|
-
...(opts.trustProxyIp ? { trustProxy: true } : {}),
|
|
730
|
-
...(opts.tls ? { https: { key: opts.tls.key, cert: opts.tls.cert } } : {}),
|
|
731
|
-
});
|
|
732
|
-
// headersTimeout must stay above keepAliveTimeout, else Node may close an idle
|
|
733
|
-
// keep-alive socket mid-request under load. Default: just above keepAlive.
|
|
734
|
-
app.server.headersTimeout = opts.server?.headersTimeout ?? keepAlive + 10_000;
|
|
735
|
-
// Stream every body straight through — never parse/buffer (this is a proxy).
|
|
736
|
-
app.removeAllContentTypeParsers();
|
|
737
|
-
app.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
|
738
|
-
await app.register(reply_from_1.default, {
|
|
739
|
-
disableRequestLogging: true,
|
|
740
|
-
undici: {
|
|
741
|
-
connections: opts.undici?.connections ?? 256,
|
|
742
|
-
pipelining: opts.undici?.pipelining ?? 1,
|
|
743
|
-
keepAliveTimeout: opts.undici?.keepAliveTimeout ?? 60_000,
|
|
744
|
-
bodyTimeout: opts.undici?.bodyTimeout ?? 0, // 0 = unlimited — never cut long-lived SSE / streaming MCP responses
|
|
745
|
-
headersTimeout: opts.undici?.headersTimeout ?? 60_000, // upstream must send response headers within 60s
|
|
746
|
-
...(opts.undici?.allowH2 ? { allowH2: true } : {}), // multiplex many MCP streams over few h2 conns (Cloud Run speaks h2)
|
|
747
|
-
connect: {
|
|
748
|
-
rejectUnauthorized: opts.undici?.rejectUnauthorized ?? false, // local-CA/self-signed by default; set true for verified east-west on GCP
|
|
749
|
-
...(opts.undici?.cert ? { cert: opts.undici.cert } : {}), // ── mTLS: present a client cert to upstreams ──
|
|
750
|
-
...(opts.undici?.key ? { key: opts.undici.key } : {}),
|
|
751
|
-
...(opts.undici?.ca ? { ca: opts.undici.ca } : {}), // trust a private CA for the server side
|
|
752
|
-
},
|
|
753
|
-
},
|
|
754
|
-
});
|
|
755
|
-
// Host-agnostic operational probes (answered before routing).
|
|
756
|
-
app.get('/livez', (_req, reply) => { reply.type('text/plain').send('ok'); });
|
|
757
|
-
app.get('/readyz', (_req, reply) => {
|
|
758
|
-
const ready = routes.length > 0;
|
|
759
|
-
reply.code(ready ? 200 : 503).type('text/plain').send(ready ? 'ready' : 'no routes loaded');
|
|
760
|
-
});
|
|
761
|
-
// Per-service health rollup (opt-in; token-guarded). For a status page that
|
|
762
|
-
// prefers HTTP over the Redis `status:*` fan-out. Off by default → 404 (don't
|
|
763
|
-
// leak topology on a public edge unless explicitly enabled).
|
|
764
|
-
app.get('/statusz', (request, reply) => {
|
|
765
|
-
// Fail closed: /statusz is served ONLY when both enabled AND a token is set
|
|
766
|
-
// (never expose topology/health unauthenticated). Constant-time token compare.
|
|
767
|
-
if (!opts.statusEndpoint || !opts.statusToken) {
|
|
768
|
-
reply.code(404).type('text/plain').send('not found');
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
const tok = request.query?.token ?? hdr(request.raw.headers['x-status-token']) ?? '';
|
|
772
|
-
const a = Buffer.from(tok), b = Buffer.from(opts.statusToken);
|
|
773
|
-
if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b)) {
|
|
774
|
-
reply.code(401).type('text/plain').send('unauthorized');
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
reply.type('application/json').send(JSON.stringify({ generatedAt: Date.now(), services: buildStatusSnapshot(), endpoints: opts.healthChecker?.snapshot() ?? [] }));
|
|
778
|
-
});
|
|
779
|
-
// Process self-metrics (event-loop lag / rss / cpu / req rates) — same token gate as /statusz.
|
|
780
|
-
app.get('/metricsz', (request, reply) => {
|
|
781
|
-
if (!opts.metrics || !opts.statusToken) {
|
|
782
|
-
reply.code(404).type('text/plain').send('not found');
|
|
783
|
-
return;
|
|
784
|
-
}
|
|
785
|
-
const tok = request.query?.token ?? hdr(request.raw.headers['x-status-token']) ?? '';
|
|
786
|
-
const a = Buffer.from(tok), b = Buffer.from(opts.statusToken);
|
|
787
|
-
if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b)) {
|
|
788
|
-
reply.code(401).type('text/plain').send('unauthorized');
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
reply.type('application/json').send(JSON.stringify(opts.metrics.snapshot()));
|
|
792
|
-
});
|
|
793
|
-
app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
|
|
794
|
-
// Edge CORS authority: stamp Access-Control-Allow-Origin on EVERY response that lacks it — including the
|
|
795
|
-
// 401/403/5xx the gateway generates ITSELF (edge auth/authz/proxy errors) which never reach an upstream.
|
|
796
|
-
// Without it the browser sees the error WITHOUT CORS and reports a CORS failure that MASKS the real
|
|
797
|
-
// status (e.g. a 401 the app should handle by re-authing). Add ONLY if absent so a proxied success keeps
|
|
798
|
-
// the upstream's header (no duplicate). No credentials (Bearer APIs; matches setCors).
|
|
799
|
-
app.addHook('onSend', (request, reply, payload, done) => {
|
|
800
|
-
const origin = request.headers.origin;
|
|
801
|
-
if (origin && !reply.getHeader('access-control-allow-origin')) {
|
|
802
|
-
// A credentialed-allowlist origin gets the EXACT origin + ACAC:true (so a gateway-generated
|
|
803
|
-
// 401/403/5xx on a credentialed first-party origin still passes the browser's CORS check);
|
|
804
|
-
// everyone else gets the reflected origin WITHOUT credentials (matches setCors).
|
|
805
|
-
if (corsCredentialOrigins.has(origin.toLowerCase())) {
|
|
806
|
-
reply.header('access-control-allow-origin', origin);
|
|
807
|
-
reply.header('access-control-allow-credentials', 'true');
|
|
808
|
-
}
|
|
809
|
-
else {
|
|
810
|
-
reply.header('access-control-allow-origin', String(origin));
|
|
811
|
-
}
|
|
812
|
-
reply.header('vary', 'Origin');
|
|
813
|
-
}
|
|
814
|
-
done(null, payload);
|
|
815
|
-
});
|
|
816
|
-
app.addHook('onResponse', (request, reply, done) => {
|
|
817
|
-
const st = reqState.get(request);
|
|
818
|
-
if (st) {
|
|
819
|
-
recordEdge(st.route, Date.now() - st.startedAt, reply.statusCode ?? 0, st.caller, st.version);
|
|
820
|
-
finish(st.trace, st.route.service, reply.statusCode ?? 0, { upstream: st.upLabel, ...(st.version ? { version: st.version } : {}), ...(st.caller ? { caller: st.caller } : {}) });
|
|
821
|
-
reqState.delete(request);
|
|
822
|
-
}
|
|
823
|
-
const sp = spanByReq.get(request);
|
|
824
|
-
if (sp) {
|
|
825
|
-
opts.tracing.end(sp, reply.statusCode ?? 0, { ...(st?.upLabel ? { 'upstream.url': st.upLabel } : {}), ...(st?.version ? { 'lensmcp.version': st.version } : {}) });
|
|
826
|
-
spanByReq.delete(request);
|
|
827
|
-
}
|
|
828
|
-
opts.metrics?.recordRequest(reply.statusCode ?? 0);
|
|
829
|
-
if (accessLog)
|
|
830
|
-
logAccess(request, reply, st);
|
|
831
|
-
done();
|
|
832
|
-
});
|
|
833
|
-
app.all('/', handler);
|
|
834
|
-
app.all('/*', handler);
|
|
835
|
-
app.server.on('upgrade', upgrade);
|
|
836
|
-
return app;
|
|
837
|
-
};
|
|
838
|
-
// ---- listeners ----
|
|
839
|
-
const apps = [];
|
|
840
|
-
const boundPorts = [];
|
|
841
|
-
for (const port of ports) {
|
|
842
|
-
const app = await buildApp();
|
|
843
|
-
await app.listen({ port, host: '0.0.0.0' });
|
|
844
|
-
const addr = app.server.address();
|
|
845
|
-
const bound = typeof addr === 'object' && addr ? addr.port : port;
|
|
846
|
-
boundPorts.push(bound);
|
|
847
|
-
console.log(`[gateway] listening on ${opts.tls ? 'https' : 'http'}://0.0.0.0:${bound}`);
|
|
848
|
-
apps.push(app);
|
|
849
|
-
}
|
|
850
|
-
opts.healthChecker?.start(); // begin probing now that the route table (and tracked URLs) is warm
|
|
851
|
-
let stopped = false;
|
|
852
|
-
return {
|
|
853
|
-
ports: boundPorts,
|
|
854
|
-
routes: () => routes,
|
|
855
|
-
status: buildStatusSnapshot,
|
|
856
|
-
stop: async () => {
|
|
857
|
-
if (stopped)
|
|
858
|
-
return;
|
|
859
|
-
stopped = true;
|
|
860
|
-
clearInterval(edgeFlusher);
|
|
861
|
-
unwatch?.();
|
|
862
|
-
opts.healthChecker?.stop();
|
|
863
|
-
opts.rateLimiter?.stop();
|
|
864
|
-
opts.metrics?.stop();
|
|
865
|
-
emit('info', 'gateway down', 'gateway-down', { kind: 'gateway-down' });
|
|
866
|
-
await Promise.all(apps.map((a) => a.close())); // reply-from closes its undici pool on close
|
|
867
|
-
},
|
|
868
|
-
};
|
|
869
|
-
}
|
|
21
|
+
var server_1 = require("./prod-runtime/server");
|
|
22
|
+
Object.defineProperty(exports, "startProdGateway", { enumerable: true, get: function () { return server_1.startProdGateway; } });
|