@roamcode.ai/server 1.1.0 → 1.2.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/dist/container/relay.js +521 -140
- package/dist/index.d.ts +55 -8
- package/dist/index.js +1130 -278
- package/dist/relay-start.d.ts +15 -3
- package/dist/relay-start.js +521 -140
- package/dist/start.js +569 -109
- package/package.json +2 -2
package/dist/relay-start.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/relay-start.ts
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
closeSync as closeSync2,
|
|
6
|
+
constants as constants2,
|
|
7
|
+
fstatSync as fstatSync2,
|
|
8
|
+
lstatSync as lstatSync2,
|
|
9
|
+
openSync as openSync2,
|
|
10
|
+
readFileSync as readFileSync2,
|
|
11
|
+
readdirSync,
|
|
12
|
+
realpathSync
|
|
13
|
+
} from "fs";
|
|
5
14
|
import { join as join2, resolve } from "path";
|
|
6
15
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
7
16
|
|
|
@@ -27,6 +36,7 @@ var PLAN_DEFAULTS = {
|
|
|
27
36
|
team: { maxRoutes: 25, maxDevicesPerRoute: 64 },
|
|
28
37
|
enterprise: { maxRoutes: 500, maxDevicesPerRoute: 500 }
|
|
29
38
|
};
|
|
39
|
+
var UNSAFE_TERMINAL_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
30
40
|
function safeId(value) {
|
|
31
41
|
if (typeof value !== "string" || !/^rra_[A-Za-z0-9_-]{16,128}$/.test(value)) {
|
|
32
42
|
throw new Error("invalid relay account id");
|
|
@@ -36,7 +46,7 @@ function safeId(value) {
|
|
|
36
46
|
function safeLabel(value) {
|
|
37
47
|
if (typeof value !== "string") throw new Error("relay account label is required");
|
|
38
48
|
const label = value.trim().replace(/\s+/g, " ");
|
|
39
|
-
if (!label || label.length > 120 ||
|
|
49
|
+
if (!label || label.length > 120 || UNSAFE_TERMINAL_TEXT.test(label)) {
|
|
40
50
|
throw new Error("invalid relay account label");
|
|
41
51
|
}
|
|
42
52
|
return label;
|
|
@@ -65,6 +75,33 @@ function safeHash(value) {
|
|
|
65
75
|
}
|
|
66
76
|
return value;
|
|
67
77
|
}
|
|
78
|
+
function safeLookup(value) {
|
|
79
|
+
if (typeof value !== "string" || !/^lookup:[A-Za-z0-9_-]{43}$/.test(value)) {
|
|
80
|
+
throw new Error("invalid relay account credential lookup");
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
function credentialMaterial(input) {
|
|
85
|
+
if (typeof input === "string") {
|
|
86
|
+
return {
|
|
87
|
+
credentialHash: relayAccountCredentialHash(input),
|
|
88
|
+
credentialLookup: relayAccountCredentialLookup(input)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if ("credential" in input && input.credential !== void 0) {
|
|
92
|
+
if (input.credentialHash !== void 0 || input.credentialLookup !== void 0) {
|
|
93
|
+
throw new Error("relay account credential must be raw or pre-hashed, not both");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
credentialHash: relayAccountCredentialHash(input.credential),
|
|
97
|
+
credentialLookup: relayAccountCredentialLookup(input.credential)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
credentialHash: safeHash(input.credentialHash),
|
|
102
|
+
credentialLookup: safeLookup(input.credentialLookup)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
68
105
|
function hashMatches(expected, credential) {
|
|
69
106
|
try {
|
|
70
107
|
const left = Buffer.from(safeHash(expected));
|
|
@@ -98,14 +135,24 @@ function createMemoryStore(options) {
|
|
|
98
135
|
const accounts = /* @__PURE__ */ new Map();
|
|
99
136
|
const lookup = /* @__PURE__ */ new Map();
|
|
100
137
|
const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
|
|
138
|
+
const verifyCredential = (credential) => {
|
|
139
|
+
let credentialLookup;
|
|
140
|
+
try {
|
|
141
|
+
credentialLookup = relayAccountCredentialLookup(credential);
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const id = lookup.get(credentialLookup);
|
|
146
|
+
const record = id ? accounts.get(id) : void 0;
|
|
147
|
+
return record && record.status !== "deleted" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
|
|
148
|
+
};
|
|
101
149
|
return {
|
|
102
150
|
mode: "memory",
|
|
103
151
|
createAccount(input, now = Date.now()) {
|
|
104
152
|
const id = safeId(generateAccountId());
|
|
105
153
|
if (accounts.has(id)) throw new Error("relay account already exists");
|
|
106
154
|
const plan = safePlan(input.plan ?? "free");
|
|
107
|
-
const credentialHash =
|
|
108
|
-
const credentialLookup = relayAccountCredentialLookup(input.credential);
|
|
155
|
+
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
109
156
|
if (lookup.has(credentialLookup)) throw new Error("relay account credential already exists");
|
|
110
157
|
const defaults = PLAN_DEFAULTS[plan];
|
|
111
158
|
const record = {
|
|
@@ -137,16 +184,10 @@ function createMemoryStore(options) {
|
|
|
137
184
|
listAccounts({ includeDeleted = false } = {}) {
|
|
138
185
|
return [...accounts.values()].filter((record) => includeDeleted || record.status !== "deleted").sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map(clone);
|
|
139
186
|
},
|
|
187
|
+
verifyCredential,
|
|
140
188
|
authenticate(credential) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
credentialLookup = relayAccountCredentialLookup(credential);
|
|
144
|
-
} catch {
|
|
145
|
-
return void 0;
|
|
146
|
-
}
|
|
147
|
-
const id = lookup.get(credentialLookup);
|
|
148
|
-
const record = id ? accounts.get(id) : void 0;
|
|
149
|
-
return record?.status === "active" && hashMatches(record.credentialHash, credential) ? clone(record) : void 0;
|
|
189
|
+
const record = verifyCredential(credential);
|
|
190
|
+
return record?.status === "active" ? record : void 0;
|
|
150
191
|
},
|
|
151
192
|
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
152
193
|
const current = accounts.get(safeId(id));
|
|
@@ -184,8 +225,7 @@ function createMemoryStore(options) {
|
|
|
184
225
|
if (!current) return void 0;
|
|
185
226
|
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(clone(current));
|
|
186
227
|
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
187
|
-
const credentialHash =
|
|
188
|
-
const credentialLookup = relayAccountCredentialLookup(credential);
|
|
228
|
+
const { credentialHash, credentialLookup } = credentialMaterial(credential);
|
|
189
229
|
const owner = lookup.get(credentialLookup);
|
|
190
230
|
if (owner && owner !== current.id) throw new Error("relay account credential already exists");
|
|
191
231
|
lookup.delete(current.credentialLookup);
|
|
@@ -251,10 +291,21 @@ function openRelayAccountStore(options) {
|
|
|
251
291
|
`);
|
|
252
292
|
const generateAccountId = options.generateAccountId ?? (() => `rra_${randomBytes(18).toString("base64url")}`);
|
|
253
293
|
const rowById = (id) => db.prepare("SELECT * FROM relay_accounts WHERE id = ?").get(id);
|
|
294
|
+
const verifyCredential = (credential) => {
|
|
295
|
+
let credentialLookup;
|
|
296
|
+
try {
|
|
297
|
+
credentialLookup = relayAccountCredentialLookup(credential);
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status != 'deleted'").get(credentialLookup);
|
|
302
|
+
return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
|
|
303
|
+
};
|
|
254
304
|
const createAccount = db.transaction((input, now) => {
|
|
255
305
|
const id = safeId(generateAccountId());
|
|
256
306
|
if (rowById(id)) throw new Error("relay account already exists");
|
|
257
307
|
const plan = safePlan(input.plan ?? "free");
|
|
308
|
+
const { credentialHash, credentialLookup } = credentialMaterial(input);
|
|
258
309
|
const defaults = PLAN_DEFAULTS[plan];
|
|
259
310
|
const record = {
|
|
260
311
|
id,
|
|
@@ -280,8 +331,8 @@ function openRelayAccountStore(options) {
|
|
|
280
331
|
record.maxRoutes,
|
|
281
332
|
record.maxDevicesPerRoute,
|
|
282
333
|
record.revision,
|
|
283
|
-
|
|
284
|
-
|
|
334
|
+
credentialHash,
|
|
335
|
+
credentialLookup,
|
|
285
336
|
record.createdAt,
|
|
286
337
|
record.updatedAt
|
|
287
338
|
);
|
|
@@ -301,15 +352,10 @@ function openRelayAccountStore(options) {
|
|
|
301
352
|
).all();
|
|
302
353
|
return rows.map(fromRow);
|
|
303
354
|
},
|
|
355
|
+
verifyCredential,
|
|
304
356
|
authenticate(credential) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
credentialLookup = relayAccountCredentialLookup(credential);
|
|
308
|
-
} catch {
|
|
309
|
-
return void 0;
|
|
310
|
-
}
|
|
311
|
-
const row = db.prepare("SELECT * FROM relay_accounts WHERE credential_lookup = ? AND status = 'active'").get(credentialLookup);
|
|
312
|
-
return row && hashMatches(row.credential_hash, credential) ? fromRow(row) : void 0;
|
|
357
|
+
const record = verifyCredential(credential);
|
|
358
|
+
return record?.status === "active" ? record : void 0;
|
|
313
359
|
},
|
|
314
360
|
updateAccount(id, input, expectedRevision, now = Date.now()) {
|
|
315
361
|
const safeAccountId = safeId(id);
|
|
@@ -368,16 +414,11 @@ function openRelayAccountStore(options) {
|
|
|
368
414
|
const current = fromRow(currentRow);
|
|
369
415
|
if (current.revision !== expectedRevision) throw new RelayAccountRevisionConflictError(current);
|
|
370
416
|
if (current.status === "deleted") throw new Error("deleted relay account is immutable");
|
|
417
|
+
const { credentialHash, credentialLookup } = credentialMaterial(credential);
|
|
371
418
|
const result = db.prepare(
|
|
372
419
|
`UPDATE relay_accounts SET credential_hash = ?, credential_lookup = ?, revision = revision + 1,
|
|
373
420
|
updated_at = ? WHERE id = ? AND revision = ?`
|
|
374
|
-
).run(
|
|
375
|
-
relayAccountCredentialHash(credential),
|
|
376
|
-
relayAccountCredentialLookup(credential),
|
|
377
|
-
now,
|
|
378
|
-
safeAccountId,
|
|
379
|
-
expectedRevision
|
|
380
|
-
);
|
|
421
|
+
).run(credentialHash, credentialLookup, now, safeAccountId, expectedRevision);
|
|
381
422
|
if (result.changes !== 1) {
|
|
382
423
|
const latest = rowById(safeAccountId);
|
|
383
424
|
if (latest) throw new RelayAccountRevisionConflictError(fromRow(latest));
|
|
@@ -393,6 +434,7 @@ function openRelayAccountStore(options) {
|
|
|
393
434
|
import { createHash as createHash2, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
394
435
|
import { createRequire as createRequire2 } from "module";
|
|
395
436
|
var require3 = createRequire2(import.meta.url);
|
|
437
|
+
var UNSAFE_DISPLAY_TEXT = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
396
438
|
function safeId2(value, field) {
|
|
397
439
|
if (typeof value !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value)) throw new Error(`invalid relay ${field}`);
|
|
398
440
|
return value;
|
|
@@ -406,7 +448,7 @@ function safeOwnerAccountId(value) {
|
|
|
406
448
|
function safeLabel2(value) {
|
|
407
449
|
if (typeof value !== "string") throw new Error("relay route label is required");
|
|
408
450
|
const normalized = value.trim().replace(/\s+/g, " ");
|
|
409
|
-
if (!normalized || normalized.length > 80 ||
|
|
451
|
+
if (!normalized || normalized.length > 80 || UNSAFE_DISPLAY_TEXT.test(normalized)) {
|
|
410
452
|
throw new Error("invalid relay route label");
|
|
411
453
|
}
|
|
412
454
|
return normalized;
|
|
@@ -448,22 +490,72 @@ function cloneRoute(route) {
|
|
|
448
490
|
return { ...route };
|
|
449
491
|
}
|
|
450
492
|
function cloneDevice(device) {
|
|
451
|
-
return {
|
|
493
|
+
return {
|
|
494
|
+
routeId: device.routeId,
|
|
495
|
+
deviceId: device.deviceId,
|
|
496
|
+
credentialHash: device.credentialHash,
|
|
497
|
+
createdAt: device.createdAt,
|
|
498
|
+
updatedAt: device.updatedAt,
|
|
499
|
+
...device.expiresAt === void 0 ? {} : { expiresAt: device.expiresAt }
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function credentialOverlap(current, credentialHash, expiresAt, now) {
|
|
503
|
+
if (!current || expiresAt !== void 0) return {};
|
|
504
|
+
if (current.expiresAt !== void 0) {
|
|
505
|
+
if (current.credentialHash === credentialHash) {
|
|
506
|
+
throw new Error("relay bootstrap promotion must rotate the device credential");
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
previousCredentialHash: current.credentialHash,
|
|
510
|
+
previousCredentialExpiresAt: current.expiresAt
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (current.previousCredentialHash !== void 0 && current.previousCredentialExpiresAt !== void 0 && current.previousCredentialExpiresAt >= now) {
|
|
514
|
+
if (current.previousCredentialHash === credentialHash) {
|
|
515
|
+
throw new Error("relay bootstrap credential cannot become durable");
|
|
516
|
+
}
|
|
517
|
+
return {
|
|
518
|
+
previousCredentialHash: current.previousCredentialHash,
|
|
519
|
+
previousCredentialExpiresAt: current.previousCredentialExpiresAt
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
return {};
|
|
452
523
|
}
|
|
453
524
|
function createMemoryStore2(options) {
|
|
454
525
|
const routes = /* @__PURE__ */ new Map();
|
|
455
526
|
const devices = /* @__PURE__ */ new Map();
|
|
456
527
|
const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
|
|
457
528
|
const deviceKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
529
|
+
const pruneExpiredDevices = (now) => {
|
|
530
|
+
for (const [key, device] of devices) {
|
|
531
|
+
if (device.expiresAt !== void 0 && device.expiresAt < now) devices.delete(key);
|
|
532
|
+
else if (device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt < now) {
|
|
533
|
+
delete device.previousCredentialHash;
|
|
534
|
+
delete device.previousCredentialExpiresAt;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const liveDevice = (routeId, deviceId, now) => {
|
|
539
|
+
const key = deviceKey(routeId, deviceId);
|
|
540
|
+
const device = devices.get(key);
|
|
541
|
+
if (device?.expiresAt !== void 0 && device.expiresAt < now) {
|
|
542
|
+
devices.delete(key);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
return device;
|
|
546
|
+
};
|
|
547
|
+
const listRoutes = (now, ownerAccountId) => {
|
|
548
|
+
pruneExpiredDevices(now);
|
|
549
|
+
return [...routes.values()].filter((route) => ownerAccountId === void 0 || route.ownerAccountId === ownerAccountId).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)).map((route) => ({
|
|
550
|
+
id: route.id,
|
|
551
|
+
label: route.label,
|
|
552
|
+
deviceCount: [...devices.values()].filter(
|
|
553
|
+
(device) => device.routeId === route.id && (device.expiresAt === void 0 || device.expiresAt >= now)
|
|
554
|
+
).length,
|
|
555
|
+
createdAt: route.createdAt,
|
|
556
|
+
updatedAt: route.updatedAt
|
|
557
|
+
}));
|
|
558
|
+
};
|
|
467
559
|
return {
|
|
468
560
|
mode: "memory",
|
|
469
561
|
createRoute(input, now = Date.now()) {
|
|
@@ -489,6 +581,7 @@ function createMemoryStore2(options) {
|
|
|
489
581
|
return listRoutes(now, safeOwnerAccountId(ownerAccountId));
|
|
490
582
|
},
|
|
491
583
|
countDevices(routeId, now = Date.now()) {
|
|
584
|
+
pruneExpiredDevices(now);
|
|
492
585
|
const safeRouteId = safeId2(routeId, "route id");
|
|
493
586
|
return [...devices.values()].filter(
|
|
494
587
|
(device) => device.routeId === safeRouteId && (device.expiresAt === void 0 || device.expiresAt >= now)
|
|
@@ -512,18 +605,22 @@ function createMemoryStore2(options) {
|
|
|
512
605
|
return !!route && hashMatches2(route.hostCredentialHash, credential);
|
|
513
606
|
},
|
|
514
607
|
putDevice(input, now = Date.now()) {
|
|
608
|
+
pruneExpiredDevices(now);
|
|
515
609
|
const routeId = safeId2(input.routeId, "route id");
|
|
516
610
|
if (!routes.has(routeId)) throw new Error("relay route not found");
|
|
517
611
|
const deviceId = safeId2(input.deviceId, "device id");
|
|
518
612
|
const key = deviceKey(routeId, deviceId);
|
|
519
613
|
const current = devices.get(key);
|
|
614
|
+
const credentialHash = safeCredentialHash(input.credentialHash);
|
|
615
|
+
const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
|
|
520
616
|
const device = {
|
|
521
617
|
routeId,
|
|
522
618
|
deviceId,
|
|
523
|
-
credentialHash
|
|
619
|
+
credentialHash,
|
|
524
620
|
createdAt: current?.createdAt ?? now,
|
|
525
621
|
updatedAt: now,
|
|
526
|
-
...
|
|
622
|
+
...expiresAt === void 0 ? {} : { expiresAt },
|
|
623
|
+
...credentialOverlap(current, credentialHash, expiresAt, now)
|
|
527
624
|
};
|
|
528
625
|
devices.set(key, device);
|
|
529
626
|
const route = routes.get(routeId);
|
|
@@ -531,12 +628,12 @@ function createMemoryStore2(options) {
|
|
|
531
628
|
return cloneDevice(device);
|
|
532
629
|
},
|
|
533
630
|
getDevice(routeId, deviceId, now = Date.now()) {
|
|
534
|
-
const device =
|
|
535
|
-
return device
|
|
631
|
+
const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
632
|
+
return device ? cloneDevice(device) : void 0;
|
|
536
633
|
},
|
|
537
634
|
authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
|
|
538
|
-
const device =
|
|
539
|
-
return !!device && (device.
|
|
635
|
+
const device = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
636
|
+
return !!device && (hashMatches2(device.credentialHash, credential) || device.previousCredentialHash !== void 0 && device.previousCredentialExpiresAt !== void 0 && device.previousCredentialExpiresAt >= now && hashMatches2(device.previousCredentialHash, credential));
|
|
540
637
|
},
|
|
541
638
|
revokeDevice(routeId, deviceId) {
|
|
542
639
|
const safeRouteId = safeId2(routeId, "route id");
|
|
@@ -598,6 +695,8 @@ function openRelayRouteStore(options) {
|
|
|
598
695
|
route_id TEXT NOT NULL REFERENCES relay_routes(id) ON DELETE CASCADE,
|
|
599
696
|
device_id TEXT NOT NULL,
|
|
600
697
|
credential_hash TEXT NOT NULL,
|
|
698
|
+
previous_credential_hash TEXT,
|
|
699
|
+
previous_credential_expires_at INTEGER,
|
|
601
700
|
created_at INTEGER NOT NULL,
|
|
602
701
|
updated_at INTEGER NOT NULL,
|
|
603
702
|
expires_at INTEGER,
|
|
@@ -608,6 +707,12 @@ function openRelayRouteStore(options) {
|
|
|
608
707
|
if (!relayDeviceColumns.some((column) => column.name === "expires_at")) {
|
|
609
708
|
db.exec("ALTER TABLE relay_route_devices ADD COLUMN expires_at INTEGER");
|
|
610
709
|
}
|
|
710
|
+
if (!relayDeviceColumns.some((column) => column.name === "previous_credential_hash")) {
|
|
711
|
+
db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_hash TEXT");
|
|
712
|
+
}
|
|
713
|
+
if (!relayDeviceColumns.some((column) => column.name === "previous_credential_expires_at")) {
|
|
714
|
+
db.exec("ALTER TABLE relay_route_devices ADD COLUMN previous_credential_expires_at INTEGER");
|
|
715
|
+
}
|
|
611
716
|
const relayRouteColumns = db.prepare("PRAGMA table_info(relay_routes)").all();
|
|
612
717
|
if (!relayRouteColumns.some((column) => column.name === "owner_account_id")) {
|
|
613
718
|
db.exec("ALTER TABLE relay_routes ADD COLUMN owner_account_id TEXT");
|
|
@@ -616,6 +721,21 @@ function openRelayRouteStore(options) {
|
|
|
616
721
|
const generateRouteId = options.generateRouteId ?? (() => `rrt_${randomBytes2(16).toString("base64url")}`);
|
|
617
722
|
const getRoute = (id) => db.prepare("SELECT * FROM relay_routes WHERE id = ?").get(id);
|
|
618
723
|
const getDevice = (routeId, deviceId) => db.prepare("SELECT * FROM relay_route_devices WHERE route_id = ? AND device_id = ?").get(routeId, deviceId);
|
|
724
|
+
const pruneExpiredDevices = (now) => {
|
|
725
|
+
db.prepare("DELETE FROM relay_route_devices WHERE expires_at IS NOT NULL AND expires_at < ?").run(now);
|
|
726
|
+
db.prepare(
|
|
727
|
+
`UPDATE relay_route_devices SET previous_credential_hash = NULL, previous_credential_expires_at = NULL
|
|
728
|
+
WHERE previous_credential_expires_at IS NOT NULL AND previous_credential_expires_at < ?`
|
|
729
|
+
).run(now);
|
|
730
|
+
};
|
|
731
|
+
const liveDevice = (routeId, deviceId, now) => {
|
|
732
|
+
const row = getDevice(routeId, deviceId);
|
|
733
|
+
if (row?.expires_at !== null && row?.expires_at !== void 0 && row.expires_at < now) {
|
|
734
|
+
db.prepare("DELETE FROM relay_route_devices WHERE route_id = ? AND device_id = ?").run(routeId, deviceId);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
return row;
|
|
738
|
+
};
|
|
619
739
|
const createRoute = db.transaction(
|
|
620
740
|
(input, now) => {
|
|
621
741
|
const id = safeId2(input.id ?? generateRouteId(), "route id");
|
|
@@ -646,25 +766,44 @@ function openRelayRouteStore(options) {
|
|
|
646
766
|
(input, now) => {
|
|
647
767
|
const routeId = safeId2(input.routeId, "route id");
|
|
648
768
|
if (!getRoute(routeId)) throw new Error("relay route not found");
|
|
769
|
+
pruneExpiredDevices(now);
|
|
649
770
|
const deviceId = safeId2(input.deviceId, "device id");
|
|
650
771
|
const current = getDevice(routeId, deviceId);
|
|
772
|
+
const credentialHash = safeCredentialHash(input.credentialHash);
|
|
773
|
+
const expiresAt = input.expiresAt === void 0 ? void 0 : safeExpiry(input.expiresAt);
|
|
774
|
+
const overlap = credentialOverlap(
|
|
775
|
+
current ? {
|
|
776
|
+
...deviceFromRow(current),
|
|
777
|
+
...current.previous_credential_hash === null || current.previous_credential_hash === void 0 ? {} : { previousCredentialHash: current.previous_credential_hash },
|
|
778
|
+
...current.previous_credential_expires_at === null || current.previous_credential_expires_at === void 0 ? {} : { previousCredentialExpiresAt: current.previous_credential_expires_at }
|
|
779
|
+
} : void 0,
|
|
780
|
+
credentialHash,
|
|
781
|
+
expiresAt,
|
|
782
|
+
now
|
|
783
|
+
);
|
|
651
784
|
const device = {
|
|
652
785
|
routeId,
|
|
653
786
|
deviceId,
|
|
654
|
-
credentialHash
|
|
787
|
+
credentialHash,
|
|
655
788
|
createdAt: current?.created_at ?? now,
|
|
656
789
|
updatedAt: now,
|
|
657
|
-
...
|
|
790
|
+
...expiresAt === void 0 ? {} : { expiresAt }
|
|
658
791
|
};
|
|
659
792
|
db.prepare(
|
|
660
|
-
`INSERT INTO relay_route_devices
|
|
661
|
-
|
|
793
|
+
`INSERT INTO relay_route_devices
|
|
794
|
+
(route_id, device_id, credential_hash, previous_credential_hash, previous_credential_expires_at,
|
|
795
|
+
created_at, updated_at, expires_at)
|
|
796
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
662
797
|
ON CONFLICT(route_id, device_id) DO UPDATE SET credential_hash = excluded.credential_hash,
|
|
798
|
+
previous_credential_hash = excluded.previous_credential_hash,
|
|
799
|
+
previous_credential_expires_at = excluded.previous_credential_expires_at,
|
|
663
800
|
updated_at = excluded.updated_at, expires_at = excluded.expires_at`
|
|
664
801
|
).run(
|
|
665
802
|
device.routeId,
|
|
666
803
|
device.deviceId,
|
|
667
804
|
device.credentialHash,
|
|
805
|
+
overlap.previousCredentialHash ?? null,
|
|
806
|
+
overlap.previousCredentialExpiresAt ?? null,
|
|
668
807
|
device.createdAt,
|
|
669
808
|
device.updatedAt,
|
|
670
809
|
device.expiresAt ?? null
|
|
@@ -673,19 +812,22 @@ function openRelayRouteStore(options) {
|
|
|
673
812
|
return device;
|
|
674
813
|
}
|
|
675
814
|
);
|
|
676
|
-
const listRoutes = (now, ownerAccountId) =>
|
|
677
|
-
|
|
815
|
+
const listRoutes = (now, ownerAccountId) => {
|
|
816
|
+
pruneExpiredDevices(now);
|
|
817
|
+
return db.prepare(
|
|
818
|
+
`SELECT r.id, r.label, r.created_at, r.updated_at, COUNT(d.device_id) AS device_count
|
|
678
819
|
FROM relay_routes r LEFT JOIN relay_route_devices d ON d.route_id = r.id
|
|
679
820
|
AND (d.expires_at IS NULL OR d.expires_at >= ?)
|
|
680
821
|
${ownerAccountId === void 0 ? "" : "WHERE r.owner_account_id = ?"}
|
|
681
822
|
GROUP BY r.id ORDER BY r.created_at, r.id`
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
823
|
+
).all(now, ...ownerAccountId === void 0 ? [] : [safeOwnerAccountId(ownerAccountId)]).map((row) => ({
|
|
824
|
+
id: row.id,
|
|
825
|
+
label: row.label,
|
|
826
|
+
deviceCount: row.device_count,
|
|
827
|
+
createdAt: row.created_at,
|
|
828
|
+
updatedAt: row.updated_at
|
|
829
|
+
}));
|
|
830
|
+
};
|
|
689
831
|
return {
|
|
690
832
|
mode: "sqlite",
|
|
691
833
|
createRoute: (input, now = Date.now()) => cloneRoute(createRoute(input, now)),
|
|
@@ -696,6 +838,7 @@ function openRelayRouteStore(options) {
|
|
|
696
838
|
listRoutes: (now = Date.now()) => listRoutes(now),
|
|
697
839
|
listRoutesByOwner: (ownerAccountId, now = Date.now()) => listRoutes(now, ownerAccountId),
|
|
698
840
|
countDevices(routeId, now = Date.now()) {
|
|
841
|
+
pruneExpiredDevices(now);
|
|
699
842
|
const row = db.prepare(
|
|
700
843
|
`SELECT COUNT(*) AS count FROM relay_route_devices
|
|
701
844
|
WHERE route_id = ? AND (expires_at IS NULL OR expires_at >= ?)`
|
|
@@ -714,12 +857,12 @@ function openRelayRouteStore(options) {
|
|
|
714
857
|
},
|
|
715
858
|
putDevice: (input, now = Date.now()) => cloneDevice(putDevice(input, now)),
|
|
716
859
|
getDevice(routeId, deviceId, now = Date.now()) {
|
|
717
|
-
const row =
|
|
718
|
-
return row
|
|
860
|
+
const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
861
|
+
return row ? deviceFromRow(row) : void 0;
|
|
719
862
|
},
|
|
720
863
|
authenticateDevice(routeId, deviceId, credential, now = Date.now()) {
|
|
721
|
-
const row =
|
|
722
|
-
return !!row && (row.
|
|
864
|
+
const row = liveDevice(safeId2(routeId, "route id"), safeId2(deviceId, "device id"), now);
|
|
865
|
+
return !!row && (hashMatches2(row.credential_hash, credential) || row.previous_credential_hash !== null && row.previous_credential_hash !== void 0 && row.previous_credential_expires_at !== null && row.previous_credential_expires_at !== void 0 && row.previous_credential_expires_at >= now && hashMatches2(row.previous_credential_hash, credential));
|
|
723
866
|
},
|
|
724
867
|
revokeDevice(routeId, deviceId) {
|
|
725
868
|
const safeRouteId = safeId2(routeId, "route id");
|
|
@@ -736,9 +879,11 @@ function openRelayRouteStore(options) {
|
|
|
736
879
|
var BLIND_RELAY_PROTOCOL_VERSION = 1;
|
|
737
880
|
var BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES = 15e5;
|
|
738
881
|
var BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES = 4e6;
|
|
882
|
+
var BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS = 1024;
|
|
739
883
|
var BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 64;
|
|
740
884
|
var BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE = 64 * 1024 * 1024;
|
|
741
885
|
var BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE = 12e3;
|
|
886
|
+
var UNSAFE_DISPLAY_TEXT2 = /[\p{Cc}\p{Zl}\p{Zp}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
742
887
|
function safeToken(value, field) {
|
|
743
888
|
if (typeof value !== "string" || value.length < 32 || value.length > 256 || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
744
889
|
throw new Error(`invalid relay ${field}`);
|
|
@@ -758,7 +903,7 @@ function safeId3(value, field) {
|
|
|
758
903
|
function safeLabel3(value) {
|
|
759
904
|
if (typeof value !== "string") throw new Error("relay route label is required");
|
|
760
905
|
const label = value.trim().replace(/\s+/g, " ");
|
|
761
|
-
if (!label || label.length > 80 ||
|
|
906
|
+
if (!label || label.length > 80 || UNSAFE_DISPLAY_TEXT2.test(label)) throw new Error("invalid relay route label");
|
|
762
907
|
return label;
|
|
763
908
|
}
|
|
764
909
|
function safeExpiry2(value, now) {
|
|
@@ -796,7 +941,9 @@ function publicRoute(route) {
|
|
|
796
941
|
function normalizeOrigin(value) {
|
|
797
942
|
try {
|
|
798
943
|
const url = new URL(value);
|
|
799
|
-
|
|
944
|
+
const loopback = url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
|
|
945
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
946
|
+
return;
|
|
800
947
|
return url.origin;
|
|
801
948
|
} catch {
|
|
802
949
|
return;
|
|
@@ -858,6 +1005,7 @@ function createBlindRelayServer(options) {
|
|
|
858
1005
|
const idleTimeoutMs = options.idleTimeoutMs ?? 2 * 6e4;
|
|
859
1006
|
const maxFrameBytes = options.maxFrameBytes ?? BLIND_RELAY_DEFAULT_MAX_FRAME_BYTES;
|
|
860
1007
|
const maxQueueBytes = options.maxQueueBytes ?? BLIND_RELAY_DEFAULT_MAX_QUEUE_BYTES;
|
|
1008
|
+
const maxTotalConnections = options.maxTotalConnections ?? BLIND_RELAY_DEFAULT_MAX_TOTAL_CONNECTIONS;
|
|
861
1009
|
const maxConnectionsPerRoute = options.maxConnectionsPerRoute ?? BLIND_RELAY_DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
|
|
862
1010
|
const maxBytesPerMinute = options.maxBytesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_BYTES_PER_MINUTE;
|
|
863
1011
|
const maxMessagesPerMinute = options.maxMessagesPerMinute ?? BLIND_RELAY_DEFAULT_MAX_MESSAGES_PER_MINUTE;
|
|
@@ -866,17 +1014,63 @@ function createBlindRelayServer(options) {
|
|
|
866
1014
|
[idleTimeoutMs, 1e4, 60 * 6e4, "idle timeout"],
|
|
867
1015
|
[maxFrameBytes, 1024, 16 * 1024 * 1024, "frame limit"],
|
|
868
1016
|
[maxQueueBytes, 1024, 64 * 1024 * 1024, "queue limit"],
|
|
1017
|
+
[maxTotalConnections, 1, 1e5, "total connection limit"],
|
|
869
1018
|
[maxConnectionsPerRoute, 1, 1e4, "connection limit"],
|
|
870
1019
|
[maxBytesPerMinute, 1024, 1024 * 1024 * 1024, "byte rate"],
|
|
871
1020
|
[maxMessagesPerMinute, 10, 1e6, "message rate"]
|
|
872
1021
|
]) {
|
|
873
1022
|
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) throw new Error(`invalid relay ${label}`);
|
|
874
1023
|
}
|
|
875
|
-
const
|
|
1024
|
+
const maxEnvelopeBytes = Math.max(8 * 1024, Math.ceil(maxFrameBytes * 4 / 3) + 8 * 1024);
|
|
1025
|
+
const configuredOrigins = options.allowedOrigins ?? [];
|
|
1026
|
+
const normalizedOrigins = configuredOrigins.map(normalizeOrigin);
|
|
1027
|
+
if (normalizedOrigins.some((origin) => origin === void 0)) {
|
|
1028
|
+
throw new Error("invalid relay allowed origin");
|
|
1029
|
+
}
|
|
1030
|
+
const allowedOrigins = new Set(normalizedOrigins);
|
|
876
1031
|
const generateChannelId = options.generateChannelId ?? (() => `rrc_${randomBytes3(16).toString("base64url")}`);
|
|
877
1032
|
const hosts = /* @__PURE__ */ new Map();
|
|
878
1033
|
const devicesByChannel = /* @__PURE__ */ new Map();
|
|
1034
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1035
|
+
const hostRates = /* @__PURE__ */ new Map();
|
|
1036
|
+
const deviceRates = /* @__PURE__ */ new Map();
|
|
1037
|
+
const maxRateIdentities = Math.min(1e5, Math.max(256, maxTotalConnections * 4));
|
|
1038
|
+
const deviceRateKey = (routeId, deviceId) => `${routeId}\0${deviceId}`;
|
|
1039
|
+
const clearRouteRates = (routeId) => {
|
|
1040
|
+
hostRates.delete(routeId);
|
|
1041
|
+
for (const key of deviceRates.keys()) if (key.startsWith(`${routeId}\0`)) deviceRates.delete(key);
|
|
1042
|
+
};
|
|
1043
|
+
const rateWindowFor = (windows, key) => {
|
|
1044
|
+
const current = now();
|
|
1045
|
+
if (windows.size >= maxRateIdentities) {
|
|
1046
|
+
for (const [candidate, window] of windows) {
|
|
1047
|
+
if (current - window.lastSeenAt >= 12e4) windows.delete(candidate);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const existing = windows.get(key);
|
|
1051
|
+
if (existing) {
|
|
1052
|
+
existing.lastSeenAt = current;
|
|
1053
|
+
return existing;
|
|
1054
|
+
}
|
|
1055
|
+
if (windows.size >= maxRateIdentities) return;
|
|
1056
|
+
const created = { startedAt: current, lastSeenAt: current, bytes: 0, messages: 0 };
|
|
1057
|
+
windows.set(key, created);
|
|
1058
|
+
return created;
|
|
1059
|
+
};
|
|
1060
|
+
const consumeRate = (window, bytes) => {
|
|
1061
|
+
const current = now();
|
|
1062
|
+
if (current - window.startedAt >= 6e4) {
|
|
1063
|
+
window.startedAt = current;
|
|
1064
|
+
window.bytes = 0;
|
|
1065
|
+
window.messages = 0;
|
|
1066
|
+
}
|
|
1067
|
+
window.lastSeenAt = current;
|
|
1068
|
+
window.bytes += bytes;
|
|
1069
|
+
window.messages += 1;
|
|
1070
|
+
return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
|
|
1071
|
+
};
|
|
879
1072
|
const metrics = {
|
|
1073
|
+
activeConnections: 0,
|
|
880
1074
|
activeHosts: 0,
|
|
881
1075
|
activeDevices: 0,
|
|
882
1076
|
acceptedConnections: 0,
|
|
@@ -913,6 +1107,14 @@ function createBlindRelayServer(options) {
|
|
|
913
1107
|
}
|
|
914
1108
|
reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
|
|
915
1109
|
};
|
|
1110
|
+
const requireRecoverableAccount = async (request, reply) => {
|
|
1111
|
+
const account = accountStore?.verifyCredential(bearer(request) ?? "");
|
|
1112
|
+
if (account) {
|
|
1113
|
+
authenticatedAccounts.set(request, account);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
reply.header("www-authenticate", "Bearer").code(401).send({ code: "RELAY_UNAUTHORIZED", error: "unauthorized" });
|
|
1117
|
+
};
|
|
916
1118
|
app.addHook("onSend", async (_request, reply, payload) => {
|
|
917
1119
|
reply.header("cache-control", "no-store");
|
|
918
1120
|
reply.header("content-security-policy", "default-src 'none'");
|
|
@@ -932,7 +1134,12 @@ function createBlindRelayServer(options) {
|
|
|
932
1134
|
});
|
|
933
1135
|
app.get("/v1/metrics", { preHandler: requireRoot }, async () => ({
|
|
934
1136
|
protocolVersion: BLIND_RELAY_PROTOCOL_VERSION,
|
|
935
|
-
metrics: {
|
|
1137
|
+
metrics: {
|
|
1138
|
+
...metrics,
|
|
1139
|
+
activeConnections: sockets.size,
|
|
1140
|
+
activeHosts: hosts.size,
|
|
1141
|
+
activeDevices: devicesByChannel.size
|
|
1142
|
+
}
|
|
936
1143
|
}));
|
|
937
1144
|
app.get("/v1/routes", { preHandler: requireRoot }, async () => ({ routes: store.listRoutes().map(publicRoute) }));
|
|
938
1145
|
app.post(
|
|
@@ -978,6 +1185,7 @@ function createBlindRelayServer(options) {
|
|
|
978
1185
|
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
979
1186
|
return;
|
|
980
1187
|
}
|
|
1188
|
+
clearRouteRates(request.params.routeId);
|
|
981
1189
|
const host = hosts.get(request.params.routeId);
|
|
982
1190
|
host?.socket.close(4403, "route deleted");
|
|
983
1191
|
for (const device of host?.devices.values() ?? []) device.socket.close(4403, "route deleted");
|
|
@@ -1034,22 +1242,12 @@ function createBlindRelayServer(options) {
|
|
|
1034
1242
|
reply.code(404).send({ code: "RELAY_DEVICE_NOT_FOUND", error: "relay device not found" });
|
|
1035
1243
|
return;
|
|
1036
1244
|
}
|
|
1245
|
+
deviceRates.delete(deviceRateKey(request.params.routeId, request.params.deviceId));
|
|
1037
1246
|
const live = hosts.get(request.params.routeId)?.devices.get(request.params.deviceId);
|
|
1038
1247
|
live?.socket.close(4403, "device revoked");
|
|
1039
1248
|
reply.code(204).send();
|
|
1040
1249
|
}
|
|
1041
1250
|
);
|
|
1042
|
-
const consumeRate = (window, bytes) => {
|
|
1043
|
-
const current = now();
|
|
1044
|
-
if (current - window.startedAt >= 6e4) {
|
|
1045
|
-
window.startedAt = current;
|
|
1046
|
-
window.bytes = 0;
|
|
1047
|
-
window.messages = 0;
|
|
1048
|
-
}
|
|
1049
|
-
window.bytes += bytes;
|
|
1050
|
-
window.messages += 1;
|
|
1051
|
-
return window.bytes <= maxBytesPerMinute && window.messages <= maxMessagesPerMinute;
|
|
1052
|
-
};
|
|
1053
1251
|
const closeDevice = (device, code = 1e3, reason = "device disconnected") => {
|
|
1054
1252
|
if (device.closed) return;
|
|
1055
1253
|
device.closed = true;
|
|
@@ -1058,7 +1256,10 @@ function createBlindRelayServer(options) {
|
|
|
1058
1256
|
const host = hosts.get(device.routeId);
|
|
1059
1257
|
if (host?.devices.get(device.deviceId) === device) host.devices.delete(device.deviceId);
|
|
1060
1258
|
metrics.activeDevices = Math.max(0, metrics.activeDevices - 1);
|
|
1061
|
-
if (host
|
|
1259
|
+
if (host && !safeSend(host.socket, { t: "peer-close", channelId: device.channelId, code }, maxQueueBytes)) {
|
|
1260
|
+
metrics.droppedFrames += 1;
|
|
1261
|
+
closeHost(host, 4408, "relay backpressure");
|
|
1262
|
+
}
|
|
1062
1263
|
if (device.socket.readyState === device.socket.OPEN) device.socket.close(code, reason);
|
|
1063
1264
|
};
|
|
1064
1265
|
const touchDevice = (device) => {
|
|
@@ -1086,11 +1287,24 @@ function createBlindRelayServer(options) {
|
|
|
1086
1287
|
usage: { routes: store.listRoutesByOwner(account.id, now()).length, maxRoutes: account.maxRoutes }
|
|
1087
1288
|
});
|
|
1088
1289
|
const closeAccountRoutes = (accountId, reason) => {
|
|
1290
|
+
for (const host of [...hosts.values()]) if (host.ownerAccountId === accountId) closeHost(host, 4403, reason);
|
|
1291
|
+
};
|
|
1292
|
+
const purgeAccountRoutes = (accountId) => {
|
|
1293
|
+
closeAccountRoutes(accountId, "account deleted");
|
|
1089
1294
|
for (const route of store.listRoutesByOwner(accountId, now())) {
|
|
1090
|
-
|
|
1091
|
-
|
|
1295
|
+
store.deleteRoute(route.id);
|
|
1296
|
+
clearRouteRates(route.id);
|
|
1092
1297
|
}
|
|
1093
1298
|
};
|
|
1299
|
+
for (const listedRoute of store.listRoutes(now())) {
|
|
1300
|
+
const route = store.getRoute(listedRoute.id);
|
|
1301
|
+
if (!route?.ownerAccountId) continue;
|
|
1302
|
+
const owner = accountStore.getAccount(route.ownerAccountId);
|
|
1303
|
+
if (!owner || owner.status === "deleted") {
|
|
1304
|
+
store.deleteRoute(route.id);
|
|
1305
|
+
clearRouteRates(route.id);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1094
1308
|
const ownedRoute = (accountId, routeId) => {
|
|
1095
1309
|
const route = store.getRoute(routeId);
|
|
1096
1310
|
return route?.ownerAccountId === accountId ? route : void 0;
|
|
@@ -1098,18 +1312,47 @@ function createBlindRelayServer(options) {
|
|
|
1098
1312
|
app.get("/v1/accounts", { preHandler: requireRoot }, async () => ({
|
|
1099
1313
|
accounts: accountStore.listAccounts().map((account) => accountEnvelope(account))
|
|
1100
1314
|
}));
|
|
1101
|
-
|
|
1102
|
-
const accountCredential = generateRelayAccountCredential();
|
|
1315
|
+
const createAccountHandler = (clientHashedOnly) => async (request, reply) => {
|
|
1103
1316
|
try {
|
|
1317
|
+
const hasCredentialHash = request.body?.credentialHash !== void 0;
|
|
1318
|
+
const hasCredentialLookup = request.body?.credentialLookup !== void 0;
|
|
1319
|
+
if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
|
|
1320
|
+
throw new Error("client-hashed account credential material is required");
|
|
1321
|
+
}
|
|
1322
|
+
const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
|
|
1323
|
+
const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
|
|
1324
|
+
const credential = suppliedCredentialMaterial ? {
|
|
1325
|
+
credentialHash: request.body?.credentialHash,
|
|
1326
|
+
credentialLookup: request.body?.credentialLookup
|
|
1327
|
+
} : accountCredential;
|
|
1104
1328
|
const account = accountStore.createAccount({
|
|
1105
|
-
|
|
1106
|
-
|
|
1329
|
+
label: request.body?.label,
|
|
1330
|
+
...request.body?.plan === void 0 ? {} : { plan: request.body.plan },
|
|
1331
|
+
...request.body?.maxRoutes === void 0 ? {} : { maxRoutes: request.body.maxRoutes },
|
|
1332
|
+
...request.body?.maxDevicesPerRoute === void 0 ? {} : { maxDevicesPerRoute: request.body.maxDevicesPerRoute },
|
|
1333
|
+
...typeof credential === "string" ? { credential } : {
|
|
1334
|
+
credentialHash: credential.credentialHash,
|
|
1335
|
+
credentialLookup: credential.credentialLookup
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
reply.code(201).send({
|
|
1339
|
+
...accountEnvelope(account),
|
|
1340
|
+
...accountCredential === void 0 ? {} : { accountCredential }
|
|
1107
1341
|
});
|
|
1108
|
-
reply.code(201).send({ ...accountEnvelope(account), accountCredential });
|
|
1109
1342
|
} catch {
|
|
1110
1343
|
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1111
1344
|
}
|
|
1112
|
-
}
|
|
1345
|
+
};
|
|
1346
|
+
app.post(
|
|
1347
|
+
"/v1/accounts",
|
|
1348
|
+
{ preHandler: requireRoot },
|
|
1349
|
+
createAccountHandler(false)
|
|
1350
|
+
);
|
|
1351
|
+
app.post(
|
|
1352
|
+
"/v1/accounts/client-hashed",
|
|
1353
|
+
{ preHandler: requireRoot },
|
|
1354
|
+
createAccountHandler(true)
|
|
1355
|
+
);
|
|
1113
1356
|
app.patch(
|
|
1114
1357
|
"/v1/accounts/:accountId",
|
|
1115
1358
|
{ preHandler: requireRoot },
|
|
@@ -1124,7 +1367,8 @@ function createBlindRelayServer(options) {
|
|
|
1124
1367
|
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1125
1368
|
return;
|
|
1126
1369
|
}
|
|
1127
|
-
if (account.status
|
|
1370
|
+
if (account.status === "deleted") purgeAccountRoutes(account.id);
|
|
1371
|
+
else if (account.status !== "active") closeAccountRoutes(account.id, "account unavailable");
|
|
1128
1372
|
reply.code(200).send(accountEnvelope(account));
|
|
1129
1373
|
} catch (error) {
|
|
1130
1374
|
if (error instanceof RelayAccountRevisionConflictError) {
|
|
@@ -1139,34 +1383,58 @@ function createBlindRelayServer(options) {
|
|
|
1139
1383
|
}
|
|
1140
1384
|
}
|
|
1141
1385
|
);
|
|
1386
|
+
const rotateAccountHandler = (clientHashedOnly) => async (request, reply) => {
|
|
1387
|
+
try {
|
|
1388
|
+
const hasCredentialHash = request.body?.credentialHash !== void 0;
|
|
1389
|
+
const hasCredentialLookup = request.body?.credentialLookup !== void 0;
|
|
1390
|
+
if (clientHashedOnly && (!hasCredentialHash || !hasCredentialLookup)) {
|
|
1391
|
+
throw new Error("client-hashed account credential material is required");
|
|
1392
|
+
}
|
|
1393
|
+
const suppliedCredentialMaterial = hasCredentialHash || hasCredentialLookup;
|
|
1394
|
+
const accountCredential = suppliedCredentialMaterial ? void 0 : generateRelayAccountCredential();
|
|
1395
|
+
const credential = suppliedCredentialMaterial ? {
|
|
1396
|
+
credentialHash: request.body?.credentialHash,
|
|
1397
|
+
credentialLookup: request.body?.credentialLookup
|
|
1398
|
+
} : accountCredential;
|
|
1399
|
+
const account = accountStore.rotateCredential(
|
|
1400
|
+
request.params.accountId,
|
|
1401
|
+
credential,
|
|
1402
|
+
safeRevision(request.body?.expectedRevision)
|
|
1403
|
+
);
|
|
1404
|
+
if (!account) {
|
|
1405
|
+
reply.code(404).send({ code: "RELAY_ACCOUNT_NOT_FOUND", error: "relay account not found" });
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
reply.code(200).send({
|
|
1409
|
+
...accountEnvelope(account),
|
|
1410
|
+
...accountCredential === void 0 ? {} : { accountCredential }
|
|
1411
|
+
});
|
|
1412
|
+
} catch (error) {
|
|
1413
|
+
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1414
|
+
reply.code(409).send({
|
|
1415
|
+
code: "RELAY_ACCOUNT_REVISION_CONFLICT",
|
|
1416
|
+
error: "relay account revision conflict",
|
|
1417
|
+
current: accountEnvelope(error.current)
|
|
1418
|
+
});
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1142
1424
|
app.post(
|
|
1143
1425
|
"/v1/accounts/:accountId/credential",
|
|
1144
1426
|
{ preHandler: requireRoot },
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
reply.code(200).send({ ...accountEnvelope(account), accountCredential });
|
|
1158
|
-
} catch (error) {
|
|
1159
|
-
if (error instanceof RelayAccountRevisionConflictError) {
|
|
1160
|
-
reply.code(409).send({
|
|
1161
|
-
code: "RELAY_ACCOUNT_REVISION_CONFLICT",
|
|
1162
|
-
error: "relay account revision conflict",
|
|
1163
|
-
current: accountEnvelope(error.current)
|
|
1164
|
-
});
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
1167
|
-
reply.code(400).send({ code: "INVALID_RELAY_ACCOUNT", error: "invalid relay account" });
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1427
|
+
rotateAccountHandler(false)
|
|
1428
|
+
);
|
|
1429
|
+
app.post(
|
|
1430
|
+
"/v1/accounts/:accountId/credential/client-hashed",
|
|
1431
|
+
{ preHandler: requireRoot },
|
|
1432
|
+
rotateAccountHandler(true)
|
|
1433
|
+
);
|
|
1434
|
+
app.get(
|
|
1435
|
+
"/v1/account/recovery",
|
|
1436
|
+
{ preHandler: requireRecoverableAccount },
|
|
1437
|
+
async (request) => accountEnvelope(authenticatedAccounts.get(request))
|
|
1170
1438
|
);
|
|
1171
1439
|
app.get(
|
|
1172
1440
|
"/v1/account",
|
|
@@ -1244,6 +1512,7 @@ function createBlindRelayServer(options) {
|
|
|
1244
1512
|
reply.code(404).send({ code: "RELAY_ROUTE_NOT_FOUND", error: "relay route not found" });
|
|
1245
1513
|
return;
|
|
1246
1514
|
}
|
|
1515
|
+
clearRouteRates(request.params.routeId);
|
|
1247
1516
|
const host = hosts.get(request.params.routeId);
|
|
1248
1517
|
if (host) closeHost(host, 4403, "route deleted");
|
|
1249
1518
|
reply.code(204).send();
|
|
@@ -1280,9 +1549,15 @@ function createBlindRelayServer(options) {
|
|
|
1280
1549
|
}
|
|
1281
1550
|
);
|
|
1282
1551
|
}
|
|
1283
|
-
app.register(websocket);
|
|
1552
|
+
app.register(websocket, { options: { maxPayload: maxEnvelopeBytes, perMessageDeflate: false } });
|
|
1284
1553
|
app.register(async (scope) => {
|
|
1285
1554
|
scope.get("/v1/connect", { websocket: true }, (socket, request) => {
|
|
1555
|
+
if (sockets.size >= maxTotalConnections) {
|
|
1556
|
+
metrics.rejectedConnections += 1;
|
|
1557
|
+
socket.close(4429, "relay connection limit");
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
sockets.add(socket);
|
|
1286
1561
|
let authenticated = false;
|
|
1287
1562
|
let liveHost;
|
|
1288
1563
|
let liveDevice;
|
|
@@ -1301,29 +1576,41 @@ function createBlindRelayServer(options) {
|
|
|
1301
1576
|
if (!authenticated) {
|
|
1302
1577
|
try {
|
|
1303
1578
|
const hello = parseAuthHello(parseJson(raw, 8 * 1024));
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1579
|
+
const presentedOrigin = request.headers.origin;
|
|
1580
|
+
const origin = typeof presentedOrigin === "string" ? normalizeOrigin(presentedOrigin) : void 0;
|
|
1581
|
+
if (hello.role === "device" && allowedOrigins.size > 0 && presentedOrigin !== void 0 && (!origin || !allowedOrigins.has(origin))) {
|
|
1306
1582
|
reject(4403, "origin denied");
|
|
1307
1583
|
return;
|
|
1308
1584
|
}
|
|
1309
1585
|
if (hello.role === "host") {
|
|
1310
|
-
|
|
1586
|
+
const route = store.getRoute(hello.routeId);
|
|
1587
|
+
if (!route || route.ownerAccountId !== void 0 && accountStore?.getAccount(route.ownerAccountId)?.status !== "active" || !store.authenticateHost(hello.routeId, hello.credential)) {
|
|
1311
1588
|
reject(4401, "authentication failed");
|
|
1312
1589
|
return;
|
|
1313
1590
|
}
|
|
1591
|
+
const rate = rateWindowFor(hostRates, hello.routeId);
|
|
1592
|
+
if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
|
|
1593
|
+
reject(4429, "relay identity rate limit");
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1314
1596
|
const previous = hosts.get(hello.routeId);
|
|
1315
1597
|
if (previous) closeHost(previous, 4409, "superseded host");
|
|
1316
1598
|
liveHost = {
|
|
1317
1599
|
socket,
|
|
1318
1600
|
routeId: hello.routeId,
|
|
1601
|
+
...route.ownerAccountId === void 0 ? {} : { ownerAccountId: route.ownerAccountId },
|
|
1319
1602
|
devices: /* @__PURE__ */ new Map(),
|
|
1320
|
-
rate
|
|
1603
|
+
rate,
|
|
1321
1604
|
closed: false
|
|
1322
1605
|
};
|
|
1323
1606
|
hosts.set(hello.routeId, liveHost);
|
|
1324
1607
|
metrics.activeHosts += 1;
|
|
1325
1608
|
touchHost(liveHost);
|
|
1326
|
-
safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes)
|
|
1609
|
+
if (!safeSend(socket, { t: "ready", role: "host", protocolVersion: 1 }, maxQueueBytes)) {
|
|
1610
|
+
metrics.rejectedConnections += 1;
|
|
1611
|
+
closeHost(liveHost, 4408, "relay backpressure");
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1327
1614
|
} else {
|
|
1328
1615
|
if (!routeAccountIsActive(hello.routeId) || !store.authenticateDevice(hello.routeId, hello.deviceId, hello.credential, now())) {
|
|
1329
1616
|
reject(4401, "authentication failed");
|
|
@@ -1338,6 +1625,11 @@ function createBlindRelayServer(options) {
|
|
|
1338
1625
|
reject(4429, "route connection limit");
|
|
1339
1626
|
return;
|
|
1340
1627
|
}
|
|
1628
|
+
const rate = rateWindowFor(deviceRates, deviceRateKey(hello.routeId, hello.deviceId));
|
|
1629
|
+
if (!rate || !consumeRate(rate, Buffer.byteLength(raw.toString()))) {
|
|
1630
|
+
reject(4429, "relay identity rate limit");
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1341
1633
|
const previous = host.devices.get(hello.deviceId);
|
|
1342
1634
|
if (previous) closeDevice(previous, 4409, "superseded device");
|
|
1343
1635
|
const channelId = safeId3(generateChannelId(), "channel id");
|
|
@@ -1346,15 +1638,20 @@ function createBlindRelayServer(options) {
|
|
|
1346
1638
|
routeId: hello.routeId,
|
|
1347
1639
|
deviceId: hello.deviceId,
|
|
1348
1640
|
channelId,
|
|
1349
|
-
rate
|
|
1641
|
+
rate,
|
|
1350
1642
|
closed: false
|
|
1351
1643
|
};
|
|
1352
1644
|
host.devices.set(hello.deviceId, liveDevice);
|
|
1353
1645
|
devicesByChannel.set(channelId, liveDevice);
|
|
1354
1646
|
metrics.activeDevices += 1;
|
|
1355
1647
|
touchDevice(liveDevice);
|
|
1356
|
-
safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes)
|
|
1648
|
+
if (!safeSend(socket, { t: "ready", role: "device", protocolVersion: 1, channelId }, maxQueueBytes)) {
|
|
1649
|
+
metrics.rejectedConnections += 1;
|
|
1650
|
+
closeDevice(liveDevice, 4408, "relay backpressure");
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1357
1653
|
if (!safeSend(host.socket, { t: "peer-open", channelId, deviceId: hello.deviceId }, maxQueueBytes)) {
|
|
1654
|
+
metrics.rejectedConnections += 1;
|
|
1358
1655
|
closeDevice(liveDevice, 4408, "host backpressure");
|
|
1359
1656
|
return;
|
|
1360
1657
|
}
|
|
@@ -1368,11 +1665,18 @@ function createBlindRelayServer(options) {
|
|
|
1368
1665
|
return;
|
|
1369
1666
|
}
|
|
1370
1667
|
try {
|
|
1371
|
-
const value = parseJson(raw,
|
|
1668
|
+
const value = parseJson(raw, maxEnvelopeBytes);
|
|
1372
1669
|
if (liveDevice) {
|
|
1373
1670
|
touchDevice(liveDevice);
|
|
1374
1671
|
if (value?.t === "ping") {
|
|
1375
|
-
|
|
1672
|
+
if (!consumeRate(liveDevice.rate, 1)) {
|
|
1673
|
+
closeDevice(liveDevice, 4429, "rate limit");
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
|
|
1677
|
+
metrics.droppedFrames += 1;
|
|
1678
|
+
closeDevice(liveDevice, 4408, "relay backpressure");
|
|
1679
|
+
}
|
|
1376
1680
|
return;
|
|
1377
1681
|
}
|
|
1378
1682
|
const frame = parsePayload(value, false, maxFrameBytes);
|
|
@@ -1397,7 +1701,14 @@ function createBlindRelayServer(options) {
|
|
|
1397
1701
|
if (liveHost) {
|
|
1398
1702
|
touchHost(liveHost);
|
|
1399
1703
|
if (value?.t === "ping") {
|
|
1400
|
-
|
|
1704
|
+
if (!consumeRate(liveHost.rate, 1)) {
|
|
1705
|
+
closeHost(liveHost, 4429, "rate limit");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (!safeSend(socket, { t: "pong", at: now() }, maxQueueBytes)) {
|
|
1709
|
+
metrics.droppedFrames += 1;
|
|
1710
|
+
closeHost(liveHost, 4408, "relay backpressure");
|
|
1711
|
+
}
|
|
1401
1712
|
return;
|
|
1402
1713
|
}
|
|
1403
1714
|
if (value?.t === "close-peer") {
|
|
@@ -1435,6 +1746,7 @@ function createBlindRelayServer(options) {
|
|
|
1435
1746
|
}
|
|
1436
1747
|
});
|
|
1437
1748
|
socket.once("close", () => {
|
|
1749
|
+
sockets.delete(socket);
|
|
1438
1750
|
clearTimeout(handshakeTimer);
|
|
1439
1751
|
if (liveDevice) closeDevice(liveDevice);
|
|
1440
1752
|
if (liveHost) closeHost(liveHost);
|
|
@@ -1445,20 +1757,45 @@ function createBlindRelayServer(options) {
|
|
|
1445
1757
|
});
|
|
1446
1758
|
app.addHook("onClose", async () => {
|
|
1447
1759
|
for (const host of [...hosts.values()]) closeHost(host, 1001, "relay shutting down");
|
|
1760
|
+
for (const socket of [...sockets]) socket.close(1001, "relay shutting down");
|
|
1761
|
+
hostRates.clear();
|
|
1762
|
+
deviceRates.clear();
|
|
1448
1763
|
if (ownsStore) store.close();
|
|
1449
1764
|
});
|
|
1450
1765
|
return {
|
|
1451
1766
|
app,
|
|
1452
1767
|
store,
|
|
1453
1768
|
...accountStore ? { accountStore } : {},
|
|
1454
|
-
metrics: () => ({
|
|
1769
|
+
metrics: () => ({
|
|
1770
|
+
...metrics,
|
|
1771
|
+
activeConnections: sockets.size,
|
|
1772
|
+
activeHosts: hosts.size,
|
|
1773
|
+
activeDevices: devicesByChannel.size
|
|
1774
|
+
})
|
|
1455
1775
|
};
|
|
1456
1776
|
}
|
|
1457
1777
|
|
|
1458
1778
|
// src/data-dir.ts
|
|
1459
|
-
import {
|
|
1779
|
+
import {
|
|
1780
|
+
closeSync,
|
|
1781
|
+
constants,
|
|
1782
|
+
existsSync,
|
|
1783
|
+
fchmodSync,
|
|
1784
|
+
fstatSync,
|
|
1785
|
+
fsyncSync,
|
|
1786
|
+
linkSync,
|
|
1787
|
+
lstatSync,
|
|
1788
|
+
mkdirSync,
|
|
1789
|
+
openSync,
|
|
1790
|
+
readFileSync,
|
|
1791
|
+
renameSync,
|
|
1792
|
+
unlinkSync,
|
|
1793
|
+
writeFileSync
|
|
1794
|
+
} from "fs";
|
|
1460
1795
|
import { randomBytes as randomBytes4 } from "crypto";
|
|
1461
|
-
import { join } from "path";
|
|
1796
|
+
import { dirname, join } from "path";
|
|
1797
|
+
var MAX_ACCESS_TOKEN_BYTES = 4 * 1024;
|
|
1798
|
+
var MAX_ACCESS_TOKEN_FILE_BYTES = MAX_ACCESS_TOKEN_BYTES + 2;
|
|
1462
1799
|
function resolveDataDir(env, exists = existsSync) {
|
|
1463
1800
|
if (env.ROAMCODE_DATA_DIR) return env.ROAMCODE_DATA_DIR;
|
|
1464
1801
|
if (env.REMOTE_CODER_DATA_DIR) return env.REMOTE_CODER_DATA_DIR;
|
|
@@ -1485,8 +1822,51 @@ function relayDataDir(env) {
|
|
|
1485
1822
|
function relayOrigins(env) {
|
|
1486
1823
|
return (env.ROAMCODE_RELAY_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
1487
1824
|
}
|
|
1825
|
+
function privateSecretFile(path, field) {
|
|
1826
|
+
let descriptor;
|
|
1827
|
+
try {
|
|
1828
|
+
const before = lstatSync2(path);
|
|
1829
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
|
|
1830
|
+
throw new Error("unsafe secret file");
|
|
1831
|
+
}
|
|
1832
|
+
descriptor = openSync2(path, constants2.O_RDONLY | (constants2.O_NOFOLLOW ?? 0));
|
|
1833
|
+
const opened = fstatSync2(descriptor);
|
|
1834
|
+
if (!opened.isFile() || opened.size > 4096 || (opened.mode & 63) !== 0 || typeof process.getuid === "function" && opened.uid !== process.getuid() || opened.dev !== before.dev || opened.ino !== before.ino) {
|
|
1835
|
+
throw new Error("unsafe secret file");
|
|
1836
|
+
}
|
|
1837
|
+
return readFileSync2(descriptor, "utf8").trim();
|
|
1838
|
+
} catch {
|
|
1839
|
+
throw new Error(`${field} could not be read securely`);
|
|
1840
|
+
} finally {
|
|
1841
|
+
if (descriptor !== void 0) closeSync2(descriptor);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1488
1844
|
function previousRootTokens(env) {
|
|
1489
|
-
|
|
1845
|
+
const inline = (env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
1846
|
+
const directory = env.ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR?.trim();
|
|
1847
|
+
if (inline.length > 0 && directory) {
|
|
1848
|
+
throw new Error(
|
|
1849
|
+
"ROAMCODE_RELAY_PREVIOUS_ROOT_TOKENS and ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR are mutually exclusive"
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
if (!directory) return inline;
|
|
1853
|
+
let before;
|
|
1854
|
+
let entries;
|
|
1855
|
+
try {
|
|
1856
|
+
before = lstatSync2(directory);
|
|
1857
|
+
if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 63) !== 0 || typeof process.getuid === "function" && before.uid !== process.getuid()) {
|
|
1858
|
+
throw new Error("unsafe secret directory");
|
|
1859
|
+
}
|
|
1860
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
1861
|
+
const after = lstatSync2(directory);
|
|
1862
|
+
if (after.dev !== before.dev || after.ino !== before.ino || (after.mode & 63) !== 0 || typeof process.getuid === "function" && after.uid !== process.getuid() || entries.some((entry) => !entry.isFile())) {
|
|
1863
|
+
throw new Error("unsafe secret directory");
|
|
1864
|
+
}
|
|
1865
|
+
} catch {
|
|
1866
|
+
throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR could not be read securely");
|
|
1867
|
+
}
|
|
1868
|
+
if (entries.length > 3) throw new Error("ROAMCODE_RELAY_PREVIOUS_ROOT_TOKEN_DIR may contain at most three files");
|
|
1869
|
+
return entries.map((entry) => entry.name).sort().map((name) => privateSecretFile(join2(directory, name), "previous relay root capability"));
|
|
1490
1870
|
}
|
|
1491
1871
|
function explicitBoolean(value, field) {
|
|
1492
1872
|
if (value === void 0 || value === "" || value === "0" || value === "false") return false;
|
|
@@ -1499,18 +1879,12 @@ function secretValue(env, directKey, fileKey) {
|
|
|
1499
1879
|
if (direct && file) throw new Error(`${directKey} and ${fileKey} are mutually exclusive`);
|
|
1500
1880
|
if (direct) return direct;
|
|
1501
1881
|
if (!file) return void 0;
|
|
1502
|
-
|
|
1503
|
-
try {
|
|
1504
|
-
contents = readFileSync2(file, "utf8");
|
|
1505
|
-
} catch {
|
|
1506
|
-
throw new Error(`${fileKey} could not be read`);
|
|
1507
|
-
}
|
|
1508
|
-
if (Buffer.byteLength(contents) > 4096) throw new Error(`${fileKey} is too large`);
|
|
1509
|
-
return contents.trim();
|
|
1882
|
+
return privateSecretFile(file, fileKey);
|
|
1510
1883
|
}
|
|
1511
1884
|
async function startBlindRelay(env = process.env) {
|
|
1512
1885
|
const rootToken = secretValue(env, "ROAMCODE_RELAY_ROOT_TOKEN", "ROAMCODE_RELAY_ROOT_TOKEN_FILE");
|
|
1513
1886
|
if (!rootToken) throw new Error("ROAMCODE_RELAY_ROOT_TOKEN or ROAMCODE_RELAY_ROOT_TOKEN_FILE is required");
|
|
1887
|
+
const previousRoots = previousRootTokens(env);
|
|
1514
1888
|
const allowedOrigins = relayOrigins(env);
|
|
1515
1889
|
const allowAnyOrigin = explicitBoolean(env.ROAMCODE_RELAY_ALLOW_ANY_ORIGIN, "relay allow-any-origin flag");
|
|
1516
1890
|
if (env.NODE_ENV === "production" && allowedOrigins.length === 0 && !allowAnyOrigin) {
|
|
@@ -1539,7 +1913,7 @@ async function startBlindRelay(env = process.env) {
|
|
|
1539
1913
|
try {
|
|
1540
1914
|
relay = createBlindRelayServer({
|
|
1541
1915
|
rootToken,
|
|
1542
|
-
previousRootTokens:
|
|
1916
|
+
previousRootTokens: previousRoots,
|
|
1543
1917
|
store,
|
|
1544
1918
|
...accountStore ? { accountStore } : {},
|
|
1545
1919
|
allowedOrigins,
|
|
@@ -1571,6 +1945,13 @@ async function startBlindRelay(env = process.env) {
|
|
|
1571
1945
|
64 * 1024 * 1024,
|
|
1572
1946
|
"relay queue limit"
|
|
1573
1947
|
),
|
|
1948
|
+
maxTotalConnections: boundedInteger(
|
|
1949
|
+
env.ROAMCODE_RELAY_MAX_TOTAL_CONNECTIONS,
|
|
1950
|
+
1024,
|
|
1951
|
+
1,
|
|
1952
|
+
1e5,
|
|
1953
|
+
"relay total connection limit"
|
|
1954
|
+
),
|
|
1574
1955
|
maxConnectionsPerRoute: boundedInteger(
|
|
1575
1956
|
env.ROAMCODE_RELAY_MAX_CONNECTIONS_PER_ROUTE,
|
|
1576
1957
|
64,
|