@otto-code/relay 0.8.19 → 0.9.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/cloudflare-adapter.d.ts +7 -0
- package/dist/cloudflare-adapter.js +81 -0
- package/dist/crypto.js +20 -1
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -46,6 +46,13 @@ export declare class RelayDurableObject {
|
|
|
46
46
|
private hasServerDataSocket;
|
|
47
47
|
private hasClientSocket;
|
|
48
48
|
private closeExistingServerSockets;
|
|
49
|
+
private hasClientCapacity;
|
|
50
|
+
private validateV2Admission;
|
|
51
|
+
/**
|
|
52
|
+
* Count opaque frames without inspecting them. The counter is part of the
|
|
53
|
+
* hibernatable socket attachment so a wake/sleep cycle cannot reset a flood.
|
|
54
|
+
*/
|
|
55
|
+
private consumeMessageAllowance;
|
|
49
56
|
private handleControlKeepalive;
|
|
50
57
|
private nudgeOrResetControlForConnection;
|
|
51
58
|
private frameByteSize;
|
|
@@ -29,6 +29,12 @@ const CURRENT_RELAY_VERSION = "2";
|
|
|
29
29
|
const MAX_PENDING_FRAMES_PER_CONNECTION = 200;
|
|
30
30
|
const MAX_PENDING_BYTES_PER_CONNECTION = 1024 * 1024; // fits one max-size (1 MiB) frame
|
|
31
31
|
const MAX_PENDING_BYTES_TOTAL = 16 * 1024 * 1024; // across all connectionIds in this DO
|
|
32
|
+
const MAX_ROUTE_KEY_BYTES = 256;
|
|
33
|
+
const MAX_CLIENT_SOCKETS_PER_SESSION = 4;
|
|
34
|
+
const DATA_MESSAGE_WINDOW_MS = 10000;
|
|
35
|
+
const MAX_DATA_MESSAGES_PER_WINDOW = 200;
|
|
36
|
+
const CONTROL_MESSAGE_WINDOW_MS = 60000;
|
|
37
|
+
const MAX_CONTROL_MESSAGES_PER_WINDOW = 6;
|
|
32
38
|
function resolveRelayVersion(rawValue) {
|
|
33
39
|
if (rawValue == null)
|
|
34
40
|
return LEGACY_RELAY_VERSION;
|
|
@@ -71,6 +77,13 @@ function getString(record, key) {
|
|
|
71
77
|
const value = record[key];
|
|
72
78
|
return typeof value === "string" ? value : undefined;
|
|
73
79
|
}
|
|
80
|
+
function getFiniteNumber(record, key) {
|
|
81
|
+
const value = record[key];
|
|
82
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
function utf8ByteLength(value) {
|
|
85
|
+
return new TextEncoder().encode(value).byteLength;
|
|
86
|
+
}
|
|
74
87
|
function getGlobalWebSocketPair() {
|
|
75
88
|
// Access WebSocketPair from global scope (Cloudflare Workers runtime)
|
|
76
89
|
// Use Reflect to access global property without type assertions
|
|
@@ -136,6 +149,53 @@ export class RelayDurableObject {
|
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
}
|
|
152
|
+
hasClientCapacity() {
|
|
153
|
+
return this.state.getWebSockets("client").length < MAX_CLIENT_SOCKETS_PER_SESSION;
|
|
154
|
+
}
|
|
155
|
+
validateV2Admission(role, resolvedConnectionId) {
|
|
156
|
+
// A real daemon only opens a v2 data socket after the client socket has
|
|
157
|
+
// connected and its control notification has been delivered. Refusing an
|
|
158
|
+
// orphan data socket eliminates an otherwise unbounded fake-socket shape.
|
|
159
|
+
if (role === "server" && resolvedConnectionId && !this.hasClientSocket(resolvedConnectionId)) {
|
|
160
|
+
return new Response("No matching client connection", { status: 409 });
|
|
161
|
+
}
|
|
162
|
+
if (role === "client" && !this.hasClientCapacity()) {
|
|
163
|
+
return new Response("Relay session client capacity reached", { status: 429 });
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Count opaque frames without inspecting them. The counter is part of the
|
|
169
|
+
* hibernatable socket attachment so a wake/sleep cycle cannot reset a flood.
|
|
170
|
+
*/
|
|
171
|
+
consumeMessageAllowance(ws, attachment) {
|
|
172
|
+
const connectionId = getString(attachment, "connectionId");
|
|
173
|
+
const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION;
|
|
174
|
+
// v1 has no separate control channel: both roles carry opaque relay data.
|
|
175
|
+
const isControl = version === CURRENT_RELAY_VERSION && attachment.role === "server" && !connectionId;
|
|
176
|
+
const windowMs = isControl ? CONTROL_MESSAGE_WINDOW_MS : DATA_MESSAGE_WINDOW_MS;
|
|
177
|
+
const maxMessages = isControl ? MAX_CONTROL_MESSAGES_PER_WINDOW : MAX_DATA_MESSAGES_PER_WINDOW;
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const windowStartedAt = Math.floor(now / windowMs) * windowMs;
|
|
180
|
+
const previousWindowStartedAt = getFiniteNumber(attachment, "rateWindowStartedAt");
|
|
181
|
+
const previousCount = getFiniteNumber(attachment, "rateWindowMessageCount") ?? 0;
|
|
182
|
+
const count = previousWindowStartedAt === windowStartedAt ? previousCount : 0;
|
|
183
|
+
if (count >= maxMessages) {
|
|
184
|
+
try {
|
|
185
|
+
ws.close(1013, "Relay message rate exceeded");
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// ignore
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
serializeAttachment(ws, {
|
|
193
|
+
...attachment,
|
|
194
|
+
rateWindowStartedAt: windowStartedAt,
|
|
195
|
+
rateWindowMessageCount: count + 1,
|
|
196
|
+
});
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
139
199
|
// COMPAT(relay-json-ping): Old daemons (< v0.1.76) send JSON {type:"ping"} on the control
|
|
140
200
|
// socket and rely on a JSON {type:"pong"} reply to keep controlLastSeenAt fresh. New daemons
|
|
141
201
|
// use WebSocket protocol pings (auto-answered at the edge, DO stays hibernated). Remove this
|
|
@@ -309,6 +369,9 @@ export class RelayDurableObject {
|
|
|
309
369
|
: connectionId;
|
|
310
370
|
const isServerControl = role === "server" && !resolvedConnectionId;
|
|
311
371
|
const isServerData = role === "server" && !!resolvedConnectionId;
|
|
372
|
+
const admissionError = this.validateV2Admission(role, resolvedConnectionId);
|
|
373
|
+
if (admissionError)
|
|
374
|
+
return admissionError;
|
|
312
375
|
// Close any existing server-side connection with the same identity.
|
|
313
376
|
// - server-control: single per serverId
|
|
314
377
|
// - server-data: single per connectionId
|
|
@@ -395,6 +458,9 @@ export class RelayDurableObject {
|
|
|
395
458
|
return;
|
|
396
459
|
}
|
|
397
460
|
const attachment = attachmentRaw;
|
|
461
|
+
if (!this.consumeMessageAllowance(ws, attachment)) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
398
464
|
const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION;
|
|
399
465
|
if (version === LEGACY_RELAY_VERSION) {
|
|
400
466
|
const targetRole = attachment.role === "server" ? "client" : "server";
|
|
@@ -533,6 +599,21 @@ export default {
|
|
|
533
599
|
if (!serverId) {
|
|
534
600
|
return new Response("Missing serverId parameter", { status: 400 });
|
|
535
601
|
}
|
|
602
|
+
if (utf8ByteLength(serverId) > MAX_ROUTE_KEY_BYTES) {
|
|
603
|
+
return new Response("serverId is too long", { status: 400 });
|
|
604
|
+
}
|
|
605
|
+
const connectionId = url.searchParams.get("connectionId");
|
|
606
|
+
if (connectionId !== null && utf8ByteLength(connectionId) > MAX_ROUTE_KEY_BYTES) {
|
|
607
|
+
return new Response("connectionId is too long", { status: 400 });
|
|
608
|
+
}
|
|
609
|
+
const role = url.searchParams.get("role");
|
|
610
|
+
if (role !== "server" && role !== "client") {
|
|
611
|
+
return new Response("Missing or invalid role parameter", { status: 400 });
|
|
612
|
+
}
|
|
613
|
+
const upgradeHeader = request.headers.get("Upgrade");
|
|
614
|
+
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
|
615
|
+
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
616
|
+
}
|
|
536
617
|
const version = resolveRelayVersion(url.searchParams.get("v"));
|
|
537
618
|
if (!version) {
|
|
538
619
|
return new Response("Invalid v parameter (expected 1 or 2)", { status: 400 });
|
package/dist/crypto.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import nacl from "tweetnacl";
|
|
15
15
|
import { fromByteArray, toByteArray } from "base64-js";
|
|
16
16
|
const NONCE_LENGTH = nacl.box.nonceLength; // 24
|
|
17
|
+
const ZERO_X25519_SHARED_RESULT = new Uint8Array(nacl.box.sharedKeyLength);
|
|
17
18
|
let prngReady = false;
|
|
18
19
|
function getGlobalCrypto() {
|
|
19
20
|
const g = globalThis;
|
|
@@ -48,6 +49,18 @@ function encodeBase64(bytes) {
|
|
|
48
49
|
function decodeBase64(base64) {
|
|
49
50
|
return toByteArray(base64);
|
|
50
51
|
}
|
|
52
|
+
function decodePublicKeyBase64(base64) {
|
|
53
|
+
if (typeof base64 !== "string" ||
|
|
54
|
+
base64.length % 4 !== 0 ||
|
|
55
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(base64)) {
|
|
56
|
+
throw new Error("Invalid public key encoding");
|
|
57
|
+
}
|
|
58
|
+
const bytes = decodeBase64(base64);
|
|
59
|
+
if (encodeBase64(bytes) !== base64) {
|
|
60
|
+
throw new Error("Invalid public key encoding");
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
51
64
|
function toUint8(data) {
|
|
52
65
|
return typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
53
66
|
}
|
|
@@ -68,7 +81,7 @@ export function exportPublicKey(publicKey) {
|
|
|
68
81
|
return encodeBase64(publicKey);
|
|
69
82
|
}
|
|
70
83
|
export function importPublicKey(base64) {
|
|
71
|
-
const bytes =
|
|
84
|
+
const bytes = decodePublicKeyBase64(base64);
|
|
72
85
|
if (bytes.byteLength !== nacl.box.publicKeyLength) {
|
|
73
86
|
throw new Error(`Invalid public key length (expected ${nacl.box.publicKeyLength})`);
|
|
74
87
|
}
|
|
@@ -94,6 +107,12 @@ export function deriveSharedKey(ourSecretKey, peerPublicKey) {
|
|
|
94
107
|
if (peerPublicKey.byteLength !== nacl.box.publicKeyLength) {
|
|
95
108
|
throw new Error(`Invalid peer public key length (expected ${nacl.box.publicKeyLength})`);
|
|
96
109
|
}
|
|
110
|
+
const rawSharedResult = nacl.scalarMult(ourSecretKey, peerPublicKey);
|
|
111
|
+
const isAllZero = nacl.verify(rawSharedResult, ZERO_X25519_SHARED_RESULT);
|
|
112
|
+
rawSharedResult.fill(0);
|
|
113
|
+
if (isAllZero) {
|
|
114
|
+
throw new Error("Invalid peer public key");
|
|
115
|
+
}
|
|
97
116
|
return nacl.box.before(peerPublicKey, ourSecretKey);
|
|
98
117
|
}
|
|
99
118
|
/**
|
package/dist/types.d.ts
CHANGED
|
@@ -23,5 +23,12 @@ export interface RelaySessionAttachment {
|
|
|
23
23
|
*/
|
|
24
24
|
connectionId?: string | null;
|
|
25
25
|
createdAt: number;
|
|
26
|
+
/**
|
|
27
|
+
* Ephemeral per-socket message allowance. This travels with Cloudflare's
|
|
28
|
+
* hibernatable WebSocket attachment only; it is neither user identity nor
|
|
29
|
+
* relay history and disappears when the socket closes.
|
|
30
|
+
*/
|
|
31
|
+
rateWindowStartedAt?: number;
|
|
32
|
+
rateWindowMessageCount?: number;
|
|
26
33
|
}
|
|
27
34
|
//# sourceMappingURL=types.d.ts.map
|