@lensmcp/cluster 1.21.2 → 1.21.4
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/create-webpack-dev.d.ts +45 -0
- package/create-webpack-dev.js +1 -1
- package/executors/gateway/body-stall.d.ts +37 -0
- package/executors/gateway/body-stall.js +1 -0
- package/executors/gateway/canonical-path.d.ts +65 -0
- package/executors/gateway/canonical-path.js +1 -0
- package/executors/gateway/client-ip.d.ts +44 -0
- package/executors/gateway/client-ip.js +1 -0
- package/executors/gateway/jwks-verify.js +1 -1
- package/executors/gateway/main.prod-gateway.js +2 -2
- package/executors/gateway/manifest.d.ts +46 -4
- package/executors/gateway/manifest.js +1 -1
- package/executors/gateway/prod-config.d.ts +74 -0
- package/executors/gateway/prod-config.js +1 -0
- package/executors/gateway/prod-runtime/access-log.js +1 -1
- package/executors/gateway/prod-runtime/app.d.ts +6 -0
- package/executors/gateway/prod-runtime/app.js +1 -1
- package/executors/gateway/prod-runtime/auth.d.ts +1 -1
- package/executors/gateway/prod-runtime/auth.js +1 -1
- package/executors/gateway/prod-runtime/body-stall.d.ts +5 -0
- package/executors/gateway/prod-runtime/body-stall.js +1 -0
- package/executors/gateway/prod-runtime/cors.js +1 -1
- package/executors/gateway/prod-runtime/edge.d.ts +19 -3
- package/executors/gateway/prod-runtime/edge.js +1 -1
- package/executors/gateway/prod-runtime/handler.js +1 -1
- package/executors/gateway/prod-runtime/rollout.d.ts +11 -4
- package/executors/gateway/prod-runtime/rollout.js +1 -1
- package/executors/gateway/prod-runtime/routing.js +1 -1
- package/executors/gateway/prod-runtime/server.js +1 -1
- package/executors/gateway/prod-runtime/types.d.ts +30 -3
- package/executors/gateway/prod-runtime/upgrade.d.ts +25 -0
- package/executors/gateway/prod-runtime/upgrade.js +10 -2
- package/executors/gateway/prod-runtime/upstream.d.ts +3 -1
- package/executors/gateway/prod-runtime/upstream.js +1 -1
- package/executors/gateway/providers-prod.d.ts +13 -4
- package/executors/gateway/providers-prod.js +1 -1
- package/executors/gateway/registry-source.js +1 -1
- package/executors/gateway/rollout-ops.js +2 -2
- package/executors/gateway/runtime/auth.d.ts +6 -1
- package/executors/gateway/runtime/auth.js +1 -1
- package/executors/gateway/runtime/control.js +2 -1
- package/executors/gateway/runtime/dev-auth.d.ts +6 -2
- package/executors/gateway/runtime/dev-auth.js +1 -1
- package/executors/gateway/runtime/discovery.js +1 -1
- package/executors/gateway/runtime/edge.d.ts +19 -0
- package/executors/gateway/runtime/edge.js +1 -1
- package/executors/gateway/runtime/handler.js +1 -1
- package/executors/gateway/runtime/lens-children.d.ts +7 -0
- package/executors/gateway/runtime/lens-children.js +1 -1
- package/executors/gateway/runtime/lifecycle.d.ts +69 -7
- package/executors/gateway/runtime/lifecycle.js +3 -3
- package/executors/gateway/runtime/pod-probe.d.ts +37 -0
- package/executors/gateway/runtime/pod-probe.js +1 -0
- package/executors/gateway/runtime/proxy.js +1 -1
- package/executors/gateway/runtime/scope.d.ts +15 -0
- package/executors/gateway/runtime/scope.js +1 -1
- package/executors/gateway/runtime/server.js +2 -2
- package/executors/gateway/runtime/service-keys.d.ts +42 -1
- package/executors/gateway/runtime/service-keys.js +1 -1
- package/executors/gateway/runtime/types.d.ts +193 -1
- package/executors/gateway/runtime/types.js +1 -1
- package/executors/gateway/runtime/upgrade.js +1 -1
- package/executors/gateway/runtime/workspace-log.d.ts +7 -0
- package/executors/gateway/runtime/workspace-log.js +1 -0
- package/main.devserver.js +7 -7
- package/package.json +4 -4
package/create-webpack-dev.d.ts
CHANGED
|
@@ -23,4 +23,49 @@ export interface DevWebpackOptions {
|
|
|
23
23
|
/** Parent terminates TLS (gateway.https) — reload notifications go over https. */
|
|
24
24
|
httpsReload?: boolean;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Notifies the devserver parent on every successful rebuild (`POST /webpack/reload` → rolling hot-swap of the
|
|
28
|
+
* pod children) AND stamps the pod's WATCHER TESTIMONY (`POST /webpack/watching`): every `watchRun`, `done`,
|
|
29
|
+
* `failed` and `watchClose`, plus any watchpack error. The parent relays the latest testimony to its children,
|
|
30
|
+
* which serve it in `/__lensmcp/alive`; the gateway's pod sweeper reads it before a `stale-source-set` recycle
|
|
31
|
+
* (`lifecycle.ts` `podWatcherVerdict`). Without it the sweeper had NO evidence and scaled every pod to zero on
|
|
32
|
+
* every source-set change in its scope (issues/dev-gateway/07). Both posts are best-effort: a parent that
|
|
33
|
+
* predates `/webpack/watching` answers 404 and the plugin stays quiet.
|
|
34
|
+
*/
|
|
35
|
+
export declare class DevServerReloadPlugin {
|
|
36
|
+
private base;
|
|
37
|
+
private secure;
|
|
38
|
+
/** The devserver parent's CONTROL socket — preferred over the TCP port (see `post`). */
|
|
39
|
+
private controlSock;
|
|
40
|
+
/** Bounded, newest-last paths of the last few `watchRun`s — diagnostics for the register, not policy. */
|
|
41
|
+
private static readonly RECENT_PATH_CAP;
|
|
42
|
+
private testimony;
|
|
43
|
+
constructor(port: number, secure?: boolean, controlSock?: string);
|
|
44
|
+
/**
|
|
45
|
+
* Deliver to the parent's CONTROL SOCKET first (`$TMPDIR[/<wsKey>]/<service>-devserver/parent.sock`), then
|
|
46
|
+
* the TCP port. The socket path is bind-STOLEN by whichever parent generation started last, so during a
|
|
47
|
+
* rolling recycle (lensmcp issues/dev-gateway/09) this webpack's rebuilds reach ITS OWN parent even while the
|
|
48
|
+
* retiring generation still holds the TCP port; the port is the fallback for a parent without the socket.
|
|
49
|
+
*/
|
|
50
|
+
private post;
|
|
51
|
+
/** The current testimony snapshot (exported for the spec — the wire shape the parent receives). */
|
|
52
|
+
snapshot(): PodWatcherTestimonyWire;
|
|
53
|
+
private stampWatchRun;
|
|
54
|
+
private degrade;
|
|
55
|
+
private tellWatching;
|
|
56
|
+
apply(compiler: any): void;
|
|
57
|
+
}
|
|
58
|
+
/** The devserver parent's control socket for `serviceName` — the SAME derivation as `main.devserver.ts`'s
|
|
59
|
+
* `SOCK_DIR` (`$TMPDIR[/<LENSMCP_WS_KEY>]/<service>-devserver/parent.sock`). */
|
|
60
|
+
export declare function devserverControlSock(serviceName: string, env?: NodeJS.ProcessEnv): string;
|
|
61
|
+
/** Wire shape of the testimony (mirrors `PodWatcherTestimony` in the gateway runtime; kept structural so the
|
|
62
|
+
* webpack side does not import the gateway's types). */
|
|
63
|
+
export interface PodWatcherTestimonyWire {
|
|
64
|
+
schemaVersion: 1;
|
|
65
|
+
lastWatchRunAt: number;
|
|
66
|
+
lastBuildAt: number;
|
|
67
|
+
buildsSinceSpawn: number;
|
|
68
|
+
degraded: false | string;
|
|
69
|
+
recentPaths: string[];
|
|
70
|
+
}
|
|
26
71
|
export declare function createDevWebpackConfig(options: DevWebpackOptions): Record<string, any>;
|
package/create-webpack-dev.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var
|
|
1
|
+
"use strict";var R=Object.defineProperty;var d=(n,t)=>R(n,"name",{value:t,configurable:!0});var T=Object.defineProperty,u=d((n,t)=>T(n,"name",{value:t,configurable:!0}),"u");Object.defineProperty(exports,"__esModule",{value:!0}),exports.DevServerReloadPlugin=void 0,exports.devserverControlSock=devserverControlSock,exports.createDevWebpackConfig=createDevWebpackConfig;const tslib_1=require("tslib"),app_plugin_1=require("@nx/webpack/app-plugin"),fs=tslib_1.__importStar(require("node:fs")),os=tslib_1.__importStar(require("node:os")),path=tslib_1.__importStar(require("node:path")),webpack_node_externals_1=tslib_1.__importDefault(require("webpack-node-externals")),build_scope_patterns_1=require("./build-scope-patterns"),tsgo_check_plugin_1=require("./tsgo-check-plugin"),webpack=require("webpack");class DevServerReloadPlugin{static{d(this,"DevServerReloadPlugin")}static{u(this,"DevServerReloadPlugin")}static{this.RECENT_PATH_CAP=32}constructor(t,o=!1,r){this.testimony={schemaVersion:1,lastWatchRunAt:0,lastBuildAt:0,buildsSinceSpawn:0,degraded:!1,recentPaths:[]},this.secure=o,this.base=`${o?"https":"http"}://localhost:${t}`,this.controlSock=r}post(t,o){const r=JSON.stringify(o),s=u(()=>new Promise((a,c)=>{const l=(this.secure?require("node:https"):require("node:http")).request(`${this.base}${t}`,{method:"POST",headers:{"content-type":"application/json"},rejectUnauthorized:!1},p=>{p.resume(),p.on("end",a)});l.on("error",c),l.end(r)}),"viaTcp"),i=this.controlSock;return!i||!fs.existsSync(i)?s():new Promise((a,c)=>{const l=require("node:http").request({socketPath:i,path:t,method:"POST",headers:{"content-type":"application/json"}},p=>{p.resume(),p.on("end",a)});l.on("error",c),l.end(r)}).catch(s)}snapshot(){return{...this.testimony,recentPaths:[...this.testimony.recentPaths]}}stampWatchRun(t){if(this.testimony.lastWatchRunAt=Date.now(),t.length){const o=[...this.testimony.recentPaths,...t];this.testimony.recentPaths=o.slice(-DevServerReloadPlugin.RECENT_PATH_CAP)}}degrade(t){this.testimony.degraded=t}tellWatching(){this.post("/webpack/watching",this.testimony).catch(()=>{})}apply(t){const o="DevServerReloadPlugin";let r=!1;t.hooks.watchRun?.tap(o,s=>{const i=[...s?.modifiedFiles??[],...s?.removedFiles??[]].filter(a=>typeof a=="string");if(this.stampWatchRun(i),!r){const a=s?.watchFileSystem?.watcher;a&&typeof a.on=="function"&&(r=!0,a.on("error",c=>{this.degrade(`watcher error: ${c?.message??String(c)}`),this.tellWatching()}))}this.tellWatching()}),t.hooks.watchClose?.tap(o,()=>{this.degrade("watch closed"),this.tellWatching()}),t.hooks.failed?.tap(o,s=>{this.degrade(`compiler failed: ${s?.message??String(s)}`),this.tellWatching()}),t.hooks.done.tap(o,async s=>{try{if(s.hasErrors()){this.tellWatching();return}this.testimony.lastBuildAt=Date.now(),this.testimony.buildsSinceSpawn+=1,await this.post("/webpack/reload",{watcher:this.testimony}),console.log("[DevServerReloadPlugin] notified devserver to reload")}catch{console.log("[DevServerReloadPlugin] devserver not running yet")}})}}exports.DevServerReloadPlugin=DevServerReloadPlugin;function devserverControlSock(n,t=process.env){const o=t.LENSMCP_WS_KEY||"",r=o?path.join(os.tmpdir(),o,`${n}-devserver`):path.join(os.tmpdir(),`${n}-devserver`);return path.join(r,"parent.sock")}d(devserverControlSock,"devserverControlSock"),u(devserverControlSock,"devserverControlSock");function createDevWebpackConfig(n){const{appRoot:t,outputDir:o,main:r,tsConfig:s,workspaceRoot:i,assets:a=[],port:c,memoryLimit:l=8192,buildLibsFromSource:p=!0,orgScopes:b=[],bundlePackages:y=[],additionalEntryPoints:k=[],nodeExternalsConfig:m,webpackConfigPath:v}=n,{allowlistPatterns:C,scopePrefixes:E,scopePatterns:N}=(0,build_scope_patterns_1.buildScopePatterns)(b),P=process.env.LENSMCP_TYPECHECK,S=P==="tsgo"?(0,tsgo_check_plugin_1.resolveTsgoBin)(i,process.env.LENSMCP_TSGO_BIN):null;if(P==="tsgo"&&!S){const e=`${tsgo_check_plugin_1.TSGO_MISSING_CAUSE}. Fell back to fork-ts-checker, which reports to the console only \u2014 so typecheck:// has no producer for this project until it is fixed`;console.warn(`[serve] ${n.appName}: LENSMCP_TYPECHECK=tsgo but ${e}.`),(0,tsgo_check_plugin_1.reportTsgoUnavailable)({appName:n.appName,tsConfig:s,workspaceRoot:i,detail:e})}const f=!!S,_=y.map(e=>new RegExp(`^${e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}(/|$)`)),W=[/webpack\/hot\/poll\?100/,...C,..._,...m?.allowlist||[]],w={output:{path:o,...process.env.NODE_ENV!=="production"&&{devtoolModuleFilenameTemplate:"[absolute-resource-path]"},clean:!0},externals:[(0,webpack_node_externals_1.default)({allowlist:W,additionalModuleDirs:m?.additionalModuleDirs||[],...m?.importType&&{importType:m.importType}}),({request:e},g)=>{if(e&&path.isAbsolute(e)&&e.startsWith(i)&&!e.includes("node_modules")||e&&(e.startsWith(path.join(i,"apps"))||e.startsWith(path.join(i,"libs")))||e&&E.some(h=>e.startsWith(h))||e&&N.some(h=>h.test(e))||e&&_.some(h=>h.test(e)))return g();if(e&&!(e.startsWith("./")||e.startsWith(".."))){const h=e.startsWith("@")?e.split("/").slice(0,2).join("/"):e.split("/")[0];return fs.existsSync(path.join(i,"node_modules",h))?g(null,`commonjs ${e}`):g()}return g()}],mode:"development",devtool:"eval-cheap-module-source-map",plugins:[new webpack.HotModuleReplacementPlugin,new app_plugin_1.NxAppWebpackPlugin({target:"node22",compiler:"tsc",main:r,additionalEntryPoints:k,verbose:!0,sourceMap:"eval-cheap-module-source-map",mergeExternals:!0,externalDependencies:[],memoryLimit:l,tsConfig:s,assets:a,optimization:!1,progress:!1,outputHashing:"none",generatePackageJson:!1,watchDependencies:!0,typeCheckOptions:process.env.LENSMCP_TYPECHECK==="off"||f?!1:{async:!0},buildLibsFromSource:p}),new DevServerReloadPlugin(c,n.httpsReload===!0,devserverControlSock(n.serviceName)),...f?[new tsgo_check_plugin_1.TsgoCheckPlugin({appName:n.appName,tsConfig:s,workspaceRoot:i,...process.env.LENSMCP_TSGO_BIN?{binPath:process.env.LENSMCP_TSGO_BIN}:{}})]:[]],snapshot:{managedPaths:[/^(.+?[\\/])?node_modules[\\/]/]},watch:!0,watchOptions:{ignored:["**/*.env.template","**/config.template.json","**/*.md","**/dist/**","**/migrations/**"]},cache:{type:"filesystem"}};if(v){const e=require(path.resolve(t,v));return(e.default||e)(w)}return w}d(createDevWebpackConfig,"createDevWebpackConfig"),u(createDevWebpackConfig,"createDevWebpackConfig");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bidirectional progress bound — SHARED by both gateways (lifted from `prod-runtime/body-stall.ts`;
|
|
3
|
+
* the dev proxy arms the same guard on its h1 and h2 paths, issues/dev-gateway/12).
|
|
4
|
+
*
|
|
5
|
+
* `requestTimeout` is deliberately 0 on this proxy — a hard request deadline would cut the
|
|
6
|
+
* long-lived streaming (SSE, streamable-HTTP MCP) it exists to carry. But `headersTimeout`
|
|
7
|
+
* only ever bounded HEADERS, and undici's `bodyTimeout` is 0 too, so nothing bounded a
|
|
8
|
+
* request BODY: a client could send headers, have the gateway dispatch upstream, and then
|
|
9
|
+
* dribble one byte a minute — holding one of the 256 per-origin pool connections for as long
|
|
10
|
+
* as it liked. 256 of those and a service is unreachable, from one unauthenticated client.
|
|
11
|
+
*
|
|
12
|
+
* The bound here is on STALL, not duration: a request is killed only when it has transferred
|
|
13
|
+
* NOTHING for the whole window. A slow-but-progressing upload is untouched, and a request
|
|
14
|
+
* whose body has already been fully received is never watched at all — so an indefinitely
|
|
15
|
+
* open SSE *response* is unaffected.
|
|
16
|
+
*
|
|
17
|
+
* Progress is read from `socket.bytesRead` rather than a `data` listener, because attaching
|
|
18
|
+
* one would put the request stream into flowing mode and steal the body from reply-from.
|
|
19
|
+
*
|
|
20
|
+
* The RESPONSE direction needs the same bound for the same reason. A client that requests a
|
|
21
|
+
* large body and then stops reading applies backpressure all the way up the pipe, and the
|
|
22
|
+
* undici pool connection serving it stays busy for as long as the client cares to wait — 256
|
|
23
|
+
* of those (the default per-origin pool) make a service unreachable for everyone else, from
|
|
24
|
+
* one unauthenticated client. `armResponse` watches for that, and it is a STALL bound too:
|
|
25
|
+
* a response is killed only while bytes are queued in the socket AND none have moved for the
|
|
26
|
+
* whole window, so a client that is draining slowly is untouched and an idle SSE stream (with
|
|
27
|
+
* nothing queued) is never even a candidate.
|
|
28
|
+
*/
|
|
29
|
+
import type * as http from 'node:http';
|
|
30
|
+
export interface BodyStallGuard {
|
|
31
|
+
/** Watch a request that is still receiving a body. No-op for a body-less request. */
|
|
32
|
+
arm(req: http.IncomingMessage): void;
|
|
33
|
+
/** Watch a response for a client that has stopped draining it. */
|
|
34
|
+
armResponse(res: http.ServerResponse): void;
|
|
35
|
+
stop(): void;
|
|
36
|
+
}
|
|
37
|
+
export declare function createBodyStallGuard(stallMs: number): BodyStallGuard;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var u=Object.defineProperty;var a=(r,s)=>u(r,"name",{value:s,configurable:!0});var y=Object.defineProperty,c=a((r,s)=>y(r,"name",{value:s,configurable:!0}),"c");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createBodyStallGuard=createBodyStallGuard;const OFF={arm:c(()=>{},"arm"),armResponse:c(()=>{},"armResponse"),stop:c(()=>{},"stop")};function createBodyStallGuard(r){if(!Number.isFinite(r)||r<=0)return OFF;const s=new Map,i=new Map,l=Math.max(500,Math.min(r,5e3)),d=setInterval(c(()=>{if(s.size===0&&i.size===0)return;const e=Date.now();for(const[t,n]of s){if(t.complete||t.destroyed||!t.socket||t.socket.destroyed){s.delete(t);continue}const o=t.socket.bytesRead;if(o!==n.bytes){n.bytes=o,n.since=e;continue}e-n.since>=r&&(s.delete(t),t.socket.destroy())}for(const[t,n]of i){const o=t.socket;if(t.writableEnded||t.destroyed||!o||o.destroyed){i.delete(t);continue}if(o.writableLength===0){n.bytes=o.bytesWritten,n.since=e;continue}if(o.bytesWritten!==n.bytes){n.bytes=o.bytesWritten,n.since=e;continue}e-n.since>=r&&(i.delete(t),o.destroy())}},"tick"),l);return d.unref?.(),{arm(e){const t=e.headers["content-length"];if(!(String(e.headers["transfer-encoding"]??"").toLowerCase().includes("chunked")||typeof t=="string"&&Number(t)>0)||e.complete||!e.socket)return;s.set(e,{bytes:e.socket.bytesRead,since:Date.now()});const n=c(()=>{s.delete(e)},"done");e.on("end",n),e.on("close",n),e.on("aborted",n)},armResponse(e){const t=e.socket;if(!t||e.writableEnded||e.destroyed)return;i.set(e,{bytes:t.bytesWritten,since:Date.now()});const n=c(()=>{i.delete(e)},"done");e.on("finish",n),e.on("close",n)},stop(){clearInterval(d),s.clear(),i.clear()}}}a(createBodyStallGuard,"createBodyStallGuard"),c(createBodyStallGuard,"createBodyStallGuard");
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE request-path canonicalizer — shared by BOTH gateways.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `prod-runtime/edge.ts` (prod-gateway-2 #01/#03/#04/#12) so the dev gateway can stop
|
|
5
|
+
* routing, authorising and forwarding the RAW `req.url` (issues/dev-gateway/11: `/./admin`, an
|
|
6
|
+
* absolute-form target and `/..;/admin` all skipped dev's path policy while prod had been fixed).
|
|
7
|
+
* `prod-runtime/edge.ts` and `runtime/edge.ts` re-export everything here, so each pipeline keeps its
|
|
8
|
+
* import surface and neither can drift from the other.
|
|
9
|
+
*/
|
|
10
|
+
import type * as http from 'node:http';
|
|
11
|
+
export declare const ENCODED_STRUCTURE: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* The ONE canonical form of a request target. Everything downstream — mount matching, the auth
|
|
14
|
+
* override, the ABAC `path` attribute, the forwarded URL and the WebSocket request line — is
|
|
15
|
+
* derived from this, so the gateway can no longer authorise one string and forward another.
|
|
16
|
+
*
|
|
17
|
+
* Two representations, because they answer different questions:
|
|
18
|
+
* - `path` keeps the ORIGINAL percent-encoding, so what reaches the upstream is byte-equivalent
|
|
19
|
+
* to what the client asked for (decoding and re-encoding would turn `%3F` into a real `?`).
|
|
20
|
+
* - `policy` is the same path DECODED, so `/%61dmin` is judged as `/admin`.
|
|
21
|
+
*/
|
|
22
|
+
export interface CanonicalTarget {
|
|
23
|
+
/** Canonical path, original encoding preserved — routed and forwarded. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Canonical path, decoded — matched against auth overrides and ABAC rules. */
|
|
26
|
+
policy: string;
|
|
27
|
+
/** The query string including `?`, exactly as received (never re-encoded), or ''. */
|
|
28
|
+
query: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Canonicalize a request target, or `undefined` if it is unsafe (→ 400 / drop).
|
|
32
|
+
*
|
|
33
|
+
* Three rejections and two collapses, each one a fixed bug:
|
|
34
|
+
* - an ABSOLUTE-form target (`GET http://host/admin`) is refused. Node leaves the scheme and
|
|
35
|
+
* authority in `req.url`, `find-my-way` strips them only for its own lookup, and
|
|
36
|
+
* `@fastify/reply-from` accepts an absolute source whose origin matches the route — so the
|
|
37
|
+
* segments the override was matched against were `['http:', 'host', 'admin']` and the
|
|
38
|
+
* upstream still got `/admin`.
|
|
39
|
+
* - matrix parameters and the fragment are stripped BEFORE any segment is judged, so
|
|
40
|
+
* `/..;/admin` cannot hide a traversal behind a parameter (it used to survive the `..`
|
|
41
|
+
* test and then have the `;x` trimmed off, yielding `/../admin`).
|
|
42
|
+
* - a `..` segment is rejected, never resolved.
|
|
43
|
+
* - empty (`//`) and single-dot (`/./`) segments collapse, because the upstream's own URL
|
|
44
|
+
* parsing collapses them and the policy matcher already ignored them — the gateway used to
|
|
45
|
+
* authorise `/./admin` and forward `/admin`.
|
|
46
|
+
* - a trailing slash is PRESERVED (RFC 3986 §5.2.4: `/a/.` resolves to `/a/`), since
|
|
47
|
+
* `/a` and `/a/` are different resources to many backends.
|
|
48
|
+
*/
|
|
49
|
+
export declare const canonicalize: (rawUrl: string) => CanonicalTarget | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Where the request target AS RECEIVED is kept, when canonicalization changed it.
|
|
52
|
+
*
|
|
53
|
+
* The handler replaces `req.url` with the canonical form so routing, policy and the wire cannot
|
|
54
|
+
* diverge — but that form is also what the access log reads, and an operator investigating a
|
|
55
|
+
* probe needs to see what the client actually sent: `/./admin` and `/admin` are the same line in
|
|
56
|
+
* the log otherwise, and the attempt disappears. Set ONLY when the two differ, so ordinary
|
|
57
|
+
* traffic carries no extra work and no extra field.
|
|
58
|
+
*/
|
|
59
|
+
export declare const RAW_TARGET: unique symbol;
|
|
60
|
+
/** The target as received, if canonicalization rewrote it. */
|
|
61
|
+
export declare const rawTargetOf: (req: http.IncomingMessage) => string | undefined;
|
|
62
|
+
/** Remember the target as received, for the access log. */
|
|
63
|
+
export declare const rememberRawTarget: (req: http.IncomingMessage, raw: string) => void;
|
|
64
|
+
/** The decoded canonical path for auth matching, or undefined if the path is unsafe. */
|
|
65
|
+
export declare const canonicalPath: (rawUrl: string) => string | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var x=Object.defineProperty;var f=(e,t)=>x(e,"name",{value:t,configurable:!0});var u=Object.defineProperty,r=f((e,t)=>u(e,"name",{value:t,configurable:!0}),"r");Object.defineProperty(exports,"__esModule",{value:!0}),exports.canonicalPath=exports.rememberRawTarget=exports.rawTargetOf=exports.RAW_TARGET=exports.canonicalize=exports.ENCODED_STRUCTURE=void 0,exports.ENCODED_STRUCTURE=/%2e|%2f|%5c|%00|\\/i;const NEEDS_NORMALIZE=/[%;#\\]|\/\/|(?:^|\/)\.{1,2}(?:\/|$)/,canonicalize=r(e=>{if(e.charCodeAt(0)!==47)return;const t=e.indexOf("?"),a=t===-1?e:e.slice(0,t),s=t===-1?"":e.slice(t);if(a.indexOf("\0")!==-1)return;if(!NEEDS_NORMALIZE.test(a))return{path:a,policy:a,query:s};if(exports.ENCODED_STRUCTURE.test(a))return;const c=a.split("#")[0].split("/").map(n=>n.split(";")[0]),i=[],p=[];for(const n of c){let o;try{o=decodeURIComponent(n)}catch{return}if(o.indexOf("\0")!==-1)return;if(!(o===""||o===".")){if(o==="..")return;i.push(n),p.push(o)}}const l=c[c.length-1],T=i.length>0&&(l===""||l===".")?"/":"";return{path:"/"+i.join("/")+T,policy:"/"+p.join("/")+T,query:s}},"canonicalize");exports.canonicalize=canonicalize,exports.RAW_TARGET=Symbol.for("lensmcp.gateway.rawTarget");const rawTargetOf=r(e=>e[exports.RAW_TARGET],"rawTargetOf");exports.rawTargetOf=rawTargetOf;const rememberRawTarget=r((e,t)=>{e[exports.RAW_TARGET]=t},"rememberRawTarget");exports.rememberRawTarget=rememberRawTarget;const canonicalPath=r(e=>(0,exports.canonicalize)(e)?.policy,"canonicalPath");exports.canonicalPath=canonicalPath;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE client-address resolver — shared by BOTH gateways.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `prod-runtime/rollout.ts` (prod-gateway-2 #05 + #11) so the dev gateway resolves the
|
|
5
|
+
* caller the same way: the socket peer by default; a `clientIpHeader` only when the peer is a trusted
|
|
6
|
+
* front proxy; `X-Forwarded-For` counted from the RIGHT by a trusted hop count, never the leftmost entry
|
|
7
|
+
* (the one a client can always write itself). `forwardedFor` is its outbound twin: append to a trusted
|
|
8
|
+
* chain, REPLACE an untrusted one — so an upstream is never told a story about who is calling it.
|
|
9
|
+
*
|
|
10
|
+
* Dev used the raw socket peer for the ABAC `ip` attribute and passed the client's own `X-Forwarded-For`
|
|
11
|
+
* through (h2 path appended to it; h1 path forwarded it verbatim), so behind a front proxy (the Caddy the
|
|
12
|
+
* workspace once ran on :443) an `ip` rule saw the proxy and a client could prepend any chain it liked
|
|
13
|
+
* (issues/dev-gateway/11, the resolver half).
|
|
14
|
+
*/
|
|
15
|
+
import type * as http from 'node:http';
|
|
16
|
+
export interface ClientIpOptions {
|
|
17
|
+
/** A front proxy's client-address header (e.g. `cf-connecting-ip`), trusted only from a trusted peer. */
|
|
18
|
+
clientIpHeader?: string;
|
|
19
|
+
/** CIDRs of peers allowed to assert `clientIpHeader`. Empty ⇒ loopback only (a co-located sidecar). */
|
|
20
|
+
clientIpTrustedProxies?: readonly string[];
|
|
21
|
+
/** How many proxy hops in front of us are trusted to have appended to `X-Forwarded-For`. 0 = none. */
|
|
22
|
+
trustProxyHops?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ClientIpResolver {
|
|
25
|
+
/** The authoritative client address for `req`, or `fallback` (normally the socket peer). */
|
|
26
|
+
clientIpOf(req: http.IncomingMessage, fallback: string): string;
|
|
27
|
+
/** The `X-Forwarded-For` to send upstream: trusted chain + peer, or just the peer. */
|
|
28
|
+
forwardedFor(req: http.IncomingMessage, peer: string): string;
|
|
29
|
+
/** Is this peer address a trusted front proxy? */
|
|
30
|
+
peerTrusted(peer?: string): boolean;
|
|
31
|
+
readonly trustProxyHops: number;
|
|
32
|
+
}
|
|
33
|
+
/** An IPv4-mapped IPv6 peer (`::ffff:127.0.0.1`, what a dual-stack listener reports) → its IPv4 form. */
|
|
34
|
+
export declare const normalizePeer: (addr: string) => string;
|
|
35
|
+
/** `1.2.3.4:51234` (an X-Forwarded-For port suffix) → `1.2.3.4`. IPv6 keeps its colons unless bracketed. */
|
|
36
|
+
export declare const stripPort: (v: string) => string;
|
|
37
|
+
export declare function createClientIpResolver(opts?: ClientIpOptions): ClientIpResolver;
|
|
38
|
+
/**
|
|
39
|
+
* The dev gateway's knobs (the prod gateway reads its `LENSMCP_GW_*` twins in `prod-config.ts`):
|
|
40
|
+
* - `LENSMCP_TRUST_PROXY` — trusted `X-Forwarded-For` hop count (default 0: the socket peer is the client).
|
|
41
|
+
* - `LENSMCP_CLIENT_IP_HEADER` — a front proxy's client-address header, honoured from trusted peers only.
|
|
42
|
+
* - `LENSMCP_CLIENT_IP_TRUSTED_PROXIES` — comma-separated CIDRs that may assert it (default: loopback).
|
|
43
|
+
*/
|
|
44
|
+
export declare function clientIpOptionsFromEnv(env?: NodeJS.ProcessEnv): ClientIpOptions;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var u=Object.defineProperty;var f=(e,r)=>u(e,"name",{value:r,configurable:!0});var a=Object.defineProperty,o=f((e,r)=>a(e,"name",{value:r,configurable:!0}),"o");Object.defineProperty(exports,"__esModule",{value:!0}),exports.stripPort=exports.normalizePeer=void 0,exports.createClientIpResolver=createClientIpResolver,exports.clientIpOptionsFromEnv=clientIpOptionsFromEnv;const manifest_1=require("./manifest"),first=o(e=>{const r=Array.isArray(e)?e[0]:e;return typeof r=="string"&&r.length>0?r:void 0},"first"),normalizePeer=o(e=>e.startsWith("::ffff:")&&e.indexOf(":",7)===-1?e.slice(7):e,"normalizePeer");exports.normalizePeer=normalizePeer;const stripPort=o(e=>{if(e.startsWith("[")){const p=e.indexOf("]");return p===-1?e:e.slice(1,p)}const r=e.indexOf(":");return r!==-1&&e.indexOf(":",r+1)===-1?e.slice(0,r):e},"stripPort");exports.stripPort=stripPort;function createClientIpResolver(e={}){const r=e.clientIpHeader?.toLowerCase(),p=e.clientIpTrustedProxies??[],l=Math.max(0,Math.floor(e.trustProxyHops??0)),c=o(t=>{if(!t)return!1;const s=t.startsWith("::ffff:")?t.slice(7):t;return p.length===0?s==="127.0.0.1"||t==="::1":p.some(i=>(0,manifest_1.matchCidr)(s,i))},"peerTrusted"),d=o(t=>String(t.headers["x-forwarded-for"]??"").split(",").map(s=>s.trim()).filter(Boolean),"forwardedChain");return{clientIpOf:o((t,s)=>{if(r&&c(t.socket?.remoteAddress??void 0)){const i=first(t.headers[r]);if(i){const n=(0,exports.stripPort)(i.split(",")[0].trim());if(n)return n}}if(l>0){const i=d(t),n=i.length-l;if(n>=0&&i[n])return(0,exports.stripPort)(i[n])}return(0,exports.normalizePeer)(s)},"clientIpOf"),forwardedFor:o((t,s)=>{const i=(0,exports.normalizePeer)(s);if(l>0||r&&c(s)){const n=d(t);return n.length?`${n.join(", ")}, ${i}`:i}return i},"forwardedFor"),peerTrusted:c,trustProxyHops:l}}f(createClientIpResolver,"createClientIpResolver"),o(createClientIpResolver,"createClientIpResolver");function clientIpOptionsFromEnv(e=process.env){const r=Number(e.LENSMCP_TRUST_PROXY),p=String(e.LENSMCP_CLIENT_IP_TRUSTED_PROXIES??"").split(",").map(l=>l.trim()).filter(Boolean);return{...Number.isFinite(r)&&r>0?{trustProxyHops:Math.floor(r)}:{},...e.LENSMCP_CLIENT_IP_HEADER?{clientIpHeader:e.LENSMCP_CLIENT_IP_HEADER}:{},...p.length?{clientIpTrustedProxies:p}:{}}}f(clientIpOptionsFromEnv,"clientIpOptionsFromEnv"),o(clientIpOptionsFromEnv,"clientIpOptionsFromEnv");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var J=Object.defineProperty;var
|
|
1
|
+
"use strict";var J=Object.defineProperty;var d=(e,n)=>J(e,"name",{value:n,configurable:!0});var v=Object.defineProperty,c=d((e,n)=>v(e,"name",{value:n,configurable:!0}),"c");Object.defineProperty(exports,"__esModule",{value:!0}),exports.createJwksVerifier=createJwksVerifier;const node_crypto_1=require("node:crypto"),ALG_TO_HASH={RS256:"sha256",RS384:"sha384",RS512:"sha512"},b64urlJsonSafe=c(e=>JSON.parse(Buffer.from(e,"base64url").toString("utf8")),"b64urlJsonSafe");function createJwksVerifier(e){const n=(e.algorithms??["RS256"]).filter(r=>r in ALG_TO_HASH),_=e.cacheMaxAgeMs??6e5,b=e.missRefreshMs??3e4,h=e.clockToleranceSec??60,S=e.fetchImpl??globalThis.fetch,p=e.audience===void 0?void 0:Array.isArray(e.audience)?e.audience:[e.audience];let o=new Map,f,g=0;const m=c(async()=>{const r=await S(e.jwksUrl,{headers:{accept:"application/json"}});if(!r.ok)throw new Error(`JWKS ${e.jwksUrl} \u2192 HTTP ${r.status}`);const a=await r.json(),t=new Map;for(const s of a.keys??[])if(!(s.kty!=="RSA"||!s.n||!s.e)&&!(s.use&&s.use!=="sig"))try{const u=(0,node_crypto_1.createPublicKey)({key:s,format:"jwk"});t.set(s.kid??"__single__",u)}catch{}t.size>0&&(o=t)},"load"),l=c(()=>(f||(f=m().finally(()=>{f=void 0})),f),"refresh"),A=c(r=>{r-g<b||(g=r,l().catch(()=>{}))},"refreshOnMiss"),k=setInterval(()=>{l().catch(()=>{})},_);return k.unref?.(),{verify:c(r=>{const a=r.split(".");if(a.length!==3)return;let t;try{t=b64urlJsonSafe(a[0])}catch{return}if(!t.alg||!n.includes(t.alg))return;const s=t.kid!==void 0?o.get(t.kid):o.get("__single__")??(o.size===1?[...o.values()][0]:void 0);if(!s){A(Date.now());return}let u;try{u=(0,node_crypto_1.verify)(ALG_TO_HASH[t.alg],Buffer.from(`${a[0]}.${a[1]}`),s,Buffer.from(a[2],"base64url"))}catch{return}if(!u)return;let i;try{i=b64urlJsonSafe(a[1])}catch{return}const w=Date.now()/1e3;if(!(typeof i.exp=="number"&&w>i.exp+h)&&!(typeof i.nbf=="number"&&w<i.nbf-h)&&!(e.issuer&&i.iss!==e.issuer)){if(p){const y=i.aud;if(!(Array.isArray(y)?y:[y]).some(j=>p.includes(j)))return}return i}},"verify"),refresh:l,stop:c(()=>clearInterval(k),"stop")}}d(createJwksVerifier,"createJwksVerifier"),c(createJwksVerifier,"createJwksVerifier");
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";var
|
|
2
|
-
|
|
1
|
+
"use strict";var ce=Object.defineProperty;var T=(e,r)=>ce(e,"name",{value:r,configurable:!0});var ie=Object.defineProperty,a=T((e,r)=>ie(e,"name",{value:r,configurable:!0}),"a");Object.defineProperty(exports,"__esModule",{value:!0});const tslib_1=require("tslib"),node_fs_1=require("node:fs"),node_crypto_1=require("node:crypto"),node_path_1=require("node:path"),node_os_1=require("node:os"),node_cluster_1=tslib_1.__importDefault(require("node:cluster")),prod_gateway_lib_1=require("./prod-gateway.lib"),jwks_verify_1=require("./jwks-verify"),otel_tracing_1=require("./otel-tracing"),health_check_1=require("./health-check"),rate_limit_1=require("./rate-limit"),metrics_1=require("./metrics"),edge_1=require("./prod-runtime/edge"),registry_source_1=require("./registry-source"),providers_prod_1=require("./providers-prod"),prod_config_1=require("./prod-config");function readJson(e,r){if(!e)return r;try{return JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(n){return console.error(`[gateway] could not read ${e}:`,n.message),r}}T(readJson,"readJson"),a(readJson,"readJson");function watchJsonFile(e,r,n=1e3){let u=-1;const E=setInterval(a(()=>{if(!(0,node_fs_1.existsSync)(e))return;let p;try{p=(0,node_fs_1.statSync)(e).mtimeMs}catch{return}if(p===u)return;let y;try{y=JSON.parse((0,node_fs_1.readFileSync)(e,"utf8"))}catch(_){console.error("[gateway] endpoints reload:",_.message);return}u=p,r(y)},"tick"),n);return E.unref?.(),()=>clearInterval(E)}T(watchJsonFile,"watchJsonFile"),a(watchJsonFile,"watchJsonFile");async function main(){const e=process.env,r=(0,prod_config_1.parseGatewayEnv)(e),n=r.ports;process.on("uncaughtException",o=>console.error("[gateway] uncaughtException:",o)),process.on("unhandledRejection",o=>console.error("[gateway] unhandledRejection:",o));const u=[];let E=!1;const p=a(async o=>{if(!E){E=!0,console.log(`[gateway] ${o} \u2014 draining\u2026`);for(const t of u)try{await t()}catch{}process.exit(0)}},"shutdown");for(const o of["SIGTERM","SIGINT"])process.on(o,()=>{p(o)});const y=e.LENSMCP_GW_REDIS_URL;let _,w,b,$;const O={};let f;if(y){const o=require("ioredis"),t=new o(y);f=t;const s=(0,registry_source_1.createRegistryProviders)((0,registry_source_1.redisRegistrySource)(t,e.LENSMCP_GW_REDIS_PREFIX?{prefix:e.LENSMCP_GW_REDIS_PREFIX}:{}),{onError:a(i=>console.error("[gateway] registry:",i instanceof Error?i.message:i),"onError")});await s.start(),_=s.manifests,w=s.pods,$=s,console.log(`[gateway] registry: Redis-backed, live (${_.list().length} service(s))`)}else{const o=e.LENSMCP_GW_MANIFESTS;o?_=(0,providers_prod_1.fileManifestProvider)(o,{onError:a(s=>console.error("[gateway] manifest:",s instanceof Error?s.message:s),"onError")}):(console.warn("[gateway] no LENSMCP_GW_MANIFESTS / LENSMCP_GW_REDIS_URL \u2014 starting with an EMPTY route table (all requests 404 until manifests arrive)."),_=(0,providers_prod_1.staticManifestProvider)([]));const t=e.LENSMCP_GW_ENDPOINTS;if(t){const s=a(S=>console.error("[gateway] endpoints:",S instanceof Error?S.message:S),"onEpError"),i=readJson(t,{}),d=Object.keys(i).length,l=(0,providers_prod_1.isRolloutShape)(i);w=l?(0,providers_prod_1.rolloutPodProvider)(i,{onError:s}):(0,providers_prod_1.endpointsPodProvider)(i,{onError:s}),console.log(d?`[gateway] endpoints: ${l?"ROLLOUT (weighted cohorts)":"flat"} \u2014 ${d} service(s)`:`[gateway] endpoints: ${(0,node_fs_1.existsSync)(t)?"present but empty/unreadable":"file not there yet"} \u2014 watching ${t}`),b=watchJsonFile(t,S=>{try{w?.update?.(S),O.current?.refreshUpstreams(),console.log("[gateway] endpoints reloaded")}catch(ae){console.error("[gateway] endpoints reload failed:",ae.message)}})}}const Z=readJson(e.LENSMCP_GW_KEYS,{}),A=e.LENSMCP_GW_TLS_KEY&&e.LENSMCP_GW_TLS_CERT?{key:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_KEY),cert:(0,node_fs_1.readFileSync)(e.LENSMCP_GW_TLS_CERT)}:void 0,ee=r.jwt.presenceOnly,F=r.jwt.jwksUrl,M=r.jwt.secret,g=F?(0,jwks_verify_1.createJwksVerifier)({jwksUrl:F,...r.jwt.issuer?{issuer:r.jwt.issuer}:{},...r.jwt.audience?{audience:r.jwt.audience}:{}}):void 0,te=a(o=>{if(!M)return;const t=o.split(".");if(t.length===3)try{const s=(0,node_crypto_1.createHmac)("sha256",M).update(t[0]+"."+t[1]).digest("base64url"),i=Buffer.from(t[2]),d=Buffer.from(s);if(i.length!==d.length||!(0,node_crypto_1.timingSafeEqual)(i,d))return;const l=JSON.parse(Buffer.from(t[1],"base64url").toString());return typeof l.exp=="number"&&Date.now()/1e3>l.exp?void 0:l}catch{return}},"hs256"),j=a(o=>{const t=String(o.headers.authorization??"");if(!t.startsWith("Bearer "))return;const s=t.slice(7);return g?g.verify(s):te(s)},"bearerClaims"),q=g||M?j:void 0,re=a((o,t)=>{if(o==="jwt"){if(g||M){if(!j(t))throw new Error("invalid or missing token");return}if(!ee)throw new Error("jwt route but no verifier configured (set LENSMCP_GW_JWKS_URL or LENSMCP_GW_JWT_SECRET)");if(!String(t.headers.authorization??"").startsWith("Bearer "))throw new Error("missing bearer token")}},"authenticate"),{attributeHeaders:D,trustProxyHops:oe,trustProxyIp:x,uidHeader:U,cookie:J}=r,m=e.LENSMCP_EVENT_FILE,K=m?(()=>{try{return require("@lensmcp/node-instrumentation")}catch{return}})():void 0,H=K?.createEventFloodGuard({burst:30,perSec:5});let z=!1;const I=m?o=>{try{const t=H?H.admit(String(o.fingerprint??"")):{ok:!0,suppressed:0};if(!t.ok)return;z||((0,node_fs_1.mkdirSync)((0,node_path_1.dirname)(m),{recursive:!0}),z=!0);const s=o.raw,i=JSON.stringify(t.suppressed>0?{...o,raw:{...s,floodSuppressed:t.suppressed}}:o)+`
|
|
2
|
+
`;K?.maybeRotateEventFile(m,i.length),(0,node_fs_1.appendFileSync)(m,i)}catch{}}:void 0;if(g)try{await g.refresh(),console.log("[gateway] JWKS loaded")}catch(o){console.error("[gateway] initial JWKS load failed (will retry in background):",o.message)}const{accessLog:se,clientIpHeader:B,clientIpTrustedProxies:Y,undici:c}=r;if(r.mtlsFromRedis&&f){const o=e.LENSMCP_GW_REDIS_PREFIX??"lensmcp:gw",[t,s,i]=await f.mget(`${o}:mtls:cert`,`${o}:mtls:key`,`${o}:mtls:ca`);t&&(c.cert=t),s&&(c.key=s),i&&(c.ca=i),(t||s)&&console.log("[gateway] mTLS materials loaded from Redis")}else e.LENSMCP_GW_MTLS_CERT&&(c.cert=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CERT)),e.LENSMCP_GW_MTLS_KEY&&(c.key=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_KEY)),e.LENSMCP_GW_MTLS_CA&&(c.ca=(0,node_fs_1.readFileSync)(e.LENSMCP_GW_MTLS_CA));(0,prod_config_1.finalizeUpstreamTls)(c).autoEnabled&&console.log("[gateway] upstream TLS verification ENABLED (a CA is configured; set LENSMCP_GW_UPSTREAM_TLS_VERIFY=0 to opt out)"),(0,prod_config_1.finalizeUpstreamH2)(c,e)&&console.log(`[gateway] mTLS to upstreams ON over ${c.allowH2?"h2":"h1.1"} (server validation ${c.rejectUnauthorized?"enabled":"OFF"})`);const{upstreamRetries:v,server:V}=r,P=(0,otel_tracing_1.setupTracing)({...e.OTEL_SERVICE_NAME?{serviceName:e.OTEL_SERVICE_NAME}:{},...e.LENSMCP_GW_OTEL_SAMPLE?{sampleRatio:Number(e.LENSMCP_GW_OTEL_SAMPLE)}:{}});P&&console.log(`[gateway] OTLP tracing on \u2192 ${(0,edge_1.safeUrl)(e.OTEL_EXPORTER_OTLP_ENDPOINT)}`);const{statusEnabled:R,statusToken:h}=r,X=(0,node_os_1.hostname)(),ne=r.statusTtlSec,Q=f,C=R&&Q?o=>{for(const t of o)Q.set(`status:${t.service}:${X}`,JSON.stringify({...t,instance:X,at:Date.now()}),"EX",ne).catch(()=>{})}:void 0;R&&!h&&console.warn("[gateway] LENSMCP_GW_STATUS=1 but no LENSMCP_GW_STATUS_TOKEN \u2014 /statusz stays DISABLED (404) until a token is set (fail-closed). The Redis status:* fan-out still runs."),R&&console.log(`[gateway] status on \u2014 /statusz${h?"":" (endpoint disabled: no token)"}${C?" + Redis status:* fan-out":""}`);const G=r.health?(0,health_check_1.createHealthChecker)(r.health):void 0;G&&console.log(`[gateway] active health-checking on (path ${r.health?.path??"/healthz"})`);let L,k;if(r.rateLimit){const{limit:o,windowMs:t,wantRedis:s,failClosed:i}=r.rateLimit;if(s){const d=r.rateLimit.dedicatedUrl;let l;if(d){const S=require("ioredis");k=new S(d),l=k}else f&&(l=f);l?(L=(0,rate_limit_1.createRedisRateLimiter)(l,{limit:o,windowMs:t,...r.rateLimit.timeoutMs!==void 0?{timeoutMs:r.rateLimit.timeoutMs}:{},...i?{failOpen:!1}:{}}),console.log(`[gateway] rate limit on \u2014 ${o} req / ${t}ms GLOBAL (Redis: ${d?"dedicated":"shared registry"}, fail-${i?"closed":"open"}) per client IP`)):console.warn("[gateway] LENSMCP_GW_RATE_LIMIT_REDIS set but no Redis configured (set LENSMCP_GW_REDIS_URL or LENSMCP_GW_RATE_LIMIT_REDIS_URL) \u2014 falling back to PER-INSTANCE in-memory limiting.")}L||(L=(0,rate_limit_1.createRateLimiter)({limit:o,windowMs:t}),console.log(`[gateway] rate limit on \u2014 ${o} req / ${t}ms per client IP (in-memory, per-instance)`))}const W=r.metricsEnabled?(0,metrics_1.createProcessMetrics)():void 0;W&&console.log(`[gateway] process metrics on \u2014 /metricsz${h?" (token-gated)":" (DISABLED: needs LENSMCP_GW_STATUS_TOKEN)"}`);const N=await(0,prod_gateway_lib_1.startProdGateway)({...n.length?{ports:n}:{},manifests:_,...w?{pods:w}:{},serviceKeys:Z,authenticate:re,...A?{tls:A}:{},...I?{emit:I}:{},...q?{identify:q}:{},...D.length?{attributeHeaders:D}:{},...x?{trustProxyIp:x,trustProxyHops:oe}:{},...J?{cookie:J}:{},...U?{uidHeader:U}:{},...se?{accessLog:!0}:{},...B?{clientIpHeader:B}:{},...Y.length?{clientIpTrustedProxies:Y}:{},...P?{tracing:P}:{},...R?{statusEndpoint:!0}:{},...h?{statusToken:h}:{},...C?{onStatus:C}:{},...G?{healthChecker:G}:{},...L?{rateLimiter:L}:{},...W?{metrics:W}:{},...r.trafficFlushMs!==void 0?{trafficFlushMs:r.trafficFlushMs}:{},...v!==void 0&&Number.isFinite(v)?{upstreamRetries:v}:{},...Object.keys(c).length?{undici:c}:{},...Object.keys(V).length?{server:V}:{}});O.current=N,console.log(`[gateway] production gateway up on ${N.ports.join(", ")} (${N.routes().length} routes${I?", lens ON":""})`),u.push(()=>_.stop?.(),()=>b?.(),()=>$?.stop(),()=>k?.quit(),()=>g?.stop(),()=>N.stop(),()=>P?.shutdown())}T(main,"main"),a(main,"main");const WORKERS=(0,prod_config_1.workerCount)(process.env);if(WORKERS>1&&node_cluster_1.default.isPrimary){console.log(`[gateway] primary ${process.pid}: forking ${WORKERS} workers (one per core)\u2026`),process.env.LENSMCP_GW_RATE_LIMIT&&!(process.env.LENSMCP_GW_RATE_LIMIT_REDIS==="1"||process.env.LENSMCP_GW_RATE_LIMIT_REDIS_URL)&&console.warn(`[gateway] WARNING: in-memory rate limiter + ${WORKERS} workers \u2192 the limit is enforced PER WORKER (effective \u2248 ${WORKERS}\xD7 ${process.env.LENSMCP_GW_RATE_LIMIT}/window). Set LENSMCP_GW_RATE_LIMIT_REDIS=1 for one shared global budget, or divide LENSMCP_GW_RATE_LIMIT by ${WORKERS}.`);let e=!1;for(let n=0;n<WORKERS;n++)node_cluster_1.default.fork();node_cluster_1.default.on("exit",(n,u,E)=>{e||(console.warn(`[gateway] worker ${n.process.pid} exited (code=${u} sig=${E??""}) \u2014 respawning.`),node_cluster_1.default.fork())});const r=a(n=>{if(!e){e=!0,console.log(`[gateway] primary: ${n} \u2014 stopping ${Object.keys(node_cluster_1.default.workers??{}).length} workers\u2026`);for(const u of Object.values(node_cluster_1.default.workers??{}))try{u?.kill(n)}catch{}setTimeout(()=>process.exit(0),12e3).unref()}},"stopAll");process.on("SIGTERM",()=>r("SIGTERM")),process.on("SIGINT",()=>r("SIGINT"))}else main().catch(e=>{console.error("[gateway] FATAL: could not start \u2014",e instanceof Error?e.stack??e.message:e),process.exit(1)});
|
|
@@ -116,10 +116,15 @@ export interface PodProvider {
|
|
|
116
116
|
/** Pick an upstream for this service. `opts.version` pins a rollout cohort
|
|
117
117
|
* (sticky canary / debugging); otherwise traffic is split by cohort weight.
|
|
118
118
|
* `opts.isHealthy` (when given) steers a pooled pick toward healthy endpoints
|
|
119
|
-
* — fail-open: if a whole pool looks down it still returns one.
|
|
119
|
+
* — fail-open: if a whole pool looks down it still returns one.
|
|
120
|
+
* `opts.allowDrained` admits a `weight: 0` cohort as a pin target. The GATEWAY sets it when
|
|
121
|
+
* the version came from its own targeting (an ABAC rule aimed at a canary that takes no
|
|
122
|
+
* organic traffic — the point of a targeted rollout); a version named by the CLIENT never
|
|
123
|
+
* gets it, so a rolled-back build cannot be reached by sending its version string. */
|
|
120
124
|
pick(service: string, opts?: {
|
|
121
125
|
version?: string;
|
|
122
126
|
isHealthy?: (url: string) => boolean;
|
|
127
|
+
allowDrained?: boolean;
|
|
123
128
|
}): Upstream | undefined;
|
|
124
129
|
/** All upstream URLs this provider could route to — for active health-checking. */
|
|
125
130
|
endpoints?(): string[];
|
|
@@ -165,7 +170,15 @@ export interface Route {
|
|
|
165
170
|
/** Resolved auth for the route root (overrides refine per-path at match). */
|
|
166
171
|
auth: AuthMode;
|
|
167
172
|
}
|
|
168
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Exact hostname or `*.suffix` wildcard (the bare suffix also matches).
|
|
175
|
+
*
|
|
176
|
+
* Case-INSENSITIVE, because DNS is: `APP.X` and `app.x` name the same host to every other layer,
|
|
177
|
+
* and a byte-exact compare here meant an upper-cased `Host` missed its own route and fell through
|
|
178
|
+
* to whatever claimed the rest of the request — usually the host-less catch-all, under ITS auth
|
|
179
|
+
* policy. Where that catch-all and the named host share an upstream, that was a way to reach a
|
|
180
|
+
* guarded service unauthenticated. The path matcher folds for the same reason.
|
|
181
|
+
*/
|
|
169
182
|
export declare function hostMatches(pattern: string, host: string): boolean;
|
|
170
183
|
/**
|
|
171
184
|
* THE matcher — shared by dev and prod. Generic over the route shape so the
|
|
@@ -187,7 +200,36 @@ export declare function matchRoute<R extends {
|
|
|
187
200
|
prefix?: string;
|
|
188
201
|
apex?: string;
|
|
189
202
|
}>(routes: readonly R[], host: string, url: string): R | undefined;
|
|
190
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* Is `url` at or under the mount `prefix`? Segment-anchored and case-insensitive.
|
|
205
|
+
*
|
|
206
|
+
* Both properties are fixed bugs. A bare `startsWith` let `/adminx` fall into the `/admin`
|
|
207
|
+
* mount (`handler.ts` already enforces a segment boundary for `prependPrefix`, and the two
|
|
208
|
+
* must agree), and a case-SENSITIVE compare let `/Admin/x` miss the mount entirely and fall
|
|
209
|
+
* through to a sibling — most often the host-less catch-all, under ITS auth policy. Folding
|
|
210
|
+
* is safe in both directions: an upstream that routes case-sensitively simply 404s a path
|
|
211
|
+
* whose case it does not serve, whereas NOT folding hands the request to the wrong policy.
|
|
212
|
+
*/
|
|
213
|
+
export declare function pathUnderPrefix(url: string, prefix: string): boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Specificity sort: host+path beats host-only beats catch-all, and among equals the LONGER path
|
|
216
|
+
* wins. Mutates+returns.
|
|
217
|
+
*
|
|
218
|
+
* The length tie-break is what makes `buildRouteTable`'s promise ("sorted by specificity so
|
|
219
|
+
* `matchRoute` picks the most specific") true for two path mounts on ONE host. The rank alone
|
|
220
|
+
* scores the PRESENCE of host/prefix, never its length, so `/api` and `/api/tasks` tie at 3 and
|
|
221
|
+
* `Array.sort` — stable — leaves them in manifest-arrival order. `matchRoute` then takes the FIRST
|
|
222
|
+
* `startsWith` hit, so whichever arrived first swallows the other's whole surface: `/api` catches
|
|
223
|
+
* `/api/tasks/v1`, or `/api/tasks` sorts first and `/api` never matches anything under it.
|
|
224
|
+
*
|
|
225
|
+
* Measured before this tie-break existed (foodguard, 2026-08-31): a second service declaring `/api`
|
|
226
|
+
* beside the incumbent took the ENTIRE mount, and a `@Public` health route on the incumbent began
|
|
227
|
+
* answering 401 because the edge was applying the OTHER service's auth policy to its path. Two
|
|
228
|
+
* services could not share a host prefix at all, in dev or prod — both run this function.
|
|
229
|
+
*
|
|
230
|
+
* Longest-prefix-match is the standard routing semantic and the one the caller already documents;
|
|
231
|
+
* the rank ordering above is unchanged, so only the previously-arbitrary tie moves.
|
|
232
|
+
*/
|
|
191
233
|
export declare function sortBySpecificity<R extends {
|
|
192
234
|
host?: string;
|
|
193
235
|
prefix?: string;
|
|
@@ -245,7 +287,7 @@ export declare function manifestFromClusterDecl(project: string, decl: {
|
|
|
245
287
|
/** A flat, read-only attribute bag assembled per request: verified JWT claims,
|
|
246
288
|
* `ip`, `header.<name>`, `device` (sticky key), `path`, `method`. */
|
|
247
289
|
export type Attrs = Record<string, unknown>;
|
|
248
|
-
export type RuleOp = 'eq' | 'ne' | 'in' | 'nin' | 'prefix' | 'suffix' | 'contains' | 'hastoken' | 'regex' | 'exists' | 'gt' | 'lt' | 'cidr';
|
|
290
|
+
export type RuleOp = 'eq' | 'ne' | 'in' | 'nin' | 'prefix' | 'suffix' | 'contains' | 'domain' | 'hastoken' | 'regex' | 'exists' | 'gt' | 'lt' | 'cidr';
|
|
249
291
|
export interface Condition {
|
|
250
292
|
attr: string;
|
|
251
293
|
op: RuleOp;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var h=Object.defineProperty;var
|
|
1
|
+
"use strict";var h=Object.defineProperty;var i=(t,r)=>h(t,"name",{value:r,configurable:!0});var f=Object.defineProperty,o=i((t,r)=>f(t,"name",{value:r,configurable:!0}),"o");Object.defineProperty(exports,"__esModule",{value:!0}),exports.smoothWeightedSelector=smoothWeightedSelector,exports.hostMatches=hostMatches,exports.matchRoute=matchRoute,exports.pathUnderPrefix=pathUnderPrefix,exports.sortBySpecificity=sortBySpecificity,exports.authForPath=authForPath,exports.authPathMatches=authPathMatches,exports.authRuleFor=authRuleFor,exports.authRuleForPath=authRuleForPath,exports.buildRouteTable=buildRouteTable,exports.manifestFromClusterDecl=manifestFromClusterDecl,exports.matchCidr=matchCidr,exports.evalRule=evalRule,exports.pickTargetedVersion=pickTargetedVersion,exports.fnv1a=fnv1a,exports.bucketByWeight=bucketByWeight;function smoothWeightedSelector(t){const r=t.map(()=>0),e=t.reduce((s,n)=>s+Math.max(0,n),0);return()=>{if(e<=0||t.length===0)return 0;let s=0;for(let n=0;n<t.length;n++)r[n]+=Math.max(0,t[n]),r[n]>r[s]&&(s=n);return r[s]-=e,s}}i(smoothWeightedSelector,"smoothWeightedSelector"),o(smoothWeightedSelector,"smoothWeightedSelector");function hostMatches(t,r){const e=t.toLowerCase(),s=r.toLowerCase();return e.startsWith("*.")?s.endsWith(e.slice(1))||s===e.slice(2):s===e}i(hostMatches,"hostMatches"),o(hostMatches,"hostMatches");function matchRoute(t,r,e){const s=t.some(a=>a.host&&hostMatches(a.host,r));let n;if(s)n=t.filter(a=>a.host&&hostMatches(a.host,r)),n.some(a=>a.host===r)&&(n=n.filter(a=>a.host===r));else{const a=t.filter(u=>!u.host);if(a.length<=1)n=a;else{const u=a.filter(l=>l.apex&&(r===l.apex||r.endsWith("."+l.apex))).sort((l,c)=>c.apex.length-l.apex.length);n=u.length>0?u:a.filter(l=>!l.apex)}}return n.find(a=>!a.prefix||pathUnderPrefix(e,a.prefix))}i(matchRoute,"matchRoute"),o(matchRoute,"matchRoute");function pathUnderPrefix(t,r){let e=r.length;for(;e>0&&r.charCodeAt(e-1)===47;)e-=1;if(e===0)return!0;if(t.length<e||t.slice(0,e).toLowerCase()!==r.slice(0,e).toLowerCase())return!1;const s=t.charCodeAt(e);return Number.isNaN(s)||s===47||s===63}i(pathUnderPrefix,"pathUnderPrefix"),o(pathUnderPrefix,"pathUnderPrefix");function sortBySpecificity(t){const r=o(e=>(e.host?2:0)+(e.prefix?1:0),"rank");return t.sort((e,s)=>r(s)-r(e)||(s.prefix?.length??0)-(e.prefix?.length??0)),t}i(sortBySpecificity,"sortBySpecificity"),o(sortBySpecificity,"sortBySpecificity");function authForPath(t,r,e){if(e)return"internal";const s=t.auth?.overrides??[];for(const n of s)if(r.startsWith(n.path))return n.mode;return t.auth?.default??"jwt"}i(authForPath,"authForPath"),o(authForPath,"authForPath");function authPathMatches(t,r){const e=t.split("/").filter(Boolean),s=(r.split("?")[0]??"").split("/").filter(Boolean);if(s.length<e.length)return!1;for(let n=0;n<e.length;n++){const a=e[n];if(!(a==="*"||a.startsWith(":"))&&a.toLowerCase()!==s[n].toLowerCase())return!1}return!0}i(authPathMatches,"authPathMatches"),o(authPathMatches,"authPathMatches");function authRuleFor(t,r,e){const s=e.toUpperCase();for(const n of t?.overrides??[]){if(!authPathMatches(n.path,r)||n.methods&&!n.methods.some(u=>u.toUpperCase()===s))continue;const a=n.rule??(n.permission?{attr:"perms",op:"hastoken",value:n.permission}:void 0);return{mode:n.mode??t?.default??"jwt",...a?{rule:a}:{}}}return{mode:t?.default??"jwt"}}i(authRuleFor,"authRuleFor"),o(authRuleFor,"authRuleFor");function authRuleForPath(t,r,e,s){return s?{mode:"internal"}:authRuleFor(t.auth,r,e)}i(authRuleForPath,"authRuleForPath"),o(authRuleForPath,"authRuleForPath");function buildRouteTable(t){const r=[];for(const e of t){const s=e.auth?.default??"jwt";for(const n of e.mounts){const a={service:e.service,...n.path?{prefix:n.path}:{},...e.prependPrefix?{prependPrefix:e.prependPrefix}:{},...e.upstream?{upstream:e.upstream}:{}};r.push({...a,host:n.host,auth:s}),e.internal&&e.internalHost!==!1&&r.push({...a,host:typeof e.internalHost=="string"?e.internalHost:`internal.${n.host}`,internal:!0,auth:"internal"})}e.default&&r.push({service:e.service,...e.prependPrefix?{prependPrefix:e.prependPrefix}:{},...e.upstream?{upstream:e.upstream}:{},default:!0,auth:s})}return sortBySpecificity(r)}i(buildRouteTable,"buildRouteTable"),o(buildRouteTable,"buildRouteTable");function manifestFromClusterDecl(t,r){const e=r.upstream??(r.port?`http://localhost:${r.port}`:void 0);if(!(!r.service&&!e))return{service:r.service??t,mounts:r.host?[{host:r.host,...r.path?{path:r.path}:{}}]:[],internal:!!r.service,...r.internalHost!==void 0?{internalHost:r.internalHost}:{},...r.default?{default:!0}:{},...r.prependPrefix?{prependPrefix:r.prependPrefix}:{},...r.auth?{auth:r.auth}:{},...e?{upstream:e}:{}}}i(manifestFromClusterDecl,"manifestFromClusterDecl"),o(manifestFromClusterDecl,"manifestFromClusterDecl");const isGroup=o(t=>"all"in t||"any"in t||"not"in t,"isGroup"),reCache=new Map,compileRe=o(t=>{if(t.length>512)return null;if(reCache.has(t))return reCache.get(t);let r;try{r=new RegExp(t)}catch{r=null}return reCache.set(t,r),r},"compileRe");function ipToInt(t){const r=(t.startsWith("::ffff:")?t.slice(7):t).split(".");if(r.length!==4)return null;let e=0;for(const s of r){const n=Number(s);if(!Number.isInteger(n)||n<0||n>255||s.length>1&&s[0]==="0")return null;e=e*256+n}return e>>>0}i(ipToInt,"ipToInt"),o(ipToInt,"ipToInt");function matchCidr(t,r){const e=r.indexOf("/");if(e<0)return!1;const s=Number(r.slice(e+1));if(!Number.isInteger(s)||s<0||s>32)return!1;const n=ipToInt(t),a=ipToInt(r.slice(0,e));if(n===null||a===null)return!1;if(s===0)return!0;const u=(s===32?4294967295:~((1<<32-s)-1))>>>0;return(n&u)>>>0===(a&u)>>>0}i(matchCidr,"matchCidr"),o(matchCidr,"matchCidr");const str=o(t=>t==null?"":String(t),"str"),eq=o((t,r)=>str(t)===str(r),"eq");function evalCondition(t,r){const e=r[t.attr],s=e!=null;switch(t.op){case"exists":return s;case"ne":return!s||!eq(e,t.value)}if(!s)return!1;switch(t.op){case"eq":return eq(e,t.value);case"in":return Array.isArray(t.value)&&t.value.some(n=>Array.isArray(e)?e.some(a=>eq(a,n)):eq(e,n));case"nin":return Array.isArray(t.value)&&!t.value.some(n=>Array.isArray(e)?e.some(a=>eq(a,n)):eq(e,n));case"prefix":return str(e).startsWith(str(t.value));case"suffix":return str(e).endsWith(str(t.value));case"domain":{const n=str(e).lastIndexOf("@"),a=(n===-1?str(e):str(e).slice(n+1)).toLowerCase().replace(/\.$/,""),u=str(t.value).toLowerCase().replace(/^\./,"").replace(/\.$/,"");return!!u&&(a===u||a.endsWith("."+u))}case"contains":return Array.isArray(e)?e.some(n=>eq(n,t.value)):str(e).includes(str(t.value));case"hastoken":{const n=str(t.value);return Array.isArray(e)?e.some(a=>eq(a,t.value)):str(e).split(/[\s,]+/).filter(Boolean).includes(n)}case"regex":{const n=compileRe(str(t.value));return n?n.test(str(e)):!1}case"gt":return Number(e)>Number(t.value);case"lt":return Number(e)<Number(t.value);case"cidr":return matchCidr(str(e),str(t.value));default:return!1}}i(evalCondition,"evalCondition"),o(evalCondition,"evalCondition");function evalRule(t,r){if(!isGroup(t))return evalCondition(t,r);const e=Array.isArray(t.all)&&t.all.length?t.all:void 0,s=Array.isArray(t.any)&&t.any.length?t.any:void 0,n=t.not&&Object.keys(t.not).length?t.not:void 0;return n&&evalRule(n,r)||e&&!e.every(a=>evalRule(a,r))||s&&!s.some(a=>evalRule(a,r))?!1:!!(e||s||n)}i(evalRule,"evalRule"),o(evalRule,"evalRule");function pickTargetedVersion(t,r){for(const e of t)if(evalRule(e.when,r))return{version:e.version,...e.name?{rule:e.name}:{}}}i(pickTargetedVersion,"pickTargetedVersion"),o(pickTargetedVersion,"pickTargetedVersion");function fnv1a(t){let r=2166136261;for(let e=0;e<t.length;e++)r^=t.charCodeAt(e),r=Math.imul(r,16777619);return r>>>0}i(fnv1a,"fnv1a"),o(fnv1a,"fnv1a");function bucketByWeight(t,r){const e=r.reduce((a,u)=>a+Math.max(0,u),0);if(e<=0||r.length===0)return 0;const s=fnv1a(t)%1e4;let n=0;for(let a=0;a<r.length;a++)if(n+=Math.max(0,r[a])/e*1e4,s<n)return a;return r.length-1}i(bucketByWeight,"bucketByWeight"),o(bucketByWeight,"bucketByWeight");
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ProdGatewayOptions } from './prod-runtime/types';
|
|
2
|
+
export interface RateLimitConfig {
|
|
3
|
+
limit: number;
|
|
4
|
+
windowMs: number;
|
|
5
|
+
/** Use the shared/dedicated Redis budget rather than per-instance memory. */
|
|
6
|
+
wantRedis: boolean;
|
|
7
|
+
/** A Redis URL just for the limiter; otherwise the registry client is reused. */
|
|
8
|
+
dedicatedUrl?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Deny on a Redis error/timeout. Default false — the limiter is defence in depth, and a
|
|
11
|
+
* Redis blip must not 429 every request. */
|
|
12
|
+
failClosed: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface JwtConfig {
|
|
15
|
+
jwksUrl?: string;
|
|
16
|
+
secret?: string;
|
|
17
|
+
issuer?: string;
|
|
18
|
+
audience?: string[];
|
|
19
|
+
/** Accept any `Bearer <anything>` on a `jwt` route when no verifier is wired. Opt-in, weak. */
|
|
20
|
+
presenceOnly: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface GatewayEnvConfig {
|
|
23
|
+
/** Bind ports: explicit `LENSMCP_GW_PORTS`, else the platform `PORT` contract, else []. */
|
|
24
|
+
ports: number[];
|
|
25
|
+
/** Cluster worker processes; `auto`/`max`/`0` ⇒ one per core. Always ≥ 1. */
|
|
26
|
+
workers: number;
|
|
27
|
+
undici: NonNullable<ProdGatewayOptions['undici']>;
|
|
28
|
+
server: NonNullable<ProdGatewayOptions['server']>;
|
|
29
|
+
upstreamRetries?: number;
|
|
30
|
+
trustProxyIp: boolean;
|
|
31
|
+
trustProxyHops: number;
|
|
32
|
+
cookie?: NonNullable<ProdGatewayOptions['cookie']>;
|
|
33
|
+
attributeHeaders: string[];
|
|
34
|
+
clientIpHeader?: string;
|
|
35
|
+
clientIpTrustedProxies: string[];
|
|
36
|
+
uidHeader?: string;
|
|
37
|
+
accessLog: boolean;
|
|
38
|
+
statusEnabled: boolean;
|
|
39
|
+
statusToken?: string;
|
|
40
|
+
metricsEnabled: boolean;
|
|
41
|
+
/** TTL for the Redis `status:*` fan-out — three flush windows, never under 3s. */
|
|
42
|
+
statusTtlSec: number;
|
|
43
|
+
trafficFlushMs?: number;
|
|
44
|
+
rateLimit?: RateLimitConfig;
|
|
45
|
+
health?: {
|
|
46
|
+
path?: string;
|
|
47
|
+
intervalMs?: number;
|
|
48
|
+
timeoutMs?: number;
|
|
49
|
+
};
|
|
50
|
+
healthCheckEnabled: boolean;
|
|
51
|
+
jwt: JwtConfig;
|
|
52
|
+
mtlsFromRedis: boolean;
|
|
53
|
+
}
|
|
54
|
+
/** Parse everything the gateway can decide from the environment alone. Pure. */
|
|
55
|
+
export declare function parseGatewayEnv(env: NodeJS.ProcessEnv): GatewayEnvConfig;
|
|
56
|
+
/**
|
|
57
|
+
* Worker processes for multi-core. `auto`/`max`/`0` ⇒ one per core; anything unparseable or
|
|
58
|
+
* non-positive ⇒ 1, so a typo runs a single healthy gateway rather than none.
|
|
59
|
+
*/
|
|
60
|
+
export declare function workerCount(env: NodeJS.ProcessEnv): number;
|
|
61
|
+
/**
|
|
62
|
+
* The last word on upstream TLS, applied AFTER the mTLS materials are loaded (which is
|
|
63
|
+
* effectful, so it cannot live in `parseGatewayEnv`).
|
|
64
|
+
*
|
|
65
|
+
* A configured CA that is never checked against is inert: `rejectUnauthorized:false` skips
|
|
66
|
+
* verification entirely, so an operator who mounted their private CA to pin east-west TLS got no
|
|
67
|
+
* pinning at all and no warning. A CA is an explicit statement of who to trust — honour it
|
|
68
|
+
* unless verification was explicitly turned off. Returns what changed, for the boot log.
|
|
69
|
+
*/
|
|
70
|
+
export declare function finalizeUpstreamTls(undici: NonNullable<ProdGatewayOptions['undici']>): {
|
|
71
|
+
autoEnabled: boolean;
|
|
72
|
+
};
|
|
73
|
+
/** mTLS rides on HTTP/2 unless explicitly disabled. Returns whether a client cert is in play. */
|
|
74
|
+
export declare function finalizeUpstreamH2(undici: NonNullable<ProdGatewayOptions['undici']>, env: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var w=Object.defineProperty;var o=(e,E)=>w(e,"name",{value:E,configurable:!0});var k=Object.defineProperty,i=o((e,E)=>k(e,"name",{value:E,configurable:!0}),"i");Object.defineProperty(exports,"__esModule",{value:!0}),exports.parseGatewayEnv=parseGatewayEnv,exports.workerCount=workerCount,exports.finalizeUpstreamTls=finalizeUpstreamTls,exports.finalizeUpstreamH2=finalizeUpstreamH2;const node_os_1=require("node:os"),num=i(e=>{if(e===void 0||e.trim()==="")return;const E=Number(e);return Number.isFinite(E)?E:void 0},"num"),csv=i(e=>(e??"").split(",").map(E=>E.trim()).filter(Boolean),"csv"),flag=i(e=>e==="1","flag");function parseGatewayEnv(e){const E=csv(e.LENSMCP_GW_PORTS??e.PORT).map(Number).filter(a=>Number.isFinite(a)&&a>=0&&a<=65535),S=Math.max(0,Math.floor(num(e.LENSMCP_GW_TRUST_PROXY)??0)),t={},N=e.LENSMCP_GW_UPSTREAM_TLS_VERIFY;N==="1"?t.rejectUnauthorized=!0:N==="0"&&(t.rejectUnauthorized=!1),flag(e.LENSMCP_GW_UPSTREAM_H2)&&(t.allowH2=!0);const L=num(e.LENSMCP_GW_UNDICI_CONNECTIONS);L!==void 0&&(t.connections=L);const T=num(e.LENSMCP_GW_UNDICI_BODY_TIMEOUT_MS);T!==void 0&&(t.bodyTimeout=T);const P=num(e.LENSMCP_GW_UNDICI_PIPELINING);P!==void 0&&(t.pipelining=P);const W=num(e.LENSMCP_GW_UNDICI_HEADERS_TIMEOUT_MS);W!==void 0&&(t.headersTimeout=W);const I=num(e.LENSMCP_GW_UNDICI_KEEPALIVE_MS);I!==void 0&&(t.keepAliveTimeout=I);const _={},G=num(e.LENSMCP_GW_KEEPALIVE_MS);G!==void 0&&(_.keepAliveTimeout=G);const d=num(e.LENSMCP_GW_REQUEST_TIMEOUT_MS);d!==void 0&&(_.requestTimeout=d);const u=num(e.LENSMCP_GW_HEADERS_TIMEOUT_MS);u!==void 0&&(_.headersTimeout=u);const c=num(e.LENSMCP_GW_MAX_HEADERS);c!==void 0&&(_.maxHeadersCount=c);const l=num(e.LENSMCP_GW_CONN_CHECK_MS);l!==void 0&&(_.connectionsCheckingInterval=l);const R=num(e.LENSMCP_GW_MAX_CONNECTIONS);R!==void 0&&(_.maxConnections=R);const A=num(e.LENSMCP_GW_BODY_STALL_MS);A!==void 0&&(_.bodyStallMs=A);const M=e.LENSMCP_GW_COOKIE_SECRET,r=e.LENSMCP_GW_COOKIE_NAME,n=e.LENSMCP_GW_COOKIE_DOMAIN,m=num(e.LENSMCP_GW_COOKIE_MAXAGE),U=M||r||n?{...r?{name:r}:{},...M?{secret:M}:{},...n?{domain:n}:{},...m!==void 0?{maxAge:m}:{}}:void 0,v=num(e.LENSMCP_GW_RATE_LIMIT),s=e.LENSMCP_GW_RATE_LIMIT_REDIS_URL,O=num(e.LENSMCP_GW_RATE_LIMIT_TIMEOUT_MS),p=v===void 0?void 0:{limit:v,windowMs:num(e.LENSMCP_GW_RATE_WINDOW_MS)??6e4,wantRedis:flag(e.LENSMCP_GW_RATE_LIMIT_REDIS)||!!s,...s?{dedicatedUrl:s}:{},...O!==void 0?{timeoutMs:O}:{},failClosed:flag(e.LENSMCP_GW_RATE_LIMIT_FAIL_CLOSED)},H=e.LENSMCP_GW_HEALTH_PATH,D=num(e.LENSMCP_GW_HEALTH_INTERVAL_MS),f=num(e.LENSMCP_GW_HEALTH_TIMEOUT_MS),h=flag(e.LENSMCP_GW_HEALTH_CHECK),b=h?{...H?{path:H}:{},...D!==void 0?{intervalMs:D}:{},...f!==void 0?{timeoutMs:f}:{}}:void 0,C=num(e.LENSMCP_GW_TRAFFIC_FLUSH_MS),x=csv(e.LENSMCP_GW_JWT_AUD);return{ports:E,workers:workerCount(e),undici:t,server:_,...num(e.LENSMCP_GW_UPSTREAM_RETRIES)!==void 0?{upstreamRetries:num(e.LENSMCP_GW_UPSTREAM_RETRIES)}:{},trustProxyIp:S>0,trustProxyHops:S,...U?{cookie:U}:{},attributeHeaders:csv(e.LENSMCP_GW_ATTR_HEADERS),...e.LENSMCP_GW_CLIENT_IP_HEADER?{clientIpHeader:e.LENSMCP_GW_CLIENT_IP_HEADER}:{},clientIpTrustedProxies:csv(e.LENSMCP_GW_CLIENT_IP_TRUSTED_PROXIES),...e.LENSMCP_GW_UID_HEADER?{uidHeader:e.LENSMCP_GW_UID_HEADER}:{},accessLog:flag(e.LENSMCP_GW_ACCESS_LOG),statusEnabled:flag(e.LENSMCP_GW_STATUS),...e.LENSMCP_GW_STATUS_TOKEN?{statusToken:e.LENSMCP_GW_STATUS_TOKEN}:{},metricsEnabled:flag(e.LENSMCP_GW_METRICS),statusTtlSec:Math.max(3,Math.ceil((C??5e3)/1e3*3)),...C!==void 0?{trafficFlushMs:C}:{},...p?{rateLimit:p}:{},...b?{health:b}:{},healthCheckEnabled:h,jwt:{...e.LENSMCP_GW_JWKS_URL?{jwksUrl:e.LENSMCP_GW_JWKS_URL}:{},...e.LENSMCP_GW_JWT_SECRET?{secret:e.LENSMCP_GW_JWT_SECRET}:{},...e.LENSMCP_GW_JWT_ISS?{issuer:e.LENSMCP_GW_JWT_ISS}:{},...x.length?{audience:x}:{},presenceOnly:flag(e.LENSMCP_GW_JWT_PRESENCE_ONLY)},mtlsFromRedis:flag(e.LENSMCP_GW_MTLS_FROM_REDIS)}}o(parseGatewayEnv,"parseGatewayEnv"),i(parseGatewayEnv,"parseGatewayEnv");function workerCount(e){const E=(e.LENSMCP_GW_WORKERS??"1").trim().toLowerCase();if(E==="auto"||E==="max"||E==="0")return Math.max(1,(0,node_os_1.cpus)().length);const S=Math.floor(Number(E));return Number.isFinite(S)&&S>0?S:1}o(workerCount,"workerCount"),i(workerCount,"workerCount");function finalizeUpstreamTls(e){return e.ca&&e.rejectUnauthorized===void 0?(e.rejectUnauthorized=!0,{autoEnabled:!0}):{autoEnabled:!1}}o(finalizeUpstreamTls,"finalizeUpstreamTls"),i(finalizeUpstreamTls,"finalizeUpstreamTls");function finalizeUpstreamH2(e,E){return e.cert&&e.key?(E.LENSMCP_GW_UPSTREAM_H2!=="0"&&(e.allowH2=!0),!0):!1}o(finalizeUpstreamH2,"finalizeUpstreamH2"),i(finalizeUpstreamH2,"finalizeUpstreamH2");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var
|
|
1
|
+
"use strict";var g=Object.defineProperty;var u=(t,r)=>g(t,"name",{value:r,configurable:!0});var d=Object.defineProperty,n=u((t,r)=>d(t,"name",{value:r,configurable:!0}),"n");Object.defineProperty(exports,"__esModule",{value:!0}),exports.logAccess=void 0;const edge_1=require("./edge"),logAccess=n((t,r,e)=>{const s=t.raw,o=r.statusCode??0,a=(s.headers.host||"").split(":")[0],i=(s.url||"/").split("?")[0],c=(0,edge_1.rawTargetOf)(s),l=Math.round(r.elapsedTime);try{console.log(JSON.stringify({severity:o>=500?"ERROR":o>=400?"WARNING":"INFO",time:new Date().toISOString(),message:`${s.method} ${a}${i} ${o} ${l}ms`,method:s.method,host:a,path:i,status:o,ms:l,...c?{rawPath:c.split("?")[0]}:{},...e?{service:e.route.service,upstream:e.upLabel}:{},...e?.version?{version:e.version}:{},...e?.caller?{caller:e.caller}:{},...s.headers["x-request-id"]?{requestId:String(s.headers["x-request-id"])}:{}}))}catch{}},"logAccess");exports.logAccess=logAccess;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds one Fastify app per port (shared route table + providers). Streams
|
|
3
|
+
* every body straight through (never parses/buffers — this is a proxy), wires
|
|
4
|
+
* reply-from's undici pool, the operational probes (/livez /readyz /statusz
|
|
5
|
+
* /metricsz), and the trust / CORS / edge-finish hooks.
|
|
6
|
+
*/
|
|
1
7
|
import type * as http from 'node:http';
|
|
2
8
|
import type * as net from 'node:net';
|
|
3
9
|
import { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
|