@lensmcp/cluster 1.18.4 → 1.18.7
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/basic-ssl.js +1 -241
- package/build-scope-patterns.js +1 -40
- package/create-webpack-dev.js +1 -186
- package/create-webpack-prod.js +1 -169
- package/executors/build/build.impl.js +1 -98
- package/executors/gateway/gateway-errors.js +1 -43
- package/executors/gateway/gateway.impl.js +1 -53
- package/executors/gateway/gateway.lib.js +1 -29
- package/executors/gateway/health-check.js +1 -66
- package/executors/gateway/jwks-verify.js +1 -121
- package/executors/gateway/main.prod-gateway.js +2 -573
- package/executors/gateway/main.rollout.js +11 -117
- package/executors/gateway/manifest.js +1 -374
- package/executors/gateway/metrics.js +1 -56
- package/executors/gateway/otel-tracing.js +1 -74
- package/executors/gateway/prod-gateway.lib.js +1 -22
- package/executors/gateway/prod-runtime/access-log.js +1 -24
- package/executors/gateway/prod-runtime/app.js +1 -123
- package/executors/gateway/prod-runtime/auth.js +1 -51
- package/executors/gateway/prod-runtime/cors.js +1 -40
- package/executors/gateway/prod-runtime/edge.js +1 -65
- package/executors/gateway/prod-runtime/handler.js +1 -226
- package/executors/gateway/prod-runtime/hooks.js +1 -42
- package/executors/gateway/prod-runtime/observability.js +1 -125
- package/executors/gateway/prod-runtime/rollout.js +1 -103
- package/executors/gateway/prod-runtime/routing.js +1 -40
- package/executors/gateway/prod-runtime/server.js +1 -79
- package/executors/gateway/prod-runtime/trust.js +1 -32
- package/executors/gateway/prod-runtime/types.js +1 -2
- package/executors/gateway/prod-runtime/upgrade.js +4 -116
- package/executors/gateway/prod-runtime/upstream.js +1 -21
- package/executors/gateway/providers-prod.js +1 -232
- package/executors/gateway/rate-limit.js +2 -75
- package/executors/gateway/registry-source.js +1 -131
- package/executors/gateway/rollout-ops.js +2 -167
- package/executors/gateway/runtime/auth.js +1 -64
- package/executors/gateway/runtime/chooser.js +12 -45
- package/executors/gateway/runtime/control.js +1 -128
- package/executors/gateway/runtime/dev-auth.js +1 -108
- package/executors/gateway/runtime/discovery.js +1 -123
- package/executors/gateway/runtime/edge.js +1 -47
- package/executors/gateway/runtime/handler.js +1 -183
- package/executors/gateway/runtime/hooks.js +1 -55
- package/executors/gateway/runtime/lens-children.js +1 -651
- package/executors/gateway/runtime/lifecycle.js +3 -842
- package/executors/gateway/runtime/observability.js +2 -148
- package/executors/gateway/runtime/pod-env.js +2 -89
- package/executors/gateway/runtime/proxy.js +1 -457
- package/executors/gateway/runtime/route-registry.js +1 -72
- package/executors/gateway/runtime/scope.js +1 -117
- package/executors/gateway/runtime/server.js +3 -487
- package/executors/gateway/runtime/service-keys.js +1 -49
- package/executors/gateway/runtime/types.js +1 -151
- package/executors/gateway/runtime/upgrade.js +1 -71
- package/executors/gateway/runtime/workspace-registry.js +1 -99
- package/executors/gateway/ssrf-guard.js +1 -190
- package/executors/serve/serve.impl.js +1 -280
- package/executors/trust/trust.impl.js +4 -162
- package/gateway.js +1 -35
- package/index.js +1 -16
- package/main.devserver.js +10 -1117
- package/package.json +4 -3
- package/tsgo-check-plugin.js +4 -364
- package/typecheck-bus.js +4 -256
|
@@ -1,573 +1,2 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.
|
|
3
|
-
const tslib_1 = require("tslib");
|
|
4
|
-
/**
|
|
5
|
-
* Production gateway process entry — the thing the Docker image runs. Reads
|
|
6
|
-
* its whole configuration from the environment (12-factor) and composes
|
|
7
|
-
* `startProdGateway` with the file-backed manifest provider + endpoint pod
|
|
8
|
-
* provider. Stateless and replica-safe.
|
|
9
|
-
*
|
|
10
|
-
* Env:
|
|
11
|
-
* LENSMCP_GW_PORTS csv of ports (default "8080", or "8443" with TLS)
|
|
12
|
-
* LENSMCP_GW_WORKERS cluster worker processes for multi-core (default 1; "auto"/"max" =
|
|
13
|
-
* os.cpus().length). Each worker shares the port. SECURITY: with the
|
|
14
|
-
* IN-MEMORY rate limiter the limit is enforced PER WORKER (~N×) — set
|
|
15
|
-
* LENSMCP_GW_RATE_LIMIT_REDIS=1 (one shared budget) when running >1 worker.
|
|
16
|
-
* LENSMCP_GW_MANIFESTS path to a manifests JSON file (array or {manifests:[]})
|
|
17
|
-
* LENSMCP_GW_ENDPOINTS path to an endpoints JSON for gateway-side LB / rollout
|
|
18
|
-
* (only needed for services whose manifest has no `upstream`).
|
|
19
|
-
* Flat: {service:[url,...]}
|
|
20
|
-
* Rollout: {service:[{version,weight,urls:[url,...]}]} ← weighted
|
|
21
|
-
* split across version cohorts; file is watched so weight
|
|
22
|
-
* changes ramp/rollback traffic LIVE (no redeploy).
|
|
23
|
-
* LENSMCP_GW_KEYS path to a {service: x-internal-token} JSON (the trust registry)
|
|
24
|
-
* LENSMCP_GW_TLS_KEY path to TLS private key PEM (enables https)
|
|
25
|
-
* LENSMCP_GW_TLS_CERT path to TLS cert PEM
|
|
26
|
-
* jwt routes ALWAYS require auth: validated against the verifier (JWKS/HS256) when
|
|
27
|
-
* configured, else FAIL CLOSED (401). LENSMCP_GW_JWT_PRESENCE_ONLY=1 opts into a
|
|
28
|
-
* weak bearer-presence-only edge guard when no verifier is wired (not recommended).
|
|
29
|
-
* LENSMCP_EVENT_FILE set → enable the lens bus (OPT-IN; off by default)
|
|
30
|
-
*
|
|
31
|
-
* Targeted rollout (attribute-based routing; docs/design/targeted-rollout.md):
|
|
32
|
-
* LENSMCP_GW_ENDPOINTS also accepts the targeted shape per service:
|
|
33
|
-
* {service:{rules:[{name,when,version}],cohorts:[…]}}
|
|
34
|
-
* LENSMCP_GW_JWT_SECRET HS256 secret → identify() exposes VERIFIED claims as
|
|
35
|
-
* rule attributes (sub/email/tenant/roles…). RS256/JWKS = prod upgrade.
|
|
36
|
-
* LENSMCP_GW_ATTR_HEADERS csv of request headers exposed to rules as `header.<name>`
|
|
37
|
-
* LENSMCP_GW_TRUST_PROXY "1" → take the `ip` attribute from X-Forwarded-For (behind an LB)
|
|
38
|
-
* LENSMCP_GW_UID_HEADER header carrying a device/user id — sticky key when no cookie
|
|
39
|
-
* LENSMCP_GW_COOKIE_SECRET set → enable the HMAC-signed device cookie (sticky bucketing)
|
|
40
|
-
* LENSMCP_GW_COOKIE_NAME device cookie name (default "lensmcp_did")
|
|
41
|
-
* LENSMCP_GW_COOKIE_DOMAIN cookie Domain (e.g. ".tetros.ai" to span subdomains)
|
|
42
|
-
* LENSMCP_GW_COOKIE_MAXAGE cookie Max-Age seconds (default 31536000)
|
|
43
|
-
*
|
|
44
|
-
* Edge tuning (all optional — lib defaults are production-shaped):
|
|
45
|
-
* PORT platform-injected bind port (used when LENSMCP_GW_PORTS is unset)
|
|
46
|
-
* LENSMCP_GW_ACCESS_LOG "1" → one Cloud-Logging-shaped JSON line per request to stdout
|
|
47
|
-
* LENSMCP_GW_UPSTREAM_TLS_VERIFY "1" → verify upstream TLS certs (set for east-west on GCP;
|
|
48
|
-
* default off keeps local-CA / self-signed upstreams working)
|
|
49
|
-
* LENSMCP_GW_UPSTREAM_H2 "1" → HTTP/2 to upstreams (multiplex many MCP streams; Cloud Run speaks h2).
|
|
50
|
-
* Auto-on when mTLS is configured; set "0" to force h1.1.
|
|
51
|
-
* mTLS to upstreams (optional client cert; rides on h2). Server validation is the
|
|
52
|
-
* LENSMCP_GW_UPSTREAM_TLS_VERIFY toggle above ("option to not validate" = leave it off):
|
|
53
|
-
* LENSMCP_GW_MTLS_FROM_REDIS "1" → load cert/key/ca from shared Redis keys
|
|
54
|
-
* <prefix>:mtls:{cert,key,ca} (rotate by writing Redis; rolling-restart to apply)
|
|
55
|
-
* LENSMCP_GW_MTLS_CERT / _KEY / _CA PEM file paths (used when not loading from Redis)
|
|
56
|
-
* LENSMCP_GW_CLIENT_IP_HEADER header with the authoritative client IP from the front
|
|
57
|
-
* proxy (set `cf-connecting-ip` behind Cloudflare; feeds IP-based ABAC)
|
|
58
|
-
* LENSMCP_GW_CLIENT_IP_TRUSTED_PROXIES csv of CIDRs allowed to set that header
|
|
59
|
-
* (default loopback only — prevents off-proxy IP spoofing)
|
|
60
|
-
* LENSMCP_GW_UNDICI_CONNECTIONS per-origin connection pool size (default 256; RIGHT-SIZE to the
|
|
61
|
-
* upstream latency — fewer is faster for a fast/local upstream)
|
|
62
|
-
* LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS undici response-body timeout; default 0 = unlimited
|
|
63
|
-
* LENSMCP_GW_UNDICI_PIPELINING requests packed per upstream connection (default 1; >1 lifts
|
|
64
|
-
* throughput on uniformly-fast upstreams but risks head-of-line
|
|
65
|
-
* blocking on streaming responses — leave 1 for mixed traffic)
|
|
66
|
-
* LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS upstream response-headers timeout (default 60000)
|
|
67
|
-
* LENSMCP_GW_UNDICI_KEEPALIVE_MS undici per-origin keep-alive timeout (default 60000)
|
|
68
|
-
* (keeps long-lived SSE / streaming MCP responses open)
|
|
69
|
-
* LENSMCP_GW_KEEPALIVE_MS server keep-alive timeout; default 620000, kept ABOVE the GCP
|
|
70
|
-
* external ALB's 600s backend keep-alive (avoids intermittent 502s)
|
|
71
|
-
* LENSMCP_GW_REQUEST_TIMEOUT_MS inbound request timeout; default 0 = disabled (streaming)
|
|
72
|
-
*
|
|
73
|
-
* Distributed tracing (OpenTelemetry — on only when the OTLP endpoint is set):
|
|
74
|
-
* OTEL_EXPORTER_OTLP_ENDPOINT OTLP/HTTP endpoint (Cloud Trace / Collector / X-Ray / Tempo / Jaeger).
|
|
75
|
-
* Set → gateway emits a SERVER span per request + propagates W3C traceparent downstream.
|
|
76
|
-
* OTEL_SERVICE_NAME resource service.name (default "lensmcp-gateway")
|
|
77
|
-
* OTEL_EXPORTER_OTLP_HEADERS exporter auth headers (standard OTLP env, read by the exporter)
|
|
78
|
-
* LENSMCP_GW_OTEL_SAMPLE head sample ratio 0..1 for root spans (default 1)
|
|
79
|
-
*
|
|
80
|
-
* Status rollup (for a status page — observed health, not the routing registry):
|
|
81
|
-
* LENSMCP_GW_STATUS "1" → expose GET /statusz + (with Redis) fan health to status:<svc>:<instance>
|
|
82
|
-
* LENSMCP_GW_STATUS_TOKEN if set, /statusz (and /metricsz) require ?token= or x-status-token to match
|
|
83
|
-
* LENSMCP_GW_METRICS "1" → expose token-gated /metricsz (event-loop lag / rss / cpu / req rates) for load tests
|
|
84
|
-
*
|
|
85
|
-
* Active health-checking (probe upstreams; route pools around dead endpoints):
|
|
86
|
-
* LENSMCP_GW_HEALTH_CHECK "1" → probe every upstream and steer pooled picks to healthy ones
|
|
87
|
-
* LENSMCP_GW_HEALTH_PATH probe path (default /healthz); healthy = answers with status < 500
|
|
88
|
-
* LENSMCP_GW_HEALTH_INTERVAL_MS / LENSMCP_GW_HEALTH_TIMEOUT_MS probe period / timeout
|
|
89
|
-
*
|
|
90
|
-
* Rate limit (opt-in, per client IP; defense-in-depth behind the edge throttle):
|
|
91
|
-
* LENSMCP_GW_RATE_LIMIT requests allowed per window (enables the limiter)
|
|
92
|
-
* LENSMCP_GW_RATE_WINDOW_MS window the limit refills over (default 60000)
|
|
93
|
-
* LENSMCP_GW_RATE_LIMIT_REDIS "1" → ONE GLOBAL budget across all containers via the
|
|
94
|
-
* shared registry Redis (atomic Lua token-bucket); else per-instance
|
|
95
|
-
* LENSMCP_GW_RATE_LIMIT_REDIS_URL dedicated Redis for the limiter (else reuse the registry one)
|
|
96
|
-
* LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS per-request Redis budget before the fail policy (default 100)
|
|
97
|
-
* LENSMCP_GW_RATE_LIMIT_FAIL_CLOSED "1" → deny on a Redis error/timeout (default: fail OPEN)
|
|
98
|
-
*
|
|
99
|
-
* Manifests + endpoints + keys are FILE-backed here; a registry-backed
|
|
100
|
-
* ManifestProvider (Redis/ConfigMap) drops in behind the same interface.
|
|
101
|
-
*/
|
|
102
|
-
const node_fs_1 = require("node:fs");
|
|
103
|
-
const node_crypto_1 = require("node:crypto");
|
|
104
|
-
const node_path_1 = require("node:path");
|
|
105
|
-
const node_os_1 = require("node:os");
|
|
106
|
-
const node_cluster_1 = tslib_1.__importDefault(require("node:cluster"));
|
|
107
|
-
const prod_gateway_lib_1 = require("./prod-gateway.lib");
|
|
108
|
-
const jwks_verify_1 = require("./jwks-verify");
|
|
109
|
-
const otel_tracing_1 = require("./otel-tracing");
|
|
110
|
-
const health_check_1 = require("./health-check");
|
|
111
|
-
const rate_limit_1 = require("./rate-limit");
|
|
112
|
-
const metrics_1 = require("./metrics");
|
|
113
|
-
const registry_source_1 = require("./registry-source");
|
|
114
|
-
const providers_prod_1 = require("./providers-prod");
|
|
115
|
-
function readJson(path, fallback) {
|
|
116
|
-
if (!path)
|
|
117
|
-
return fallback;
|
|
118
|
-
try {
|
|
119
|
-
return JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
120
|
-
}
|
|
121
|
-
catch (e) {
|
|
122
|
-
console.error(`[gateway] could not read ${path}:`, e.message);
|
|
123
|
-
return fallback;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
/** Endpoints file is either flat `{svc:[url]}` or rollout `{svc:[{version,weight,urls}]}`. */
|
|
127
|
-
function isCohortShape(eps) {
|
|
128
|
-
const first = Object.values(eps)[0];
|
|
129
|
-
return Array.isArray(first) && first.length > 0 && typeof first[0] === 'object' && first[0] !== null && 'urls' in first[0];
|
|
130
|
-
}
|
|
131
|
-
/** Poll a JSON file and invoke `onChange` with its parsed contents whenever it
|
|
132
|
-
* changes — drives LIVE rollout ramps (edit weights → traffic shifts, no
|
|
133
|
-
* redeploy). Returns a stop fn. */
|
|
134
|
-
function watchJsonFile(path, onChange, pollMs = 1000) {
|
|
135
|
-
let mtime = -1;
|
|
136
|
-
const tick = () => {
|
|
137
|
-
if (!(0, node_fs_1.existsSync)(path))
|
|
138
|
-
return;
|
|
139
|
-
let m;
|
|
140
|
-
try {
|
|
141
|
-
m = (0, node_fs_1.statSync)(path).mtimeMs;
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (m === mtime)
|
|
147
|
-
return;
|
|
148
|
-
let data;
|
|
149
|
-
try {
|
|
150
|
-
data = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
|
|
151
|
-
}
|
|
152
|
-
catch (e) {
|
|
153
|
-
console.error('[gateway] endpoints reload:', e.message);
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
mtime = m;
|
|
157
|
-
onChange(data);
|
|
158
|
-
};
|
|
159
|
-
const timer = setInterval(tick, pollMs);
|
|
160
|
-
timer.unref?.();
|
|
161
|
-
return () => clearInterval(timer);
|
|
162
|
-
}
|
|
163
|
-
async function main() {
|
|
164
|
-
const env = process.env;
|
|
165
|
-
// Bind ports: explicit LENSMCP_GW_PORTS wins; else honor the platform `PORT`
|
|
166
|
-
// contract (Cloud Run / buildpacks inject it); else the lib default (8080/8443).
|
|
167
|
-
const ports = (env['LENSMCP_GW_PORTS'] ?? env['PORT'] ?? '').split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
168
|
-
// Process-level safety net: a single uncaught async error must not kill the
|
|
169
|
-
// pod and 503 every in-flight request — log and keep serving.
|
|
170
|
-
process.on('uncaughtException', (err) => console.error('[gateway] uncaughtException:', err));
|
|
171
|
-
process.on('unhandledRejection', (reason) => console.error('[gateway] unhandledRejection:', reason));
|
|
172
|
-
// Config source: a Redis REGISTRY (zero-touch auto-sync — a deploy / the
|
|
173
|
-
// `lensmcp rollout --redis` helper writes keys + publishes, gateway re-reads
|
|
174
|
-
// live) takes precedence; otherwise file-backed manifests + endpoints (watched).
|
|
175
|
-
const redisUrl = env['LENSMCP_GW_REDIS_URL'];
|
|
176
|
-
let manifests;
|
|
177
|
-
let pods;
|
|
178
|
-
let stopEndpointsWatch;
|
|
179
|
-
let registry;
|
|
180
|
-
// Reused (when present) for the status:* fan-out and shared mTLS material reads.
|
|
181
|
-
let redisClient;
|
|
182
|
-
if (redisUrl) {
|
|
183
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
184
|
-
const Redis = require('ioredis');
|
|
185
|
-
const client = new Redis(redisUrl);
|
|
186
|
-
redisClient = client;
|
|
187
|
-
const reg = (0, registry_source_1.createRegistryProviders)((0, registry_source_1.redisRegistrySource)(client, env['LENSMCP_GW_REDIS_PREFIX'] ? { prefix: env['LENSMCP_GW_REDIS_PREFIX'] } : {}), { onError: (e) => console.error('[gateway] registry:', e instanceof Error ? e.message : e) });
|
|
188
|
-
await reg.start(); // warm the route table before serving
|
|
189
|
-
manifests = reg.manifests;
|
|
190
|
-
pods = reg.pods;
|
|
191
|
-
registry = reg;
|
|
192
|
-
console.log(`[gateway] registry: Redis-backed, live (${manifests.list().length} service(s))`);
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
const manifestFile = env['LENSMCP_GW_MANIFESTS'];
|
|
196
|
-
if (manifestFile) {
|
|
197
|
-
manifests = (0, providers_prod_1.fileManifestProvider)(manifestFile, { onError: (e) => console.error('[gateway] manifest:', e instanceof Error ? e.message : e) });
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
console.warn('[gateway] no LENSMCP_GW_MANIFESTS / LENSMCP_GW_REDIS_URL — starting with an EMPTY route table (all requests 404 until manifests arrive).');
|
|
201
|
-
manifests = (0, providers_prod_1.staticManifestProvider)([]);
|
|
202
|
-
}
|
|
203
|
-
// Endpoints drive gateway-side LB / progressive rollout. Shapes:
|
|
204
|
-
// flat {svc:[url,...]} → endpointsPodProvider
|
|
205
|
-
// rollout {svc:[{version,weight,urls:[...]}]} → rolloutPodProvider (weighted split)
|
|
206
|
-
// targeted {svc:{rules,cohorts}} → rolloutPodProvider (ABAC)
|
|
207
|
-
// The file is watched so weights ramp or roll back LIVE — no gateway redeploy.
|
|
208
|
-
const endpointsFile = env['LENSMCP_GW_ENDPOINTS'];
|
|
209
|
-
const endpointsRaw = readJson(endpointsFile, {});
|
|
210
|
-
if (Object.keys(endpointsRaw).length) {
|
|
211
|
-
const rollout = isCohortShape(endpointsRaw);
|
|
212
|
-
pods = rollout
|
|
213
|
-
? (0, providers_prod_1.rolloutPodProvider)(endpointsRaw)
|
|
214
|
-
: (0, providers_prod_1.endpointsPodProvider)(endpointsRaw);
|
|
215
|
-
console.log(`[gateway] endpoints: ${rollout ? 'ROLLOUT (weighted cohorts)' : 'flat'} — ${Object.keys(endpointsRaw).length} service(s)`);
|
|
216
|
-
if (endpointsFile) {
|
|
217
|
-
stopEndpointsWatch = watchJsonFile(endpointsFile, (next) => {
|
|
218
|
-
try {
|
|
219
|
-
pods?.update?.(next);
|
|
220
|
-
console.log('[gateway] endpoints reloaded');
|
|
221
|
-
}
|
|
222
|
-
catch (e) {
|
|
223
|
-
console.error('[gateway] endpoints reload failed:', e.message);
|
|
224
|
-
}
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const serviceKeys = readJson(env['LENSMCP_GW_KEYS'], {});
|
|
230
|
-
const tls = env['LENSMCP_GW_TLS_KEY'] && env['LENSMCP_GW_TLS_CERT']
|
|
231
|
-
? { key: (0, node_fs_1.readFileSync)(env['LENSMCP_GW_TLS_KEY']), cert: (0, node_fs_1.readFileSync)(env['LENSMCP_GW_TLS_CERT']) }
|
|
232
|
-
: undefined;
|
|
233
|
-
// ── JWT verification: JWKS (RS256, prod) preferred, HS256 secret as fallback ──
|
|
234
|
-
// One verifier powers BOTH identify() (claims → ABAC attributes) and the edge
|
|
235
|
-
// authenticate(). A `jwt` route with NO verifier configured FAILS CLOSED (401),
|
|
236
|
-
// unless LENSMCP_GW_JWT_PRESENCE_ONLY=1 explicitly opts into a weak bearer-presence
|
|
237
|
-
// edge guard (no signature check). Default is fail-closed, not pass-through.
|
|
238
|
-
const jwtPresenceOnly = env['LENSMCP_GW_JWT_PRESENCE_ONLY'] === '1';
|
|
239
|
-
const jwksUrl = env['LENSMCP_GW_JWKS_URL'];
|
|
240
|
-
const jwtSecret = env['LENSMCP_GW_JWT_SECRET'];
|
|
241
|
-
const jwks = jwksUrl
|
|
242
|
-
? (0, jwks_verify_1.createJwksVerifier)({
|
|
243
|
-
jwksUrl,
|
|
244
|
-
...(env['LENSMCP_GW_JWT_ISS'] ? { issuer: env['LENSMCP_GW_JWT_ISS'] } : {}),
|
|
245
|
-
...(env['LENSMCP_GW_JWT_AUD'] ? { audience: env['LENSMCP_GW_JWT_AUD'].split(',').map((s) => s.trim()) } : {}),
|
|
246
|
-
})
|
|
247
|
-
: undefined;
|
|
248
|
-
const hs256 = (tok) => {
|
|
249
|
-
if (!jwtSecret)
|
|
250
|
-
return undefined;
|
|
251
|
-
const parts = tok.split('.');
|
|
252
|
-
if (parts.length !== 3)
|
|
253
|
-
return undefined;
|
|
254
|
-
try {
|
|
255
|
-
const expect = (0, node_crypto_1.createHmac)('sha256', jwtSecret).update(parts[0] + '.' + parts[1]).digest('base64url');
|
|
256
|
-
const a = Buffer.from(parts[2]), b = Buffer.from(expect);
|
|
257
|
-
if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b))
|
|
258
|
-
return undefined;
|
|
259
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
260
|
-
if (typeof payload.exp === 'number' && Date.now() / 1000 > payload.exp)
|
|
261
|
-
return undefined;
|
|
262
|
-
return payload;
|
|
263
|
-
}
|
|
264
|
-
catch {
|
|
265
|
-
return undefined;
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
const bearerClaims = (req) => {
|
|
269
|
-
const auth = String(req.headers['authorization'] ?? '');
|
|
270
|
-
if (!auth.startsWith('Bearer '))
|
|
271
|
-
return undefined;
|
|
272
|
-
const tok = auth.slice(7);
|
|
273
|
-
return jwks ? jwks.verify(tok) : hs256(tok);
|
|
274
|
-
};
|
|
275
|
-
const identify = (jwks || jwtSecret) ? bearerClaims : undefined;
|
|
276
|
-
const authenticate = (mode, req) => {
|
|
277
|
-
if (mode !== 'jwt')
|
|
278
|
-
return;
|
|
279
|
-
if (jwks || jwtSecret) {
|
|
280
|
-
if (!bearerClaims(req))
|
|
281
|
-
throw new Error('invalid or missing token');
|
|
282
|
-
return;
|
|
283
|
-
} // real verification
|
|
284
|
-
// No verifier configured → FAIL CLOSED, unless presence-only is explicitly opted in.
|
|
285
|
-
if (!jwtPresenceOnly)
|
|
286
|
-
throw new Error('jwt route but no verifier configured (set LENSMCP_GW_JWKS_URL or LENSMCP_GW_JWT_SECRET)');
|
|
287
|
-
if (!String(req.headers['authorization'] ?? '').startsWith('Bearer '))
|
|
288
|
-
throw new Error('missing bearer token');
|
|
289
|
-
};
|
|
290
|
-
const attributeHeaders = (env['LENSMCP_GW_ATTR_HEADERS'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
291
|
-
const trustProxyIp = env['LENSMCP_GW_TRUST_PROXY'] === '1';
|
|
292
|
-
const uidHeader = env['LENSMCP_GW_UID_HEADER'];
|
|
293
|
-
const cookie = (env['LENSMCP_GW_COOKIE_SECRET'] || env['LENSMCP_GW_COOKIE_NAME'] || env['LENSMCP_GW_COOKIE_DOMAIN'])
|
|
294
|
-
? {
|
|
295
|
-
...(env['LENSMCP_GW_COOKIE_NAME'] ? { name: env['LENSMCP_GW_COOKIE_NAME'] } : {}),
|
|
296
|
-
...(env['LENSMCP_GW_COOKIE_SECRET'] ? { secret: env['LENSMCP_GW_COOKIE_SECRET'] } : {}),
|
|
297
|
-
...(env['LENSMCP_GW_COOKIE_DOMAIN'] ? { domain: env['LENSMCP_GW_COOKIE_DOMAIN'] } : {}),
|
|
298
|
-
...(env['LENSMCP_GW_COOKIE_MAXAGE'] ? { maxAge: Number(env['LENSMCP_GW_COOKIE_MAXAGE']) } : {}),
|
|
299
|
-
}
|
|
300
|
-
: undefined;
|
|
301
|
-
// Lens bus is OPT-IN: only when LENSMCP_EVENT_FILE is set do we attach a sink.
|
|
302
|
-
const eventFile = env['LENSMCP_EVENT_FILE'];
|
|
303
|
-
let dirReady = false;
|
|
304
|
-
const emit = eventFile
|
|
305
|
-
? (event) => {
|
|
306
|
-
try {
|
|
307
|
-
if (!dirReady) {
|
|
308
|
-
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(eventFile), { recursive: true });
|
|
309
|
-
dirReady = true;
|
|
310
|
-
}
|
|
311
|
-
(0, node_fs_1.appendFileSync)(eventFile, JSON.stringify(event) + '\n');
|
|
312
|
-
}
|
|
313
|
-
catch { /* lens offline — never block traffic */ }
|
|
314
|
-
}
|
|
315
|
-
: undefined;
|
|
316
|
-
// Warm the JWKS before serving so the first identity-routed requests have keys.
|
|
317
|
-
if (jwks) {
|
|
318
|
-
try {
|
|
319
|
-
await jwks.refresh();
|
|
320
|
-
console.log('[gateway] JWKS loaded');
|
|
321
|
-
}
|
|
322
|
-
catch (e) {
|
|
323
|
-
console.error('[gateway] initial JWKS load failed (will retry in background):', e.message);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
// ── edge tuning (all optional; sensible prod defaults live in the lib) ──
|
|
327
|
-
const accessLog = env['LENSMCP_GW_ACCESS_LOG'] === '1';
|
|
328
|
-
const clientIpHeader = env['LENSMCP_GW_CLIENT_IP_HEADER']; // e.g. cf-connecting-ip behind Cloudflare
|
|
329
|
-
// CIDRs of trusted front proxies that may set the client-IP header (default: loopback only).
|
|
330
|
-
const clientIpTrustedProxies = (env['LENSMCP_GW_CLIENT_IP_TRUSTED_PROXIES'] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
331
|
-
const undici = {};
|
|
332
|
-
if (env['LENSMCP_GW_UPSTREAM_TLS_VERIFY'] === '1')
|
|
333
|
-
undici.rejectUnauthorized = true; // "option to not validate" = leave unset
|
|
334
|
-
if (env['LENSMCP_GW_UPSTREAM_H2'] === '1')
|
|
335
|
-
undici.allowH2 = true;
|
|
336
|
-
if (env['LENSMCP_GW_UNDICI_CONNECTIONS'])
|
|
337
|
-
undici.connections = Number(env['LENSMCP_GW_UNDICI_CONNECTIONS']);
|
|
338
|
-
if (env['LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS'])
|
|
339
|
-
undici.bodyTimeout = Number(env['LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS']);
|
|
340
|
-
// Pipelining > 1 packs more requests per upstream connection (higher throughput on fast upstreams) but
|
|
341
|
-
// risks head-of-line blocking — a slow/streaming response stalls the requests queued behind it on that
|
|
342
|
-
// connection. Default 1 (safe for mixed/streaming traffic); raise only for uniformly-fast upstreams.
|
|
343
|
-
if (env['LENSMCP_GW_UNDICI_PIPELINING'])
|
|
344
|
-
undici.pipelining = Number(env['LENSMCP_GW_UNDICI_PIPELINING']);
|
|
345
|
-
if (env['LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS'])
|
|
346
|
-
undici.headersTimeout = Number(env['LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS']);
|
|
347
|
-
if (env['LENSMCP_GW_UNDICI_KEEPALIVE_MS'])
|
|
348
|
-
undici.keepAliveTimeout = Number(env['LENSMCP_GW_UNDICI_KEEPALIVE_MS']);
|
|
349
|
-
// ── mTLS client materials (optional): from the shared registry Redis, or files ──
|
|
350
|
-
if (env['LENSMCP_GW_MTLS_FROM_REDIS'] === '1' && redisClient) {
|
|
351
|
-
const p = env['LENSMCP_GW_REDIS_PREFIX'] ?? 'lensmcp:gw';
|
|
352
|
-
const [cert, key, ca] = await redisClient.mget(`${p}:mtls:cert`, `${p}:mtls:key`, `${p}:mtls:ca`);
|
|
353
|
-
if (cert)
|
|
354
|
-
undici.cert = cert;
|
|
355
|
-
if (key)
|
|
356
|
-
undici.key = key;
|
|
357
|
-
if (ca)
|
|
358
|
-
undici.ca = ca;
|
|
359
|
-
if (cert || key)
|
|
360
|
-
console.log('[gateway] mTLS materials loaded from Redis');
|
|
361
|
-
}
|
|
362
|
-
else {
|
|
363
|
-
if (env['LENSMCP_GW_MTLS_CERT'])
|
|
364
|
-
undici.cert = (0, node_fs_1.readFileSync)(env['LENSMCP_GW_MTLS_CERT']);
|
|
365
|
-
if (env['LENSMCP_GW_MTLS_KEY'])
|
|
366
|
-
undici.key = (0, node_fs_1.readFileSync)(env['LENSMCP_GW_MTLS_KEY']);
|
|
367
|
-
if (env['LENSMCP_GW_MTLS_CA'])
|
|
368
|
-
undici.ca = (0, node_fs_1.readFileSync)(env['LENSMCP_GW_MTLS_CA']);
|
|
369
|
-
}
|
|
370
|
-
// mTLS rides on HTTP/2 by default (unless explicitly disabled with LENSMCP_GW_UPSTREAM_H2=0).
|
|
371
|
-
if (undici.cert && undici.key) {
|
|
372
|
-
if (env['LENSMCP_GW_UPSTREAM_H2'] !== '0')
|
|
373
|
-
undici.allowH2 = true;
|
|
374
|
-
console.log(`[gateway] mTLS to upstreams ON over ${undici.allowH2 ? 'h2' : 'h1.1'} (server validation ${undici.rejectUnauthorized ? 'enabled' : 'OFF'})`);
|
|
375
|
-
}
|
|
376
|
-
const server = {};
|
|
377
|
-
if (env['LENSMCP_GW_KEEPALIVE_MS'])
|
|
378
|
-
server.keepAliveTimeout = Number(env['LENSMCP_GW_KEEPALIVE_MS']);
|
|
379
|
-
if (env['LENSMCP_GW_REQUEST_TIMEOUT_MS'])
|
|
380
|
-
server.requestTimeout = Number(env['LENSMCP_GW_REQUEST_TIMEOUT_MS']);
|
|
381
|
-
// OTel distributed tracing — on only when OTEL_EXPORTER_OTLP_ENDPOINT is set.
|
|
382
|
-
const tracing = (0, otel_tracing_1.setupTracing)({
|
|
383
|
-
...(env['OTEL_SERVICE_NAME'] ? { serviceName: env['OTEL_SERVICE_NAME'] } : {}),
|
|
384
|
-
...(env['LENSMCP_GW_OTEL_SAMPLE'] ? { sampleRatio: Number(env['LENSMCP_GW_OTEL_SAMPLE']) } : {}),
|
|
385
|
-
});
|
|
386
|
-
if (tracing)
|
|
387
|
-
console.log(`[gateway] OTLP tracing on → ${env['OTEL_EXPORTER_OTLP_ENDPOINT']}`);
|
|
388
|
-
// Status rollup: /statusz endpoint (token-guarded) + a per-instance Redis
|
|
389
|
-
// `status:<service>:<host>` fan-out (TTL'd) that a status page reads with a
|
|
390
|
-
// read-only ACL scoped to `status:*` — never the routing control-plane keys.
|
|
391
|
-
const statusEnabled = env['LENSMCP_GW_STATUS'] === '1';
|
|
392
|
-
const statusToken = env['LENSMCP_GW_STATUS_TOKEN'];
|
|
393
|
-
const instance = (0, node_os_1.hostname)();
|
|
394
|
-
const statusTtl = Math.max(3, Math.ceil((Number(env['LENSMCP_GW_TRAFFIC_FLUSH_MS'] ?? 5000) / 1000) * 3));
|
|
395
|
-
const statusRedis = redisClient;
|
|
396
|
-
const onStatus = (statusEnabled && statusRedis)
|
|
397
|
-
? (snapshot) => {
|
|
398
|
-
for (const s of snapshot) {
|
|
399
|
-
void statusRedis.set(`status:${s.service}:${instance}`, JSON.stringify({ ...s, instance, at: Date.now() }), 'EX', statusTtl).catch(() => undefined);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
: undefined;
|
|
403
|
-
if (statusEnabled && !statusToken)
|
|
404
|
-
console.warn('[gateway] LENSMCP_GW_STATUS=1 but no LENSMCP_GW_STATUS_TOKEN — /statusz stays DISABLED (404) until a token is set (fail-closed). The Redis status:* fan-out still runs.');
|
|
405
|
-
if (statusEnabled)
|
|
406
|
-
console.log(`[gateway] status on — /statusz${statusToken ? '' : ' (endpoint disabled: no token)'}${onStatus ? ' + Redis status:* fan-out' : ''}`);
|
|
407
|
-
// Active upstream health-checking — probes upstreams, routes pools around dead
|
|
408
|
-
// endpoints, and feeds true liveness into /statusz. Default off (passive only).
|
|
409
|
-
const healthChecker = env['LENSMCP_GW_HEALTH_CHECK'] === '1'
|
|
410
|
-
? (0, health_check_1.createHealthChecker)({
|
|
411
|
-
...(env['LENSMCP_GW_HEALTH_PATH'] ? { path: env['LENSMCP_GW_HEALTH_PATH'] } : {}),
|
|
412
|
-
...(env['LENSMCP_GW_HEALTH_INTERVAL_MS'] ? { intervalMs: Number(env['LENSMCP_GW_HEALTH_INTERVAL_MS']) } : {}),
|
|
413
|
-
...(env['LENSMCP_GW_HEALTH_TIMEOUT_MS'] ? { timeoutMs: Number(env['LENSMCP_GW_HEALTH_TIMEOUT_MS']) } : {}),
|
|
414
|
-
})
|
|
415
|
-
: undefined;
|
|
416
|
-
if (healthChecker)
|
|
417
|
-
console.log(`[gateway] active health-checking on (path ${env['LENSMCP_GW_HEALTH_PATH'] ?? '/healthz'})`);
|
|
418
|
-
// Per-client-IP rate limit (opt-in; defense-in-depth behind Cloudflare/Cloud Armor).
|
|
419
|
-
// LENSMCP_GW_RATE_LIMIT_REDIS=1 makes it ONE GLOBAL budget shared by every gateway
|
|
420
|
-
// container (atomic Lua token-bucket on the shared registry Redis — or a dedicated
|
|
421
|
-
// LENSMCP_GW_RATE_LIMIT_REDIS_URL); fail-open on a Redis blip. Otherwise it's the
|
|
422
|
-
// per-instance in-memory limiter (whose effective limit multiplies by container count).
|
|
423
|
-
let rateLimiter;
|
|
424
|
-
let rlRedisClient; // a dedicated client we own and must quit
|
|
425
|
-
if (env['LENSMCP_GW_RATE_LIMIT']) {
|
|
426
|
-
const limit = Number(env['LENSMCP_GW_RATE_LIMIT']);
|
|
427
|
-
const windowMs = Number(env['LENSMCP_GW_RATE_WINDOW_MS'] ?? 60_000);
|
|
428
|
-
const wantRedis = env['LENSMCP_GW_RATE_LIMIT_REDIS'] === '1' || !!env['LENSMCP_GW_RATE_LIMIT_REDIS_URL'];
|
|
429
|
-
if (wantRedis) {
|
|
430
|
-
const dedicatedUrl = env['LENSMCP_GW_RATE_LIMIT_REDIS_URL'];
|
|
431
|
-
let client;
|
|
432
|
-
if (dedicatedUrl) {
|
|
433
|
-
const Redis = require('ioredis');
|
|
434
|
-
rlRedisClient = new Redis(dedicatedUrl);
|
|
435
|
-
client = rlRedisClient;
|
|
436
|
-
}
|
|
437
|
-
else if (redisClient) {
|
|
438
|
-
client = redisClient; // reuse the registry Redis
|
|
439
|
-
}
|
|
440
|
-
if (client) {
|
|
441
|
-
const failClosed = env['LENSMCP_GW_RATE_LIMIT_FAIL_CLOSED'] === '1';
|
|
442
|
-
rateLimiter = (0, rate_limit_1.createRedisRateLimiter)(client, {
|
|
443
|
-
limit, windowMs,
|
|
444
|
-
...(env['LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS'] ? { timeoutMs: Number(env['LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS']) } : {}),
|
|
445
|
-
...(failClosed ? { failOpen: false } : {}),
|
|
446
|
-
});
|
|
447
|
-
console.log(`[gateway] rate limit on — ${limit} req / ${windowMs}ms GLOBAL (Redis: ${dedicatedUrl ? 'dedicated' : 'shared registry'}, fail-${failClosed ? 'closed' : 'open'}) per client IP`);
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
console.warn('[gateway] LENSMCP_GW_RATE_LIMIT_REDIS set but no Redis configured (set LENSMCP_GW_REDIS_URL or LENSMCP_GW_RATE_LIMIT_REDIS_URL) — falling back to PER-INSTANCE in-memory limiting.');
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
if (!rateLimiter) {
|
|
454
|
-
rateLimiter = (0, rate_limit_1.createRateLimiter)({ limit, windowMs });
|
|
455
|
-
console.log(`[gateway] rate limit on — ${limit} req / ${windowMs}ms per client IP (in-memory, per-instance)`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
// Process self-metrics (event-loop lag / rss / cpu / req rates) for load tests + monitoring.
|
|
459
|
-
const metrics = env['LENSMCP_GW_METRICS'] === '1' ? (0, metrics_1.createProcessMetrics)() : undefined;
|
|
460
|
-
if (metrics)
|
|
461
|
-
console.log(`[gateway] process metrics on — /metricsz${statusToken ? ' (token-gated)' : ' (DISABLED: needs LENSMCP_GW_STATUS_TOKEN)'}`);
|
|
462
|
-
const handle = await (0, prod_gateway_lib_1.startProdGateway)({
|
|
463
|
-
...(ports.length ? { ports } : {}),
|
|
464
|
-
manifests,
|
|
465
|
-
...(pods ? { pods } : {}),
|
|
466
|
-
serviceKeys,
|
|
467
|
-
authenticate,
|
|
468
|
-
...(tls ? { tls } : {}),
|
|
469
|
-
...(emit ? { emit } : {}),
|
|
470
|
-
...(identify ? { identify } : {}),
|
|
471
|
-
...(attributeHeaders.length ? { attributeHeaders } : {}),
|
|
472
|
-
...(trustProxyIp ? { trustProxyIp } : {}),
|
|
473
|
-
...(cookie ? { cookie } : {}),
|
|
474
|
-
...(uidHeader ? { uidHeader } : {}),
|
|
475
|
-
...(accessLog ? { accessLog: true } : {}),
|
|
476
|
-
...(clientIpHeader ? { clientIpHeader } : {}),
|
|
477
|
-
...(clientIpTrustedProxies.length ? { clientIpTrustedProxies } : {}),
|
|
478
|
-
...(tracing ? { tracing } : {}),
|
|
479
|
-
...(statusEnabled ? { statusEndpoint: true } : {}),
|
|
480
|
-
...(statusToken ? { statusToken } : {}),
|
|
481
|
-
...(onStatus ? { onStatus } : {}),
|
|
482
|
-
...(healthChecker ? { healthChecker } : {}),
|
|
483
|
-
...(rateLimiter ? { rateLimiter } : {}),
|
|
484
|
-
...(metrics ? { metrics } : {}),
|
|
485
|
-
...(env['LENSMCP_GW_TRAFFIC_FLUSH_MS'] ? { trafficFlushMs: Number(env['LENSMCP_GW_TRAFFIC_FLUSH_MS']) } : {}),
|
|
486
|
-
...(Object.keys(undici).length ? { undici } : {}),
|
|
487
|
-
...(Object.keys(server).length ? { server } : {}),
|
|
488
|
-
});
|
|
489
|
-
console.log(`[gateway] production gateway up on ${handle.ports.join(', ')} (${handle.routes().length} routes${emit ? ', lens ON' : ''})`);
|
|
490
|
-
const shutdown = async (sig) => {
|
|
491
|
-
console.log(`[gateway] ${sig} — draining…`);
|
|
492
|
-
try {
|
|
493
|
-
manifests.stop?.();
|
|
494
|
-
}
|
|
495
|
-
catch { /* ignore */ }
|
|
496
|
-
try {
|
|
497
|
-
stopEndpointsWatch?.();
|
|
498
|
-
}
|
|
499
|
-
catch { /* ignore */ }
|
|
500
|
-
try {
|
|
501
|
-
registry?.stop();
|
|
502
|
-
}
|
|
503
|
-
catch { /* ignore */ }
|
|
504
|
-
try {
|
|
505
|
-
await rlRedisClient?.quit();
|
|
506
|
-
}
|
|
507
|
-
catch { /* ignore */ } // the dedicated rate-limit client we created (handle.stop() stops the limiter itself)
|
|
508
|
-
try {
|
|
509
|
-
jwks?.stop();
|
|
510
|
-
}
|
|
511
|
-
catch { /* ignore */ }
|
|
512
|
-
await handle.stop();
|
|
513
|
-
try {
|
|
514
|
-
await tracing?.shutdown();
|
|
515
|
-
}
|
|
516
|
-
catch { /* ignore */ } // flush buffered spans before exit
|
|
517
|
-
process.exit(0);
|
|
518
|
-
};
|
|
519
|
-
for (const sig of ['SIGTERM', 'SIGINT'])
|
|
520
|
-
process.on(sig, () => void shutdown(sig));
|
|
521
|
-
}
|
|
522
|
-
// ── Multi-core (opt-in via LENSMCP_GW_WORKERS) ──────────────────────────────
|
|
523
|
-
// A single Node process is ONE thread; a high-RPS edge should use every core. `LENSMCP_GW_WORKERS`
|
|
524
|
-
// forks N worker processes (default 1; `auto`/`max`/`0` ⇒ os.cpus().length). The PRIMARY only supervises
|
|
525
|
-
// — fork, respawn on crash, propagate SIGTERM — while each WORKER runs the full gateway and shares the
|
|
526
|
-
// listening port via Node's cluster module (connections round-robin across workers), so throughput scales
|
|
527
|
-
// ~linearly with cores. NB: state is per-worker — use the Redis rate limiter + Redis status fan-out for
|
|
528
|
-
// cross-worker consistency; a stateless route-and-forward proxy needs nothing extra.
|
|
529
|
-
function workerCount() {
|
|
530
|
-
const v = (process.env['LENSMCP_GW_WORKERS'] ?? '1').trim().toLowerCase();
|
|
531
|
-
if (v === 'auto' || v === 'max' || v === '0')
|
|
532
|
-
return Math.max(1, (0, node_os_1.cpus)().length);
|
|
533
|
-
const n = Math.floor(Number(v));
|
|
534
|
-
return Number.isFinite(n) && n > 0 ? n : 1;
|
|
535
|
-
}
|
|
536
|
-
const WORKERS = workerCount();
|
|
537
|
-
if (WORKERS > 1 && node_cluster_1.default.isPrimary) {
|
|
538
|
-
console.log(`[gateway] primary ${process.pid}: forking ${WORKERS} workers (one per core)…`);
|
|
539
|
-
// SECURITY: the in-memory rate limiter holds per-PROCESS buckets, so with N workers the effective
|
|
540
|
-
// global limit is ~N× the configured value (abuse/DoS protection is weakened). Use the Redis-backed
|
|
541
|
-
// limiter (one shared budget across workers) when running multi-core, or divide the per-worker limit.
|
|
542
|
-
if (process.env['LENSMCP_GW_RATE_LIMIT'] && !(process.env['LENSMCP_GW_RATE_LIMIT_REDIS'] === '1' || process.env['LENSMCP_GW_RATE_LIMIT_REDIS_URL'])) {
|
|
543
|
-
console.warn(`[gateway] WARNING: in-memory rate limiter + ${WORKERS} workers → the limit is enforced PER WORKER (effective ≈ ${WORKERS}× ${process.env['LENSMCP_GW_RATE_LIMIT']}/window). Set LENSMCP_GW_RATE_LIMIT_REDIS=1 for one shared global budget, or divide LENSMCP_GW_RATE_LIMIT by ${WORKERS}.`);
|
|
544
|
-
}
|
|
545
|
-
let stopping = false;
|
|
546
|
-
for (let i = 0; i < WORKERS; i++)
|
|
547
|
-
node_cluster_1.default.fork();
|
|
548
|
-
node_cluster_1.default.on('exit', (w, code, sig) => {
|
|
549
|
-
if (stopping)
|
|
550
|
-
return;
|
|
551
|
-
console.warn(`[gateway] worker ${w.process.pid} exited (code=${code} sig=${sig ?? ''}) — respawning.`);
|
|
552
|
-
node_cluster_1.default.fork();
|
|
553
|
-
});
|
|
554
|
-
const stopAll = (sig) => {
|
|
555
|
-
if (stopping)
|
|
556
|
-
return;
|
|
557
|
-
stopping = true;
|
|
558
|
-
console.log(`[gateway] primary: ${sig} — stopping ${Object.keys(node_cluster_1.default.workers ?? {}).length} workers…`);
|
|
559
|
-
for (const w of Object.values(node_cluster_1.default.workers ?? {})) {
|
|
560
|
-
try {
|
|
561
|
-
w?.kill(sig);
|
|
562
|
-
}
|
|
563
|
-
catch { /* gone */ }
|
|
564
|
-
}
|
|
565
|
-
setTimeout(() => process.exit(0), 12_000).unref(); // hard cap after workers drain
|
|
566
|
-
};
|
|
567
|
-
process.on('SIGTERM', () => stopAll('SIGTERM'));
|
|
568
|
-
process.on('SIGINT', () => stopAll('SIGINT'));
|
|
569
|
-
}
|
|
570
|
-
else {
|
|
571
|
-
// Worker (or single-process default): run the full gateway.
|
|
572
|
-
void main();
|
|
573
|
-
}
|
|
1
|
+
"use strict";var Q=Object.defineProperty;var L=(e,o)=>Q(e,"name",{value:o,configurable:!0});var X=Object.defineProperty,a=L((e,o)=>X(e,"name",{value:o,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0});const tslib_1=require("tslib"),node_fs_1=require("node:fs"),node_crypto_1=require("node:crypto"),node_path_1=require("node:path"),node_os_1=require("node:os"),node_cluster_1=tslib_1.__importDefault(require("node:cluster")),prod_gateway_lib_1=require("./prod-gateway.lib"),jwks_verify_1=require("./jwks-verify"),otel_tracing_1=require("./otel-tracing"),health_check_1=require("./health-check"),rate_limit_1=require("./rate-limit"),metrics_1=require("./metrics"),registry_source_1=require("./registry-source"),providers_prod_1=require("./providers-prod");function readJson(e,o){if(!e)return o;try{return JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(n){return console.error(`[gateway] could not read ${e}:`,n.message),o}}L(readJson,"readJson"),a(readJson,"readJson");function isCohortShape(e){const o=Object.values(e)[0];return Array.isArray(o)&&o.length>0&&typeof o[0]=="object"&&o[0]!==null&&"urls"in o[0]}L(isCohortShape,"isCohortShape"),a(isCohortShape,"isCohortShape");function watchJsonFile(e,o,n=1e3){let E=-1;const c=setInterval(a(()=>{if(!(0,node_fs_1.existsSync)(e))return;let N;try{N=(0,node_fs_1.statSync)(e).mtimeMs}catch{return}if(N===E)return;let u;try{u=JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(l){console.error("[gateway] endpoints reload:",l.message);return}E=N,o(u)},"tick"),n);return c.unref?.(),()=>clearInterval(c)}L(watchJsonFile,"watchJsonFile"),a(watchJsonFile,"watchJsonFile");async function main(){const e=process.env,o=(e.LENSMCP_GW_PORTS??e.PORT??"").split(",").map(t=>Number(t.trim())).filter(Boolean);process.on("uncaughtException",t=>console.error("[gateway] uncaughtException:",t)),process.on("unhandledRejection",t=>console.error("[gateway] unhandledRejection:",t));const n=e.LENSMCP_GW_REDIS_URL;let E,c,N,u,l;if(n){const t=require("ioredis"),r=new t(n);l=r;const _=(0,registry_source_1.createRegistryProviders)((0,registry_source_1.redisRegistrySource)(r,e.LENSMCP_GW_REDIS_PREFIX?{prefix:e.LENSMCP_GW_REDIS_PREFIX}:{}),{onError:a(s=>console.error("[gateway] registry:",s instanceof Error?s.message:s),"onError")});await _.start(),E=_.manifests,c=_.pods,u=_,console.log(`[gateway] registry: Redis-backed, live (${E.list().length} service(s))`)}else{const t=e.LENSMCP_GW_MANIFESTS;t?E=(0,providers_prod_1.fileManifestProvider)(t,{onError:a(s=>console.error("[gateway] manifest:",s instanceof Error?s.message:s),"onError")}):(console.warn("[gateway] no LENSMCP_GW_MANIFESTS / LENSMCP_GW_REDIS_URL \u2014 starting with an EMPTY route table (all requests 404 until manifests arrive)."),E=(0,providers_prod_1.staticManifestProvider)([]));const r=e.LENSMCP_GW_ENDPOINTS,_=readJson(r,{});if(Object.keys(_).length){const s=isCohortShape(_);c=s?(0,providers_prod_1.rolloutPodProvider)(_):(0,providers_prod_1.endpointsPodProvider)(_),console.log(`[gateway] endpoints: ${s?"ROLLOUT (weighted cohorts)":"flat"} \u2014 ${Object.keys(_).length} service(s)`),r&&(N=watchJsonFile(r,S=>{try{c?.update?.(S),console.log("[gateway] endpoints reloaded")}catch(P){console.error("[gateway] endpoints reload failed:",P.message)}}))}}const J=readJson(e.LENSMCP_GW_KEYS,{}),w=e.LENSMCP_GW_TLS_KEY&&e.LENSMCP_GW_TLS_CERT?{key:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_KEY),cert:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_CERT)}:void 0,j=e.LENSMCP_GW_JWT_PRESENCE_ONLY==="1",O=e.LENSMCP_GW_JWKS_URL,I=e.LENSMCP_GW_JWT_SECRET,M=O?(0,jwks_verify_1.createJwksVerifier)({jwksUrl:O,...e.LENSMCP_GW_JWT_ISS?{issuer:e.LENSMCP_GW_JWT_ISS}:{},...e.LENSMCP_GW_JWT_AUD?{audience:e.LENSMCP_GW_JWT_AUD.split(",").map(t=>t.trim())}:{}}):void 0,x=a(t=>{if(!I)return;const r=t.split(".");if(r.length===3)try{const _=(0,node_crypto_1.createHmac)("sha256",I).update(r[0]+"."+r[1]).digest("base64url"),s=Buffer.from(r[2]),S=Buffer.from(_);if(s.length!==S.length||!(0,node_crypto_1.timingSafeEqual)(s,S))return;const P=JSON.parse(Buffer.from(r[1],"base64url").toString());return typeof P.exp=="number"&&Date.now()/1e3>P.exp?void 0:P}catch{return}},"hs256"),A=a(t=>{const r=String(t.headers.authorization??"");if(!r.startsWith("Bearer "))return;const _=r.slice(7);return M?M.verify(_):x(_)},"bearerClaims"),b=M||I?A:void 0,B=a((t,r)=>{if(t==="jwt"){if(M||I){if(!A(r))throw new Error("invalid or missing token");return}if(!j)throw new Error("jwt route but no verifier configured (set LENSMCP_GW_JWKS_URL or LENSMCP_GW_JWT_SECRET)");if(!String(r.headers.authorization??"").startsWith("Bearer "))throw new Error("missing bearer token")}},"authenticate"),v=(e.LENSMCP_GW_ATTR_HEADERS??"").split(",").map(t=>t.trim()).filter(Boolean),D=e.LENSMCP_GW_TRUST_PROXY==="1",U=e.LENSMCP_GW_UID_HEADER,k=e.LENSMCP_GW_COOKIE_SECRET||e.LENSMCP_GW_COOKIE_NAME||e.LENSMCP_GW_COOKIE_DOMAIN?{...e.LENSMCP_GW_COOKIE_NAME?{name:e.LENSMCP_GW_COOKIE_NAME}:{},...e.LENSMCP_GW_COOKIE_SECRET?{secret:e.LENSMCP_GW_COOKIE_SECRET}:{},...e.LENSMCP_GW_COOKIE_DOMAIN?{domain:e.LENSMCP_GW_COOKIE_DOMAIN}:{},...e.LENSMCP_GW_COOKIE_MAXAGE?{maxAge:Number(e.LENSMCP_GW_COOKIE_MAXAGE)}:{}}:void 0,g=e.LENSMCP_EVENT_FILE;let H=!1;const m=g?t=>{try{H||((0,node_fs_1.mkdirSync)((0,node_path_1.dirname)(g),{recursive:!0}),H=!0),(0,node_fs_1.appendFileSync)(g,JSON.stringify(t)+`
|
|
2
|
+
`)}catch{}}:void 0;if(M)try{await M.refresh(),console.log("[gateway] JWKS loaded")}catch(t){console.error("[gateway] initial JWKS load failed (will retry in background):",t.message)}const V=e.LENSMCP_GW_ACCESS_LOG==="1",$=e.LENSMCP_GW_CLIENT_IP_HEADER,F=(e.LENSMCP_GW_CLIENT_IP_TRUSTED_PROXIES??"").split(",").map(t=>t.trim()).filter(Boolean),i={};if(e.LENSMCP_GW_UPSTREAM_TLS_VERIFY==="1"&&(i.rejectUnauthorized=!0),e.LENSMCP_GW_UPSTREAM_H2==="1"&&(i.allowH2=!0),e.LENSMCP_GW_UNDICI_CONNECTIONS&&(i.connections=Number(e.LENSMCP_GW_UNDICI_CONNECTIONS)),e.LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS&&(i.bodyTimeout=Number(e.LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS)),e.LENSMCP_GW_UNDICI_PIPELINING&&(i.pipelining=Number(e.LENSMCP_GW_UNDICI_PIPELINING)),e.LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS&&(i.headersTimeout=Number(e.LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS)),e.LENSMCP_GW_UNDICI_KEEPALIVE_MS&&(i.keepAliveTimeout=Number(e.LENSMCP_GW_UNDICI_KEEPALIVE_MS)),e.LENSMCP_GW_MTLS_FROM_REDIS==="1"&&l){const t=e.LENSMCP_GW_REDIS_PREFIX??"lensmcp:gw",[r,_,s]=await l.mget(`${t}:mtls:cert`,`${t}:mtls:key`,`${t}:mtls:ca`);r&&(i.cert=r),_&&(i.key=_),s&&(i.ca=s),(r||_)&&console.log("[gateway] mTLS materials loaded from Redis")}else e.LENSMCP_GW_MTLS_CERT&&(i.cert=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CERT)),e.LENSMCP_GW_MTLS_KEY&&(i.key=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_KEY)),e.LENSMCP_GW_MTLS_CA&&(i.ca=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CA));i.cert&&i.key&&(e.LENSMCP_GW_UPSTREAM_H2!=="0"&&(i.allowH2=!0),console.log(`[gateway] mTLS to upstreams ON over ${i.allowH2?"h2":"h1.1"} (server validation ${i.rejectUnauthorized?"enabled":"OFF"})`));const d={};e.LENSMCP_GW_KEEPALIVE_MS&&(d.keepAliveTimeout=Number(e.LENSMCP_GW_KEEPALIVE_MS)),e.LENSMCP_GW_REQUEST_TIMEOUT_MS&&(d.requestTimeout=Number(e.LENSMCP_GW_REQUEST_TIMEOUT_MS));const W=(0,otel_tracing_1.setupTracing)({...e.OTEL_SERVICE_NAME?{serviceName:e.OTEL_SERVICE_NAME}:{},...e.LENSMCP_GW_OTEL_SAMPLE?{sampleRatio:Number(e.LENSMCP_GW_OTEL_SAMPLE)}:{}});W&&console.log(`[gateway] OTLP tracing on \u2192 ${e.OTEL_EXPORTER_OTLP_ENDPOINT}`);const G=e.LENSMCP_GW_STATUS==="1",C=e.LENSMCP_GW_STATUS_TOKEN,K=(0,node_os_1.hostname)(),Y=Math.max(3,Math.ceil(Number(e.LENSMCP_GW_TRAFFIC_FLUSH_MS??5e3)/1e3*3)),q=l,R=G&&q?t=>{for(const r of t)q.set(`status:${r.service}:${K}`,JSON.stringify({...r,instance:K,at:Date.now()}),"EX",Y).catch(()=>{})}:void 0;G&&!C&&console.warn("[gateway] LENSMCP_GW_STATUS=1 but no LENSMCP_GW_STATUS_TOKEN \u2014 /statusz stays DISABLED (404) until a token is set (fail-closed). The Redis status:* fan-out still runs."),G&&console.log(`[gateway] status on \u2014 /statusz${C?"":" (endpoint disabled: no token)"}${R?" + Redis status:* fan-out":""}`);const f=e.LENSMCP_GW_HEALTH_CHECK==="1"?(0,health_check_1.createHealthChecker)({...e.LENSMCP_GW_HEALTH_PATH?{path:e.LENSMCP_GW_HEALTH_PATH}:{},...e.LENSMCP_GW_HEALTH_INTERVAL_MS?{intervalMs:Number(e.LENSMCP_GW_HEALTH_INTERVAL_MS)}:{},...e.LENSMCP_GW_HEALTH_TIMEOUT_MS?{timeoutMs:Number(e.LENSMCP_GW_HEALTH_TIMEOUT_MS)}:{}}):void 0;f&&console.log(`[gateway] active health-checking on (path ${e.LENSMCP_GW_HEALTH_PATH??"/healthz"})`);let T,y;if(e.LENSMCP_GW_RATE_LIMIT){const t=Number(e.LENSMCP_GW_RATE_LIMIT),r=Number(e.LENSMCP_GW_RATE_WINDOW_MS??6e4);if(e.LENSMCP_GW_RATE_LIMIT_REDIS==="1"||e.LENSMCP_GW_RATE_LIMIT_REDIS_URL){const _=e.LENSMCP_GW_RATE_LIMIT_REDIS_URL;let s;if(_){const S=require("ioredis");y=new S(_),s=y}else l&&(s=l);if(s){const S=e.LENSMCP_GW_RATE_LIMIT_FAIL_CLOSED==="1";T=(0,rate_limit_1.createRedisRateLimiter)(s,{limit:t,windowMs:r,...e.LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS?{timeoutMs:Number(e.LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS)}:{},...S?{failOpen:!1}:{}}),console.log(`[gateway] rate limit on \u2014 ${t} req / ${r}ms GLOBAL (Redis: ${_?"dedicated":"shared registry"}, fail-${S?"closed":"open"}) per client IP`)}else console.warn("[gateway] LENSMCP_GW_RATE_LIMIT_REDIS set but no Redis configured (set LENSMCP_GW_REDIS_URL or LENSMCP_GW_RATE_LIMIT_REDIS_URL) \u2014 falling back to PER-INSTANCE in-memory limiting.")}T||(T=(0,rate_limit_1.createRateLimiter)({limit:t,windowMs:r}),console.log(`[gateway] rate limit on \u2014 ${t} req / ${r}ms per client IP (in-memory, per-instance)`))}const p=e.LENSMCP_GW_METRICS==="1"?(0,metrics_1.createProcessMetrics)():void 0;p&&console.log(`[gateway] process metrics on \u2014 /metricsz${C?" (token-gated)":" (DISABLED: needs LENSMCP_GW_STATUS_TOKEN)"}`);const h=await(0,prod_gateway_lib_1.startProdGateway)({...o.length?{ports:o}:{},manifests:E,...c?{pods:c}:{},serviceKeys:J,authenticate:B,...w?{tls:w}:{},...m?{emit:m}:{},...b?{identify:b}:{},...v.length?{attributeHeaders:v}:{},...D?{trustProxyIp:D}:{},...k?{cookie:k}:{},...U?{uidHeader:U}:{},...V?{accessLog:!0}:{},...$?{clientIpHeader:$}:{},...F.length?{clientIpTrustedProxies:F}:{},...W?{tracing:W}:{},...G?{statusEndpoint:!0}:{},...C?{statusToken:C}:{},...R?{onStatus:R}:{},...f?{healthChecker:f}:{},...T?{rateLimiter:T}:{},...p?{metrics:p}:{},...e.LENSMCP_GW_TRAFFIC_FLUSH_MS?{trafficFlushMs:Number(e.LENSMCP_GW_TRAFFIC_FLUSH_MS)}:{},...Object.keys(i).length?{undici:i}:{},...Object.keys(d).length?{server:d}:{}});console.log(`[gateway] production gateway up on ${h.ports.join(", ")} (${h.routes().length} routes${m?", lens ON":""})`);const z=a(async t=>{console.log(`[gateway] ${t} \u2014 draining\u2026`);try{E.stop?.()}catch{}try{N?.()}catch{}try{u?.stop()}catch{}try{await y?.quit()}catch{}try{M?.stop()}catch{}await h.stop();try{await W?.shutdown()}catch{}process.exit(0)},"shutdown");for(const t of["SIGTERM","SIGINT"])process.on(t,()=>{z(t)})}L(main,"main"),a(main,"main");function workerCount(){const e=(process.env.LENSMCP_GW_WORKERS??"1").trim().toLowerCase();if(e==="auto"||e==="max"||e==="0")return Math.max(1,(0,node_os_1.cpus)().length);const o=Math.floor(Number(e));return Number.isFinite(o)&&o>0?o:1}L(workerCount,"workerCount"),a(workerCount,"workerCount");const WORKERS=workerCount();if(WORKERS>1&&node_cluster_1.default.isPrimary){console.log(`[gateway] primary ${process.pid}: forking ${WORKERS} workers (one per core)\u2026`),process.env.LENSMCP_GW_RATE_LIMIT&&!(process.env.LENSMCP_GW_RATE_LIMIT_REDIS==="1"||process.env.LENSMCP_GW_RATE_LIMIT_REDIS_URL)&&console.warn(`[gateway] WARNING: in-memory rate limiter + ${WORKERS} workers \u2192 the limit is enforced PER WORKER (effective \u2248 ${WORKERS}\xD7 ${process.env.LENSMCP_GW_RATE_LIMIT}/window). Set LENSMCP_GW_RATE_LIMIT_REDIS=1 for one shared global budget, or divide LENSMCP_GW_RATE_LIMIT by ${WORKERS}.`);let e=!1;for(let n=0;n<WORKERS;n++)node_cluster_1.default.fork();node_cluster_1.default.on("exit",(n,E,c)=>{e||(console.warn(`[gateway] worker ${n.process.pid} exited (code=${E} sig=${c??""}) \u2014 respawning.`),node_cluster_1.default.fork())});const o=a(n=>{if(!e){e=!0,console.log(`[gateway] primary: ${n} \u2014 stopping ${Object.keys(node_cluster_1.default.workers??{}).length} workers\u2026`);for(const E of Object.values(node_cluster_1.default.workers??{}))try{E?.kill(n)}catch{}setTimeout(()=>process.exit(0),12e3).unref()}},"stopAll");process.on("SIGTERM",()=>o("SIGTERM")),process.on("SIGINT",()=>o("SIGINT"))}else main();
|