@lensmcp/cluster 1.18.4 → 1.18.6
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,117 +1 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.baseDomainOf = baseDomainOf;
|
|
4
|
-
exports.readLensScope = readLensScope;
|
|
5
|
-
exports.deriveMcpHttpPort = deriveMcpHttpPort;
|
|
6
|
-
exports.readSourceSetExclude = readSourceSetExclude;
|
|
7
|
-
exports.lensKeyFrom = lensKeyFrom;
|
|
8
|
-
exports.lensSlug = lensSlug;
|
|
9
|
-
const tslib_1 = require("tslib");
|
|
10
|
-
/**
|
|
11
|
-
* Per-workspace lens scope + dashboard-host derivation. Pure (fs reads only),
|
|
12
|
-
* mirrors the `lensmcp` CLI's `workspace-scope` (kept in lock-step).
|
|
13
|
-
*/
|
|
14
|
-
const fs = tslib_1.__importStar(require("node:fs"));
|
|
15
|
-
const path = tslib_1.__importStar(require("node:path"));
|
|
16
|
-
/** The base domain for the lens dashboard host — the longest common dotted
|
|
17
|
-
* suffix of the declared cluster hosts (e.g. ['api.tetros.ai.local',
|
|
18
|
-
* 'tetros.ai.local'] → 'tetros.ai.local'). Falls back to the first host, then
|
|
19
|
-
* to 'lens.local' if no host is declared. */
|
|
20
|
-
function baseDomainOf(hosts) {
|
|
21
|
-
const clean = hosts.filter((h) => !!h && !h.startsWith('*.'));
|
|
22
|
-
if (clean.length === 0)
|
|
23
|
-
return 'local';
|
|
24
|
-
// Use the SHORTEST host as the candidate base (most likely the apex), then
|
|
25
|
-
// require every other host to end with it.
|
|
26
|
-
const shortest = clean.reduce((a, b) => (b.length < a.length ? b : a));
|
|
27
|
-
if (clean.every((h) => h === shortest || h.endsWith('.' + shortest)))
|
|
28
|
-
return shortest;
|
|
29
|
-
// No clean common apex — derive the trailing 2-3 labels of the shortest host.
|
|
30
|
-
const parts = shortest.split('.');
|
|
31
|
-
return parts.slice(-Math.min(parts.length, 3)).join('.');
|
|
32
|
-
}
|
|
33
|
-
/** Lens-infra port bases — must equal `BASE_PORTS` in the CLI's workspace-scope. */
|
|
34
|
-
const BASE_PORTS = { dashboard: 4321, mcpHttp: 4500 };
|
|
35
|
-
/** FNV-1a → 0..(range-1). Same derivation as the CLI's `hashToRange`. */
|
|
36
|
-
function hashToRange(s, range) {
|
|
37
|
-
let h = 0x811c9dc5;
|
|
38
|
-
for (let i = 0; i < s.length; i++) {
|
|
39
|
-
h ^= s.charCodeAt(i);
|
|
40
|
-
h = Math.imul(h, 0x01000193);
|
|
41
|
-
}
|
|
42
|
-
return Math.abs(h | 0) % range;
|
|
43
|
-
}
|
|
44
|
-
/** Per-workspace lens scope (key + ports + mount path). Prefers the
|
|
45
|
-
* CLI-written `.lensmcp/config.json` (so the gateway agrees with `lensmcp gateway`
|
|
46
|
-
* / `lensmcp dashboard` / the MCP shim); falls back to deriving from the workspace. */
|
|
47
|
-
function readLensScope(root) {
|
|
48
|
-
try {
|
|
49
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
|
|
50
|
-
if (cfg && typeof cfg.key === 'string') {
|
|
51
|
-
return {
|
|
52
|
-
key: cfg.key,
|
|
53
|
-
dashboardPort: cfg.ports?.dashboard ?? BASE_PORTS.dashboard,
|
|
54
|
-
// Derive rather than fall back to a constant: a constant would put two
|
|
55
|
-
// config-less workspaces on ONE port, which is the cross-feed bug.
|
|
56
|
-
mcpHttpPort: cfg.ports?.mcpHttp ?? deriveMcpHttpPort(cfg.key),
|
|
57
|
-
basePath: cfg.dashboardBasePath ?? '/' + cfg.key,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
/* no config yet — derive below */
|
|
63
|
-
}
|
|
64
|
-
const key = lensKeyFrom(root);
|
|
65
|
-
return {
|
|
66
|
-
key,
|
|
67
|
-
dashboardPort: BASE_PORTS.dashboard,
|
|
68
|
-
mcpHttpPort: deriveMcpHttpPort(key),
|
|
69
|
-
basePath: '/' + key,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
/** The workspace's MCP HTTP port, derived from its key. In lock-step with the CLI. */
|
|
73
|
-
function deriveMcpHttpPort(key) {
|
|
74
|
-
return BASE_PORTS.mcpHttp + hashToRange(key, 200);
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Per-workspace extra SOURCE-SET excludes (`.lensmcp/config.json` →
|
|
78
|
-
* `sourceSet.exclude: string[]`) — top-level dir names the staleness scanner
|
|
79
|
-
* must NOT treat as source. The scanner deliberately over-includes every
|
|
80
|
-
* unmanaged bucket (`shared/`, `docs/`, …) in every pod's scope, but a bucket
|
|
81
|
-
* like `docs/` can host source-extension files (`.json`, `.mdx`) that no pod
|
|
82
|
-
* can ever import — each add there flips the signature and recycles pods for
|
|
83
|
-
* nothing (foodguard 2026-07-29: a `docs/**\/*-intake.json` add recycled the
|
|
84
|
-
* dashboard; 475 recycles in one day were scope-C adds). Names only (no
|
|
85
|
-
* globs), matched exactly like the built-in excludes. Empty when unset.
|
|
86
|
-
*/
|
|
87
|
-
function readSourceSetExclude(root) {
|
|
88
|
-
try {
|
|
89
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(root, '.lensmcp', 'config.json'), 'utf8'));
|
|
90
|
-
const raw = cfg?.sourceSet?.exclude;
|
|
91
|
-
if (Array.isArray(raw)) {
|
|
92
|
-
return new Set(raw.filter((d) => typeof d === 'string' && d.length > 0 && !d.includes('/')));
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
/* no config — nothing extra to exclude */
|
|
97
|
-
}
|
|
98
|
-
return new Set();
|
|
99
|
-
}
|
|
100
|
-
function lensKeyFrom(root) {
|
|
101
|
-
try {
|
|
102
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
103
|
-
if (pkg?.name)
|
|
104
|
-
return lensSlug(pkg.name);
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
/* fall through to dir name */
|
|
108
|
-
}
|
|
109
|
-
return lensSlug(path.basename(root));
|
|
110
|
-
}
|
|
111
|
-
function lensSlug(input) {
|
|
112
|
-
return (input
|
|
113
|
-
.toLowerCase()
|
|
114
|
-
.replace(/^@[^/]+\//, '')
|
|
115
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
116
|
-
.replace(/(^-|-$)/g, '') || 'workspace');
|
|
117
|
-
}
|
|
1
|
+
"use strict";var p=Object.defineProperty;var n=(t,r)=>p(t,"name",{value:r,configurable:!0});var c=Object.defineProperty,s=n((t,r)=>c(t,"name",{value:r,configurable:!0}),"s");Object.defineProperty(exports,"__esModule",{value:!0}),exports.baseDomainOf=baseDomainOf,exports.readLensScope=readLensScope,exports.deriveMcpHttpPort=deriveMcpHttpPort,exports.readSourceSetExclude=readSourceSetExclude,exports.lensKeyFrom=lensKeyFrom,exports.lensSlug=lensSlug;const tslib_1=require("tslib"),fs=tslib_1.__importStar(require("node:fs")),path=tslib_1.__importStar(require("node:path"));function baseDomainOf(t){const r=t.filter(o=>!!o&&!o.startsWith("*."));if(r.length===0)return"local";const e=r.reduce((o,i)=>i.length<o.length?i:o);if(r.every(o=>o===e||o.endsWith("."+e)))return e;const a=e.split(".");return a.slice(-Math.min(a.length,3)).join(".")}n(baseDomainOf,"baseDomainOf"),s(baseDomainOf,"baseDomainOf");const BASE_PORTS={dashboard:4321,mcpHttp:4500};function hashToRange(t,r){let e=2166136261;for(let a=0;a<t.length;a++)e^=t.charCodeAt(a),e=Math.imul(e,16777619);return Math.abs(e|0)%r}n(hashToRange,"hashToRange"),s(hashToRange,"hashToRange");function readLensScope(t){try{const e=JSON.parse(fs.readFileSync(path.join(t,".lensmcp","config.json"),"utf8"));if(e&&typeof e.key=="string")return{key:e.key,dashboardPort:e.ports?.dashboard??BASE_PORTS.dashboard,mcpHttpPort:e.ports?.mcpHttp??deriveMcpHttpPort(e.key),basePath:e.dashboardBasePath??"/"+e.key}}catch{}const r=lensKeyFrom(t);return{key:r,dashboardPort:BASE_PORTS.dashboard,mcpHttpPort:deriveMcpHttpPort(r),basePath:"/"+r}}n(readLensScope,"readLensScope"),s(readLensScope,"readLensScope");function deriveMcpHttpPort(t){return BASE_PORTS.mcpHttp+hashToRange(t,200)}n(deriveMcpHttpPort,"deriveMcpHttpPort"),s(deriveMcpHttpPort,"deriveMcpHttpPort");function readSourceSetExclude(t){try{const r=JSON.parse(fs.readFileSync(path.join(t,".lensmcp","config.json"),"utf8"))?.sourceSet?.exclude;if(Array.isArray(r))return new Set(r.filter(e=>typeof e=="string"&&e.length>0&&!e.includes("/")))}catch{}return new Set}n(readSourceSetExclude,"readSourceSetExclude"),s(readSourceSetExclude,"readSourceSetExclude");function lensKeyFrom(t){try{const r=JSON.parse(fs.readFileSync(path.join(t,"package.json"),"utf8"));if(r?.name)return lensSlug(r.name)}catch{}return lensSlug(path.basename(t))}n(lensKeyFrom,"lensKeyFrom"),s(lensKeyFrom,"lensKeyFrom");function lensSlug(t){return t.toLowerCase().replace(/^@[^/]+\//,"").replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"")||"workspace"}n(lensSlug,"lensSlug"),s(lensSlug,"lensSlug");
|
|
@@ -1,487 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
exports.buildEdgeVerifierPool = buildEdgeVerifierPool;
|
|
5
|
-
exports.classifyClientError = classifyClientError;
|
|
6
|
-
exports.createClientErrorLogGate = createClientErrorLogGate;
|
|
7
|
-
exports.startGateway = startGateway;
|
|
8
|
-
const tslib_1 = require("tslib");
|
|
9
|
-
/**
|
|
10
|
-
* The composition root. `startGateway` resolves options, discovers routes, wires
|
|
11
|
-
* every layer (observability → service lifecycle → lens children → proxy → auth →
|
|
12
|
-
* hooks → handler/upgrade), boots TLS + listeners, and returns a `GatewayHandle`.
|
|
13
|
-
*
|
|
14
|
-
* The TOP-TO-BOTTOM ORDER here is itself a contract: service keys load before the
|
|
15
|
-
* runtime is built; lens children boot (pushing the dashboard route) BEFORE the
|
|
16
|
-
* cert SANs are derived from `routes`; eager services boot before listeners; the
|
|
17
|
-
* `gateway-up` event fires only after the ports are bound.
|
|
18
|
-
*/
|
|
19
|
-
const http = tslib_1.__importStar(require("node:http"));
|
|
20
|
-
const path = tslib_1.__importStar(require("node:path"));
|
|
21
|
-
const discovery_1 = require("./discovery");
|
|
22
|
-
const scope_1 = require("./scope");
|
|
23
|
-
const route_registry_1 = require("./route-registry");
|
|
24
|
-
const control_1 = require("./control");
|
|
25
|
-
const workspace_registry_1 = require("./workspace-registry");
|
|
26
|
-
const service_keys_1 = require("./service-keys");
|
|
27
|
-
const observability_1 = require("./observability");
|
|
28
|
-
const lifecycle_1 = require("./lifecycle");
|
|
29
|
-
const lens_children_1 = require("./lens-children");
|
|
30
|
-
const proxy_1 = require("./proxy");
|
|
31
|
-
const auth_1 = require("./auth");
|
|
32
|
-
const hooks_1 = require("./hooks");
|
|
33
|
-
const handler_1 = require("./handler");
|
|
34
|
-
const upgrade_1 = require("./upgrade");
|
|
35
|
-
const jwks_verify_1 = require("../jwks-verify");
|
|
36
|
-
const types_1 = require("./types");
|
|
37
|
-
/**
|
|
38
|
-
* A `fetch`-shaped shim for the JWKS pull that accepts the dev gateway's OWN self-signed cert. The dev
|
|
39
|
-
* IdP's JWKS is served behind the same local HTTPS front door (e.g. `https://auth.tetros.ai.local`), whose
|
|
40
|
-
* cert chains to the local basic-ssl CA — global `fetch` would reject it. Read-only GET of a public,
|
|
41
|
-
* gateway-served JWKS document; the SIGNATURE trust comes from verifying tokens against those keys, not
|
|
42
|
-
* from the transport. Falls back to plain `http` for an http URL. Dev-only (only the dev executor uses it).
|
|
43
|
-
*/
|
|
44
|
-
function insecureLocalFetch(url, init) {
|
|
45
|
-
return new Promise((resolve, reject) => {
|
|
46
|
-
const mod = url.startsWith('https:') ? require('node:https') : require('node:http');
|
|
47
|
-
const req = mod.request(url, { method: 'GET', headers: init?.headers, rejectUnauthorized: false }, (res) => {
|
|
48
|
-
const chunks = [];
|
|
49
|
-
res.on('data', (c) => chunks.push(c));
|
|
50
|
-
res.on('end', () => {
|
|
51
|
-
const status = res.statusCode ?? 0;
|
|
52
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
53
|
-
resolve({ ok: status >= 200 && status < 300, status, json: async () => JSON.parse(text) });
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
req.on('error', reject);
|
|
57
|
-
req.end();
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* The dev edge's JWT verifiers — matching the prod gateway's RS256/JWKS posture so the LOCAL edge rejects
|
|
62
|
-
* forged tokens instead of decode-accepting them, and MULTI-ISSUER for the shared daemon: every
|
|
63
|
-
* `auth.<apex>` host in the LIVE route table is an IdP for its apex, and `for(host)` picks the verifier
|
|
64
|
-
* whose apex the request host belongs to (longest apex wins) — so a foodguard token is verified against
|
|
65
|
-
* `auth.foodguard.local/jwks.json`, a tetros token against `auth.tetros.ai.local/jwks.json`, on the SAME
|
|
66
|
-
* :443 daemon. `LENSMCP_GW_JWKS_URL` (explicit, same env as prod) keeps its old meaning: one verifier for
|
|
67
|
-
* everything. Auth hosts come from project.json (trusted), never from a token — no SSRF/attacker-chosen
|
|
68
|
-
* JWKS. `for()` returns undefined when the host matches no IdP apex (→ non-HS256 tokens fail closed
|
|
69
|
-
* unless the insecure opt-in is set).
|
|
70
|
-
*/
|
|
71
|
-
function buildEdgeVerifierPool(getRoutes) {
|
|
72
|
-
const explicit = process.env['LENSMCP_GW_JWKS_URL'];
|
|
73
|
-
const issuer = process.env['LENSMCP_GW_JWT_ISS'];
|
|
74
|
-
const byApex = new Map();
|
|
75
|
-
let explicitVerifier;
|
|
76
|
-
const make = (jwksUrl, label) => {
|
|
77
|
-
console.log(`[gateway] edge JWT: verifying via JWKS ${jwksUrl}${label}`);
|
|
78
|
-
const v = (0, jwks_verify_1.createJwksVerifier)({ jwksUrl, ...(issuer ? { issuer } : {}), fetchImpl: insecureLocalFetch });
|
|
79
|
-
void v.refresh().catch((e) => console.warn('[gateway] edge JWKS warm failed (will retry):', e.message));
|
|
80
|
-
return v;
|
|
81
|
-
};
|
|
82
|
-
return {
|
|
83
|
-
// PER-APEX resolution over the LIVE route table: every `auth.<apex>` host is an
|
|
84
|
-
// IdP for its apex, so a SHARED multi-workspace daemon verifies each workspace's
|
|
85
|
-
// tokens against ITS OWN IdP's JWKS. Reading the routes lazily means a workspace
|
|
86
|
-
// REGISTERED LATER (control-plane /register) gets its verifier with no rebuild
|
|
87
|
-
// hook — and `LENSMCP_GW_JWKS_URL` keeps its old meaning (one verifier for all).
|
|
88
|
-
// The auth host comes from project.json (trusted), never from a token — no
|
|
89
|
-
// attacker-chosen JWKS.
|
|
90
|
-
for(host) {
|
|
91
|
-
if (explicit)
|
|
92
|
-
return (explicitVerifier ??= make(explicit, ''));
|
|
93
|
-
const h = host.split(':')[0].toLowerCase();
|
|
94
|
-
let best;
|
|
95
|
-
for (const r of getRoutes()) {
|
|
96
|
-
const rh = r.host;
|
|
97
|
-
if (typeof rh !== 'string' || !/^auth\./.test(rh) || rh.startsWith('internal.'))
|
|
98
|
-
continue;
|
|
99
|
-
const apex = rh.slice('auth.'.length);
|
|
100
|
-
if ((h === apex || h.endsWith(`.${apex}`)) && (!best || apex.length > best.length))
|
|
101
|
-
best = apex;
|
|
102
|
-
}
|
|
103
|
-
if (!best)
|
|
104
|
-
return undefined;
|
|
105
|
-
let v = byApex.get(best);
|
|
106
|
-
if (!v) {
|
|
107
|
-
v = make(`https://auth.${best}/jwks.json`, ` (auto-derived for apex ${best})`);
|
|
108
|
-
byApex.set(best, v);
|
|
109
|
-
}
|
|
110
|
-
return v;
|
|
111
|
-
},
|
|
112
|
-
stop() {
|
|
113
|
-
explicitVerifier?.stop();
|
|
114
|
-
for (const v of byApex.values())
|
|
115
|
-
v.stop();
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/** Client-abort errnos that are NORMAL front-door traffic (a closed tab, a sleeping laptop, a canceled
|
|
120
|
-
* HMR socket) — dropped quietly. Anything else on an inbound socket is logged before the drop. */
|
|
121
|
-
exports.BENIGN_SOCKET_ERRNOS = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNABORTED', 'ECANCELED']);
|
|
122
|
-
function classifyClientError(code, message) {
|
|
123
|
-
if (code && exports.BENIGN_SOCKET_ERRNOS.has(code))
|
|
124
|
-
return { status: null, report: false, cause: 'client-abort' };
|
|
125
|
-
// Peer half-closed mid-message: nobody is waiting for a 400, and writing one is the spurious-400 vector.
|
|
126
|
-
if (code === 'HPE_INVALID_EOF_STATE')
|
|
127
|
-
return { status: null, report: false, cause: 'truncated-at-eof' };
|
|
128
|
-
if (code === 'ERR_HTTP_REQUEST_TIMEOUT')
|
|
129
|
-
return { status: 408, report: true, cause: 'request-timeout' };
|
|
130
|
-
if (code === 'HPE_HEADER_OVERFLOW')
|
|
131
|
-
return { status: 431, report: true, cause: 'header-overflow' };
|
|
132
|
-
if (code?.startsWith('HPE_'))
|
|
133
|
-
return { status: 400, report: true, cause: 'parse-error' };
|
|
134
|
-
return { status: 400, report: true, cause: code ?? message ?? 'unknown' };
|
|
135
|
-
}
|
|
136
|
-
const CLIENT_ERROR_REASON = {
|
|
137
|
-
400: 'Bad Request',
|
|
138
|
-
408: 'Request Timeout',
|
|
139
|
-
431: 'Request Header Fields Too Large',
|
|
140
|
-
};
|
|
141
|
-
/** Per-cause log throttle for `clientError`: a desynced client or a port scanner can produce these in a
|
|
142
|
-
* tight loop, and the point is a dev NOTICING one — not drowning in thousands. One line per cause per
|
|
143
|
-
* window; the suppressed count rides the next admitted line. Exported for the unit test. */
|
|
144
|
-
function createClientErrorLogGate(windowMs = 10_000) {
|
|
145
|
-
const lastAt = new Map();
|
|
146
|
-
return (cause) => {
|
|
147
|
-
const now = Date.now();
|
|
148
|
-
const prev = lastAt.get(cause) ?? 0;
|
|
149
|
-
if (now - prev < windowMs)
|
|
150
|
-
return false;
|
|
151
|
-
lastAt.set(cause, now);
|
|
152
|
-
return true;
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
/** Attach the inbound-socket error guard. EVERY socket the front door accepts gets an 'error' listener
|
|
156
|
-
* the moment it exists — both the raw TCP socket ('connection') and the post-handshake TLSSocket
|
|
157
|
-
* ('secureConnection', a DIFFERENT emitter). A client abort is routine, but with NO listener Node turns
|
|
158
|
-
* it into an UNHANDLED 'error' event that kills the whole daemon (observed: a days-old gateway died on
|
|
159
|
-
* `read ECONNRESET` at TLSWrap.onStreamRead — the exposed window is a socket the http layer has handed
|
|
160
|
-
* off, e.g. right after an 'upgrade', where no internal handler is attached any more). Handling =
|
|
161
|
-
* destroy the ONE socket; never the process. */
|
|
162
|
-
function guardInboundSocket(socket) {
|
|
163
|
-
socket.on('error', (err) => {
|
|
164
|
-
if (!exports.BENIGN_SOCKET_ERRNOS.has(err.code ?? '')) {
|
|
165
|
-
console.warn(`[gateway] inbound socket error (${err.code ?? err.message}) — dropping this connection only`);
|
|
166
|
-
}
|
|
167
|
-
socket.destroy();
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
async function startGateway(options, context) {
|
|
171
|
-
// NB: this local `https` (TLS-on flag) deliberately SHADOWS the `node:https`
|
|
172
|
-
// module — which is why TLS/agents are reached via `require('node:https')`.
|
|
173
|
-
const https = options.https !== false;
|
|
174
|
-
const ports = options.ports?.length ? options.ports : [443];
|
|
175
|
-
const scanTtlMs = options.scanTtlMs ?? 1500;
|
|
176
|
-
const startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
|
|
177
|
-
const sweepMs = options.sweepMs ?? 10_000;
|
|
178
|
-
const trafficFlushMs = options.trafficFlushMs ?? 5000;
|
|
179
|
-
// Set by stop(); read via rt.stopped() so an in-flight cold start neither
|
|
180
|
-
// spawns a process nor logs after the gateway is torn down.
|
|
181
|
-
let stopped = false;
|
|
182
|
-
// The gateway is a first-class lens source — the shared event file the MCP tails.
|
|
183
|
-
const eventFile = process.env['LENSMCP_EVENT_FILE'] ?? path.join(context.root, '.lensmcp', 'events.jsonl');
|
|
184
|
-
// --- discover ---------------------------------------------------------------
|
|
185
|
-
// The workspace key namespaces this workspace's pod sock dirs ($TMPDIR/<wsKey>/<service>-devserver) so a
|
|
186
|
-
// SHARED multi-workspace gateway can host several workspaces without their same-named services colliding
|
|
187
|
-
// (planning/multi-workspace-gateway.md). It flows to each service's devserver via `LENSMCP_WS_KEY` (see
|
|
188
|
-
// lifecycle.ts), and `discoverRoutes` derives the matching pool dir the gateway scans.
|
|
189
|
-
const wsKey = (0, scope_1.readLensScope)(context.root).key;
|
|
190
|
-
const { routes: discovered, services } = (0, discovery_1.discoverRoutes)(context.root, context.projectsConfigurations?.projects ?? {}, undefined, wsKey);
|
|
191
|
-
if (discovered.length === 0) {
|
|
192
|
-
throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
|
|
193
|
-
}
|
|
194
|
-
// --- the live multi-workspace route-fragment registry -----------------------
|
|
195
|
-
// This workspace registers its OWN fragment; `routes` is the merged table rebuilt IN PLACE from the
|
|
196
|
-
// registry (byte-identical to `discovered` for one workspace). A second workspace registers its fragment
|
|
197
|
-
// via the control endpoint and the daemon rebuilds live (planning/multi-workspace-gateway.md P3).
|
|
198
|
-
const registry = new route_registry_1.RouteRegistry();
|
|
199
|
-
registry.register({ wsKey, root: context.root, routes: discovered, services });
|
|
200
|
-
const routes = []; // the LIVE table the request pipeline reads (a stable ref, mutated in place)
|
|
201
|
-
// Assigned after the TLS servers are built (below) — re-mints the gateway cert with the CURRENT route
|
|
202
|
-
// hosts and hot-swaps it. Declared HERE (above rebuildRoutes) so rebuildRoutes can trigger it AFTER every
|
|
203
|
-
// live route change; a no-op until the TLS servers exist (the boot mint happens separately, below).
|
|
204
|
-
let refreshCert = () => { };
|
|
205
|
-
const rebuildRoutes = () => {
|
|
206
|
-
(0, route_registry_1.rebuildRoutesInPlace)(routes, registry);
|
|
207
|
-
// The cert tracks the live route hosts BY CONSTRUCTION: any register/unregister rebuilds the table then
|
|
208
|
-
// re-mints, so a dynamically-registered workspace's hostnames always join the SANs (no ERR_CERT_COMMON_
|
|
209
|
-
// NAME_INVALID). Wiring it here (not in the control onRegister) avoids the ordering trap where the cert
|
|
210
|
-
// was minted from the STALE table because onRegister ran before the control handler's rebuild.
|
|
211
|
-
refreshCert();
|
|
212
|
-
};
|
|
213
|
-
rebuildRoutes();
|
|
214
|
-
// --- per-service api keys (the gateway is the trust boundary) ---------------
|
|
215
|
-
const { serviceKeys, keyToProject } = (0, service_keys_1.loadServiceKeys)(context.root, services);
|
|
216
|
-
// --- the shared runtime (state + knobs; stopped() is a live getter) ---------
|
|
217
|
-
const rt = {
|
|
218
|
-
root: context.root,
|
|
219
|
-
wsKey,
|
|
220
|
-
eventFile,
|
|
221
|
-
routes,
|
|
222
|
-
registry,
|
|
223
|
-
rebuildRoutes,
|
|
224
|
-
services,
|
|
225
|
-
serviceKeys,
|
|
226
|
-
keyToProject,
|
|
227
|
-
scanTtlMs,
|
|
228
|
-
startupTimeoutMs,
|
|
229
|
-
sweepMs,
|
|
230
|
-
trafficFlushMs,
|
|
231
|
-
https,
|
|
232
|
-
// Absolute project root per project name — the scanner narrows each child's staleness scope with it
|
|
233
|
-
// (`sourceScopeDirs`), so a `server/**` file add stops recycling every `web/**` vite. Absent (a bare
|
|
234
|
-
// context with no projectsConfigurations) ⇒ the workspace-wide clock, i.e. previous behavior.
|
|
235
|
-
projectRoots: Object.fromEntries(Object.entries(context.projectsConfigurations?.projects ?? {})
|
|
236
|
-
.map(([name, cfg]) => [name, path.resolve(context.root, cfg.root)])),
|
|
237
|
-
stopped: () => stopped,
|
|
238
|
-
};
|
|
239
|
-
// --- layers -----------------------------------------------------------------
|
|
240
|
-
const obs = (0, observability_1.createObservability)(eventFile, { trafficFlushMs });
|
|
241
|
-
// One gate for every front-door port — a desynced client must not get N log lines because we bound N ports.
|
|
242
|
-
const admitClientErrorLog = createClientErrorLogGate();
|
|
243
|
-
const svcLayer = (0, lifecycle_1.createServiceLayer)(rt, obs);
|
|
244
|
-
const lens = (0, lens_children_1.createLensChildren)(rt, obs, options);
|
|
245
|
-
// The daemon CONTROL PLANE (loopback ~/.lensmcp/control.sock): a second workspace's `gateway start`
|
|
246
|
-
// REGISTERS its fragment here instead of fighting for :443, and the daemon HOSTS its services (spawns
|
|
247
|
-
// them, model A) + its dashboard (planning/multi-workspace-gateway.md P3). Harmless for a lone gateway.
|
|
248
|
-
const workspaceReapers = new Map();
|
|
249
|
-
let controlServer;
|
|
250
|
-
try {
|
|
251
|
-
controlServer = (0, control_1.startControlServer)({
|
|
252
|
-
rt,
|
|
253
|
-
killService: (svc, reason) => svcLayer.killSpawned(svc, reason),
|
|
254
|
-
onRegister: async (fragment) => {
|
|
255
|
-
// Host the registered workspace's dashboard AND its lens FRONTEND apps (both spawned in ITS root/bus;
|
|
256
|
-
// the dashboard appends its lensmcp.local/<key> route + the apps rewrite their route targets to the
|
|
257
|
-
// actual bound port — all BEFORE the control handler's rebuild, which AWAITS this). The cert re-mint
|
|
258
|
-
// happens in rebuildRoutes (called by the control handler right after this), once the full table is live.
|
|
259
|
-
const reapDash = await lens.hostWorkspaceDashboard(fragment);
|
|
260
|
-
const reapApps = await lens.hostWorkspaceApps(fragment);
|
|
261
|
-
workspaceReapers.set(fragment.wsKey, () => { reapDash?.(); reapApps(); });
|
|
262
|
-
},
|
|
263
|
-
onUnregister: (fragment) => {
|
|
264
|
-
workspaceReapers.get(fragment.wsKey)?.();
|
|
265
|
-
workspaceReapers.delete(fragment.wsKey);
|
|
266
|
-
},
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
catch (e) {
|
|
270
|
-
console.warn('[gateway] control plane failed to start (multi-workspace register disabled):', e.message);
|
|
271
|
-
}
|
|
272
|
-
const proxyLayer = (0, proxy_1.createProxy)(rt, obs, svcLayer);
|
|
273
|
-
// Edge JWT verification (mirrors the prod gateway): when a JWKS source is configured/derivable, the dev
|
|
274
|
-
// edge verifies RS256 tokens by `kid` against the IdP's published keys and REJECTS anything it can't
|
|
275
|
-
// verify (rogue kid / bad sig / alg:none / HS256-confusion / expired) — closing the dev decode-only bypass.
|
|
276
|
-
const edgeVerifiers = buildEdgeVerifierPool(() => rt.routes);
|
|
277
|
-
const edgeAuth = (0, auth_1.createEdgeAuth)(rt, obs, edgeVerifiers);
|
|
278
|
-
// Warm the key cache (non-fatal, non-blocking): the IdP pod may still be booting, so a failure here just
|
|
279
|
-
// means the first tokens fail closed until the on-miss/periodic refresh lands the keys (matches prod).
|
|
280
|
-
// Custom user middleware (the JWT seam; internal.* routes skip it) is wired as
|
|
281
|
-
// the BUILT-IN FIRST `afterAuth` hook — so "custom middleware runs AFTER core
|
|
282
|
-
// auth" and the `{ mode:'middleware' }` trace step are preserved.
|
|
283
|
-
let middleware;
|
|
284
|
-
if (options.middleware) {
|
|
285
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
286
|
-
const mod = require(path.resolve(context.root, options.middleware));
|
|
287
|
-
middleware = (mod.default ?? mod);
|
|
288
|
-
}
|
|
289
|
-
const builtinMiddleware = (ctx) => {
|
|
290
|
-
if (!middleware)
|
|
291
|
-
return;
|
|
292
|
-
try {
|
|
293
|
-
middleware(ctx.req);
|
|
294
|
-
}
|
|
295
|
-
catch (e) {
|
|
296
|
-
obs.traceStep(ctx.trace, 'auth', { mode: 'middleware', ok: false, reason: e.message });
|
|
297
|
-
if (ctx.route)
|
|
298
|
-
obs.finishTrace(ctx.trace, ctx.route.project, 401);
|
|
299
|
-
ctx.sendError(401, 'unauthorized', `Gateway middleware rejected the request: ${e.message}`);
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
const userHooks = options.hooks ?? {};
|
|
303
|
-
const hooks = (0, hooks_1.createHooks)({
|
|
304
|
-
...userHooks,
|
|
305
|
-
afterAuth: [builtinMiddleware, ...(userHooks.afterAuth ?? [])],
|
|
306
|
-
});
|
|
307
|
-
const handler = (0, handler_1.createHandler)({ rt, obs, auth: edgeAuth, proxy: proxyLayer, hooks });
|
|
308
|
-
const upgrade = (0, upgrade_1.createUpgrade)({ rt, auth: edgeAuth, proxy: proxyLayer, hooks });
|
|
309
|
-
// Background timers (both `.unref()`'d): traffic-edge flush + idle sweeper.
|
|
310
|
-
const edgeFlusher = obs.startEdgeFlusher();
|
|
311
|
-
const sweeper = svcLayer.startSweeper();
|
|
312
|
-
// --- lens-mode children: dashboard + MCP singletons + `lens:true` apps ------
|
|
313
|
-
// MUST run before TLS — it pushes the dashboard route, which joins the cert SANs.
|
|
314
|
-
await lens.bootSingletonsAndApps();
|
|
315
|
-
// Eager services boot with the gateway.
|
|
316
|
-
for (const svc of services) {
|
|
317
|
-
if (svc.decl.eager)
|
|
318
|
-
void svcLayer.ensureUp(svc);
|
|
319
|
-
}
|
|
320
|
-
// --- TLS + listeners --------------------------------------------------------
|
|
321
|
-
let pem;
|
|
322
|
-
let caPath;
|
|
323
|
-
let mintCert;
|
|
324
|
-
if (https) {
|
|
325
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
326
|
-
const basicSsl = require('../../../basic-ssl');
|
|
327
|
-
const cacheDir = (0, service_keys_1.gatewayCacheDir)(context.root);
|
|
328
|
-
// SANs = the CURRENT route hosts. `rt.routes` is rebuilt live on register, so a re-mint (refreshCert
|
|
329
|
-
// below) automatically includes a newly-registered workspace's hostnames.
|
|
330
|
-
mintCert = () => basicSsl.getCertificateSync(cacheDir, 'gateway', rt.routes.map((r) => r.host).filter((h) => !!h));
|
|
331
|
-
pem = mintCert();
|
|
332
|
-
caPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca) — one anchor for every daemon
|
|
333
|
-
}
|
|
334
|
-
const servers = [];
|
|
335
|
-
const boundPorts = [];
|
|
336
|
-
for (const port of ports) {
|
|
337
|
-
// HTTP/2 (with allowHTTP1) for the TLS front door. A Vite dev page fetches
|
|
338
|
-
// HUNDREDS of ESM modules; over HTTP/1.1 the browser's ~6-connection-per-origin
|
|
339
|
-
// cap serializes that waterfall — each module sits "Stalled" waiting for a
|
|
340
|
-
// connection slot (measured: ~94% of a module's wall-time is stall, ~6% is the
|
|
341
|
-
// server), which is what makes dev asset loading feel slow through the gateway.
|
|
342
|
-
// h2 multiplexes every module over ONE connection + ONE TLS handshake, erasing
|
|
343
|
-
// both the 6-connection stall and the repeated handshakes. `allowHTTP1: true`
|
|
344
|
-
// keeps H1 clients working — notably Vite's HMR WebSocket, which browsers open
|
|
345
|
-
// as an H1 `Upgrade` on a SEPARATE connection (h2 has no Upgrade) and which still
|
|
346
|
-
// lands on `server.on('upgrade')` below. The forwarder (proxy.ts) branches by
|
|
347
|
-
// protocol: H1 keeps the proven node-http-proxy path; h2 uses a native forwarder
|
|
348
|
-
// because node-http-proxy emits connection-specific headers that are illegal in h2.
|
|
349
|
-
const server = pem
|
|
350
|
-
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
351
|
-
require('node:http2').createSecureServer({
|
|
352
|
-
key: pem,
|
|
353
|
-
cert: pem,
|
|
354
|
-
allowHTTP1: true,
|
|
355
|
-
// A full Vite dev-app reload streams TENS of MB of transformed ESM
|
|
356
|
-
// over ONE h2 session. Node's default `maxSessionMemory` is 10 MB —
|
|
357
|
-
// cross it and the session RSTs its streams / sends GOAWAY, which the
|
|
358
|
-
// browser reports as a BURST of ERR_HTTP2_PROTOCOL_ERROR (every
|
|
359
|
-
// in-flight module fails at the same instant). Raise the ceiling and
|
|
360
|
-
// the per-stream/peer limits to dev-front-door proportions.
|
|
361
|
-
maxSessionMemory: 512,
|
|
362
|
-
settings: { maxConcurrentStreams: 512 },
|
|
363
|
-
peerMaxConcurrentStreams: 512,
|
|
364
|
-
}, (req, res) => handler(req, res))
|
|
365
|
-
: http.createServer(handler);
|
|
366
|
-
server.on('upgrade', upgrade);
|
|
367
|
-
server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
|
|
368
|
-
// Inbound-socket resilience (the ECONNRESET-crash fix): guard every accepted socket, and end
|
|
369
|
-
// handshake-phase ('tlsClientError') / H1 parse-phase ('clientError') faults on the ONE connection.
|
|
370
|
-
// Registering 'clientError' replaces Node's default 400-writer, so keep its semantics: a best-effort
|
|
371
|
-
// 400 on a still-writable socket, then destroy.
|
|
372
|
-
server.on('connection', guardInboundSocket);
|
|
373
|
-
server.on('secureConnection', guardInboundSocket); // never fires on a plain-http server — harmless
|
|
374
|
-
server.on('tlsClientError', (_err, tlsSocket) => tlsSocket.destroy());
|
|
375
|
-
server.on('clientError', (rawErr, socket) => {
|
|
376
|
-
// Node adds `bytesParsed`/`rawPacket` on an HTTP parse error (not on the base ErrnoException type):
|
|
377
|
-
// `bytesParsed` is the single most useful field for telling a truncated reuse race (0 / very low) from
|
|
378
|
-
// a genuinely malformed request, which is exactly what the old handler discarded.
|
|
379
|
-
const err = rawErr;
|
|
380
|
-
const v = classifyClientError(err.code, err.message);
|
|
381
|
-
// OBSERVABILITY (this used to be silent — the whole reason a gateway-invented 400 looked like an app
|
|
382
|
-
// bug). Rate-limited per cause so a port scanner or a desynced client cannot flood the log; the bus
|
|
383
|
-
// `emit` is separately flood-guarded by its fingerprint.
|
|
384
|
-
if (v.report && admitClientErrorLog(v.cause)) {
|
|
385
|
-
const peer = socket.remoteAddress ?? 'unknown';
|
|
386
|
-
const answer = v.status === null ? 'dropped' : String(v.status);
|
|
387
|
-
console.warn(`[gateway] inbound H1 clientError from ${peer}: ${v.cause} (${err.code ?? err.message}${err.bytesParsed !== undefined ? `, bytesParsed=${err.bytesParsed}` : ''}) → ${answer}. This status is the GATEWAY's, not the upstream service's.`);
|
|
388
|
-
obs.emit('warning', `inbound clientError: ${v.cause} → ${answer}`, `gateway-client-error:${v.cause}`, {
|
|
389
|
-
kind: 'gateway-client-error', cause: v.cause, code: err.code ?? null, status: v.status, peer,
|
|
390
|
-
bytesParsed: err.bytesParsed ?? null,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
// Preserved intent: a best-effort status on a still-writable socket, then destroy.
|
|
394
|
-
if (v.status !== null && socket.writable)
|
|
395
|
-
socket.end(`HTTP/1.1 ${v.status} ${CLIENT_ERROR_REASON[v.status]}\r\n\r\n`);
|
|
396
|
-
socket.destroy();
|
|
397
|
-
});
|
|
398
|
-
// KEEP-ALIVE REUSE RACE. Node's server default `keepAliveTimeout` is 5s — and a Node HTTP client's
|
|
399
|
-
// pooled agent (Node 19+ `globalAgent` is keepAlive:true; vite's dev proxy uses it) idles on ~the same
|
|
400
|
-
// 5s. Both sides then race the SAME deadline: the client writes a request onto a socket the server is
|
|
401
|
-
// simultaneously closing, the server reads a truncated message, and the front door answers a bare
|
|
402
|
-
// `400` that the dev proxy forwards to the browser verbatim — a 400 on a request the service would
|
|
403
|
-
// have served 200. The documented fix is for the server's idle window to EXCEED every client's, so the
|
|
404
|
-
// server never closes first and the client always controls socket reuse. `headersTimeout` must stay
|
|
405
|
-
// above `keepAliveTimeout` (it bounds a request that has started arriving), and `requestTimeout` keeps
|
|
406
|
-
// its Node default. Dev-only front door, a handful of clients → holding idle sockets longer is free.
|
|
407
|
-
server.keepAliveTimeout = types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS;
|
|
408
|
-
if (server.headersTimeout < types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS + 5_000) {
|
|
409
|
-
server.headersTimeout = types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS + 5_000;
|
|
410
|
-
}
|
|
411
|
-
// h2 SESSION resilience: without a `sessionError` handler ANY session-level
|
|
412
|
-
// fault (a client GOAWAY mid-burst, a flood-protection trip, a decode error)
|
|
413
|
-
// is unhandled → Node tears the session down, failing EVERY sibling stream at
|
|
414
|
-
// once. Handle it so ONE bad session logs + dies alone, never throwing and
|
|
415
|
-
// never taking the process (or other clients' sessions) with it. `session`
|
|
416
|
-
// errors are surfaced BOTH per-session and (unhandled) as `sessionError`.
|
|
417
|
-
const isH2 = pem != null;
|
|
418
|
-
if (isH2) {
|
|
419
|
-
const h2 = server;
|
|
420
|
-
h2.on('session', (session) => {
|
|
421
|
-
session.on('error', (err) => {
|
|
422
|
-
const code = err.code ?? err.message;
|
|
423
|
-
console.warn(`[gateway] h2 session error (${code}) — dropping this session only`);
|
|
424
|
-
if (!session.destroyed)
|
|
425
|
-
session.destroy();
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
h2.on('sessionError', (err) => {
|
|
429
|
-
console.warn(`[gateway] h2 sessionError: ${err.code ?? err.message}`);
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
await new Promise((resolve) => {
|
|
433
|
-
server.listen(port, () => {
|
|
434
|
-
const actual = server.address()?.port ?? port;
|
|
435
|
-
boundPorts.push(actual);
|
|
436
|
-
console.log(`[gateway] Listening on ${pem ? 'https' : 'http'}://localhost:${actual}`);
|
|
437
|
-
resolve();
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
servers.push(server);
|
|
441
|
-
}
|
|
442
|
-
// Now that the TLS servers exist, wire the live cert refresh: on a workspace register/unregister the
|
|
443
|
-
// control endpoint calls this to re-mint the gateway cert with the CURRENT route hosts and hot-swap it
|
|
444
|
-
// into every HTTP/2 secure server (setSecureContext), so a dynamically-registered workspace's hostnames
|
|
445
|
-
// are covered (no ERR_CERT_COMMON_NAME_INVALID).
|
|
446
|
-
if (mintCert) {
|
|
447
|
-
refreshCert = () => {
|
|
448
|
-
const fresh = mintCert();
|
|
449
|
-
for (const s of servers) {
|
|
450
|
-
s.setSecureContext?.({ key: fresh, cert: fresh });
|
|
451
|
-
}
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
console.log('[gateway] Routes:');
|
|
455
|
-
for (const r of routes) {
|
|
456
|
-
const dest = r.pool ? `pods@${r.pool.dir}` : r.target;
|
|
457
|
-
console.log(`[gateway] ${r.host ?? '(default)'} → ${dest}${r.prependPrefix ? ` (+${r.prependPrefix})` : ''}${r.internal ? ' [internal: no middleware]' : ''} [${r.project}]`);
|
|
458
|
-
}
|
|
459
|
-
if (caPath)
|
|
460
|
-
console.log(`[gateway] CA: ${caPath} — one-time setup via the trust executor.`);
|
|
461
|
-
obs.emit('info', `gateway up: ${routes.length} routes on ${boundPorts.join('/')}`, 'cluster-gateway', {
|
|
462
|
-
kind: 'gateway-up',
|
|
463
|
-
ports: boundPorts,
|
|
464
|
-
routes: routes.map((r) => ({ host: r.host ?? '(default)', project: r.project, mode: r.pool ? 'pods' : 'tcp', internal: !!r.internal, prependPrefix: r.prependPrefix })),
|
|
465
|
-
});
|
|
466
|
-
const stop = async () => {
|
|
467
|
-
if (stopped)
|
|
468
|
-
return;
|
|
469
|
-
stopped = true;
|
|
470
|
-
clearInterval(edgeFlusher);
|
|
471
|
-
clearInterval(sweeper);
|
|
472
|
-
obs.emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
|
|
473
|
-
edgeVerifiers.stop(); // clear every JWKS periodic-refresh timer
|
|
474
|
-
controlServer?.close(); // tear down the control plane + its socket
|
|
475
|
-
(0, workspace_registry_1.unregisterWorkspace)(wsKey); // drop this workspace from the global chooser registry (graceful stop)
|
|
476
|
-
for (const svc of services)
|
|
477
|
-
svcLayer.killSpawned(svc, 'gateway shutdown');
|
|
478
|
-
// Reap the lens-mode managed children (dashboard + MCP singletons + each `lens:true` app's Vite/capture)
|
|
479
|
-
// — SIGTERM → grace → SIGKILL, awaited, so a SIGTERM-ignoring vite is gone BEFORE the daemon exits
|
|
480
|
-
// (otherwise it orphans + keeps its port). Same teardown as the pod services.
|
|
481
|
-
await lens.reapAll();
|
|
482
|
-
await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
|
|
483
|
-
proxyLayer.closeProxy();
|
|
484
|
-
proxyLayer.destroyAgents(); // release pooled upstream sockets
|
|
485
|
-
};
|
|
486
|
-
return { ports: boundPorts, routes, services, stop };
|
|
487
|
-
}
|
|
1
|
+
"use strict";var Z=Object.defineProperty;var m=(r,o)=>Z(r,"name",{value:o,configurable:!0});var X=Object.defineProperty,n=m((r,o)=>X(r,"name",{value:o,configurable:!0}),"n");Object.defineProperty(exports,"__esModule",{value:!0}),exports.BENIGN_SOCKET_ERRNOS=void 0,exports.buildEdgeVerifierPool=buildEdgeVerifierPool,exports.classifyClientError=classifyClientError,exports.createClientErrorLogGate=createClientErrorLogGate,exports.startGateway=startGateway;const tslib_1=require("tslib"),http=tslib_1.__importStar(require("node:http")),path=tslib_1.__importStar(require("node:path")),discovery_1=require("./discovery"),scope_1=require("./scope"),route_registry_1=require("./route-registry"),control_1=require("./control"),workspace_registry_1=require("./workspace-registry"),service_keys_1=require("./service-keys"),observability_1=require("./observability"),lifecycle_1=require("./lifecycle"),lens_children_1=require("./lens-children"),proxy_1=require("./proxy"),auth_1=require("./auth"),hooks_1=require("./hooks"),handler_1=require("./handler"),upgrade_1=require("./upgrade"),jwks_verify_1=require("../jwks-verify"),types_1=require("./types");function insecureLocalFetch(r,o){return new Promise((g,h)=>{const w=(r.startsWith("https:")?require("node:https"):require("node:http")).request(r,{method:"GET",headers:o?.headers,rejectUnauthorized:!1},_=>{const l=[];_.on("data",u=>l.push(u)),_.on("end",()=>{const u=_.statusCode??0,a=Buffer.concat(l).toString("utf8");g({ok:u>=200&&u<300,status:u,json:n(async()=>JSON.parse(a),"json")})})});w.on("error",h),w.end()})}m(insecureLocalFetch,"insecureLocalFetch"),n(insecureLocalFetch,"insecureLocalFetch");function buildEdgeVerifierPool(r){const o=process.env.LENSMCP_GW_JWKS_URL,g=process.env.LENSMCP_GW_JWT_ISS,h=new Map;let w;const _=n((l,u)=>{console.log(`[gateway] edge JWT: verifying via JWKS ${l}${u}`);const a=(0,jwks_verify_1.createJwksVerifier)({jwksUrl:l,...g?{issuer:g}:{},fetchImpl:insecureLocalFetch});return a.refresh().catch(E=>console.warn("[gateway] edge JWKS warm failed (will retry):",E.message)),a},"make");return{for(l){if(o)return w??=_(o,"");const u=l.split(":")[0].toLowerCase();let a;for(const v of r()){const S=v.host;if(typeof S!="string"||!/^auth\./.test(S)||S.startsWith("internal."))continue;const d=S.slice(5);(u===d||u.endsWith(`.${d}`))&&(!a||d.length>a.length)&&(a=d)}if(!a)return;let E=h.get(a);return E||(E=_(`https://auth.${a}/jwks.json`,` (auto-derived for apex ${a})`),h.set(a,E)),E},stop(){w?.stop();for(const l of h.values())l.stop()}}}m(buildEdgeVerifierPool,"buildEdgeVerifierPool"),n(buildEdgeVerifierPool,"buildEdgeVerifierPool"),exports.BENIGN_SOCKET_ERRNOS=new Set(["ECONNRESET","EPIPE","ETIMEDOUT","ECONNABORTED","ECANCELED"]);function classifyClientError(r,o){return r&&exports.BENIGN_SOCKET_ERRNOS.has(r)?{status:null,report:!1,cause:"client-abort"}:r==="HPE_INVALID_EOF_STATE"?{status:null,report:!1,cause:"truncated-at-eof"}:r==="ERR_HTTP_REQUEST_TIMEOUT"?{status:408,report:!0,cause:"request-timeout"}:r==="HPE_HEADER_OVERFLOW"?{status:431,report:!0,cause:"header-overflow"}:r?.startsWith("HPE_")?{status:400,report:!0,cause:"parse-error"}:{status:400,report:!0,cause:r??o??"unknown"}}m(classifyClientError,"classifyClientError"),n(classifyClientError,"classifyClientError");const CLIENT_ERROR_REASON={400:"Bad Request",408:"Request Timeout",431:"Request Header Fields Too Large"};function createClientErrorLogGate(r=1e4){const o=new Map;return g=>{const h=Date.now(),w=o.get(g)??0;return h-w<r?!1:(o.set(g,h),!0)}}m(createClientErrorLogGate,"createClientErrorLogGate"),n(createClientErrorLogGate,"createClientErrorLogGate");function guardInboundSocket(r){r.on("error",o=>{exports.BENIGN_SOCKET_ERRNOS.has(o.code??"")||console.warn(`[gateway] inbound socket error (${o.code??o.message}) \u2014 dropping this connection only`),r.destroy()})}m(guardInboundSocket,"guardInboundSocket"),n(guardInboundSocket,"guardInboundSocket");async function startGateway(r,o){const g=r.https!==!1,h=r.ports?.length?r.ports:[443],w=r.scanTtlMs??1500,_=r.startupTimeoutMs??12e4,l=r.sweepMs??1e4,u=r.trafficFlushMs??5e3;let a=!1;const E=process.env.LENSMCP_EVENT_FILE??path.join(o.root,".lensmcp","events.jsonl"),v=(0,scope_1.readLensScope)(o.root).key,{routes:S,services:d}=(0,discovery_1.discoverRoutes)(o.root,o.projectsConfigurations?.projects??{},void 0,v);if(S.length===0)throw new Error("[gateway] no project declares a `cluster` field \u2014 nothing to route.");const q=new route_registry_1.RouteRegistry;q.register({wsKey:v,root:o.root,routes:S,services:d});const T=[];let x=n(()=>{},"refreshCert");const N=n(()=>{(0,route_registry_1.rebuildRoutesInPlace)(T,q),x()},"rebuildRoutes");N();const{serviceKeys:V,keyToProject:D}=(0,service_keys_1.loadServiceKeys)(o.root,d),f={root:o.root,wsKey:v,eventFile:E,routes:T,registry:q,rebuildRoutes:N,services:d,serviceKeys:V,keyToProject:D,scanTtlMs:w,startupTimeoutMs:_,sweepMs:l,trafficFlushMs:u,https:g,projectRoots:Object.fromEntries(Object.entries(o.projectsConfigurations?.projects??{}).map(([e,t])=>[e,path.resolve(o.root,t.root)])),stopped:n(()=>a,"stopped")},p=(0,observability_1.createObservability)(E,{trafficFlushMs:u}),B=createClientErrorLogGate(),P=(0,lifecycle_1.createServiceLayer)(f,p),b=(0,lens_children_1.createLensChildren)(f,p,r),j=new Map;let I;try{I=(0,control_1.startControlServer)({rt:f,killService:n((e,t)=>P.killSpawned(e,t),"killService"),onRegister:n(async e=>{const t=await b.hostWorkspaceDashboard(e),i=await b.hostWorkspaceApps(e);j.set(e.wsKey,()=>{t?.(),i()})},"onRegister"),onUnregister:n(e=>{j.get(e.wsKey)?.(),j.delete(e.wsKey)},"onUnregister")})}catch(e){console.warn("[gateway] control plane failed to start (multi-workspace register disabled):",e.message)}const C=(0,proxy_1.createProxy)(f,p,P),L=buildEdgeVerifierPool(()=>f.routes),W=(0,auth_1.createEdgeAuth)(f,p,L);let A;if(r.middleware){const e=require(path.resolve(o.root,r.middleware));A=e.default??e}const J=n(e=>{if(A)try{A(e.req)}catch(t){p.traceStep(e.trace,"auth",{mode:"middleware",ok:!1,reason:t.message}),e.route&&p.finishTrace(e.trace,e.route.project,401),e.sendError(401,"unauthorized",`Gateway middleware rejected the request: ${t.message}`)}},"builtinMiddleware"),G=r.hooks??{},K=(0,hooks_1.createHooks)({...G,afterAuth:[J,...G.afterAuth??[]]}),U=(0,handler_1.createHandler)({rt:f,obs:p,auth:W,proxy:C,hooks:K}),Y=(0,upgrade_1.createUpgrade)({rt:f,auth:W,proxy:C,hooks:K}),z=p.startEdgeFlusher(),Q=P.startSweeper();await b.bootSingletonsAndApps();for(const e of d)e.decl.eager&&P.ensureUp(e);let k,M,$;if(g){const e=require("../../../basic-ssl"),t=(0,service_keys_1.gatewayCacheDir)(o.root);$=n(()=>e.getCertificateSync(t,"gateway",f.routes.map(i=>i.host).filter(i=>!!i)),"mintCert"),k=$(),M=e.caCertPath()}const O=[],R=[];for(const e of h){const t=k?require("node:http2").createSecureServer({key:k,cert:k,allowHTTP1:!0,maxSessionMemory:512,settings:{maxConcurrentStreams:512},peerMaxConcurrentStreams:512},(i,s)=>U(i,s)):http.createServer(U);if(t.on("upgrade",Y),t.on("error",i=>console.error(`[gateway] port ${e}: ${i.code}`)),t.on("connection",guardInboundSocket),t.on("secureConnection",guardInboundSocket),t.on("tlsClientError",(i,s)=>s.destroy()),t.on("clientError",(i,s)=>{const y=i,c=classifyClientError(y.code,y.message);if(c.report&&B(c.cause)){const F=s.remoteAddress??"unknown",H=c.status===null?"dropped":String(c.status);console.warn(`[gateway] inbound H1 clientError from ${F}: ${c.cause} (${y.code??y.message}${y.bytesParsed!==void 0?`, bytesParsed=${y.bytesParsed}`:""}) \u2192 ${H}. This status is the GATEWAY's, not the upstream service's.`),p.emit("warning",`inbound clientError: ${c.cause} \u2192 ${H}`,`gateway-client-error:${c.cause}`,{kind:"gateway-client-error",cause:c.cause,code:y.code??null,status:c.status,peer:F,bytesParsed:y.bytesParsed??null})}c.status!==null&&s.writable&&s.end(`HTTP/1.1 ${c.status} ${CLIENT_ERROR_REASON[c.status]}\r
|
|
2
|
+
\r
|
|
3
|
+
`),s.destroy()}),t.keepAliveTimeout=types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS,t.headersTimeout<types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS+5e3&&(t.headersTimeout=types_1.GATEWAY_KEEPALIVE_TIMEOUT_MS+5e3),k!=null){const i=t;i.on("session",s=>{s.on("error",y=>{const c=y.code??y.message;console.warn(`[gateway] h2 session error (${c}) \u2014 dropping this session only`),s.destroyed||s.destroy()})}),i.on("sessionError",s=>{console.warn(`[gateway] h2 sessionError: ${s.code??s.message}`)})}await new Promise(i=>{t.listen(e,()=>{const s=t.address()?.port??e;R.push(s),console.log(`[gateway] Listening on ${k?"https":"http"}://localhost:${s}`),i()})}),O.push(t)}$&&(x=n(()=>{const e=$();for(const t of O)t.setSecureContext?.({key:e,cert:e})},"refreshCert")),console.log("[gateway] Routes:");for(const e of T){const t=e.pool?`pods@${e.pool.dir}`:e.target;console.log(`[gateway] ${e.host??"(default)"} \u2192 ${t}${e.prependPrefix?` (+${e.prependPrefix})`:""}${e.internal?" [internal: no middleware]":""} [${e.project}]`)}return M&&console.log(`[gateway] CA: ${M} \u2014 one-time setup via the trust executor.`),p.emit("info",`gateway up: ${T.length} routes on ${R.join("/")}`,"cluster-gateway",{kind:"gateway-up",ports:R,routes:T.map(e=>({host:e.host??"(default)",project:e.project,mode:e.pool?"pods":"tcp",internal:!!e.internal,prependPrefix:e.prependPrefix}))}),{ports:R,routes:T,services:d,stop:n(async()=>{if(!a){a=!0,clearInterval(z),clearInterval(Q),p.emit("info","gateway down","cluster-gateway",{kind:"gateway-down"}),L.stop(),I?.close(),(0,workspace_registry_1.unregisterWorkspace)(v);for(const e of d)P.killSpawned(e,"gateway shutdown");await b.reapAll(),await Promise.all(O.map(e=>new Promise(t=>e.close(()=>t())))),C.closeProxy(),C.destroyAgents()}},"stop")}}m(startGateway,"startGateway"),n(startGateway,"startGateway");
|