@mycadet/gateway 0.12.9 → 0.12.11
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/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/credential-reader.test.ts +1 -1
- package/src/__tests__/credential-watcher.test.ts +1 -1
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +4 -4
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
- package/src/__tests__/remote-web-pairing-token.test.ts +1 -1
- package/src/auth/guardian-bootstrap.ts +1 -1
- package/src/cli/__tests__/pairing.test.ts +131 -0
- package/src/cli/index.ts +12 -1
- package/src/cli/pairing.ts +71 -0
- package/src/credential-reader.ts +1 -1
- package/src/http/routes/guardian-pin.ts +2 -1
- package/src/http/routes/ingress-domains.test.ts +5 -2
- package/src/http/routes/ingress-domains.ts +2 -8
- package/src/http/routes/live-voice-websocket.ts +2 -1
- package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
- package/src/http/routes/plugin-webhook-websocket.ts +2 -1
- package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
- package/src/http/routes/runtime-audio-stream.ts +2 -1
- package/src/http/routes/speech-relay-websocket.ts +4 -3
- package/src/http/utils.ts +3 -0
- package/src/index.ts +4 -3
- package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
- package/src/remote-web/pairing-challenge-store.ts +130 -2
- package/src/remote-web/pairing-mint.ts +94 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Guardian-authed recording of a tunnel's public URL.
|
|
3
3
|
*
|
|
4
4
|
* POST /v1/ingress/domains — the CLI (its ngrok agent, today) tells the
|
|
5
5
|
* gateway the public URL its tunnel published; the gateway writes
|
|
@@ -19,11 +19,7 @@ import { normalizeHttpPublicBaseUrlWithoutTrailingSlash } from "@mycadet/service
|
|
|
19
19
|
|
|
20
20
|
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
21
21
|
import { writeManagedPublicBaseUrl } from "../../tunnel/client.js";
|
|
22
|
-
import {
|
|
23
|
-
enforceLoopbackOnly,
|
|
24
|
-
errorResponse,
|
|
25
|
-
rejectBrowserOrigin,
|
|
26
|
-
} from "../loopback-guard.js";
|
|
22
|
+
import { errorResponse, rejectBrowserOrigin } from "../loopback-guard.js";
|
|
27
23
|
|
|
28
24
|
const SaveIngressDomainRequestSchema = z.object({
|
|
29
25
|
publicBaseUrl: z.string(),
|
|
@@ -41,8 +37,6 @@ export async function handleSaveIngressDomain(
|
|
|
41
37
|
});
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
const guardError = enforceLoopbackOnly(req, clientIp, "ingress-domains");
|
|
45
|
-
if (guardError) return guardError;
|
|
46
40
|
const originError = rejectBrowserOrigin(req, clientIp, "ingress-domains");
|
|
47
41
|
if (originError) return originError;
|
|
48
42
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
requireBoundGuardian,
|
|
17
17
|
requireManagedGuardian,
|
|
18
18
|
} from "./guardian-pin.js";
|
|
19
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
19
20
|
|
|
20
21
|
const log = getLogger("live-voice-ws");
|
|
21
22
|
|
|
@@ -53,7 +54,7 @@ export function createLiveVoiceWebsocketHandler(config: GatewayConfig) {
|
|
|
53
54
|
req: Request,
|
|
54
55
|
server: import("bun").Server<unknown>,
|
|
55
56
|
): Promise<Response | undefined> {
|
|
56
|
-
if (req
|
|
57
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
57
58
|
return new Response("Upgrade Required", { status: 426 });
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isPluginWebhookSocketData,
|
|
14
14
|
type PluginWebhookSocketData,
|
|
15
15
|
} from "./plugin-webhook-websocket.js";
|
|
16
|
+
import { IngressRoute } from "../../channels/plugin-ingress.js";
|
|
16
17
|
|
|
17
18
|
initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long"));
|
|
18
19
|
|
|
@@ -24,7 +25,7 @@ const CONFIG = {
|
|
|
24
25
|
|
|
25
26
|
const PLUGIN_SECRET = "plugin-webhook-secret";
|
|
26
27
|
const CADET_SECRET = "platform-webhook-secret";
|
|
27
|
-
const WS_PATH = "/webhooks/plugins/
|
|
28
|
+
const WS_PATH = "/webhooks/plugins/example/realtime";
|
|
28
29
|
|
|
29
30
|
const ROUTE = {
|
|
30
31
|
path: "realtime",
|
|
@@ -43,7 +44,7 @@ function credentialsFor(entries: Record<string, string>) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const CREDENTIALS = credentialsFor({
|
|
46
|
-
"credential/
|
|
47
|
+
"credential/example/webhook_secret": PLUGIN_SECRET,
|
|
47
48
|
"credential/cadet/webhook_secret": CADET_SECRET,
|
|
48
49
|
});
|
|
49
50
|
|
|
@@ -53,17 +54,9 @@ function resolution(
|
|
|
53
54
|
return { approved: [], pending: [], problems: [], ...overrides };
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function approvedWith(
|
|
57
|
-
routes: {
|
|
58
|
-
path: string;
|
|
59
|
-
kind: "http" | "websocket";
|
|
60
|
-
signer: "plugin" | "cadet";
|
|
61
|
-
handshake: "signed-headers" | "signed-query";
|
|
62
|
-
description: string;
|
|
63
|
-
}[],
|
|
64
|
-
): PluginIngressResolution {
|
|
57
|
+
function approvedWith(routes: IngressRoute[]): PluginIngressResolution {
|
|
65
58
|
return resolution({
|
|
66
|
-
approved: [{ plugin: "
|
|
59
|
+
approved: [{ plugin: "example", routes, digest: "d".repeat(32) }],
|
|
67
60
|
});
|
|
68
61
|
}
|
|
69
62
|
|
|
@@ -128,13 +121,13 @@ describe("upgrade gate", () => {
|
|
|
128
121
|
const res = await handle(
|
|
129
122
|
upgradeRequest(),
|
|
130
123
|
server,
|
|
131
|
-
"
|
|
124
|
+
"example",
|
|
132
125
|
"realtime",
|
|
133
126
|
);
|
|
134
127
|
|
|
135
128
|
expect(res).toBeUndefined();
|
|
136
129
|
expect(upgrades).toHaveLength(1);
|
|
137
|
-
expect(upgrades[0]!.plugin).toBe("
|
|
130
|
+
expect(upgrades[0]!.plugin).toBe("example");
|
|
138
131
|
expect(isPluginWebhookSocketData(upgrades[0])).toBe(true);
|
|
139
132
|
});
|
|
140
133
|
|
|
@@ -145,7 +138,7 @@ describe("upgrade gate", () => {
|
|
|
145
138
|
const res = await handle(
|
|
146
139
|
upgradeRequest({ upgrade: false }),
|
|
147
140
|
server,
|
|
148
|
-
"
|
|
141
|
+
"example",
|
|
149
142
|
"realtime",
|
|
150
143
|
);
|
|
151
144
|
|
|
@@ -158,7 +151,7 @@ describe("upgrade gate", () => {
|
|
|
158
151
|
resolve: () =>
|
|
159
152
|
resolution({
|
|
160
153
|
pending: [
|
|
161
|
-
{ plugin: "
|
|
154
|
+
{ plugin: "example", routes: [ROUTE], digest: "d".repeat(32) },
|
|
162
155
|
],
|
|
163
156
|
}),
|
|
164
157
|
});
|
|
@@ -167,7 +160,7 @@ describe("upgrade gate", () => {
|
|
|
167
160
|
const res = await handle(
|
|
168
161
|
upgradeRequest(),
|
|
169
162
|
server,
|
|
170
|
-
"
|
|
163
|
+
"example",
|
|
171
164
|
"realtime",
|
|
172
165
|
);
|
|
173
166
|
|
|
@@ -183,7 +176,7 @@ describe("upgrade gate", () => {
|
|
|
183
176
|
resolution({
|
|
184
177
|
pending: [
|
|
185
178
|
{
|
|
186
|
-
plugin: "
|
|
179
|
+
plugin: "example",
|
|
187
180
|
routes: [{ ...ROUTE, signer: "cadet" as const }],
|
|
188
181
|
digest: "d".repeat(32),
|
|
189
182
|
},
|
|
@@ -195,7 +188,7 @@ describe("upgrade gate", () => {
|
|
|
195
188
|
const res = await handle(
|
|
196
189
|
upgradeRequest({ secret: CADET_SECRET }),
|
|
197
190
|
server,
|
|
198
|
-
"
|
|
191
|
+
"example",
|
|
199
192
|
"realtime",
|
|
200
193
|
);
|
|
201
194
|
|
|
@@ -210,7 +203,7 @@ describe("upgrade gate", () => {
|
|
|
210
203
|
resolution({
|
|
211
204
|
pending: [
|
|
212
205
|
{
|
|
213
|
-
plugin: "
|
|
206
|
+
plugin: "example",
|
|
214
207
|
routes: [{ ...ROUTE, signer: "cadet" as const }],
|
|
215
208
|
digest: "d".repeat(32),
|
|
216
209
|
},
|
|
@@ -222,7 +215,7 @@ describe("upgrade gate", () => {
|
|
|
222
215
|
const res = await handle(
|
|
223
216
|
upgradeRequest({ secret: PLUGIN_SECRET }),
|
|
224
217
|
server,
|
|
225
|
-
"
|
|
218
|
+
"example",
|
|
226
219
|
"realtime",
|
|
227
220
|
);
|
|
228
221
|
|
|
@@ -240,7 +233,7 @@ describe("upgrade gate", () => {
|
|
|
240
233
|
const res = await handle(
|
|
241
234
|
upgradeRequest(),
|
|
242
235
|
server,
|
|
243
|
-
"
|
|
236
|
+
"example",
|
|
244
237
|
"realtime",
|
|
245
238
|
);
|
|
246
239
|
|
|
@@ -255,7 +248,7 @@ describe("upgrade gate", () => {
|
|
|
255
248
|
const res = await handle(
|
|
256
249
|
upgradeRequest({ path: "/webhooks/plugins/meeting-bot/admin" }),
|
|
257
250
|
server,
|
|
258
|
-
"
|
|
251
|
+
"example",
|
|
259
252
|
"admin",
|
|
260
253
|
);
|
|
261
254
|
|
|
@@ -274,7 +267,7 @@ describe("upgrade gate", () => {
|
|
|
274
267
|
const res = await handle(
|
|
275
268
|
upgradeRequest(),
|
|
276
269
|
server,
|
|
277
|
-
"
|
|
270
|
+
"example",
|
|
278
271
|
"realtime",
|
|
279
272
|
);
|
|
280
273
|
|
|
@@ -291,7 +284,7 @@ describe("handshake signature", () => {
|
|
|
291
284
|
const res = await handle(
|
|
292
285
|
upgradeRequest({ secret: "" }),
|
|
293
286
|
server,
|
|
294
|
-
"
|
|
287
|
+
"example",
|
|
295
288
|
"realtime",
|
|
296
289
|
);
|
|
297
290
|
|
|
@@ -306,7 +299,7 @@ describe("handshake signature", () => {
|
|
|
306
299
|
const res = await handle(
|
|
307
300
|
upgradeRequest({ secret: "nope" }),
|
|
308
301
|
server,
|
|
309
|
-
"
|
|
302
|
+
"example",
|
|
310
303
|
"realtime",
|
|
311
304
|
);
|
|
312
305
|
|
|
@@ -324,7 +317,7 @@ describe("handshake signature", () => {
|
|
|
324
317
|
timestamp: String(Math.floor(Date.now() / 1000) - 3600),
|
|
325
318
|
}),
|
|
326
319
|
server,
|
|
327
|
-
"
|
|
320
|
+
"example",
|
|
328
321
|
"realtime",
|
|
329
322
|
);
|
|
330
323
|
|
|
@@ -339,7 +332,7 @@ describe("handshake signature", () => {
|
|
|
339
332
|
const res = await handle(
|
|
340
333
|
upgradeRequest({ timestamp: "" }),
|
|
341
334
|
server,
|
|
342
|
-
"
|
|
335
|
+
"example",
|
|
343
336
|
"realtime",
|
|
344
337
|
);
|
|
345
338
|
|
|
@@ -355,7 +348,7 @@ describe("handshake signature", () => {
|
|
|
355
348
|
const res = await handle(
|
|
356
349
|
upgradeRequest({ signPath: "/webhooks/plugins/meeting-bot/other" }),
|
|
357
350
|
server,
|
|
358
|
-
"
|
|
351
|
+
"example",
|
|
359
352
|
"realtime",
|
|
360
353
|
);
|
|
361
354
|
|
|
@@ -370,7 +363,7 @@ describe("handshake signature", () => {
|
|
|
370
363
|
const res = await handle(
|
|
371
364
|
upgradeRequest(),
|
|
372
365
|
server,
|
|
373
|
-
"
|
|
366
|
+
"example",
|
|
374
367
|
"realtime",
|
|
375
368
|
);
|
|
376
369
|
|
|
@@ -388,7 +381,7 @@ describe("handshake signature", () => {
|
|
|
388
381
|
await handle(
|
|
389
382
|
upgradeRequest({ secret: CADET_SECRET }),
|
|
390
383
|
server,
|
|
391
|
-
"
|
|
384
|
+
"example",
|
|
392
385
|
"realtime",
|
|
393
386
|
),
|
|
394
387
|
).toBeUndefined();
|
|
@@ -397,7 +390,7 @@ describe("handshake signature", () => {
|
|
|
397
390
|
const rejected = await handle(
|
|
398
391
|
upgradeRequest({ secret: PLUGIN_SECRET }),
|
|
399
392
|
server,
|
|
400
|
-
"
|
|
393
|
+
"example",
|
|
401
394
|
"realtime",
|
|
402
395
|
);
|
|
403
396
|
expect(rejected?.status).toBe(403);
|
|
@@ -433,7 +426,7 @@ describe("signed-query handshake", () => {
|
|
|
433
426
|
const res = await handle(
|
|
434
427
|
signedQueryRequest(),
|
|
435
428
|
server,
|
|
436
|
-
"
|
|
429
|
+
"example",
|
|
437
430
|
"realtime",
|
|
438
431
|
);
|
|
439
432
|
|
|
@@ -454,7 +447,7 @@ describe("signed-query handshake", () => {
|
|
|
454
447
|
const res = await handle(
|
|
455
448
|
new Request(url.toString(), { headers: { upgrade: "websocket" } }),
|
|
456
449
|
server,
|
|
457
|
-
"
|
|
450
|
+
"example",
|
|
458
451
|
"realtime",
|
|
459
452
|
);
|
|
460
453
|
|
|
@@ -469,7 +462,7 @@ describe("signed-query handshake", () => {
|
|
|
469
462
|
const res = await handle(
|
|
470
463
|
signedQueryRequest({ secret: "not-the-plugin-secret" }),
|
|
471
464
|
server,
|
|
472
|
-
"
|
|
465
|
+
"example",
|
|
473
466
|
"realtime",
|
|
474
467
|
);
|
|
475
468
|
|
|
@@ -487,7 +480,7 @@ describe("signed-query handshake", () => {
|
|
|
487
480
|
const res = await handle(
|
|
488
481
|
signedQueryRequest(),
|
|
489
482
|
server,
|
|
490
|
-
"
|
|
483
|
+
"example",
|
|
491
484
|
"realtime",
|
|
492
485
|
);
|
|
493
486
|
|
|
@@ -503,7 +496,7 @@ describe("signed-query handshake", () => {
|
|
|
503
496
|
const res = await handle(
|
|
504
497
|
upgradeRequest({ secret: PLUGIN_SECRET }),
|
|
505
498
|
server,
|
|
506
|
-
"
|
|
499
|
+
"example",
|
|
507
500
|
"realtime",
|
|
508
501
|
);
|
|
509
502
|
|
|
@@ -523,7 +516,7 @@ describe("signed-query handshake", () => {
|
|
|
523
516
|
const res = await handle(
|
|
524
517
|
new Request(url.toString()),
|
|
525
518
|
server,
|
|
526
|
-
"
|
|
519
|
+
"example",
|
|
527
520
|
"realtime",
|
|
528
521
|
);
|
|
529
522
|
|
|
@@ -551,7 +544,7 @@ function socketData(
|
|
|
551
544
|
return {
|
|
552
545
|
wsType: "plugin-webhook",
|
|
553
546
|
config: CONFIG,
|
|
554
|
-
plugin: "
|
|
547
|
+
plugin: "example",
|
|
555
548
|
path: "realtime",
|
|
556
549
|
queue: [],
|
|
557
550
|
queuedBytes: 0,
|
|
@@ -591,7 +584,7 @@ describe("frame delivery", () => {
|
|
|
591
584
|
expect(calls).toHaveLength(1);
|
|
592
585
|
expect(calls[0]!.method).toBe("user_route_post");
|
|
593
586
|
expect(calls[0]!.params).toMatchObject({
|
|
594
|
-
pathParams: { path: "plugins/
|
|
587
|
+
pathParams: { path: "plugins/example/realtime" },
|
|
595
588
|
});
|
|
596
589
|
// The frame travels as the raw body, byte-for-byte.
|
|
597
590
|
expect(new TextDecoder().decode(calls[0]!.binary)).toBe(evt("joined"));
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
timestampWithinTolerance,
|
|
42
42
|
verifyCadetSignature,
|
|
43
43
|
} from "../cadet-signature.js";
|
|
44
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
44
45
|
|
|
45
46
|
const log = getLogger("plugin-webhook-ws");
|
|
46
47
|
|
|
@@ -131,7 +132,7 @@ export function createPluginWebhookWebsocketHandler(deps: PluginWebhookWsDeps) {
|
|
|
131
132
|
plugin: string,
|
|
132
133
|
path: string,
|
|
133
134
|
): Promise<Response | undefined> {
|
|
134
|
-
if (req
|
|
135
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
135
136
|
return new Response("Upgrade Required", { status: 426 });
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -21,7 +21,14 @@ import { methodNotAllowed, readJsonStringField } from "../route-helpers.js";
|
|
|
21
21
|
|
|
22
22
|
const MAX_CHALLENGE_BODY_BYTES = 512;
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Normalize an advertised base URL to `origin + trailing-slash-stripped path`,
|
|
26
|
+
* or null when the value is empty, unparseable, non-http(s), or hostless.
|
|
27
|
+
* Shared with the direct pairing mint (remote-web/pairing-mint.ts), which
|
|
28
|
+
* validates the CLI-passed URL with the exact same rules the HTTP route
|
|
29
|
+
* applies.
|
|
30
|
+
*/
|
|
31
|
+
export function parsePublicBaseUrl(value: string): string | null {
|
|
25
32
|
if (!value.trim()) {
|
|
26
33
|
return null;
|
|
27
34
|
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { admitActorToken } from "../../auth/actor-token-revocation.js";
|
|
36
36
|
import { parseSub } from "../../auth/subject.js";
|
|
37
37
|
import type { GatewayConfig } from "../../config.js";
|
|
38
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Cap on frames held while the upstream socket is still connecting, so a
|
|
@@ -83,7 +84,7 @@ export function authorizeRuntimeAudioStream(
|
|
|
83
84
|
config: GatewayConfig,
|
|
84
85
|
log: Logger,
|
|
85
86
|
): RuntimeAudioStreamAuth {
|
|
86
|
-
if (req
|
|
87
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
87
88
|
return {
|
|
88
89
|
ok: false,
|
|
89
90
|
response: new Response("Upgrade Required", { status: 426 }),
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { OutgoingHttpHeaders } from "node:http";
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { tunnelHostForCloudHost } from "@mycadet/service-contracts/ingress";
|
|
26
26
|
|
|
27
27
|
import { resolveScopeProfile } from "../../auth/scopes.js";
|
|
28
28
|
import { validateEdgeToken } from "../../auth/token-exchange.js";
|
|
@@ -33,6 +33,7 @@ import { getLogger } from "../../logger.js";
|
|
|
33
33
|
import { getCloudBaseUrl } from "../../cloud-url.js";
|
|
34
34
|
import { TUNNEL_FORWARDED_HEADER } from "../../tunnel/bridge-utils.js";
|
|
35
35
|
import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
|
|
36
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
36
37
|
|
|
37
38
|
const log = getLogger("speech-relay-ws");
|
|
38
39
|
|
|
@@ -162,7 +163,7 @@ function jsonError(status: number, code: string, detail: string): Response {
|
|
|
162
163
|
* shared with the web live-voice client; this wrapper adds the scheme.
|
|
163
164
|
*/
|
|
164
165
|
export function tunnelOriginForPlatformHost(host: string): string | null {
|
|
165
|
-
const tunnelHost =
|
|
166
|
+
const tunnelHost = tunnelHostForCloudHost(host);
|
|
166
167
|
return tunnelHost ? `https://${tunnelHost}` : null;
|
|
167
168
|
}
|
|
168
169
|
|
|
@@ -325,7 +326,7 @@ export function createSpeechRelayUpgradeHandler(
|
|
|
325
326
|
|
|
326
327
|
// Non-upgrade request: this is the daemon's dial-failure probe.
|
|
327
328
|
// Replay the gate and hand back tunnel's own rejection.
|
|
328
|
-
if (req
|
|
329
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
329
330
|
const apiKey = await readAssistantApiKey(deps);
|
|
330
331
|
if (!apiKey) {
|
|
331
332
|
return jsonError(
|
package/src/index.ts
CHANGED
|
@@ -278,6 +278,7 @@ import {
|
|
|
278
278
|
enablePublicIngress,
|
|
279
279
|
} from "./tunnel/client.js";
|
|
280
280
|
import { VERSION_HEADER_NAME, VERSION_HEADER_VALUE } from "./version.js";
|
|
281
|
+
import { isWebsocketUpgradeRequest } from "./http/utils.js";
|
|
281
282
|
|
|
282
283
|
const log = getLogger("main");
|
|
283
284
|
|
|
@@ -889,11 +890,11 @@ async function main() {
|
|
|
889
890
|
handler: () => psHandler.handlePs(),
|
|
890
891
|
},
|
|
891
892
|
|
|
892
|
-
// ── Ingress domain recording (
|
|
893
|
+
// ── Ingress domain recording (guardian bearer, loopback fallback) ──
|
|
893
894
|
{
|
|
894
895
|
path: "/v1/ingress/domains",
|
|
895
896
|
method: "POST",
|
|
896
|
-
auth: "edge",
|
|
897
|
+
auth: "edge-guardian",
|
|
897
898
|
handler: (req, _params, getClientIp) =>
|
|
898
899
|
handleSaveIngressDomain(req, getClientIp(), configFileCache),
|
|
899
900
|
},
|
|
@@ -2309,7 +2310,7 @@ async function main() {
|
|
|
2309
2310
|
// Plugin ingress declared as `websocket`. Claimed only for a genuine
|
|
2310
2311
|
// upgrade — a plain request to the same path stays with the route table,
|
|
2311
2312
|
// where the HTTP half 404s it for not being an approved HTTP route.
|
|
2312
|
-
if (req
|
|
2313
|
+
if (isWebsocketUpgradeRequest(req)) {
|
|
2313
2314
|
const match = url.pathname.match(PLUGIN_WEBHOOK_PATH_PATTERN);
|
|
2314
2315
|
if (match) {
|
|
2315
2316
|
const upgradeResult = await handlePluginWebhookWs(
|
|
@@ -1,5 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creation-rate limit for remote-web pairing challenges — 20 per minute,
|
|
3
|
+
* FILE-BACKED under GATEWAY_SECURITY_DIR so every minting process counts
|
|
4
|
+
* against the same window: the `gateway pairing mint` CLI runs in its own
|
|
5
|
+
* process (never over the gateway's IPC socket — the assistant daemon shares
|
|
6
|
+
* that socket), and the gateway's HTTP mint route runs in the server
|
|
7
|
+
* process. A per-process window would be meaningless for the CLI (a fresh
|
|
8
|
+
* process per mint), so the timestamps persist to disk and every call
|
|
9
|
+
* re-reads them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { getGatewaySecurityDir } from "../paths.js";
|
|
22
|
+
|
|
1
23
|
const RATE_LIMIT_MAX_CHALLENGES = 20;
|
|
2
24
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
25
|
+
const STORE_FILENAME = "remote-web-pairing-creation-rate.json";
|
|
3
26
|
|
|
4
27
|
export interface RemoteWebPairingChallengeRateLimit {
|
|
5
28
|
retryAfterSeconds: number;
|
|
@@ -7,7 +30,69 @@ export interface RemoteWebPairingChallengeRateLimit {
|
|
|
7
30
|
|
|
8
31
|
const challengeCreationTimestamps: number[] = [];
|
|
9
32
|
|
|
33
|
+
let lastResolvedStorePath: string | null = null;
|
|
34
|
+
|
|
35
|
+
function rateLimitStoreFilePath(): string {
|
|
36
|
+
const resolved = join(getGatewaySecurityDir(), STORE_FILENAME);
|
|
37
|
+
lastResolvedStorePath = resolved;
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isNotExistError(err: unknown): boolean {
|
|
42
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Re-sync the timestamps from disk; a missing file is an empty window. */
|
|
46
|
+
function loadFromDisk(): void {
|
|
47
|
+
challengeCreationTimestamps.length = 0;
|
|
48
|
+
|
|
49
|
+
let raw: string;
|
|
50
|
+
try {
|
|
51
|
+
raw = readFileSync(rateLimitStoreFilePath(), "utf-8");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (isNotExistError(err)) return;
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[pairing-challenge-rate-limit] could not read ${rateLimitStoreFilePath()} — starting empty.`,
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
typeof parsed !== "object" ||
|
|
68
|
+
parsed === null ||
|
|
69
|
+
!("timestamps" in parsed) ||
|
|
70
|
+
!Array.isArray(parsed.timestamps)
|
|
71
|
+
) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const timestamp of parsed.timestamps) {
|
|
75
|
+
if (typeof timestamp === "number") {
|
|
76
|
+
challengeCreationTimestamps.push(timestamp);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Persist the current window atomically so other processes see it. */
|
|
82
|
+
function saveToDisk(): void {
|
|
83
|
+
const filePath = rateLimitStoreFilePath();
|
|
84
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
85
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
86
|
+
writeFileSync(
|
|
87
|
+
tmpPath,
|
|
88
|
+
JSON.stringify({ timestamps: [...challengeCreationTimestamps] }),
|
|
89
|
+
{ mode: 0o600 },
|
|
90
|
+
);
|
|
91
|
+
renameSync(tmpPath, filePath);
|
|
92
|
+
}
|
|
93
|
+
|
|
10
94
|
export function recordRemoteWebPairingChallengeCreation(): RemoteWebPairingChallengeRateLimit | null {
|
|
95
|
+
loadFromDisk();
|
|
11
96
|
const now = Date.now();
|
|
12
97
|
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
13
98
|
|
|
@@ -26,9 +111,18 @@ export function recordRemoteWebPairingChallengeCreation(): RemoteWebPairingChall
|
|
|
26
111
|
}
|
|
27
112
|
|
|
28
113
|
challengeCreationTimestamps.push(now);
|
|
114
|
+
saveToDisk();
|
|
29
115
|
return null;
|
|
30
116
|
}
|
|
31
117
|
|
|
32
118
|
export function resetRemoteWebPairingChallengeRateLimiterForTests(): void {
|
|
33
119
|
challengeCreationTimestamps.length = 0;
|
|
120
|
+
// Best effort on the LAST-RESOLVED path — see the challenge store's note.
|
|
121
|
+
if (lastResolvedStorePath) {
|
|
122
|
+
try {
|
|
123
|
+
rmSync(lastResolvedStorePath, { force: true });
|
|
124
|
+
} catch {
|
|
125
|
+
/* the timestamps are cleared either way */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
34
128
|
}
|