@lensmcp/cluster 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/executors/gateway/gateway.lib.d.ts.map +1 -1
- package/executors/gateway/gateway.lib.js +52 -2
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -1
- package/executors/gateway/prod-gateway.lib.js +24 -0
- package/executors/gateway/ssrf-guard.d.ts +97 -0
- package/executors/gateway/ssrf-guard.d.ts.map +1 -0
- package/executors/gateway/ssrf-guard.js +190 -0
- package/gateway.d.ts +21 -0
- package/gateway.d.ts.map +1 -0
- package/gateway.js +35 -0
- package/main.devserver.js +12 -3
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAG9D,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAGlC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAqE,KAAK,UAAU,EAAc,MAAM,YAAY,CAAC;AAQ5H,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAKzC;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/gateway.lib.ts"],"names":[],"mappings":"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"}
|
|
@@ -39,6 +39,29 @@ function verifyDevJwt(req) {
|
|
|
39
39
|
const [h, p, s] = auth.slice(7).split('.');
|
|
40
40
|
if (!h || !p || !s)
|
|
41
41
|
return undefined;
|
|
42
|
+
// An IdP that signs ASYMMETRIC (RS256 + JWKS — e.g. tetros's auth host) does NOT use the HS256 dev
|
|
43
|
+
// shared-secret this dev edge assumed. The PROD gateway verifies RS256 via jwks-verify; in dev we
|
|
44
|
+
// DECODE + exp-check the claims (no signature check) — the gateway is the only local ingress, the token
|
|
45
|
+
// came from the trusted local IdP, and a dev IdP's ephemeral per-pod keys make in-cluster JWKS
|
|
46
|
+
// verification unreliable. (The HS256 path below stays for services using the shared-secret convention.)
|
|
47
|
+
let alg;
|
|
48
|
+
try {
|
|
49
|
+
alg = JSON.parse(Buffer.from(h, 'base64url').toString())['alg'];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof alg === 'string' && alg !== 'HS256') {
|
|
55
|
+
try {
|
|
56
|
+
const rsClaims = JSON.parse(Buffer.from(p, 'base64url').toString());
|
|
57
|
+
if (typeof rsClaims['exp'] === 'number' && Date.now() / 1000 > rsClaims['exp'])
|
|
58
|
+
return undefined;
|
|
59
|
+
return rsClaims;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
42
65
|
const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
|
|
43
66
|
const a = Buffer.from(s);
|
|
44
67
|
const b = Buffer.from(expected);
|
|
@@ -694,7 +717,12 @@ async function startGateway(options, context) {
|
|
|
694
717
|
}
|
|
695
718
|
req.__lensmcpPod = path.basename(sock, '.sock');
|
|
696
719
|
traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
|
|
697
|
-
|
|
720
|
+
// NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's setRedirectHostRewrite
|
|
721
|
+
// does url.parse(options.target) on ANY 3xx response — which throws ("url must be a string, received
|
|
722
|
+
// Object") and CRASHES the gateway the instant a pod returns a redirect (e.g. the auth IdP's
|
|
723
|
+
// /authorize → login). A socket target has no host to rewrite anyway, and pods emit ABSOLUTE
|
|
724
|
+
// external redirect URLs, so no rewrite is needed.
|
|
725
|
+
proxy.web(req, res, { target: { socketPath: sock }, agent: httpAgent }, (err) => {
|
|
698
726
|
route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
|
|
699
727
|
route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
|
|
700
728
|
try {
|
|
@@ -707,7 +735,9 @@ async function startGateway(options, context) {
|
|
|
707
735
|
});
|
|
708
736
|
return;
|
|
709
737
|
}
|
|
710
|
-
|
|
738
|
+
// autoRewrite ONLY for a string (external URL) target — a non-string target crashes
|
|
739
|
+
// setRedirectHostRewrite's url.parse(options.target) on a 3xx (see the socketPath case above).
|
|
740
|
+
proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
|
|
711
741
|
console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
|
|
712
742
|
res.statusCode = 502;
|
|
713
743
|
res.end(`Upstream ${route.project} unavailable.`);
|
|
@@ -785,6 +815,26 @@ async function startGateway(options, context) {
|
|
|
785
815
|
...(route.host ? { routeHost: route.host } : {}),
|
|
786
816
|
...(route.internal ? { internal: true } : {}),
|
|
787
817
|
});
|
|
818
|
+
// CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries
|
|
819
|
+
// no token/credentials by spec, so the JWT (or x-api-key) enforcement below would 401 it and the
|
|
820
|
+
// browser's CORS check fails with "No Access-Control-Allow-Origin". The matching real request
|
|
821
|
+
// (GET/POST with the token) still goes through auth normally; the service supplies CORS on it.
|
|
822
|
+
if (req.method === 'OPTIONS') {
|
|
823
|
+
cors(req, res);
|
|
824
|
+
res.writeHead(204, {
|
|
825
|
+
'access-control-allow-methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
826
|
+
'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
|
|
827
|
+
});
|
|
828
|
+
res.end();
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
// CORS on EVERY routed response — including the 401/403/5xx the gateway generates ITSELF below (edge
|
|
832
|
+
// auth, authz, proxy errors). Without this, a gateway-generated error short-circuits before the upstream
|
|
833
|
+
// service (which carries CORS on success), so the browser sees the error WITHOUT an
|
|
834
|
+
// Access-Control-Allow-Origin and reports a CORS failure that MASKS the real status (e.g. a 401 the app
|
|
835
|
+
// should handle by re-authing). On the success path the upstream's CORS header overwrites this one
|
|
836
|
+
// (http-proxy setHeader), so there's no duplicate. The gateway is the edge CORS authority.
|
|
837
|
+
cors(req, res);
|
|
788
838
|
if (route.internal) {
|
|
789
839
|
// service-to-service: validate the CALLER's api key at the boundary
|
|
790
840
|
const presented = String(req.headers['x-api-key'] ?? '');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"prod-gateway.lib.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/prod-gateway.lib.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAIlC,OAAO,EAEL,KAAK,QAAQ,EAAc,KAAK,gBAAgB,EAAE,KAAK,WAAW,EAAqB,KAAK,KAAK,EAClG,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD;;+EAE+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,SAAS,GAAG,UAAU,GAAG,MAAM,GAAG,MAAM,CAAC;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC;oEACgE;IAChE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,gBAAgB,CAAC;IAC5B,2EAA2E;IAC3E,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC;qFACiF;IACjF,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,CAAC;IACnE,GAAG,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACtD,8EAA8E;IAC9E,IAAI,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IAChD,oDAAoD;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;qFACiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;;2EAQuE;IACvE,MAAM,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvP;;;;oFAIgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;sDAIkD;IAClD,sBAAsB,CAAC,EAAE,MAAM,EAAE,CAAC;IAClC;;oFAEgF;IAChF,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB;6FACyF;IACzF,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;0FACsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+FAA+F;IAC/F,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;gFAE4E;IAC5E,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,aAAa,EAAE,KAAK,IAAI,CAAC;IAC/C;;iFAE6E;IAC7E,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B;;;+FAG2F;IAC3F,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B;gGAC4F;IAC5F,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB;;;uFAGmF;IACnF,MAAM,CAAC,EAAE;QAAE,gBAAgB,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACrH;4EACwE;IACxE,SAAS,CAAC,EAAE,OAAO,CAAC;IAGpB;gFAC4E;IAC5E,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9E,mFAAmF;IACnF,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B;2DACuD;IACvD,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;4EACwE;IACxE,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC9E;sEACkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,KAAK,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,aAAa,EAAE,CAAC;IAC9B,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AA+BD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAmmB3F"}
|
|
@@ -415,6 +415,17 @@ async function startProdGateway(opts) {
|
|
|
415
415
|
return reply;
|
|
416
416
|
}
|
|
417
417
|
step(trace, 'route-match', { project: route.service, ...(route.prefix ? { prefix: route.prefix } : {}), ...(route.internal ? { internal: true } : {}) });
|
|
418
|
+
// CORS preflight on a ROUTED path: answer it HERE, BEFORE edge auth — an OPTIONS preflight carries no
|
|
419
|
+
// token by spec, so the auth below would 401 it and the browser's CORS check fails. The real request
|
|
420
|
+
// still authenticates. (handleNoRoute covers UNROUTED preflights; this covers routed ones.)
|
|
421
|
+
if (req.method === 'OPTIONS') {
|
|
422
|
+
setCors(reply, req);
|
|
423
|
+
reply.code(204)
|
|
424
|
+
.header('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS')
|
|
425
|
+
.header('access-control-allow-headers', String(req.headers['access-control-request-headers'] ?? '*'))
|
|
426
|
+
.send();
|
|
427
|
+
return reply;
|
|
428
|
+
}
|
|
418
429
|
// OTel SERVER span (opt-in): continues the incoming traceparent. Stored now so
|
|
419
430
|
// onResponse ends it on ANY exit path (auth reject / 503 / forward). Context is
|
|
420
431
|
// injected into req.headers just before forwarding (below) so downstream parents to it.
|
|
@@ -757,6 +768,19 @@ async function startProdGateway(opts) {
|
|
|
757
768
|
reply.type('application/json').send(JSON.stringify(opts.metrics.snapshot()));
|
|
758
769
|
});
|
|
759
770
|
app.addHook('onRequest', (request, _reply, done) => { stripTrust(request.raw); done(); });
|
|
771
|
+
// Edge CORS authority: stamp Access-Control-Allow-Origin on EVERY response that lacks it — including the
|
|
772
|
+
// 401/403/5xx the gateway generates ITSELF (edge auth/authz/proxy errors) which never reach an upstream.
|
|
773
|
+
// Without it the browser sees the error WITHOUT CORS and reports a CORS failure that MASKS the real
|
|
774
|
+
// status (e.g. a 401 the app should handle by re-authing). Add ONLY if absent so a proxied success keeps
|
|
775
|
+
// the upstream's header (no duplicate). No credentials (Bearer APIs; matches setCors).
|
|
776
|
+
app.addHook('onSend', (request, reply, payload, done) => {
|
|
777
|
+
const origin = request.headers.origin;
|
|
778
|
+
if (origin && !reply.getHeader('access-control-allow-origin')) {
|
|
779
|
+
reply.header('access-control-allow-origin', String(origin));
|
|
780
|
+
reply.header('vary', 'Origin');
|
|
781
|
+
}
|
|
782
|
+
done(null, payload);
|
|
783
|
+
});
|
|
760
784
|
app.addHook('onResponse', (request, reply, done) => {
|
|
761
785
|
const st = reqState.get(request);
|
|
762
786
|
if (st) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guard for OUTBOUND fetches of a request- or config-supplied URL.
|
|
3
|
+
*
|
|
4
|
+
* The prod gateway today never fetches an attacker-controlled URL — upstreams
|
|
5
|
+
* are pinned in the manifest, the JWKS URL is operator env, and reply-from /
|
|
6
|
+
* undici do not chase redirects. This module exists so that the MOMENT a sink
|
|
7
|
+
* IS added (a remote-MCP / OpenAPI schema import, a webhook callback, a
|
|
8
|
+
* dynamically-discovered upstream — anything that fetches a URL it did not
|
|
9
|
+
* author) there is a ready, tested guard to wrap it with, instead of a naive
|
|
10
|
+
* `if (host === '127.0.0.1')` string check that the classic bypasses defeat.
|
|
11
|
+
*
|
|
12
|
+
* It defends the three canonical bypasses:
|
|
13
|
+
* 1. DNS name that RESOLVES to an internal IP (`127.0.0.1.nip.io`, rebinding)
|
|
14
|
+
* → we resolve the host and check the RESOLVED addresses, not the string.
|
|
15
|
+
* 2. Redirect chase (`/redirect` 3xx → loopback) → `safeFetch` re-validates
|
|
16
|
+
* EVERY hop with `redirect:'manual'`; it never blindly follows a Location.
|
|
17
|
+
* 3. IPv4-mapped IPv6 loopback (`[::ffff:127.0.0.1]`, `[::ffff:7f00:1]`) →
|
|
18
|
+
* `net.BlockList` normalizes mapped forms against the IPv4 rules.
|
|
19
|
+
*
|
|
20
|
+
* Residual TOCTOU: between the resolve-and-check and the socket connect, a
|
|
21
|
+
* rebinding resolver could flip the record. Close it by pinning the connection
|
|
22
|
+
* to the validated address(es) with `pinnedLookup()` (pass to
|
|
23
|
+
* `new http.Agent({ lookup })` or an undici `connect:{ lookup }` dispatcher).
|
|
24
|
+
*
|
|
25
|
+
* Defaults are operator-overridable but FAIL CLOSED: anything unparseable, any
|
|
26
|
+
* non-http(s) scheme, embedded credentials, an empty host, or a host that
|
|
27
|
+
* resolves to no records is rejected.
|
|
28
|
+
*/
|
|
29
|
+
import { BlockList } from 'node:net';
|
|
30
|
+
import type { LookupAddress } from 'node:dns';
|
|
31
|
+
/** Thrown for every blocked URL — carries a machine-readable `reason` and, when
|
|
32
|
+
* the block was an address, the offending `address` (the resolved IP, not the
|
|
33
|
+
* hostname, so logs show what the name actually pointed at). */
|
|
34
|
+
export declare class SsrfBlockedError extends Error {
|
|
35
|
+
readonly reason: string;
|
|
36
|
+
readonly url?: string;
|
|
37
|
+
readonly address?: string;
|
|
38
|
+
constructor(reason: string, url?: string, address?: string, cause?: unknown);
|
|
39
|
+
}
|
|
40
|
+
/** True if `ip` (a literal address) falls in a blocked range. Mapped IPv6 is
|
|
41
|
+
* normalized by BlockList; an unparseable string is treated as blocked. */
|
|
42
|
+
export declare function isBlockedAddress(ip: string, blockList?: BlockList): boolean;
|
|
43
|
+
export interface FetchableUrlOptions {
|
|
44
|
+
/** Allowed URL schemes. Default `['http:', 'https:']`. */
|
|
45
|
+
allowedProtocols?: string[];
|
|
46
|
+
/** DNS resolver (injectable for tests). Default `dns.lookup(host, {all:true})`. */
|
|
47
|
+
resolve?: (host: string) => Promise<string[]>;
|
|
48
|
+
/** Override the blocked-range set. Default = the internal/non-routable set. */
|
|
49
|
+
blockList?: BlockList;
|
|
50
|
+
/** Escape hatch: skip all internal-range checks (e.g. a trusted-loopback dev
|
|
51
|
+
* sink). Off by default — opt in explicitly, never from request input. */
|
|
52
|
+
allowPrivate?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface FetchableUrl {
|
|
55
|
+
/** The parsed, validated URL. */
|
|
56
|
+
url: URL;
|
|
57
|
+
/** The host (brackets stripped for IPv6 literals). */
|
|
58
|
+
hostname: string;
|
|
59
|
+
/** Validated addresses: the literal IP, or every resolved DNS record. Feed to
|
|
60
|
+
* `pinnedLookup()` to pin the connection and close the rebinding TOCTOU. */
|
|
61
|
+
addresses: string[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate that `rawUrl` is safe to fetch. Rejects (throws `SsrfBlockedError`)
|
|
65
|
+
* a malformed URL, a disallowed scheme, embedded credentials, a missing host,
|
|
66
|
+
* a literal internal IP, or a host that resolves to ANY internal address.
|
|
67
|
+
* Returns the parsed URL and the validated address set on success.
|
|
68
|
+
*/
|
|
69
|
+
export declare function assertFetchableUrl(rawUrl: string | URL, opts?: FetchableUrlOptions): Promise<FetchableUrl>;
|
|
70
|
+
export interface SafeFetchOptions extends FetchableUrlOptions {
|
|
71
|
+
/** Max redirect hops before giving up. Default 5. */
|
|
72
|
+
maxRedirects?: number;
|
|
73
|
+
/** Injectable fetch (tests). Default global `fetch`. */
|
|
74
|
+
fetchImpl?: typeof fetch;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A `fetch` that validates the target — and re-validates every redirect hop —
|
|
78
|
+
* with the SSRF guard. Redirects are handled manually (`redirect:'manual'`) so
|
|
79
|
+
* a 3xx Location to an internal host is checked BEFORE it is followed, never
|
|
80
|
+
* chased blindly. Returns the final non-redirect `Response`.
|
|
81
|
+
*
|
|
82
|
+
* Note: each hop re-issues with the original `init` (method + body preserved —
|
|
83
|
+
* the security-relevant worst case for 307/308). A streamed request body cannot
|
|
84
|
+
* be replayed across hops.
|
|
85
|
+
*/
|
|
86
|
+
export declare function safeFetch(rawUrl: string | URL, init?: RequestInit, opts?: SafeFetchOptions): Promise<Response>;
|
|
87
|
+
type LookupCallback = (err: NodeJS.ErrnoException | null, address?: string | LookupAddress[], family?: number) => void;
|
|
88
|
+
/**
|
|
89
|
+
* A `dns.lookup`-compatible function that ONLY ever returns the pre-validated
|
|
90
|
+
* `addresses`. Pass it to `new http.Agent({ lookup })` or an undici
|
|
91
|
+
* `connect:{ lookup }` dispatcher so the socket connects to an address the
|
|
92
|
+
* guard already cleared — closing the DNS-rebinding TOCTOU between check and
|
|
93
|
+
* connect. Use the `addresses` from `assertFetchableUrl()`.
|
|
94
|
+
*/
|
|
95
|
+
export declare function pinnedLookup(addresses: readonly string[]): (hostname: string, options: unknown, callback?: LookupCallback) => void;
|
|
96
|
+
export {};
|
|
97
|
+
//# sourceMappingURL=ssrf-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ssrf-guard.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/ssrf-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAQ,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAE9C;;iEAEiE;AACjE,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;gBACd,MAAM,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAU5E;AA6BD;4EAC4E;AAC5E,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,GAAE,SAA8B,GAAG,OAAO,CAI/F;AAQD,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,mFAAmF;IACnF,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC9C,+EAA+E;IAC/E,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;+EAC2E;IAC3E,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,GAAG,EAAE,GAAG,CAAC;IACT,sDAAsD;IACtD,QAAQ,EAAE,MAAM,CAAC;IACjB;iFAC6E;IAC7E,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,GAAG,GAAG,EACpB,IAAI,GAAE,mBAAwB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAgDvB;AAED,MAAM,WAAW,gBAAiB,SAAQ,mBAAmB;IAC3D,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,wDAAwD;IACxD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;GASG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,GAAG,GAAG,EACpB,IAAI,GAAE,WAAgB,EACtB,IAAI,GAAE,gBAAqB,GAC1B,OAAO,CAAC,QAAQ,CAAC,CAgBnB;AAED,KAAK,cAAc,GAAG,CACpB,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,EACjC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa,EAAE,EAClC,MAAM,CAAC,EAAE,MAAM,KACZ,IAAI,CAAC;AAEV;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,cAAc,KAAK,IAAI,CAezE"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SsrfBlockedError = void 0;
|
|
4
|
+
exports.isBlockedAddress = isBlockedAddress;
|
|
5
|
+
exports.assertFetchableUrl = assertFetchableUrl;
|
|
6
|
+
exports.safeFetch = safeFetch;
|
|
7
|
+
exports.pinnedLookup = pinnedLookup;
|
|
8
|
+
/**
|
|
9
|
+
* SSRF guard for OUTBOUND fetches of a request- or config-supplied URL.
|
|
10
|
+
*
|
|
11
|
+
* The prod gateway today never fetches an attacker-controlled URL — upstreams
|
|
12
|
+
* are pinned in the manifest, the JWKS URL is operator env, and reply-from /
|
|
13
|
+
* undici do not chase redirects. This module exists so that the MOMENT a sink
|
|
14
|
+
* IS added (a remote-MCP / OpenAPI schema import, a webhook callback, a
|
|
15
|
+
* dynamically-discovered upstream — anything that fetches a URL it did not
|
|
16
|
+
* author) there is a ready, tested guard to wrap it with, instead of a naive
|
|
17
|
+
* `if (host === '127.0.0.1')` string check that the classic bypasses defeat.
|
|
18
|
+
*
|
|
19
|
+
* It defends the three canonical bypasses:
|
|
20
|
+
* 1. DNS name that RESOLVES to an internal IP (`127.0.0.1.nip.io`, rebinding)
|
|
21
|
+
* → we resolve the host and check the RESOLVED addresses, not the string.
|
|
22
|
+
* 2. Redirect chase (`/redirect` 3xx → loopback) → `safeFetch` re-validates
|
|
23
|
+
* EVERY hop with `redirect:'manual'`; it never blindly follows a Location.
|
|
24
|
+
* 3. IPv4-mapped IPv6 loopback (`[::ffff:127.0.0.1]`, `[::ffff:7f00:1]`) →
|
|
25
|
+
* `net.BlockList` normalizes mapped forms against the IPv4 rules.
|
|
26
|
+
*
|
|
27
|
+
* Residual TOCTOU: between the resolve-and-check and the socket connect, a
|
|
28
|
+
* rebinding resolver could flip the record. Close it by pinning the connection
|
|
29
|
+
* to the validated address(es) with `pinnedLookup()` (pass to
|
|
30
|
+
* `new http.Agent({ lookup })` or an undici `connect:{ lookup }` dispatcher).
|
|
31
|
+
*
|
|
32
|
+
* Defaults are operator-overridable but FAIL CLOSED: anything unparseable, any
|
|
33
|
+
* non-http(s) scheme, embedded credentials, an empty host, or a host that
|
|
34
|
+
* resolves to no records is rejected.
|
|
35
|
+
*/
|
|
36
|
+
const node_net_1 = require("node:net");
|
|
37
|
+
const promises_1 = require("node:dns/promises");
|
|
38
|
+
/** Thrown for every blocked URL — carries a machine-readable `reason` and, when
|
|
39
|
+
* the block was an address, the offending `address` (the resolved IP, not the
|
|
40
|
+
* hostname, so logs show what the name actually pointed at). */
|
|
41
|
+
class SsrfBlockedError extends Error {
|
|
42
|
+
constructor(reason, url, address, cause) {
|
|
43
|
+
super(`SSRF guard blocked ${url ?? 'request'}: ${reason}${address ? ` (${address})` : ''}`, cause !== undefined ? { cause } : undefined);
|
|
44
|
+
this.name = 'SsrfBlockedError';
|
|
45
|
+
this.reason = reason;
|
|
46
|
+
this.url = url;
|
|
47
|
+
this.address = address;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
exports.SsrfBlockedError = SsrfBlockedError;
|
|
51
|
+
/** The internal / non-routable ranges an outbound fetch must never reach.
|
|
52
|
+
* Mapped IPv4-in-IPv6 (`::ffff:a.b.c.d`) is matched by the IPv4 rules — verified
|
|
53
|
+
* in the spec — so we do NOT blanket-block `::ffff:0:0/96` (mapped *public*
|
|
54
|
+
* IPv4 is legitimate). */
|
|
55
|
+
function buildDefaultBlockList() {
|
|
56
|
+
const b = new node_net_1.BlockList();
|
|
57
|
+
// ── IPv4 ──
|
|
58
|
+
b.addSubnet('0.0.0.0', 8, 'ipv4'); // "this host" / 0.0.0.0
|
|
59
|
+
b.addSubnet('10.0.0.0', 8, 'ipv4'); // RFC1918 private
|
|
60
|
+
b.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC6598 CGNAT
|
|
61
|
+
b.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback
|
|
62
|
+
b.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local incl. cloud metadata 169.254.169.254
|
|
63
|
+
b.addSubnet('172.16.0.0', 12, 'ipv4'); // RFC1918 private (172.16–172.31)
|
|
64
|
+
b.addSubnet('192.0.0.0', 24, 'ipv4'); // IETF protocol assignments
|
|
65
|
+
b.addSubnet('192.168.0.0', 16, 'ipv4'); // RFC1918 private
|
|
66
|
+
b.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking
|
|
67
|
+
b.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved + 255.255.255.255 broadcast
|
|
68
|
+
// ── IPv6 ──
|
|
69
|
+
b.addAddress('::', 'ipv6'); // unspecified
|
|
70
|
+
b.addAddress('::1', 'ipv6'); // loopback
|
|
71
|
+
b.addSubnet('fc00::', 7, 'ipv6'); // unique-local (ULA)
|
|
72
|
+
b.addSubnet('fe80::', 10, 'ipv6'); // link-local
|
|
73
|
+
return b;
|
|
74
|
+
}
|
|
75
|
+
const DEFAULT_BLOCK_LIST = buildDefaultBlockList();
|
|
76
|
+
/** True if `ip` (a literal address) falls in a blocked range. Mapped IPv6 is
|
|
77
|
+
* normalized by BlockList; an unparseable string is treated as blocked. */
|
|
78
|
+
function isBlockedAddress(ip, blockList = DEFAULT_BLOCK_LIST) {
|
|
79
|
+
const fam = (0, node_net_1.isIP)(ip);
|
|
80
|
+
if (fam === 0)
|
|
81
|
+
return true; // not an IP → fail closed
|
|
82
|
+
return blockList.check(ip, fam === 6 ? 'ipv6' : 'ipv4');
|
|
83
|
+
}
|
|
84
|
+
const stripBrackets = (host) => host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
85
|
+
const defaultResolve = async (host) => (await (0, promises_1.lookup)(host, { all: true, verbatim: true })).map((r) => r.address);
|
|
86
|
+
/**
|
|
87
|
+
* Validate that `rawUrl` is safe to fetch. Rejects (throws `SsrfBlockedError`)
|
|
88
|
+
* a malformed URL, a disallowed scheme, embedded credentials, a missing host,
|
|
89
|
+
* a literal internal IP, or a host that resolves to ANY internal address.
|
|
90
|
+
* Returns the parsed URL and the validated address set on success.
|
|
91
|
+
*/
|
|
92
|
+
async function assertFetchableUrl(rawUrl, opts = {}) {
|
|
93
|
+
const protocols = opts.allowedProtocols ?? ['http:', 'https:'];
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(rawUrl);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new SsrfBlockedError('malformed-url', String(rawUrl));
|
|
100
|
+
}
|
|
101
|
+
if (!protocols.includes(url.protocol)) {
|
|
102
|
+
throw new SsrfBlockedError(`scheme-not-allowed:${url.protocol}`, url.href);
|
|
103
|
+
}
|
|
104
|
+
if (url.username !== '' || url.password !== '') {
|
|
105
|
+
throw new SsrfBlockedError('embedded-credentials', url.href);
|
|
106
|
+
}
|
|
107
|
+
const hostname = stripBrackets(url.hostname);
|
|
108
|
+
if (hostname === '')
|
|
109
|
+
throw new SsrfBlockedError('missing-host', url.href);
|
|
110
|
+
if (opts.allowPrivate) {
|
|
111
|
+
return { url, hostname, addresses: (0, node_net_1.isIP)(hostname) ? [hostname] : [] };
|
|
112
|
+
}
|
|
113
|
+
const blockList = opts.blockList ?? DEFAULT_BLOCK_LIST;
|
|
114
|
+
// Literal IP (covers IPv4, IPv6, and IPv4-mapped IPv6 like [::ffff:127.0.0.1]).
|
|
115
|
+
if ((0, node_net_1.isIP)(hostname) !== 0) {
|
|
116
|
+
if (isBlockedAddress(hostname, blockList)) {
|
|
117
|
+
throw new SsrfBlockedError('blocked-address', url.href, hostname);
|
|
118
|
+
}
|
|
119
|
+
return { url, hostname, addresses: [hostname] };
|
|
120
|
+
}
|
|
121
|
+
// DNS name → resolve and check EVERY record (defeats nip.io / rebinding).
|
|
122
|
+
const resolve = opts.resolve ?? defaultResolve;
|
|
123
|
+
let addresses;
|
|
124
|
+
try {
|
|
125
|
+
addresses = await resolve(hostname);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
throw new SsrfBlockedError('dns-resolution-failed', url.href, undefined, e);
|
|
129
|
+
}
|
|
130
|
+
if (addresses.length === 0)
|
|
131
|
+
throw new SsrfBlockedError('dns-no-records', url.href);
|
|
132
|
+
for (const addr of addresses) {
|
|
133
|
+
if (isBlockedAddress(addr, blockList)) {
|
|
134
|
+
throw new SsrfBlockedError('blocked-resolved-address', url.href, addr);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { url, hostname, addresses };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A `fetch` that validates the target — and re-validates every redirect hop —
|
|
141
|
+
* with the SSRF guard. Redirects are handled manually (`redirect:'manual'`) so
|
|
142
|
+
* a 3xx Location to an internal host is checked BEFORE it is followed, never
|
|
143
|
+
* chased blindly. Returns the final non-redirect `Response`.
|
|
144
|
+
*
|
|
145
|
+
* Note: each hop re-issues with the original `init` (method + body preserved —
|
|
146
|
+
* the security-relevant worst case for 307/308). A streamed request body cannot
|
|
147
|
+
* be replayed across hops.
|
|
148
|
+
*/
|
|
149
|
+
async function safeFetch(rawUrl, init = {}, opts = {}) {
|
|
150
|
+
const doFetch = opts.fetchImpl ?? globalThis.fetch;
|
|
151
|
+
const maxRedirects = opts.maxRedirects ?? 5;
|
|
152
|
+
let current = rawUrl;
|
|
153
|
+
for (let hop = 0;; hop++) {
|
|
154
|
+
const { url } = await assertFetchableUrl(current, opts); // re-validate THIS hop
|
|
155
|
+
const res = await doFetch(url, { ...init, redirect: 'manual' });
|
|
156
|
+
const location = res.headers.get('location');
|
|
157
|
+
if (res.status >= 300 && res.status < 400 && location) {
|
|
158
|
+
if (hop >= maxRedirects)
|
|
159
|
+
throw new SsrfBlockedError('too-many-redirects', url.href);
|
|
160
|
+
current = new URL(location, url).href; // resolve relative; loop re-validates next hop
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
return res;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A `dns.lookup`-compatible function that ONLY ever returns the pre-validated
|
|
168
|
+
* `addresses`. Pass it to `new http.Agent({ lookup })` or an undici
|
|
169
|
+
* `connect:{ lookup }` dispatcher so the socket connects to an address the
|
|
170
|
+
* guard already cleared — closing the DNS-rebinding TOCTOU between check and
|
|
171
|
+
* connect. Use the `addresses` from `assertFetchableUrl()`.
|
|
172
|
+
*/
|
|
173
|
+
function pinnedLookup(addresses) {
|
|
174
|
+
const recs = addresses.map((address) => ({
|
|
175
|
+
address,
|
|
176
|
+
family: (0, node_net_1.isIP)(address) === 6 ? 6 : 4,
|
|
177
|
+
}));
|
|
178
|
+
return (hostname, options, callback) => {
|
|
179
|
+
const cb = (typeof options === 'function' ? options : callback);
|
|
180
|
+
const all = typeof options === 'object' && options !== null && options.all === true;
|
|
181
|
+
if (recs.length === 0) {
|
|
182
|
+
cb(new Error(`SSRF guard: no pinned address for ${hostname}`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (all)
|
|
186
|
+
cb(null, recs);
|
|
187
|
+
else
|
|
188
|
+
cb(null, recs[0].address, recs[0].family);
|
|
189
|
+
};
|
|
190
|
+
}
|
package/gateway.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@lensmcp/cluster/gateway` — the PRODUCTION gateway's public, extendable surface.
|
|
3
|
+
*
|
|
4
|
+
* This is the **deployable edge** (`startProdGateway` — Fastify + undici reverse
|
|
5
|
+
* proxy, manifest-driven, the thing `Dockerfile.prod-gateway` runs). A consumer
|
|
6
|
+
* (e.g. tetros' `server/apps/gateway`) imports from here to run or extend it with
|
|
7
|
+
* its own providers/middleware and ship a specialized image.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ This is NOT the DEV cluster gateway. The dev gateway is the
|
|
10
|
+
* `@lensmcp/cluster:gateway` nx EXECUTOR (`gateway.lib.ts` → `startGateway`) — a
|
|
11
|
+
* local-only HTTPS front door on :443 that fronts unix-socket pods for
|
|
12
|
+
* development. The two share a routing core (`manifest.ts`) but are separate
|
|
13
|
+
* lifecycles: dev = an nx target you run locally; prod = a containerized service
|
|
14
|
+
* you deploy. Kept on a separate subpath so the build-time webpack-config exports
|
|
15
|
+
* in the main `@lensmcp/cluster` entry never pull in fastify/undici.
|
|
16
|
+
*/
|
|
17
|
+
export { startProdGateway, type ProdGatewayOptions, type ProdGatewayHandle, type ServiceStatus, } from './executors/gateway/prod-gateway.lib';
|
|
18
|
+
export { staticManifestProvider, endpointsPodProvider } from './executors/gateway/providers-prod';
|
|
19
|
+
export { createRegistryProviders, redisRegistrySource, type RegistrySource, type RedisLike, DEFAULT_REGISTRY_PREFIX, } from './executors/gateway/registry-source';
|
|
20
|
+
export { type RouteManifest, type RouteMount, type AuthMode, type AuthPolicy, type ManifestProvider, type PodProvider, type Upstream, type Route, manifestFromClusterDecl, } from './executors/gateway/manifest';
|
|
21
|
+
//# sourceMappingURL=gateway.d.ts.map
|
package/gateway.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/gateway.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAGH,OAAO,EACL,gBAAgB,EAChB,KAAK,kBAAkB,EACvB,KAAK,iBAAiB,EACtB,KAAK,aAAa,GACnB,MAAM,sCAAsC,CAAC;AAG9C,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAC;AAGlG,OAAO,EACL,uBAAuB,EACvB,mBAAmB,EACnB,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,uBAAuB,GACxB,MAAM,qCAAqC,CAAC;AAI7C,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,UAAU,EACf,KAAK,QAAQ,EACb,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,QAAQ,EACb,KAAK,KAAK,EACV,uBAAuB,GACxB,MAAM,8BAA8B,CAAC"}
|
package/gateway.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@lensmcp/cluster/gateway` — the PRODUCTION gateway's public, extendable surface.
|
|
4
|
+
*
|
|
5
|
+
* This is the **deployable edge** (`startProdGateway` — Fastify + undici reverse
|
|
6
|
+
* proxy, manifest-driven, the thing `Dockerfile.prod-gateway` runs). A consumer
|
|
7
|
+
* (e.g. tetros' `server/apps/gateway`) imports from here to run or extend it with
|
|
8
|
+
* its own providers/middleware and ship a specialized image.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ This is NOT the DEV cluster gateway. The dev gateway is the
|
|
11
|
+
* `@lensmcp/cluster:gateway` nx EXECUTOR (`gateway.lib.ts` → `startGateway`) — a
|
|
12
|
+
* local-only HTTPS front door on :443 that fronts unix-socket pods for
|
|
13
|
+
* development. The two share a routing core (`manifest.ts`) but are separate
|
|
14
|
+
* lifecycles: dev = an nx target you run locally; prod = a containerized service
|
|
15
|
+
* you deploy. Kept on a separate subpath so the build-time webpack-config exports
|
|
16
|
+
* in the main `@lensmcp/cluster` entry never pull in fastify/undici.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.manifestFromClusterDecl = exports.DEFAULT_REGISTRY_PREFIX = exports.redisRegistrySource = exports.createRegistryProviders = exports.endpointsPodProvider = exports.staticManifestProvider = exports.startProdGateway = void 0;
|
|
20
|
+
// The prod gateway engine + its option/handle contract.
|
|
21
|
+
var prod_gateway_lib_1 = require("./executors/gateway/prod-gateway.lib");
|
|
22
|
+
Object.defineProperty(exports, "startProdGateway", { enumerable: true, get: function () { return prod_gateway_lib_1.startProdGateway; } });
|
|
23
|
+
// Static (in-memory) providers — build a gateway from explicit manifests/endpoints.
|
|
24
|
+
var providers_prod_1 = require("./executors/gateway/providers-prod");
|
|
25
|
+
Object.defineProperty(exports, "staticManifestProvider", { enumerable: true, get: function () { return providers_prod_1.staticManifestProvider; } });
|
|
26
|
+
Object.defineProperty(exports, "endpointsPodProvider", { enumerable: true, get: function () { return providers_prod_1.endpointsPodProvider; } });
|
|
27
|
+
// Redis-backed registry providers — the prod path (manifests/rollout read live from Redis).
|
|
28
|
+
var registry_source_1 = require("./executors/gateway/registry-source");
|
|
29
|
+
Object.defineProperty(exports, "createRegistryProviders", { enumerable: true, get: function () { return registry_source_1.createRegistryProviders; } });
|
|
30
|
+
Object.defineProperty(exports, "redisRegistrySource", { enumerable: true, get: function () { return registry_source_1.redisRegistrySource; } });
|
|
31
|
+
Object.defineProperty(exports, "DEFAULT_REGISTRY_PREFIX", { enumerable: true, get: function () { return registry_source_1.DEFAULT_REGISTRY_PREFIX; } });
|
|
32
|
+
// Manifest/route types + the cluster-decl → manifest converter (derive prod
|
|
33
|
+
// manifests from the SAME project.json `cluster` declarations dev uses).
|
|
34
|
+
var manifest_1 = require("./executors/gateway/manifest");
|
|
35
|
+
Object.defineProperty(exports, "manifestFromClusterDecl", { enumerable: true, get: function () { return manifest_1.manifestFromClusterDecl; } });
|
package/main.devserver.js
CHANGED
|
@@ -600,7 +600,10 @@ else {
|
|
|
600
600
|
}
|
|
601
601
|
if (route?.target) {
|
|
602
602
|
applyGatewayMiddleware(req);
|
|
603
|
-
|
|
603
|
+
// autoRewrite ONLY for a string (external URL) target: a route may target a child socketPath
|
|
604
|
+
// OBJECT, and http-proxy's setRedirectHostRewrite does url.parse(options.target) on ANY 3xx —
|
|
605
|
+
// which throws on an object and crashes the gateway (e.g. proxying the auth IdP's /authorize → login).
|
|
606
|
+
proxy.web(req, res, { target: route.target, autoRewrite: typeof route.target === 'string', changeOrigin: true }, (err) => {
|
|
604
607
|
console.error(`[parent] proxy error to ${route.target}:`, err);
|
|
605
608
|
res.statusCode = 502;
|
|
606
609
|
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
@@ -630,7 +633,12 @@ else {
|
|
|
630
633
|
}
|
|
631
634
|
proxy.web(req, res, {
|
|
632
635
|
target: { socketPath: targetChild.socketPath },
|
|
633
|
-
autoRewrite:
|
|
636
|
+
// NO autoRewrite for a socketPath target: it's an OBJECT, and http-proxy's
|
|
637
|
+
// setRedirectHostRewrite does url.parse(options.target) on ANY 3xx response — which throws
|
|
638
|
+
// ("url must be a string, received Object") and CRASHES the gateway the moment a child
|
|
639
|
+
// returns a redirect (e.g. the auth IdP's /authorize → login). Host-rewrite is meaningless
|
|
640
|
+
// for a socket target anyway (no host to match), and our services emit ABSOLUTE external
|
|
641
|
+
// redirect URLs, so no rewrite is needed. (autoRewrite stays on the string-target route above.)
|
|
634
642
|
headers: {
|
|
635
643
|
'x-proxy-child-id': String(targetChild.id) // for logging/debugging
|
|
636
644
|
}
|
|
@@ -722,7 +730,8 @@ else {
|
|
|
722
730
|
}
|
|
723
731
|
proxy.web(req, res, {
|
|
724
732
|
target: { socketPath: targetChild.socketPath },
|
|
725
|
-
autoRewrite
|
|
733
|
+
// NO autoRewrite — a socketPath target crashes http-proxy's setRedirectHostRewrite on a 3xx
|
|
734
|
+
// (url.parse of the target OBJECT). See the public-port handler above.
|
|
726
735
|
}, (err) => {
|
|
727
736
|
targetChild.healthy = false;
|
|
728
737
|
targetChild.lastError = String(err?.stack || err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmcp/cluster",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@lensmcp/node-instrumentation": "1.
|
|
69
|
-
"@lensmcp/nx-plugin": "1.
|
|
68
|
+
"@lensmcp/node-instrumentation": "1.11.0",
|
|
69
|
+
"@lensmcp/nx-plugin": "1.11.0",
|
|
70
70
|
"fork-ts-checker-webpack-plugin": "^9.0.0",
|
|
71
71
|
"glob": "^11.0.0",
|
|
72
72
|
"http-proxy": "^1.18.0",
|