@lensmcp/cluster 1.0.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/LICENSE +202 -0
- package/README.md +130 -0
- package/basic-ssl.d.ts +7 -0
- package/basic-ssl.d.ts.map +1 -0
- package/basic-ssl.js +158 -0
- package/build-scope-patterns.d.ts +12 -0
- package/build-scope-patterns.d.ts.map +1 -0
- package/build-scope-patterns.js +40 -0
- package/create-webpack-dev.d.ts +27 -0
- package/create-webpack-dev.d.ts.map +1 -0
- package/create-webpack-dev.js +151 -0
- package/create-webpack-prod.d.ts +28 -0
- package/create-webpack-prod.d.ts.map +1 -0
- package/create-webpack-prod.js +169 -0
- package/executors/build/build.impl.d.ts +19 -0
- package/executors/build/build.impl.d.ts.map +1 -0
- package/executors/build/build.impl.js +98 -0
- package/executors/build/schema.d.ts +35 -0
- package/executors/build/schema.json +135 -0
- package/executors/gateway/gateway.impl.d.ts +23 -0
- package/executors/gateway/gateway.impl.d.ts.map +1 -0
- package/executors/gateway/gateway.impl.js +39 -0
- package/executors/gateway/gateway.lib.d.ts +130 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -0
- package/executors/gateway/gateway.lib.js +797 -0
- package/executors/gateway/jwks-verify.d.ts +28 -0
- package/executors/gateway/jwks-verify.d.ts.map +1 -0
- package/executors/gateway/jwks-verify.js +121 -0
- package/executors/gateway/main.prod-gateway.d.ts +2 -0
- package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
- package/executors/gateway/main.prod-gateway.js +290 -0
- package/executors/gateway/main.rollout.d.ts +2 -0
- package/executors/gateway/main.rollout.d.ts.map +1 -0
- package/executors/gateway/main.rollout.js +130 -0
- package/executors/gateway/manifest.d.ts +275 -0
- package/executors/gateway/manifest.d.ts.map +1 -0
- package/executors/gateway/manifest.js +344 -0
- package/executors/gateway/prod-gateway.lib.d.ts +58 -0
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
- package/executors/gateway/prod-gateway.lib.js +535 -0
- package/executors/gateway/providers-prod.d.ts +46 -0
- package/executors/gateway/providers-prod.d.ts.map +1 -0
- package/executors/gateway/providers-prod.js +199 -0
- package/executors/gateway/registry-source.d.ts +68 -0
- package/executors/gateway/registry-source.d.ts.map +1 -0
- package/executors/gateway/registry-source.js +131 -0
- package/executors/gateway/rollout-ops.d.ts +54 -0
- package/executors/gateway/rollout-ops.d.ts.map +1 -0
- package/executors/gateway/rollout-ops.js +167 -0
- package/executors/gateway/schema.d.ts +10 -0
- package/executors/gateway/schema.json +30 -0
- package/executors/serve/schema.d.ts +53 -0
- package/executors/serve/schema.json +196 -0
- package/executors/serve/serve.impl.d.ts +25 -0
- package/executors/serve/serve.impl.d.ts.map +1 -0
- package/executors/serve/serve.impl.js +243 -0
- package/executors/trust/schema.d.ts +6 -0
- package/executors/trust/schema.json +20 -0
- package/executors/trust/trust.impl.d.ts +42 -0
- package/executors/trust/trust.impl.d.ts.map +1 -0
- package/executors/trust/trust.impl.js +126 -0
- package/executors.json +24 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/main.devserver.d.ts +16 -0
- package/main.devserver.d.ts.map +1 -0
- package/main.devserver.js +812 -0
- package/package.json +66 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hostMatches = void 0;
|
|
4
|
+
exports.verifyDevJwt = verifyDevJwt;
|
|
5
|
+
exports.stampIdentity = stampIdentity;
|
|
6
|
+
exports.pickSock = pickSock;
|
|
7
|
+
exports.discoverRoutes = discoverRoutes;
|
|
8
|
+
exports.startGateway = startGateway;
|
|
9
|
+
const tslib_1 = require("tslib");
|
|
10
|
+
/**
|
|
11
|
+
* The gateway runtime, separated from the Nx executor so it is directly
|
|
12
|
+
* testable: `startGateway()` returns a handle with the actually-bound ports
|
|
13
|
+
* and a `stop()` — no signals needed. The executor is a thin wrapper.
|
|
14
|
+
*/
|
|
15
|
+
const node_child_process_1 = require("node:child_process");
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
const fs = tslib_1.__importStar(require("node:fs"));
|
|
18
|
+
const http = tslib_1.__importStar(require("node:http"));
|
|
19
|
+
const os = tslib_1.__importStar(require("node:os"));
|
|
20
|
+
const path = tslib_1.__importStar(require("node:path"));
|
|
21
|
+
const manifest_1 = require("./manifest");
|
|
22
|
+
var manifest_2 = require("./manifest"); // back-compat re-export
|
|
23
|
+
Object.defineProperty(exports, "hostMatches", { enumerable: true, get: function () { return manifest_2.hostMatches; } });
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
|
+
const httpProxy = require('http-proxy');
|
|
26
|
+
/**
|
|
27
|
+
* Dev edge auth (HS256), mirroring the prod gateway: verify the end-user token, then stamp the
|
|
28
|
+
* trusted identity headers the services read (via GatewayTrustGuard). RS256/JWKS is prod's job;
|
|
29
|
+
* dev shares the `AUTH_JWT_SECRET` the auth host signs with. Returns the claims, or undefined.
|
|
30
|
+
*/
|
|
31
|
+
function verifyDevJwt(req) {
|
|
32
|
+
const secret = process.env['AUTH_JWT_SECRET'] ?? 'tetros-dev-secret';
|
|
33
|
+
const auth = String((Array.isArray(req.headers['authorization']) ? req.headers['authorization'][0] : req.headers['authorization']) ?? '');
|
|
34
|
+
if (!auth.startsWith('Bearer '))
|
|
35
|
+
return undefined;
|
|
36
|
+
const [h, p, s] = auth.slice(7).split('.');
|
|
37
|
+
if (!h || !p || !s)
|
|
38
|
+
return undefined;
|
|
39
|
+
const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
|
|
40
|
+
const a = Buffer.from(s);
|
|
41
|
+
const b = Buffer.from(expected);
|
|
42
|
+
if (a.length !== b.length || !(0, node_crypto_1.timingSafeEqual)(a, b))
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
const claims = JSON.parse(Buffer.from(p, 'base64url').toString());
|
|
46
|
+
if (typeof claims['exp'] === 'number' && Date.now() / 1000 > claims['exp'])
|
|
47
|
+
return undefined;
|
|
48
|
+
return claims;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Stamp verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
|
|
55
|
+
function stampIdentity(req, claims) {
|
|
56
|
+
const set = (k, v) => { if (v !== undefined && v !== null && v !== '')
|
|
57
|
+
req.headers[k] = String(v); };
|
|
58
|
+
set('x-user-id', claims['sub']);
|
|
59
|
+
set('x-tenant-id', claims['tid']);
|
|
60
|
+
const roles = claims['roles'];
|
|
61
|
+
if (Array.isArray(roles) && roles.length)
|
|
62
|
+
req.headers['x-user-roles'] = roles.map(String).join(',');
|
|
63
|
+
const perms = claims['perms'];
|
|
64
|
+
if (Array.isArray(perms) && perms.length)
|
|
65
|
+
req.headers['x-user-permissions'] = perms.map(String).join(',');
|
|
66
|
+
}
|
|
67
|
+
const devAttrs = (req, claims) => ({
|
|
68
|
+
...claims,
|
|
69
|
+
ip: req.socket.remoteAddress ?? '',
|
|
70
|
+
method: (req.method ?? 'GET').toUpperCase(),
|
|
71
|
+
path: (req.url ?? '/').split('?')[0],
|
|
72
|
+
});
|
|
73
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
74
|
+
/** Round-robin over the pool's sockets, rescanning the dir when the cache ages out. */
|
|
75
|
+
function pickSock(pool, scanTtlMs = 1500) {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
if (now - pool.scannedAt > scanTtlMs) {
|
|
78
|
+
try {
|
|
79
|
+
pool.socks = fs.readdirSync(pool.dir)
|
|
80
|
+
.filter((f) => /^child-\d+\.sock$/.test(f))
|
|
81
|
+
.map((f) => path.join(pool.dir, f));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
pool.socks = [];
|
|
85
|
+
}
|
|
86
|
+
pool.scannedAt = now;
|
|
87
|
+
}
|
|
88
|
+
if (pool.socks.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
pool.idx = (pool.idx + 1) % pool.socks.length;
|
|
91
|
+
return pool.socks[pool.idx];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read every project's `cluster` (or legacy `davnx`) declaration and build
|
|
95
|
+
* the route table: host routes first (plus auto `internal.<host>` for pod
|
|
96
|
+
* services), the `default` catch-all last.
|
|
97
|
+
*/
|
|
98
|
+
function discoverRoutes(rootDir, projects, sockBaseDir = os.tmpdir()) {
|
|
99
|
+
const routes = [];
|
|
100
|
+
const services = [];
|
|
101
|
+
let defaultRoute;
|
|
102
|
+
for (const [name, p] of Object.entries(projects)) {
|
|
103
|
+
let decl;
|
|
104
|
+
try {
|
|
105
|
+
const raw = JSON.parse(fs.readFileSync(path.join(rootDir, p.root, 'project.json'), 'utf8'));
|
|
106
|
+
decl = raw.cluster ?? raw.davnx;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!decl)
|
|
112
|
+
continue;
|
|
113
|
+
let pool;
|
|
114
|
+
let svc;
|
|
115
|
+
let target;
|
|
116
|
+
if (decl.service) {
|
|
117
|
+
pool = { dir: path.join(sockBaseDir, `${decl.service}-devserver`), socks: [], idx: -1, scannedAt: 0 };
|
|
118
|
+
svc = { project: name, decl, pool, state: 'down', lastUsed: Date.now(), inflight: 0, lastScaleAt: 0 };
|
|
119
|
+
services.push(svc);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
target = decl.upstream ?? (decl.port ? `http://localhost:${decl.port}` : undefined);
|
|
123
|
+
if (!target) {
|
|
124
|
+
console.warn(`[gateway] ${name}: no service/upstream/port — skipped.`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const base = { project: name, prependPrefix: decl.prependPrefix, target, pool, svc, prefix: decl.path, auth: decl.auth };
|
|
129
|
+
if (decl.host) {
|
|
130
|
+
routes.push({ ...base, host: decl.host });
|
|
131
|
+
// internal.<host>: east-west traffic, same pods, NO middleware.
|
|
132
|
+
if (pool && decl.internalHost !== false) {
|
|
133
|
+
routes.push({
|
|
134
|
+
...base,
|
|
135
|
+
host: typeof decl.internalHost === 'string' ? decl.internalHost : `internal.${decl.host}`,
|
|
136
|
+
internal: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (decl.default)
|
|
141
|
+
defaultRoute = { ...base, prefix: undefined };
|
|
142
|
+
}
|
|
143
|
+
if (defaultRoute)
|
|
144
|
+
routes.push(defaultRoute);
|
|
145
|
+
// specificity sort (shared with prod): host+path beats host-only beats
|
|
146
|
+
// catch-all — so a path-mounted service (api.x/channels) wins over the
|
|
147
|
+
// host's owner (api.x → api).
|
|
148
|
+
(0, manifest_1.sortBySpecificity)(routes);
|
|
149
|
+
return { routes, services };
|
|
150
|
+
}
|
|
151
|
+
async function startGateway(options, context) {
|
|
152
|
+
const https = options.https !== false;
|
|
153
|
+
const ports = options.ports?.length ? options.ports : [443];
|
|
154
|
+
const scanTtlMs = options.scanTtlMs ?? 1500;
|
|
155
|
+
const startupTimeoutMs = options.startupTimeoutMs ?? 120_000;
|
|
156
|
+
// Set by stop(); consulted by spawnService/ensureUp so an in-flight cold
|
|
157
|
+
// start neither spawns a process nor logs after the gateway is torn down.
|
|
158
|
+
let stopped = false;
|
|
159
|
+
// --- LensMCP bus: the gateway is a first-class lens source -------------------
|
|
160
|
+
// Appends BaseEvent-shaped lines to the shared event file the MCP tails
|
|
161
|
+
// (same rendezvous the vite plugin and nest instrumentation use), so the
|
|
162
|
+
// lens sees the cluster itself: services up/down, cold starts, autoscale,
|
|
163
|
+
// idle kills, and aggregated host→service traffic edges for the topology.
|
|
164
|
+
const eventFile = process.env['LENSMCP_EVENT_FILE'] ?? path.join(context.root, '.lensmcp', 'events.jsonl');
|
|
165
|
+
const eid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 12);
|
|
166
|
+
const emit = (severity, title, fingerprint, raw, flowCtx) => {
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(path.dirname(eventFile), { recursive: true });
|
|
169
|
+
fs.appendFileSync(eventFile, JSON.stringify({
|
|
170
|
+
id: eid(), sessionId: 'pending', timestamp: Date.now(),
|
|
171
|
+
source: 'gateway', category: 'cluster', severity,
|
|
172
|
+
context: { sessionId: 'pending', ...flowCtx }, fingerprint, title, raw,
|
|
173
|
+
}) + '\n');
|
|
174
|
+
}
|
|
175
|
+
catch { /* lens offline — never block traffic */ }
|
|
176
|
+
};
|
|
177
|
+
const hdr = (v) => typeof v === 'string' && v !== '' ? v : Array.isArray(v) ? v[0] : undefined;
|
|
178
|
+
const beginTrace = (req) => {
|
|
179
|
+
const flowId = hdr(req.headers['x-lensmcp-flow-id']);
|
|
180
|
+
let requestId = hdr(req.headers['x-request-id']);
|
|
181
|
+
if (!flowId && !requestId && !hdr(req.headers['traceparent']))
|
|
182
|
+
return undefined;
|
|
183
|
+
if (!requestId) {
|
|
184
|
+
// Mint the request id AT THE EDGE and inject it downstream, so the
|
|
185
|
+
// gateway trace and the pod's server-request/spans share one id —
|
|
186
|
+
// that's the join key that attributes pod events to the routed service.
|
|
187
|
+
requestId = eid();
|
|
188
|
+
req.headers['x-request-id'] = requestId;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
startedAt: Date.now(),
|
|
192
|
+
method: (req.method ?? 'GET').toUpperCase(),
|
|
193
|
+
host: (req.headers.host || '').split(':')[0],
|
|
194
|
+
url: req.url ?? '/',
|
|
195
|
+
steps: [],
|
|
196
|
+
...(flowId ? { flowId } : {}),
|
|
197
|
+
...(requestId ? { requestId } : {}),
|
|
198
|
+
...(hdr(req.headers['x-lensmcp-origin-node-id']) ? { originNodeId: hdr(req.headers['x-lensmcp-origin-node-id']) } : {}),
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
const traceStep = (t, stepName, info) => {
|
|
202
|
+
if (!t)
|
|
203
|
+
return;
|
|
204
|
+
t.steps.push({ step: stepName, atMs: Date.now() - t.startedAt, ...info });
|
|
205
|
+
};
|
|
206
|
+
const finishTrace = (t, project, status, extra) => {
|
|
207
|
+
if (!t || t.done)
|
|
208
|
+
return;
|
|
209
|
+
t.done = true;
|
|
210
|
+
const durationMs = Date.now() - t.startedAt;
|
|
211
|
+
emit(status >= 500 ? 'error' : 'info', `gateway ${t.method} ${t.host}${t.url} → ${status} (${durationMs}ms)`, `gateway-request:${project}`, {
|
|
212
|
+
kind: 'gateway-request', project, host: t.host, method: t.method, url: t.url,
|
|
213
|
+
status, durationMs, startedAt: t.startedAt, steps: t.steps, ...extra,
|
|
214
|
+
}, {
|
|
215
|
+
...(t.flowId ? { flowId: t.flowId } : {}),
|
|
216
|
+
...(t.requestId ? { requestId: t.requestId } : {}),
|
|
217
|
+
...(t.originNodeId ? { originNodeId: t.originNodeId } : {}),
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
// --- discover ---------------------------------------------------------------
|
|
221
|
+
const { routes, services } = discoverRoutes(context.root, context.projectsConfigurations?.projects ?? {});
|
|
222
|
+
if (routes.length === 0) {
|
|
223
|
+
throw new Error('[gateway] no project declares a `cluster` field — nothing to route.');
|
|
224
|
+
}
|
|
225
|
+
// --- service lifecycle: scale-from-zero, autoscale, idle kill ----------------
|
|
226
|
+
const scanNow = (pool) => { pool.scannedAt = 0; return pickSock(pool, scanTtlMs) ? pool.socks.length : 0; };
|
|
227
|
+
const spawnService = (svc) => {
|
|
228
|
+
if (stopped)
|
|
229
|
+
return; // never spawn a process after the gateway is told to stop
|
|
230
|
+
const target = `${svc.project}:${svc.decl.serveTarget ?? 'serve-hmr'}`;
|
|
231
|
+
const min = Math.max(1, svc.decl.minPods ?? 1);
|
|
232
|
+
console.log(`[gateway] starting ${target} (pods=${min})…`);
|
|
233
|
+
emit('info', `service starting: ${svc.project} (${min} pod${min > 1 ? 's' : ''})`, `cluster-service:${svc.project}`, { kind: 'service-starting', project: svc.project, pods: min });
|
|
234
|
+
svc.state = 'starting';
|
|
235
|
+
svc.spawnAt = Date.now();
|
|
236
|
+
// childCount goes as a CLI option, NOT env: nx executes tasks via its
|
|
237
|
+
// daemon, whose environment is not the gateway's — env vars get lost.
|
|
238
|
+
const proc = (0, node_child_process_1.spawn)('yarn', ['nx', 'run', target, `--childCount=${min}`], {
|
|
239
|
+
cwd: context.root,
|
|
240
|
+
detached: true,
|
|
241
|
+
// NX_DAEMON=false keeps the serve task IN this spawned process group.
|
|
242
|
+
// With the daemon on, Nx hands the long-running task to the persistent
|
|
243
|
+
// daemon (a DIFFERENT group), so the later process-group kill reaps only
|
|
244
|
+
// the `yarn nx` shim and orphans the devserver + pod tree — the classic
|
|
245
|
+
// "idle-kill leaves live pods" leak. (--childCount as a CLI arg already
|
|
246
|
+
// avoids the env-loss the daemon was relied on for.)
|
|
247
|
+
env: { ...process.env, NX_DAEMON: 'false' },
|
|
248
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
249
|
+
});
|
|
250
|
+
svc.lastErrors = [];
|
|
251
|
+
svc.buildFailed = false;
|
|
252
|
+
const sniff = (d) => {
|
|
253
|
+
const text = d.toString();
|
|
254
|
+
for (const line of text.split('\n')) {
|
|
255
|
+
if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
|
|
256
|
+
svc.lastErrors.push(line.replace(/\x1b\[[0-9;]*m/g, '').trim());
|
|
257
|
+
if (svc.lastErrors.length > 12)
|
|
258
|
+
svc.lastErrors.shift();
|
|
259
|
+
svc.buildFailed = true;
|
|
260
|
+
emit('error', `service error: ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-error', project: svc.project, line: svc.lastErrors[svc.lastErrors.length - 1] });
|
|
261
|
+
}
|
|
262
|
+
if (/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(line))
|
|
263
|
+
svc.buildFailed = false;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
proc.stdout?.on('data', (d) => { if (stopped)
|
|
267
|
+
return; sniff(d); process.stdout.write(`[${svc.project}] ${d}`); });
|
|
268
|
+
proc.stderr?.on('data', (d) => { if (stopped)
|
|
269
|
+
return; sniff(d); process.stderr.write(`[${svc.project}] ${d}`); });
|
|
270
|
+
proc.on('exit', (code) => {
|
|
271
|
+
svc.proc = undefined;
|
|
272
|
+
svc.state = 'down';
|
|
273
|
+
if (stopped)
|
|
274
|
+
return; // gateway is tearing down — its dying services' exit chatter is noise
|
|
275
|
+
console.log(`[gateway] ${target} exited (${code})`);
|
|
276
|
+
emit(code === 0 ? 'info' : 'warning', `service down: ${svc.project} (exit ${code})`, `cluster-service:${svc.project}`, { kind: 'service-down', project: svc.project, code });
|
|
277
|
+
});
|
|
278
|
+
svc.proc = proc;
|
|
279
|
+
};
|
|
280
|
+
const ensureUp = async (svc) => {
|
|
281
|
+
if (scanNow(svc.pool) > 0) {
|
|
282
|
+
if (svc.state !== 'up') {
|
|
283
|
+
emit('info', `service up: ${svc.project} (${svc.pool.socks.length} pods, adopted)`, `cluster-service:${svc.project}`, { kind: 'service-up', project: svc.project, pods: svc.pool.socks.length, adopted: true });
|
|
284
|
+
}
|
|
285
|
+
svc.state = 'up';
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
if (stopped)
|
|
289
|
+
return false;
|
|
290
|
+
if (svc.state !== 'starting' && !svc.proc)
|
|
291
|
+
spawnService(svc); // a live spawn supervises itself — don't stack a second one (EADDRINUSE)
|
|
292
|
+
const deadline = Date.now() + startupTimeoutMs;
|
|
293
|
+
const failFastAt = Date.now() + 12_000; // a build error within 12s → stop waiting, report the cause
|
|
294
|
+
while (!stopped && Date.now() < deadline) {
|
|
295
|
+
await sleep(Math.min(700, startupTimeoutMs / 4));
|
|
296
|
+
if (svc.buildFailed && Date.now() > failFastAt)
|
|
297
|
+
return false;
|
|
298
|
+
if (scanNow(svc.pool) > 0) {
|
|
299
|
+
svc.state = 'up';
|
|
300
|
+
svc.buildFailed = false;
|
|
301
|
+
const durationMs = svc.spawnAt ? Date.now() - svc.spawnAt : undefined;
|
|
302
|
+
console.log(`[gateway] ${svc.project} up (${svc.pool.socks.length} pods${durationMs ? `, cold start ${durationMs}ms` : ''})`);
|
|
303
|
+
emit('info', `cold start: ${svc.project} (${svc.pool.socks.length} pods, ${durationMs ?? '?'}ms)`, `cluster-service:${svc.project}`, { kind: 'cold-start', project: svc.project, pods: svc.pool.socks.length, durationMs });
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
if (svc.state === 'down')
|
|
307
|
+
return false; // spawn died
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
};
|
|
311
|
+
const scaleUp = (svc) => {
|
|
312
|
+
const max = svc.decl.maxPods ?? svc.decl.minPods ?? 1;
|
|
313
|
+
const cur = svc.pool.socks.length;
|
|
314
|
+
const admin = svc.decl.adminUrl ?? (svc.decl.port ? `http://localhost:${svc.decl.port}` : undefined);
|
|
315
|
+
if (!admin || cur === 0 || cur >= max || svc.inflight <= cur)
|
|
316
|
+
return;
|
|
317
|
+
if (Date.now() - svc.lastScaleAt < 5000)
|
|
318
|
+
return;
|
|
319
|
+
svc.lastScaleAt = Date.now();
|
|
320
|
+
const mod = admin.startsWith('https')
|
|
321
|
+
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
322
|
+
require('node:https')
|
|
323
|
+
: http;
|
|
324
|
+
const r = mod.request(`${admin}/webpack/scale`, { method: 'POST', rejectUnauthorized: false }, (rs) => rs.resume());
|
|
325
|
+
r.on('error', () => undefined);
|
|
326
|
+
r.end(JSON.stringify({ pods: cur + 1 }));
|
|
327
|
+
console.log(`[gateway] autoscale ${svc.project}: ${cur} → ${cur + 1} pods (inflight ${svc.inflight})`);
|
|
328
|
+
emit('info', `autoscale: ${svc.project} ${cur} → ${cur + 1} pods`, `cluster-service:${svc.project}`, { kind: 'autoscale', project: svc.project, from: cur, to: cur + 1, inflight: svc.inflight });
|
|
329
|
+
};
|
|
330
|
+
// Idle sweeper: kill gateway-spawned lazy services after idleKillSec.
|
|
331
|
+
const killSpawned = (svc, why) => {
|
|
332
|
+
if (!svc.proc)
|
|
333
|
+
return;
|
|
334
|
+
console.log(`[gateway] ${svc.project} ${why} — scaling to zero.`);
|
|
335
|
+
emit('info', `idle kill: ${svc.project} (${why})`, `cluster-service:${svc.project}`, { kind: 'idle-kill', project: svc.project, why });
|
|
336
|
+
const dying = svc.proc;
|
|
337
|
+
// Detach the corpse's handlers BEFORE killing: a dying webpack/nx flushes
|
|
338
|
+
// buffered stdout/stderr as it exits, and those `sniff` callbacks would
|
|
339
|
+
// keep mutating svc.lastErrors/buildFailed (phantom build failures) — fatal
|
|
340
|
+
// if a fresh request re-spawns this same svc while the corpse still flushes.
|
|
341
|
+
try {
|
|
342
|
+
dying.stdout?.removeAllListeners('data');
|
|
343
|
+
dying.stderr?.removeAllListeners('data');
|
|
344
|
+
dying.removeAllListeners('exit');
|
|
345
|
+
}
|
|
346
|
+
catch { /* ignore */ }
|
|
347
|
+
try {
|
|
348
|
+
process.kill(-dying.pid, 'SIGTERM');
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
try {
|
|
352
|
+
dying.kill('SIGTERM');
|
|
353
|
+
}
|
|
354
|
+
catch { /* gone */ }
|
|
355
|
+
}
|
|
356
|
+
svc.proc = undefined;
|
|
357
|
+
svc.state = 'down';
|
|
358
|
+
// children die with the tree but their sock FILES survive — remove them
|
|
359
|
+
// so the canvas and the proxy never see ghost pods.
|
|
360
|
+
setTimeout(() => {
|
|
361
|
+
try {
|
|
362
|
+
for (const f of fs.readdirSync(svc.pool.dir)) {
|
|
363
|
+
if (f.endsWith('.sock'))
|
|
364
|
+
fs.unlinkSync(path.join(svc.pool.dir, f));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch { /* gone */ }
|
|
368
|
+
svc.pool.socks = [];
|
|
369
|
+
svc.pool.scannedAt = 0;
|
|
370
|
+
}, 1200).unref?.();
|
|
371
|
+
};
|
|
372
|
+
const sweeper = setInterval(() => {
|
|
373
|
+
for (const svc of services) {
|
|
374
|
+
const idle = svc.decl.idleKillSec;
|
|
375
|
+
if (!svc.proc || !idle || svc.inflight > 0)
|
|
376
|
+
continue;
|
|
377
|
+
if (Date.now() - svc.lastUsed > idle * 1000)
|
|
378
|
+
killSpawned(svc, `idle ${idle}s`);
|
|
379
|
+
}
|
|
380
|
+
}, options.sweepMs ?? 10_000);
|
|
381
|
+
sweeper.unref();
|
|
382
|
+
// Eager services boot with the gateway.
|
|
383
|
+
for (const svc of services) {
|
|
384
|
+
if (svc.decl.eager)
|
|
385
|
+
void ensureUp(svc);
|
|
386
|
+
}
|
|
387
|
+
// --- per-service api keys (be-security: the gateway is the trust boundary) --
|
|
388
|
+
// Every pod service gets a stable key in {cacheDir}/service-keys.json.
|
|
389
|
+
// Public routes: the gateway injects the TARGET's key as x-internal-token —
|
|
390
|
+
// pods are only reachable through it (unix sockets aren't on the network).
|
|
391
|
+
// internal.<host> routes: the CALLER must present its own key as x-api-key;
|
|
392
|
+
// the gateway validates it, stamps x-api-key-id (caller identity, for audit
|
|
393
|
+
// and lens traces) and forwards with the target's x-internal-token.
|
|
394
|
+
const keysDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
|
|
395
|
+
const keysPath = path.join(keysDir, 'service-keys.json');
|
|
396
|
+
let serviceKeys = {};
|
|
397
|
+
try {
|
|
398
|
+
serviceKeys = JSON.parse(fs.readFileSync(keysPath, 'utf8'));
|
|
399
|
+
}
|
|
400
|
+
catch { /* first run */ }
|
|
401
|
+
let keysDirty = false;
|
|
402
|
+
for (const svc of services) {
|
|
403
|
+
if (!serviceKeys[svc.project]) {
|
|
404
|
+
serviceKeys[svc.project] = (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
405
|
+
keysDirty = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (keysDirty) {
|
|
409
|
+
try {
|
|
410
|
+
fs.mkdirSync(keysDir, { recursive: true });
|
|
411
|
+
fs.writeFileSync(keysPath, JSON.stringify(serviceKeys, null, 2));
|
|
412
|
+
}
|
|
413
|
+
catch { /* ro fs */ }
|
|
414
|
+
}
|
|
415
|
+
const keyToProject = new Map(Object.entries(serviceKeys).map(([p, k]) => [k, p]));
|
|
416
|
+
// --- middleware (the JWT seam; internal.* routes skip it) -------------------
|
|
417
|
+
let middleware;
|
|
418
|
+
if (options.middleware) {
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
420
|
+
const mod = require(path.resolve(context.root, options.middleware));
|
|
421
|
+
middleware = (mod.default ?? mod);
|
|
422
|
+
}
|
|
423
|
+
// --- routing ------------------------------------------------------------------
|
|
424
|
+
// The matcher is SHARED with the production gateway (one routing brain, no
|
|
425
|
+
// drift — see manifest.ts `matchRoute` for the rules). Dev's Route carries
|
|
426
|
+
// pod/svc fields too; matchRoute only reads host/prefix.
|
|
427
|
+
const match = (req) => (0, manifest_1.matchRoute)(routes, (req.headers.host || '').split(':')[0], req.url ?? '/');
|
|
428
|
+
// secure:false — upstreams may terminate TLS with the same local CA.
|
|
429
|
+
// proxyTimeout — a pod that accepts the socket but never responds would
|
|
430
|
+
// otherwise pin svc.inflight forever (blocking idle-kill, skewing autoscale)
|
|
431
|
+
// and leave the gateway-request trace open; the timeout fires the proxy
|
|
432
|
+
// error path → 502 → res 'close' → inflight decrements + trace finishes.
|
|
433
|
+
// Keep-alive to upstreams (pods over unix sockets AND TCP apps). Without an
|
|
434
|
+
// agent http-proxy opens a fresh connection per request — a connect per call
|
|
435
|
+
// that ~halves throughput and adds latency. One pooled agent reuses sockets
|
|
436
|
+
// (keyed by socketPath / host:port); https targets get the verify-skipping
|
|
437
|
+
// https agent. Picked per request by target in `forward`/`upgrade`.
|
|
438
|
+
// NB: a local `const https` (TLS-on flag) shadows the module here, so the
|
|
439
|
+
// https agent is built via require — matching this file's node:https usage.
|
|
440
|
+
const agentOpts = { keepAlive: true, keepAliveMsecs: 30_000, maxSockets: 1024, maxFreeSockets: 256 };
|
|
441
|
+
const httpAgent = new http.Agent(agentOpts);
|
|
442
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
443
|
+
const httpsAgent = new (require('node:https').Agent)(agentOpts);
|
|
444
|
+
const agentForTarget = (target) => typeof target === 'string' && target.startsWith('https:') ? httpsAgent : httpAgent;
|
|
445
|
+
const proxy = httpProxy.createProxyServer({ xfwd: true, secure: false, proxyTimeout: 120_000, agent: httpAgent });
|
|
446
|
+
// Surface WHICH pod served the request (round-robin made visible).
|
|
447
|
+
proxy.on('proxyRes', (proxyRes, req) => {
|
|
448
|
+
const pod = req.__lensmcpPod;
|
|
449
|
+
if (pod)
|
|
450
|
+
proxyRes.headers['x-lensmcp-pod'] = pod;
|
|
451
|
+
// The gateway owns the edge response: drop the upstream framework leak
|
|
452
|
+
// (e.g. NestJS/Express `x-powered-by`) and brand the hop as the LensMCP gateway.
|
|
453
|
+
delete proxyRes.headers['x-powered-by'];
|
|
454
|
+
proxyRes.headers['server'] = 'LensMCP';
|
|
455
|
+
});
|
|
456
|
+
// Aggregated host→service traffic edges, flushed to the lens periodically —
|
|
457
|
+
// the request-derived topology (nodes/edges appear from observed traffic).
|
|
458
|
+
const edges = new Map();
|
|
459
|
+
const recordEdge = (route, ms, status, caller) => {
|
|
460
|
+
const host = route.host ?? '(default)';
|
|
461
|
+
const key = (caller ? caller + '⇒' : '') + host + '→' + route.project;
|
|
462
|
+
const e = edges.get(key) ?? { host, project: route.project, caller, count: 0, totalMs: 0, errors: 0 };
|
|
463
|
+
e.count += 1;
|
|
464
|
+
e.totalMs += ms;
|
|
465
|
+
if (status >= 500 || status === 0)
|
|
466
|
+
e.errors += 1;
|
|
467
|
+
edges.set(key, e);
|
|
468
|
+
};
|
|
469
|
+
const edgeFlusher = setInterval(() => {
|
|
470
|
+
for (const e of edges.values()) {
|
|
471
|
+
const avgMs = Math.round(e.totalMs / e.count);
|
|
472
|
+
emit(e.errors ? 'warning' : 'info', `traffic ${e.host} → ${e.project}: ${e.count} req, ~${avgMs}ms${e.errors ? `, ${e.errors} errors` : ''}`, `gateway-traffic:${e.host}->${e.project}`, { kind: 'gateway-traffic', host: e.host, project: e.project, caller: e.caller, count: e.count, avgMs, errors: e.errors });
|
|
473
|
+
}
|
|
474
|
+
edges.clear();
|
|
475
|
+
}, options.trafficFlushMs ?? 5000);
|
|
476
|
+
edgeFlusher.unref();
|
|
477
|
+
const forward = async (route, req, res) => {
|
|
478
|
+
const startedAt = Date.now();
|
|
479
|
+
const trace = req.__lensmcpTrace;
|
|
480
|
+
res.on('close', () => {
|
|
481
|
+
recordEdge(route, Date.now() - startedAt, res.statusCode ?? 0, req.__lensmcpCaller);
|
|
482
|
+
finishTrace(trace, route.project, res.statusCode ?? 0, {
|
|
483
|
+
...(req.__lensmcpPod ? { pod: req.__lensmcpPod } : {}),
|
|
484
|
+
...(req.__lensmcpCaller ? { caller: req.__lensmcpCaller } : {}),
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
|
|
488
|
+
req.url = route.prependPrefix + (req.url ?? '/');
|
|
489
|
+
}
|
|
490
|
+
if (route.pool) {
|
|
491
|
+
const svc = route.svc;
|
|
492
|
+
if (svc) {
|
|
493
|
+
svc.lastUsed = Date.now();
|
|
494
|
+
svc.inflight += 1;
|
|
495
|
+
res.on('close', () => { svc.inflight -= 1; svc.lastUsed = Date.now(); });
|
|
496
|
+
}
|
|
497
|
+
let sock = pickSock(route.pool, scanTtlMs);
|
|
498
|
+
if (!sock && svc) {
|
|
499
|
+
// scale from zero: start the service, buffer this request until pods are up
|
|
500
|
+
traceStep(trace, 'cold-start', { project: route.project });
|
|
501
|
+
if (await ensureUp(svc))
|
|
502
|
+
sock = pickSock(route.pool, scanTtlMs);
|
|
503
|
+
}
|
|
504
|
+
if (svc)
|
|
505
|
+
scaleUp(svc);
|
|
506
|
+
if (!sock) {
|
|
507
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
508
|
+
const errs = svc?.lastErrors?.length ? '\n\nService errors (from build/runtime):\n ' + svc.lastErrors.join('\n ') : '';
|
|
509
|
+
res.end(`Service ${route.project} is not reachable (no pods in ${route.pool.dir}).${errs}\nFix the error — the watcher recovers and the next request retries automatically.`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
req.__lensmcpPod = path.basename(sock, '.sock');
|
|
513
|
+
traceStep(trace, 'pod-select', { pod: path.basename(sock, '.sock'), pods: route.pool.socks.length, strategy: 'round-robin' });
|
|
514
|
+
proxy.web(req, res, { target: { socketPath: sock }, autoRewrite: true, agent: httpAgent }, (err) => {
|
|
515
|
+
route.pool.socks = route.pool.socks.filter((s) => s !== sock); // drop dead pod until rescan
|
|
516
|
+
route.pool.idx = -1; // removing an element shifts indices — reset so the next pick walks cleanly from 0
|
|
517
|
+
try {
|
|
518
|
+
fs.unlinkSync(sock);
|
|
519
|
+
}
|
|
520
|
+
catch { /* already gone */ } // a refused sock is a GHOST file — remove it so rescans stay truthful
|
|
521
|
+
console.error(`[gateway] pod ${path.basename(sock)} of ${route.project}:`, err.message);
|
|
522
|
+
res.statusCode = 502;
|
|
523
|
+
res.end(`Pod ${path.basename(sock)} of ${route.project} unavailable — retry.`);
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
proxy.web(req, res, { target: route.target, autoRewrite: true, changeOrigin: true, agent: agentForTarget(route.target) }, (err) => {
|
|
528
|
+
console.error(`[gateway] upstream ${route.project} (${route.target}):`, err.message);
|
|
529
|
+
res.statusCode = 502;
|
|
530
|
+
res.end(`Upstream ${route.project} unavailable.`);
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
// The gateway OWNS these headers — strip any client-supplied copy at ingress so identity
|
|
534
|
+
// + trust can only originate from the gateway (mirrors the prod gateway's stripTrust).
|
|
535
|
+
const GATEWAY_OWNED_HEADERS = ['x-internal-token', 'x-api-key-id', 'x-user-id', 'x-tenant-id', 'x-user-roles', 'x-user-permissions'];
|
|
536
|
+
const cors = (req, res) => {
|
|
537
|
+
const origin = String(req.headers.origin ?? '*');
|
|
538
|
+
res.setHeader('access-control-allow-origin', origin);
|
|
539
|
+
res.setHeader('access-control-allow-credentials', 'true');
|
|
540
|
+
res.setHeader('vary', 'Origin');
|
|
541
|
+
};
|
|
542
|
+
const handler = (req, res) => {
|
|
543
|
+
// STRIP client-supplied trust headers before anything else: the gateway is
|
|
544
|
+
// the ONLY party allowed to assert `x-internal-token` (came through me) and
|
|
545
|
+
// `x-api-key-id` (validated caller identity). A client that sends either
|
|
546
|
+
// must not have it forwarded — the gateway re-stamps them below where
|
|
547
|
+
// earned. Without this, a keyless/TCP route would leak a spoofed token and
|
|
548
|
+
// any public route would forward a spoofed caller id to downstream audit.
|
|
549
|
+
for (const h of GATEWAY_OWNED_HEADERS)
|
|
550
|
+
delete req.headers[h];
|
|
551
|
+
const trace = beginTrace(req);
|
|
552
|
+
if (trace) {
|
|
553
|
+
traceStep(trace, 'received', { host: trace.host, method: trace.method, url: trace.url });
|
|
554
|
+
req.__lensmcpTrace = trace;
|
|
555
|
+
}
|
|
556
|
+
const route = match(req);
|
|
557
|
+
if (!route) {
|
|
558
|
+
// gateway-owned endpoints are cross-origin by design (the app calls
|
|
559
|
+
// api.<domain>/health from app.<domain>) — answer CORS here; routed
|
|
560
|
+
// paths inherit CORS from the services themselves.
|
|
561
|
+
cors(req, res);
|
|
562
|
+
if (req.method === 'OPTIONS') {
|
|
563
|
+
res.writeHead(204, {
|
|
564
|
+
'access-control-allow-methods': 'GET,HEAD,OPTIONS',
|
|
565
|
+
'access-control-allow-headers': String(req.headers['access-control-request-headers'] ?? '*'),
|
|
566
|
+
});
|
|
567
|
+
res.end();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// Host-root infra: when a host only has PATH-mounted services (pure
|
|
571
|
+
// microservices under api.<domain>/{prefix}), the gateway itself owns
|
|
572
|
+
// the root — health aggregation + service discovery, like a real edge.
|
|
573
|
+
const host = (req.headers.host || '').split(':')[0];
|
|
574
|
+
const mounted = routes.filter((r) => r.host && (0, manifest_1.hostMatches)(r.host, host) && r.prefix);
|
|
575
|
+
const url = (req.url ?? '/').split('?')[0];
|
|
576
|
+
if (mounted.length > 0 && (url === '/health' || url === '/healthz')) {
|
|
577
|
+
const services = mounted.map((r) => ({
|
|
578
|
+
project: r.project,
|
|
579
|
+
prefix: r.prefix,
|
|
580
|
+
pods: r.pool ? (pickSock(r.pool, 0), r.pool.socks.length) : undefined,
|
|
581
|
+
state: r.svc?.state ?? (r.target ? 'up' : 'unknown'),
|
|
582
|
+
}));
|
|
583
|
+
const ok = services.every((s) => s.state !== 'down' || true);
|
|
584
|
+
res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json' });
|
|
585
|
+
res.end(JSON.stringify({ status: 'ok', host, services }, null, 2));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (mounted.length > 0 && url === '/.well-known/services') {
|
|
589
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
590
|
+
res.end(JSON.stringify({ host, services: mounted.map((r) => ({ prefix: r.prefix, project: r.project, internal: !!r.internal })) }, null, 2));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
594
|
+
res.end(mounted.length > 0
|
|
595
|
+
? `No service mounted at ${url}. Mounted on ${host}: ${[...new Set(mounted.map((r) => r.prefix))].join(', ')} (+ /health, /.well-known/services)`
|
|
596
|
+
: 'No gateway route for this hostname.');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
traceStep(trace, 'route-match', {
|
|
600
|
+
project: route.project,
|
|
601
|
+
...(route.prefix ? { prefix: route.prefix } : {}),
|
|
602
|
+
...(route.host ? { routeHost: route.host } : {}),
|
|
603
|
+
...(route.internal ? { internal: true } : {}),
|
|
604
|
+
});
|
|
605
|
+
if (route.internal) {
|
|
606
|
+
// service-to-service: validate the CALLER's api key at the boundary
|
|
607
|
+
const presented = String(req.headers['x-api-key'] ?? '');
|
|
608
|
+
const caller = keyToProject.get(presented);
|
|
609
|
+
if (!caller) {
|
|
610
|
+
traceStep(trace, 'auth', { mode: 'x-api-key', ok: false });
|
|
611
|
+
finishTrace(trace, route.project, 401);
|
|
612
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
|
|
613
|
+
res.end('Invalid or missing x-api-key for internal route.');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
req.headers['x-api-key-id'] = caller; // audit + lens attribution
|
|
617
|
+
req.__lensmcpCaller = caller;
|
|
618
|
+
delete req.headers['x-api-key'];
|
|
619
|
+
traceStep(trace, 'auth', { mode: 'x-api-key', ok: true, caller });
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
// Edge auth (verify the end-user JWT + inject trusted identity + enforce per-route rules),
|
|
623
|
+
// mirroring the prod gateway. Only enforced when the service declares `cluster.auth`
|
|
624
|
+
// (opt-in migration — un-migrated services stay open, as before).
|
|
625
|
+
const policy = route.auth;
|
|
626
|
+
if (policy) {
|
|
627
|
+
const url0 = (req.url ?? '/').split('?')[0];
|
|
628
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
629
|
+
const resolved = (0, manifest_1.authRuleFor)(policy, url0, method);
|
|
630
|
+
if (resolved.mode === 'jwt') {
|
|
631
|
+
const claims = verifyDevJwt(req);
|
|
632
|
+
if (!claims) {
|
|
633
|
+
traceStep(trace, 'auth', { mode: 'jwt', ok: false });
|
|
634
|
+
finishTrace(trace, route.project, 401);
|
|
635
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
|
|
636
|
+
res.end('Unauthorized: invalid or missing token');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
|
|
640
|
+
traceStep(trace, 'authz', { ok: false });
|
|
641
|
+
finishTrace(trace, route.project, 403);
|
|
642
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
643
|
+
res.end('Forbidden: insufficient permission');
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
stampIdentity(req, claims);
|
|
647
|
+
traceStep(trace, 'auth', { mode: 'jwt', ok: true });
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
traceStep(trace, 'auth', { mode: resolved.mode });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
traceStep(trace, 'auth', { mode: 'public', unconfigured: true });
|
|
655
|
+
}
|
|
656
|
+
// Custom user middleware (if any) runs AFTER core auth.
|
|
657
|
+
if (middleware) {
|
|
658
|
+
try {
|
|
659
|
+
middleware(req);
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
traceStep(trace, 'auth', { mode: 'middleware', ok: false, reason: e.message });
|
|
663
|
+
finishTrace(trace, route.project, 401);
|
|
664
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
|
|
665
|
+
res.end(`Gateway middleware rejected the request: ${e.message}`);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (route.pool && serviceKeys[route.project]) {
|
|
671
|
+
req.headers['x-internal-token'] = serviceKeys[route.project]; // pods trust the gateway
|
|
672
|
+
}
|
|
673
|
+
void forward(route, req, res);
|
|
674
|
+
};
|
|
675
|
+
const upgrade = (req, socket, head) => {
|
|
676
|
+
// WS upgrades enforce the SAME trust model as HTTP — otherwise an
|
|
677
|
+
// internal.<host> socket would be reachable with no x-api-key, and a
|
|
678
|
+
// public socket would reach pods without the x-internal-token they expect.
|
|
679
|
+
for (const h of GATEWAY_OWNED_HEADERS)
|
|
680
|
+
delete req.headers[h];
|
|
681
|
+
const route = match(req);
|
|
682
|
+
if (!route) {
|
|
683
|
+
socket.destroy();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (route.internal) {
|
|
687
|
+
const caller = keyToProject.get(String(req.headers['x-api-key'] ?? ''));
|
|
688
|
+
if (!caller) {
|
|
689
|
+
socket.destroy();
|
|
690
|
+
return;
|
|
691
|
+
} // no valid key → no internal WS
|
|
692
|
+
req.headers['x-api-key-id'] = caller;
|
|
693
|
+
delete req.headers['x-api-key'];
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
const policy = route.auth;
|
|
697
|
+
if (policy) {
|
|
698
|
+
const resolved = (0, manifest_1.authRuleFor)(policy, (req.url ?? '/').split('?')[0], (req.method ?? 'GET').toUpperCase());
|
|
699
|
+
if (resolved.mode === 'jwt') {
|
|
700
|
+
const claims = verifyDevJwt(req);
|
|
701
|
+
if (!claims) {
|
|
702
|
+
socket.destroy();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (resolved.rule && !(0, manifest_1.evalRule)(resolved.rule, devAttrs(req, claims))) {
|
|
706
|
+
socket.destroy();
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
stampIdentity(req, claims);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (middleware) {
|
|
713
|
+
try {
|
|
714
|
+
middleware(req);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
socket.destroy();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (route.pool && serviceKeys[route.project]) {
|
|
723
|
+
req.headers['x-internal-token'] = serviceKeys[route.project];
|
|
724
|
+
}
|
|
725
|
+
if (route.prependPrefix && !req.url?.startsWith(route.prependPrefix)) {
|
|
726
|
+
req.url = route.prependPrefix + (req.url ?? '/');
|
|
727
|
+
}
|
|
728
|
+
const target = route.pool
|
|
729
|
+
? (() => { const s = pickSock(route.pool, scanTtlMs); return s ? { socketPath: s } : undefined; })()
|
|
730
|
+
: route.target;
|
|
731
|
+
if (!target) {
|
|
732
|
+
socket.destroy();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
proxy.ws(req, socket, head, { target, changeOrigin: !route.pool, agent: agentForTarget(target) }, () => socket.destroy());
|
|
736
|
+
};
|
|
737
|
+
// --- TLS + listeners ------------------------------------------------------------
|
|
738
|
+
let pem;
|
|
739
|
+
let caPath;
|
|
740
|
+
if (https) {
|
|
741
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
742
|
+
const basicSsl = require('../../basic-ssl');
|
|
743
|
+
const cacheDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
|
|
744
|
+
const hosts = routes.map((r) => r.host).filter((h) => !!h);
|
|
745
|
+
pem = basicSsl.getCertificateSync(cacheDir, 'gateway', hosts);
|
|
746
|
+
caPath = basicSsl.caCertPath(cacheDir);
|
|
747
|
+
}
|
|
748
|
+
const servers = [];
|
|
749
|
+
const boundPorts = [];
|
|
750
|
+
for (const port of ports) {
|
|
751
|
+
const server = pem
|
|
752
|
+
? // eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
753
|
+
require('node:https').createServer({ key: pem, cert: pem }, handler)
|
|
754
|
+
: http.createServer(handler);
|
|
755
|
+
server.on('upgrade', upgrade);
|
|
756
|
+
server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
|
|
757
|
+
await new Promise((resolve) => {
|
|
758
|
+
server.listen(port, () => {
|
|
759
|
+
const actual = server.address()?.port ?? port;
|
|
760
|
+
boundPorts.push(actual);
|
|
761
|
+
console.log(`[gateway] Listening on ${pem ? 'https' : 'http'}://localhost:${actual}`);
|
|
762
|
+
resolve();
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
servers.push(server);
|
|
766
|
+
}
|
|
767
|
+
console.log('[gateway] Routes:');
|
|
768
|
+
for (const r of routes) {
|
|
769
|
+
const dest = r.pool ? `pods@${r.pool.dir}` : r.target;
|
|
770
|
+
console.log(`[gateway] ${r.host ?? '(default)'} → ${dest}${r.prependPrefix ? ` (+${r.prependPrefix})` : ''}${r.internal ? ' [internal: no middleware]' : ''} [${r.project}]`);
|
|
771
|
+
}
|
|
772
|
+
if (caPath)
|
|
773
|
+
console.log(`[gateway] CA: ${caPath} — one-time setup via the trust executor.`);
|
|
774
|
+
emit('info', `gateway up: ${routes.length} routes on ${boundPorts.join('/')}`, 'cluster-gateway', {
|
|
775
|
+
kind: 'gateway-up',
|
|
776
|
+
ports: boundPorts,
|
|
777
|
+
routes: routes.map((r) => ({ host: r.host ?? '(default)', project: r.project, mode: r.pool ? 'pods' : 'tcp', internal: !!r.internal, prependPrefix: r.prependPrefix })),
|
|
778
|
+
});
|
|
779
|
+
const stop = async () => {
|
|
780
|
+
if (stopped)
|
|
781
|
+
return;
|
|
782
|
+
stopped = true;
|
|
783
|
+
clearInterval(edgeFlusher);
|
|
784
|
+
clearInterval(sweeper);
|
|
785
|
+
emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
|
|
786
|
+
for (const svc of services)
|
|
787
|
+
killSpawned(svc, 'gateway shutdown');
|
|
788
|
+
await Promise.all(servers.map((s) => new Promise((r) => s.close(() => r()))));
|
|
789
|
+
try {
|
|
790
|
+
proxy.close?.();
|
|
791
|
+
}
|
|
792
|
+
catch { /* ignore */ }
|
|
793
|
+
httpAgent.destroy();
|
|
794
|
+
httpsAgent.destroy(); // release pooled upstream sockets
|
|
795
|
+
};
|
|
796
|
+
return { ports: boundPorts, routes, services, stop };
|
|
797
|
+
}
|