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