@threadedstack/wmd-web 0.0.0-stage → 0.1.13
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/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- package/wmdpack.wasm +0 -0
package/relay/policy.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// P-B10 relay: which addresses the relay may connect to. Every check is on the address the
|
|
2
|
+
// relay actually dials (after its own DNS resolution), never on a name, so a name that
|
|
3
|
+
// resolves to a private address (DNS rebinding included) is refused like the address.
|
|
4
|
+
|
|
5
|
+
import { networkInterfaces } from 'node:os'
|
|
6
|
+
|
|
7
|
+
export type Addr = { v: 4 | 6; b: Uint8Array }
|
|
8
|
+
|
|
9
|
+
/** Strict dotted-decimal IPv4 ("127.1", "0x7f.0.0.1", "010.0.0.1" and "2130706433" are not addresses). */
|
|
10
|
+
export function parseIPv4(s: string): Uint8Array | null {
|
|
11
|
+
const m = s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
12
|
+
if (!m) return null
|
|
13
|
+
const b = new Uint8Array(4)
|
|
14
|
+
for (let i = 0; i < 4; i++) {
|
|
15
|
+
const p = m[i + 1]
|
|
16
|
+
if (p.length > 1 && p[0] === '0') return null
|
|
17
|
+
const n = Number(p)
|
|
18
|
+
if (n > 255) return null
|
|
19
|
+
b[i] = n
|
|
20
|
+
}
|
|
21
|
+
return b
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** IPv6 (with "::" and a trailing dotted IPv4); zone ids ("%en0") are refused. */
|
|
25
|
+
export function parseIPv6(s: string): Uint8Array | null {
|
|
26
|
+
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1)
|
|
27
|
+
if (!s.includes(':') || !/^[0-9a-fA-F:.]+$/.test(s)) return null
|
|
28
|
+
let tail: number[] = []
|
|
29
|
+
if (s.includes('.')) {
|
|
30
|
+
const i = s.lastIndexOf(':')
|
|
31
|
+
const v4 = parseIPv4(s.slice(i + 1))
|
|
32
|
+
if (!v4) return null
|
|
33
|
+
tail = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]]
|
|
34
|
+
s = s.slice(0, i + 1)
|
|
35
|
+
if (!s.endsWith('::')) s = s.slice(0, -1)
|
|
36
|
+
}
|
|
37
|
+
const dbl = s.split('::')
|
|
38
|
+
if (dbl.length > 2) return null
|
|
39
|
+
const grp = (x: string) => (x === '' ? [] : x.split(':'))
|
|
40
|
+
const head = grp(dbl[0])
|
|
41
|
+
const rest = dbl.length === 2 ? grp(dbl[1]) : []
|
|
42
|
+
for (const g of [...head, ...rest]) if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null
|
|
43
|
+
const given = head.length + rest.length + tail.length
|
|
44
|
+
if (dbl.length === 1 ? given !== 8 : given > 7) return null
|
|
45
|
+
const words = [...head.map(h => parseInt(h, 16)), ...new Array(8 - given).fill(0), ...rest.map(h => parseInt(h, 16)), ...tail]
|
|
46
|
+
const b = new Uint8Array(16)
|
|
47
|
+
words.forEach((w, i) => {
|
|
48
|
+
b[i * 2] = w >> 8
|
|
49
|
+
b[i * 2 + 1] = w & 255
|
|
50
|
+
})
|
|
51
|
+
return b
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseIP(s: string): Addr | null {
|
|
55
|
+
const v4 = parseIPv4(s)
|
|
56
|
+
if (v4) return { v: 4, b: v4 }
|
|
57
|
+
const v6 = parseIPv6(s)
|
|
58
|
+
return v6 ? { v: 6, b: v6 } : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function addrString(a: Addr): string {
|
|
62
|
+
if (a.v === 4) return Array.from(a.b).join('.')
|
|
63
|
+
const w: string[] = []
|
|
64
|
+
for (let i = 0; i < 16; i += 2) w.push(((a.b[i] << 8) | a.b[i + 1]).toString(16))
|
|
65
|
+
return w.join(':')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inV4(b: Uint8Array, net: string, bits: number): boolean {
|
|
69
|
+
const n = parseIPv4(net)!
|
|
70
|
+
const x = ((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]) >>> 0
|
|
71
|
+
const y = ((n[0] << 24) | (n[1] << 16) | (n[2] << 8) | n[3]) >>> 0
|
|
72
|
+
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
|
|
73
|
+
return (x & mask) >>> 0 === (y & mask) >>> 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inV6(b: Uint8Array, net: string, bits: number): boolean {
|
|
77
|
+
const n = parseIPv6(net)!
|
|
78
|
+
for (let i = 0; i < 16 && bits > 0; i++, bits -= 8) {
|
|
79
|
+
const m = bits >= 8 ? 0xff : (0xff << (8 - bits)) & 0xff
|
|
80
|
+
if ((b[i] & m) !== (n[i] & m)) return false
|
|
81
|
+
}
|
|
82
|
+
return true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const V4_BLOCKED: [string, number, string][] = [
|
|
86
|
+
['169.254.169.254', 32, 'metadata'],
|
|
87
|
+
['100.100.100.200', 32, 'metadata'], // Alibaba Cloud
|
|
88
|
+
['0.0.0.0', 8, 'reserved'], // "this network"
|
|
89
|
+
['10.0.0.0', 8, 'private'],
|
|
90
|
+
['100.64.0.0', 10, 'private'], // shared address space (CGNAT)
|
|
91
|
+
['127.0.0.0', 8, 'loopback'],
|
|
92
|
+
['169.254.0.0', 16, 'link-local'],
|
|
93
|
+
['172.16.0.0', 12, 'private'],
|
|
94
|
+
['192.0.0.0', 24, 'reserved'], // IETF protocol assignments (incl. 192.0.0.192, a cloud metadata address)
|
|
95
|
+
['192.0.2.0', 24, 'reserved'], // TEST-NET-1
|
|
96
|
+
['192.88.99.0', 24, 'reserved'], // 6to4 relay anycast
|
|
97
|
+
['192.168.0.0', 16, 'private'],
|
|
98
|
+
['198.18.0.0', 15, 'reserved'], // benchmarking
|
|
99
|
+
['198.51.100.0', 24, 'reserved'], // TEST-NET-2
|
|
100
|
+
['203.0.113.0', 24, 'reserved'], // TEST-NET-3
|
|
101
|
+
['224.0.0.0', 4, 'multicast'],
|
|
102
|
+
['240.0.0.0', 4, 'reserved'], // incl. 255.255.255.255
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
const V6_BLOCKED: [string, number, string][] = [
|
|
106
|
+
['fd00:ec2::254', 128, 'metadata'], // AWS IMDS over IPv6
|
|
107
|
+
['::', 128, 'reserved'],
|
|
108
|
+
['::1', 128, 'loopback'],
|
|
109
|
+
['::', 96, 'reserved'], // IPv4-compatible (deprecated)
|
|
110
|
+
['::ffff:0:0:0', 96, 'reserved'], // IPv4-translated (SIIT, RFC 2765): refused whatever it carries
|
|
111
|
+
['64:ff9b:1::', 48, 'reserved'], // local-use NAT64
|
|
112
|
+
['100::', 64, 'reserved'], // discard
|
|
113
|
+
['2001::', 23, 'reserved'], // IETF protocol assignments, incl. Teredo (2001::/32)
|
|
114
|
+
['2001:db8::', 32, 'reserved'], // documentation
|
|
115
|
+
['3fff::', 20, 'reserved'], // documentation
|
|
116
|
+
['5f00::', 16, 'reserved'], // SRv6 SIDs
|
|
117
|
+
['fc00::', 7, 'private'], // unique local
|
|
118
|
+
['fe80::', 10, 'link-local'],
|
|
119
|
+
['fec0::', 10, 'private'], // site-local (deprecated)
|
|
120
|
+
['ff00::', 8, 'multicast'],
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
/** The ranges `allowLan` opens: RFC 1918 (10/8, 172.16/12, 192.168/16) and IPv6 unique local (fc00::/7) only. Not CGNAT (100.64/10: tailnets, some clouds' internals), site-local or anything embedded. */
|
|
124
|
+
export function isLanRange(a: Addr): boolean {
|
|
125
|
+
if (a.v === 4) return inV4(a.b, '10.0.0.0', 8) || inV4(a.b, '172.16.0.0', 12) || inV4(a.b, '192.168.0.0', 16)
|
|
126
|
+
return inV6(a.b, 'fc00::', 7) && !embeddedV4(a.b)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The IPv4 address inside an IPv6 one that carries it (mapped, translated, NAT64, 6to4, ISATAP), or null. */
|
|
130
|
+
export function embeddedV4(b: Uint8Array): Uint8Array | null {
|
|
131
|
+
if (inV6(b, '::ffff:0:0', 96)) return b.slice(12)
|
|
132
|
+
if (inV6(b, '::ffff:0:0:0', 96)) return b.slice(12)
|
|
133
|
+
if (inV6(b, '64:ff9b::', 96)) return b.slice(12)
|
|
134
|
+
if (inV6(b, '2002::', 16)) return b.slice(2, 6)
|
|
135
|
+
// ISATAP interface ids (RFC 5214): ...:0:5efe:a.b.c.d and ...:200:5efe:a.b.c.d
|
|
136
|
+
if (b[10] === 0x5e && b[11] === 0xfe && (b[8] & 0xfd) === 0 && b[9] === 0) return b.slice(12)
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A comparable form: an IPv4-mapped IPv6 address is its IPv4 address. */
|
|
141
|
+
export function addrKey(a: Addr): string {
|
|
142
|
+
if (a.v === 6 && inV6(a.b, '::ffff:0:0', 96)) return Array.from(a.b.slice(12)).join('.')
|
|
143
|
+
return addrString(a)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The relay host's own addresses (every interface, plus `extra`). */
|
|
147
|
+
export function hostAddresses(extra: string[] = []): Set<string> {
|
|
148
|
+
const out = new Set<string>()
|
|
149
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
150
|
+
for (const i of list || []) {
|
|
151
|
+
const a = parseIP(i.address.split('%')[0])
|
|
152
|
+
if (a) out.add(addrKey(a))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const x of extra) {
|
|
156
|
+
const a = parseIP(x.trim())
|
|
157
|
+
if (a) out.add(addrKey(a))
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Why the relay must not connect to this address, or null when it may. `self` holds the
|
|
164
|
+
* relay host's own addresses (addrKey form).
|
|
165
|
+
*/
|
|
166
|
+
export function blockedReason(a: Addr, self: Set<string> = new Set()): string | null {
|
|
167
|
+
if (self.has(addrKey(a))) return 'self'
|
|
168
|
+
if (a.v === 4) {
|
|
169
|
+
for (const [net, bits, why] of V4_BLOCKED) if (inV4(a.b, net, bits)) return why
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
const e = embeddedV4(a.b)
|
|
173
|
+
if (e) {
|
|
174
|
+
const r = blockedReason({ v: 4, b: e }, self)
|
|
175
|
+
if (r) return `${r} (embedded IPv4 ${Array.from(e).join('.')})`
|
|
176
|
+
}
|
|
177
|
+
for (const [net, bits, why] of V6_BLOCKED) if (inV6(a.b, net, bits)) return why
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Why the relay will not look up this name, or null: letters, digits, '-', '_', at most 253 bytes, not numeric-looking, not localhost. */
|
|
182
|
+
export function badName(name: string): string | null {
|
|
183
|
+
const n = name.toLowerCase().replace(/\.$/, '')
|
|
184
|
+
if (!n || n.length > 253) return 'bad length'
|
|
185
|
+
const labels = n.split('.')
|
|
186
|
+
for (const l of labels) if (!/^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$/.test(l)) return 'bad characters'
|
|
187
|
+
// "127.1", "0x7f000001", "2130706433": numeric forms a resolver might take as addresses
|
|
188
|
+
if (/^(0x[0-9a-f]+|\d+)$/.test(labels[labels.length - 1])) return 'numeric name'
|
|
189
|
+
if (n === 'localhost' || n.endsWith('.localhost')) return 'localhost'
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type Cidr = { v: 4 | 6; b: Uint8Array; bits: number }
|
|
194
|
+
|
|
195
|
+
/** "10.1.0.0/16", "2001:db8::/32" or a bare address (a host route); null when it is not one. */
|
|
196
|
+
export function parseCidr(s: string): Cidr | null {
|
|
197
|
+
const [a, len, ...more] = s.trim().split('/')
|
|
198
|
+
if (more.length) return null
|
|
199
|
+
const ip = parseIP(a)
|
|
200
|
+
if (!ip) return null
|
|
201
|
+
const max = ip.v === 4 ? 32 : 128
|
|
202
|
+
if (len !== undefined && !/^\d{1,3}$/.test(len)) return null
|
|
203
|
+
const bits = len === undefined ? max : Number(len)
|
|
204
|
+
return bits > max ? null : { v: ip.v, b: ip.b, bits }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function cidrContains(c: Cidr, a: Addr): boolean {
|
|
208
|
+
if (c.v !== a.v) return false
|
|
209
|
+
let bits = c.bits
|
|
210
|
+
for (let i = 0; i < c.b.length && bits > 0; i++, bits -= 8) {
|
|
211
|
+
const m = bits >= 8 ? 0xff : (0xff << (8 - bits)) & 0xff
|
|
212
|
+
if ((a.b[i] & m) !== (c.b[i] & m)) return false
|
|
213
|
+
}
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The hosted profile's extra refusals on a dialled address (plan §15), on top of blockedReason: all of NAT64
|
|
219
|
+
* (64:ff9b::/96, whatever IPv4 it carries: a NAT64 gateway on the path would reach it) and the operator's `--deny-cidr`
|
|
220
|
+
* list (the fleet's own private neighbours). IPv4-mapped and -compatible forms of a refused IPv4 range are already
|
|
221
|
+
* refused by blockedReason (embeddedV4), and 100.64/10 and fd00:ec2::254 are in its tables; a test pins that.
|
|
222
|
+
*/
|
|
223
|
+
export function hostedBlockedReason(a: Addr, deny: Cidr[]): string | null {
|
|
224
|
+
if (a.v === 6 && inV6(a.b, '64:ff9b::', 96)) return 'NAT64'
|
|
225
|
+
for (const c of deny) {
|
|
226
|
+
if (cidrContains(c, a)) return 'operator deny-cidr'
|
|
227
|
+
const e = a.v === 6 ? embeddedV4(a.b) : null
|
|
228
|
+
if (e && c.v === 4 && cidrContains(c, { v: 4, b: e })) return 'operator deny-cidr (embedded IPv4)'
|
|
229
|
+
}
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A bind address a person on the open internet cannot reach: loopback, private or link-local. Names other than localhost are public (not resolved here). */
|
|
234
|
+
export function isPrivateBind(host: string): boolean {
|
|
235
|
+
if (/^localhost$/i.test(host)) return true
|
|
236
|
+
const a = parseIP(host.replace(/%.*$/, '').replace(/^\[|\]$/g, ''))
|
|
237
|
+
if (!a) return false
|
|
238
|
+
const r = blockedReason(a)
|
|
239
|
+
if (r === 'loopback' || r === 'private' || r === 'link-local') return true
|
|
240
|
+
return false
|
|
241
|
+
}
|
package/relay/relay.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// P-B10: the network relay for wmd-web. A browser guest's TCP connections and DNS lookups
|
|
2
|
+
// arrive here over one WebSocket per guest (src/net/proto.js), and the relay makes the real
|
|
3
|
+
// connections. Small and self-hostable:
|
|
4
|
+
//
|
|
5
|
+
// WMD_RELAY_SECRET=<at least 16 chars> bun relay/relay.ts
|
|
6
|
+
//
|
|
7
|
+
// Environment (all optional but the secret):
|
|
8
|
+
// WMD_RELAY_PORT (4571), WMD_RELAY_HOST (127.0.0.1)
|
|
9
|
+
// WMD_RELAY_ORIGINS comma list of page origins allowed to connect, at /relay and at /wisp/ (default: any)
|
|
10
|
+
// WMD_RELAY_ALLOW_HOSTS comma list of DNS names, each optionally with ports ("pypi.org:443",
|
|
11
|
+
// "example.com:80|443"; no ports means 80 and 443; a leading "."
|
|
12
|
+
// matches subdomains); when set the relay answers only those names and
|
|
13
|
+
// connects only to (address, port) pairs it gave out for them to this
|
|
14
|
+
// guest (default: any public address)
|
|
15
|
+
// WMD_RELAY_SELF extra addresses that are this host (a public IP behind NAT); every
|
|
16
|
+
// interface address is included anyway
|
|
17
|
+
// WMD_RELAY_DNS resolvers to ask (ip or ip:port, comma list; default: the system's)
|
|
18
|
+
// Caps are per guest identity (the token's uid: one guest machine of one page; the token's
|
|
19
|
+
// sid when it has none) and survive reconnects for as long as that identity holds a token:
|
|
20
|
+
// WMD_RELAY_MAX_CONNS (64) connections at once; WMD_RELAY_CONNECTS_PER_MIN (600),
|
|
21
|
+
// WMD_RELAY_DNS_PER_MIN (1200), WMD_RELAY_OPENS_PER_MIN (30) WebSocket opens;
|
|
22
|
+
// WMD_RELAY_RATE_DOWN (16777216), WMD_RELAY_RATE_UP (4194304) bytes/s, every byte (a new
|
|
23
|
+
// stream's first window too); WMD_RELAY_MAX_BYTES (4294967296) bytes, both ways.
|
|
24
|
+
// One live WebSocket per identity (a newer one replaces it). WMD_RELAY_MAX_SESSIONS (100) in
|
|
25
|
+
// all, WMD_RELAY_MAX_SESSIONS_PER_GROUP (8) per token group (grp: the client the issuer saw,
|
|
26
|
+
// e.g. its address or user). The relay's state is in memory: a relay restart resets it.
|
|
27
|
+
// WMD_RELAY_BLOCK_PORTS (25) destination ports refused
|
|
28
|
+
// WMD_RELAY_LOG=0 no JSON log lines on stdout
|
|
29
|
+
// WMD_RELAY_WISP=0 do not serve Wisp at /wisp/ (W59, on by default since D-444; token
|
|
30
|
+
// clients only here: the standalone wmd-relay, relay/main.ts, is the keyless one)
|
|
31
|
+
// Test only (the relay prints a warning when they are set):
|
|
32
|
+
// WMD_RELAY_TEST_ROUTES "203.0.113.10:80=127.0.0.1:4572,..." dial the right side for the left
|
|
33
|
+
// WMD_RELAY_TEST_HOSTS "bulk.wmd.test=203.0.113.10,..." answer these names without DNS
|
|
34
|
+
//
|
|
35
|
+
// Security: every connect is checked on the address actually dialled, after the relay's
|
|
36
|
+
// own resolution (policy.ts): private, loopback, link-local, metadata, reserved and
|
|
37
|
+
// multicast ranges and this host's own addresses are refused, so a name that resolves (or
|
|
38
|
+
// rebinds) to one of them cannot reach it. A session needs a token (token.ts); connections,
|
|
39
|
+
// connect and DNS rates, bandwidth and total bytes are capped per session.
|
|
40
|
+
|
|
41
|
+
import { E, MAX_DATA, PROTOCOL } from '../src/net/proto.js'
|
|
42
|
+
import { type DialFn, type EngineOptions, type ResolveFn, createEngine } from './engine.ts'
|
|
43
|
+
import { createV1 } from './v1.ts'
|
|
44
|
+
import { type Governor, type HostedOptions, type DenySpec, createGovernor } from './hosted.ts'
|
|
45
|
+
import { type Cidr, parseCidr } from './policy.ts'
|
|
46
|
+
import { DRAIN_WORDS, type WispOptions, createWisp } from './wisp.ts'
|
|
47
|
+
|
|
48
|
+
export type { DialFn, ResolveFn, WispOptions }
|
|
49
|
+
|
|
50
|
+
export type RelayOptions = EngineOptions & {
|
|
51
|
+
port?: number
|
|
52
|
+
host?: string
|
|
53
|
+
secret: string
|
|
54
|
+
origins?: string[]
|
|
55
|
+
/** The Wisp endpoint (W59) at /wisp/. On unless `enabled: false` (on by default since S6, D-444: proven in Chromium, Firefox, WebKit and Safari). */
|
|
56
|
+
wisp?: WispOptions
|
|
57
|
+
tls?: { cert: string; key: string }
|
|
58
|
+
/** A TLS proxy or tunnel is in front: secrets on a reachable plain-ws bind are acceptable. */
|
|
59
|
+
behindTls?: boolean
|
|
60
|
+
/** The hosted profile (plan §15, hosted.ts): limits, lists, ceilings, /healthz and /readyz. */
|
|
61
|
+
hosted?: HostedOptions
|
|
62
|
+
/** Proxy addresses or CIDRs: only these peers are believed about the client address, which is the first entry from the right of `proxyHeader` that is not one of them. No "trust any". */
|
|
63
|
+
trustProxy?: string[]
|
|
64
|
+
proxyHeader?: string // (x-forwarded-for; a provider's own, e.g. cf-connecting-ip)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MAIL_PORTS = [25, 465, 587]
|
|
68
|
+
|
|
69
|
+
const isLoopbackHost = (h: string) => /^(127\.\d+\.\d+\.\d+|::1|localhost)$/i.test(h)
|
|
70
|
+
|
|
71
|
+
export function createRelay(o: RelayOptions) {
|
|
72
|
+
if (!o.secret || o.secret.length < 16) throw new Error('WMD_RELAY_SECRET must be set (at least 16 characters)')
|
|
73
|
+
const host = o.host ?? '127.0.0.1'
|
|
74
|
+
const wispOn = o.wisp?.enabled !== false
|
|
75
|
+
if (wispOn && o.wisp?.keys?.length && !o.tls && !o.behindTls && !isLoopbackHost(host)) {
|
|
76
|
+
throw new Error(`keys on plain ws:// at ${host} would cross the network in clear text; give a TLS certificate or say a TLS proxy is in front (behindTls)`)
|
|
77
|
+
}
|
|
78
|
+
let port = 0
|
|
79
|
+
// the grace after a byte ceiling is over: every open stream ends (CLOSE 0x49); the sessions stay for the next day
|
|
80
|
+
const endStreams = (words: string) => {
|
|
81
|
+
for (const s of [...engine.sessions.values()]) for (const st of [...s.streams.values()]) engine.fail(s, st, E.LIMIT, words)
|
|
82
|
+
}
|
|
83
|
+
const gov: Governor | null = o.hosted ? createGovernor({ onCeilingGrace: w => endStreams(w), ...o.hosted }) : null
|
|
84
|
+
// hosted: mail ports are always refused, whatever the operator adds
|
|
85
|
+
const blockedPorts = gov ? [...new Set([...MAIL_PORTS, ...(o.blockedPorts ?? [])])] : o.blockedPorts
|
|
86
|
+
const trusted: Cidr[] = (o.trustProxy ?? []).map(x => {
|
|
87
|
+
const c = parseCidr(x)
|
|
88
|
+
if (!c) throw new Error(`trust-proxy '${x}' is not an address or CIDR`)
|
|
89
|
+
if (c.bits < (c.v === 4 ? 8 : 16)) throw new Error(`trust-proxy '${x}' trusts too much of the internet: a prefix shorter than /8 (IPv4) or /16 (IPv6) is refused; list the proxy's address or network`)
|
|
90
|
+
return c
|
|
91
|
+
})
|
|
92
|
+
const proxy = trusted.length ? { trusted, header: (o.proxyHeader || 'x-forwarded-for').toLowerCase() } : undefined
|
|
93
|
+
const engine = createEngine({ ...o, blockedPorts, ownPort: () => port, ...(gov ? { guard: gov } : {}) })
|
|
94
|
+
const v1 = createV1(engine, o)
|
|
95
|
+
const wisp = wispOn ? createWisp(engine, { ...o, blockedPorts, port: () => port, wisp: { ...o.wisp, origins: o.wisp?.origins ?? o.origins, enabled: true, ...(gov ? { hosted: gov } : {}), ...(proxy ? { proxy } : {}) } }) : null
|
|
96
|
+
|
|
97
|
+
const streams = () => {
|
|
98
|
+
let n = 0
|
|
99
|
+
for (const s of engine.sessions.values()) n += s.streams.size
|
|
100
|
+
return n
|
|
101
|
+
}
|
|
102
|
+
// /healthz and /readyz hold counts and the abuse contact, nothing about any client, origin, destination or key
|
|
103
|
+
function health(path: string): Response {
|
|
104
|
+
const why = gov!.notAccepting()
|
|
105
|
+
const ceiling = gov!.ceiling()
|
|
106
|
+
if (path === '/readyz') return new Response(why ? `not ready: ${why}\n${gov!.contactLine}` : 'ready\n', { status: why ? 503 : 200 })
|
|
107
|
+
return Response.json({
|
|
108
|
+
version: gov!.version ?? null, sessions: engine.sessions.size, streams: streams(), accepting: !why,
|
|
109
|
+
state: gov!.isDraining() ? 'draining' : gov!.isStopped() ? 'stopped' : 'running', ceiling, abuseContact: gov!.contact,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const serverOptions = (tls?: { cert: string; key: string }): any => ({
|
|
114
|
+
port: tls && server ? port : o.port ?? 4571,
|
|
115
|
+
hostname: host,
|
|
116
|
+
// two listeners may share the port for a moment when the certificate is reloaded (reloadTls)
|
|
117
|
+
...(tls ? { tls, reusePort: true } : {}),
|
|
118
|
+
fetch(req: Request, srv: any) {
|
|
119
|
+
const url = new URL(req.url)
|
|
120
|
+
if (gov && (req.method === 'GET' || req.method === 'HEAD') && (url.pathname === '/healthz' || url.pathname === '/readyz')) {
|
|
121
|
+
const r = health(url.pathname)
|
|
122
|
+
return req.method === 'HEAD' ? new Response(null, { status: r.status, headers: r.headers }) : r // same status and headers, no body
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname === '/relay') {
|
|
125
|
+
const protos = req.headers.get('sec-websocket-protocol') || ''
|
|
126
|
+
if (wisp && protos.includes('wisp-v2') && !protos.includes(PROTOCOL)) return new Response(`this is the wmd-relay.v1 endpoint; Wisp is at ${wisp.path}\n`, { status: 400 })
|
|
127
|
+
return v1.fetch(req, srv)
|
|
128
|
+
}
|
|
129
|
+
if (wisp && url.pathname.startsWith(wisp.path)) return wisp.fetch(req, srv, url)
|
|
130
|
+
return new Response('not found\n', { status: 404 })
|
|
131
|
+
},
|
|
132
|
+
websocket: {
|
|
133
|
+
maxPayloadLength: wisp ? Math.max(MAX_DATA + 1024, wisp.maxPayload + (64 << 10)) : MAX_DATA + 1024, // headroom: the adapters close an oversized frame with 1009 in words
|
|
134
|
+
backpressureLimit: 32 << 20,
|
|
135
|
+
idleTimeout: 120,
|
|
136
|
+
sendPings: true,
|
|
137
|
+
open(ws: any) {
|
|
138
|
+
if (ws.data.kind === 'wisp') wisp!.open(ws)
|
|
139
|
+
else v1.open(ws)
|
|
140
|
+
},
|
|
141
|
+
message(ws: any, data: any) {
|
|
142
|
+
if (ws.data.kind === 'wisp') wisp!.message(ws, data)
|
|
143
|
+
else v1.message(ws, data)
|
|
144
|
+
},
|
|
145
|
+
drain(ws: any) {
|
|
146
|
+
const s = ws.data.session
|
|
147
|
+
if (s) engine.pumpAll(s)
|
|
148
|
+
},
|
|
149
|
+
close(ws: any, code: number, reason: string) {
|
|
150
|
+
if (ws.data.kind === 'wisp') return wisp!.close(ws, code, reason)
|
|
151
|
+
const s = ws.data.session
|
|
152
|
+
if (s) engine.closeSession(s, `websocket closed ${code} ${reason || ''}`.trim())
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
let server: any = null
|
|
158
|
+
server = Bun.serve(serverOptions(o.tls))
|
|
159
|
+
port = server.port
|
|
160
|
+
return {
|
|
161
|
+
get server() {
|
|
162
|
+
return server
|
|
163
|
+
},
|
|
164
|
+
port: server.port,
|
|
165
|
+
hosted: gov,
|
|
166
|
+
stats: () => ({ ...engine.stats, live: engine.sessions.size, principals: engine.principals.size }),
|
|
167
|
+
/** Hosted: end every session (CLOSE 0x01 per stream, then 1001 in words) and take no new ones until `resume`. */
|
|
168
|
+
drain(): number {
|
|
169
|
+
gov?.startDrain()
|
|
170
|
+
const all = [...engine.sessions.values()]
|
|
171
|
+
for (const s of all) {
|
|
172
|
+
if (s.ws.data.kind === 'wisp' && wisp) wisp.drain(s.ws)
|
|
173
|
+
else s.ws.close(1001, DRAIN_WORDS)
|
|
174
|
+
}
|
|
175
|
+
return all.length
|
|
176
|
+
},
|
|
177
|
+
/** Hosted: the state `ctl status` prints (counts only). */
|
|
178
|
+
status() {
|
|
179
|
+
return {
|
|
180
|
+
accepting: gov ? gov.accepting() : true, state: gov?.isDraining() ? 'draining' : gov?.isStopped() ? 'stopped' : 'running',
|
|
181
|
+
ceiling: gov?.ceiling() ?? null, sessions: engine.sessions.size, streams: streams(), usage: gov?.usage() ?? null, origins: gov?.originStats() ?? null,
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
/** Hosted: replace the deny lists; sessions and streams they now deny end. Returns how many sessions and streams. */
|
|
185
|
+
reloadDeny(spec: DenySpec): { sessions: number; streams: number } {
|
|
186
|
+
const r = gov!.reloadDeny(spec)
|
|
187
|
+
let n = 0
|
|
188
|
+
for (const s of [...engine.sessions.values()]) {
|
|
189
|
+
for (const st of [...s.streams.values()]) {
|
|
190
|
+
const i = st.to.lastIndexOf(':')
|
|
191
|
+
if (gov!.isDeniedDest(st.to.slice(0, i), Number(st.to.slice(i + 1)))) {
|
|
192
|
+
n++
|
|
193
|
+
engine.fail(s, st, E.BLOCKED, 'this destination is blocked by the operator')
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { sessions: r.ended, streams: n }
|
|
198
|
+
},
|
|
199
|
+
/** A new certificate for new connections (SIGHUP). The new listener takes the port before the old one stops listening; open sockets keep the old certificate. */
|
|
200
|
+
reloadTls(tls: { cert: string; key: string }) {
|
|
201
|
+
const next = Bun.serve(serverOptions(tls))
|
|
202
|
+
const old = server
|
|
203
|
+
server = next
|
|
204
|
+
old.stop(false)
|
|
205
|
+
},
|
|
206
|
+
stop() {
|
|
207
|
+
wisp?.stop()
|
|
208
|
+
engine.stop()
|
|
209
|
+
gov?.stop()
|
|
210
|
+
server.stop(true)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function envList(v: string | undefined): string[] {
|
|
216
|
+
return (v || '').split(',').map(x => x.trim()).filter(Boolean)
|
|
217
|
+
}
|
|
218
|
+
function envMap(v: string | undefined): Record<string, string> {
|
|
219
|
+
const out: Record<string, string> = {}
|
|
220
|
+
for (const kv of envList(v)) {
|
|
221
|
+
const i = kv.indexOf('=')
|
|
222
|
+
if (i > 0) out[kv.slice(0, i).toLowerCase()] = kv.slice(i + 1)
|
|
223
|
+
}
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
const envNum = (v: string | undefined) => (v ? Number(v) : undefined)
|
|
227
|
+
|
|
228
|
+
if (import.meta.main) {
|
|
229
|
+
const env = process.env
|
|
230
|
+
const testRoutes = envMap(env.WMD_RELAY_TEST_ROUTES)
|
|
231
|
+
const testHosts = envMap(env.WMD_RELAY_TEST_HOSTS)
|
|
232
|
+
const quiet = env.WMD_RELAY_LOG === '0'
|
|
233
|
+
const relay = createRelay({
|
|
234
|
+
secret: env.WMD_RELAY_SECRET || '',
|
|
235
|
+
port: envNum(env.WMD_RELAY_PORT),
|
|
236
|
+
host: env.WMD_RELAY_HOST,
|
|
237
|
+
origins: envList(env.WMD_RELAY_ORIGINS),
|
|
238
|
+
allowHosts: envList(env.WMD_RELAY_ALLOW_HOSTS),
|
|
239
|
+
selfAddrs: envList(env.WMD_RELAY_SELF),
|
|
240
|
+
dnsServers: envList(env.WMD_RELAY_DNS),
|
|
241
|
+
maxConns: envNum(env.WMD_RELAY_MAX_CONNS),
|
|
242
|
+
maxSessions: envNum(env.WMD_RELAY_MAX_SESSIONS),
|
|
243
|
+
maxSessionsPerGroup: envNum(env.WMD_RELAY_MAX_SESSIONS_PER_GROUP),
|
|
244
|
+
opensPerMin: envNum(env.WMD_RELAY_OPENS_PER_MIN),
|
|
245
|
+
connectsPerMin: envNum(env.WMD_RELAY_CONNECTS_PER_MIN),
|
|
246
|
+
dnsPerMin: envNum(env.WMD_RELAY_DNS_PER_MIN),
|
|
247
|
+
rateDown: envNum(env.WMD_RELAY_RATE_DOWN),
|
|
248
|
+
rateUp: envNum(env.WMD_RELAY_RATE_UP),
|
|
249
|
+
maxBytes: envNum(env.WMD_RELAY_MAX_BYTES),
|
|
250
|
+
blockedPorts: env.WMD_RELAY_BLOCK_PORTS !== undefined ? envList(env.WMD_RELAY_BLOCK_PORTS).map(Number) : undefined,
|
|
251
|
+
testRoutes,
|
|
252
|
+
testHosts,
|
|
253
|
+
log: quiet ? undefined : e => console.log(JSON.stringify({ t: Date.now(), ...e })),
|
|
254
|
+
wisp: env.WMD_RELAY_WISP === '0' ? { enabled: false } : undefined, // on by default (D-444); WMD_RELAY_WISP=0 turns it off
|
|
255
|
+
})
|
|
256
|
+
if (Object.keys(testRoutes).length || Object.keys(testHosts).length) {
|
|
257
|
+
console.log(JSON.stringify({ ev: 'warning', why: 'TEST ROUTES/HOSTS ARE SET: some refused addresses are reachable; never run a public relay like this', testRoutes, testHosts }))
|
|
258
|
+
}
|
|
259
|
+
console.log(JSON.stringify({ ev: 'listening', port: relay.port, pid: process.pid }))
|
|
260
|
+
const stop = () => {
|
|
261
|
+
relay.stop()
|
|
262
|
+
process.exit(0)
|
|
263
|
+
}
|
|
264
|
+
process.on('SIGINT', stop)
|
|
265
|
+
process.on('SIGTERM', stop)
|
|
266
|
+
}
|
package/relay/token.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// P-B10 relay: session tokens. A token is "v1.<payload>.<mac>", base64url without padding,
|
|
2
|
+
// where the payload is JSON {sid, exp (ms since the epoch), uid?, grp?} and the mac is
|
|
3
|
+
// HMAC-SHA256 of "v1.<payload>" with the relay's secret. Whoever serves the page
|
|
4
|
+
// (test/serve.ts, or wmn's server) mints them with the same secret (TokenIssuer below); the
|
|
5
|
+
// browser presents one as a WebSocket subprotocol "t.<token>" (not in the URL, so it stays
|
|
6
|
+
// out of access logs).
|
|
7
|
+
//
|
|
8
|
+
// sid this token (one live connection per sid)
|
|
9
|
+
// uid the guest identity the relay keeps caps for, across reconnects and fresh tokens
|
|
10
|
+
// (one guest machine of one page); defaults to sid
|
|
11
|
+
// grp the client the issuer saw (an address, a user): the relay caps sessions per grp
|
|
12
|
+
//
|
|
13
|
+
// Parsing is strict: a token is exactly one encoding of its payload and MAC, so the same
|
|
14
|
+
// token never verifies under two spellings.
|
|
15
|
+
|
|
16
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
const b64u = (b: Buffer) => b.toString('base64url')
|
|
19
|
+
const B64U = /^[A-Za-z0-9_-]+$/
|
|
20
|
+
const ID = /^[A-Za-z0-9_.:\/-]{1,128}$/
|
|
21
|
+
|
|
22
|
+
export type TokenFields = { sid?: string; uid?: string; grp?: string; ttlMs?: number; now?: number }
|
|
23
|
+
|
|
24
|
+
export function mintToken(secret: string, { sid = b64u(randomBytes(12)), uid, grp, ttlMs = 60 * 60_000, now = Date.now() }: TokenFields = {}): string {
|
|
25
|
+
if (!secret || secret.length < 16) throw new Error('relay secret: at least 16 characters')
|
|
26
|
+
const body: Record<string, unknown> = { sid, exp: now + ttlMs }
|
|
27
|
+
if (uid) body.uid = uid
|
|
28
|
+
if (grp) body.grp = grp
|
|
29
|
+
const payload = b64u(Buffer.from(JSON.stringify(body)))
|
|
30
|
+
const mac = createHmac('sha256', secret).update(`v1.${payload}`).digest()
|
|
31
|
+
return `v1.${payload}.${b64u(mac)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TokenInfo = { sid: string; exp: number; uid?: string; grp?: string }
|
|
35
|
+
|
|
36
|
+
/** The token's session, or an error string. */
|
|
37
|
+
export function verifyToken(secret: string, token: string, now = Date.now()): TokenInfo | string {
|
|
38
|
+
if (typeof token !== 'string' || token.length > 1024) return 'bad token'
|
|
39
|
+
const parts = token.split('.')
|
|
40
|
+
if (parts.length !== 3 || parts[0] !== 'v1' || !B64U.test(parts[1]) || !B64U.test(parts[2])) return 'bad token'
|
|
41
|
+
const want = createHmac('sha256', secret).update(`v1.${parts[1]}`).digest()
|
|
42
|
+
const got = Buffer.from(parts[2], 'base64url')
|
|
43
|
+
// the canonical spelling only (base64url decoding ignores trailing bits and junk)
|
|
44
|
+
if (b64u(got) !== parts[2] || got.length !== want.length || !timingSafeEqual(got, want)) return 'bad token signature'
|
|
45
|
+
const raw = Buffer.from(parts[1], 'base64url')
|
|
46
|
+
if (b64u(raw) !== parts[1]) return 'bad token'
|
|
47
|
+
let info: TokenInfo
|
|
48
|
+
try {
|
|
49
|
+
info = JSON.parse(raw.toString('utf8'))
|
|
50
|
+
} catch {
|
|
51
|
+
return 'bad token'
|
|
52
|
+
}
|
|
53
|
+
if (typeof info !== 'object' || !info || typeof info.sid !== 'string' || !ID.test(info.sid) || typeof info.exp !== 'number') return 'bad token'
|
|
54
|
+
for (const k of ['uid', 'grp'] as const) if (info[k] !== undefined && (typeof info[k] !== 'string' || !ID.test(info[k]!))) return 'bad token'
|
|
55
|
+
if (info.exp <= now) return 'token expired'
|
|
56
|
+
return { sid: info.sid, exp: info.exp, uid: info.uid, grp: info.grp }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The key a client's limits are kept under: IPv6 addresses by their /64 (one subscriber holds 2^64 of them). */
|
|
60
|
+
export function clientKey(client: string): string {
|
|
61
|
+
if (!client.includes(':') || /^\d+\.\d+\.\d+\.\d+$/.test(client)) return client
|
|
62
|
+
const m = client.toLowerCase().match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/) // ::ffff:1.2.3.4
|
|
63
|
+
if (m) return m[2]
|
|
64
|
+
const [head, tail] = client.toLowerCase().split('::')
|
|
65
|
+
const h = head ? head.split(':') : []
|
|
66
|
+
const t = tail === undefined ? [] : tail ? tail.split(':') : []
|
|
67
|
+
if (tail === undefined && h.length !== 8) return client
|
|
68
|
+
const groups = tail === undefined ? h : [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill('0'), ...t]
|
|
69
|
+
if (groups.length !== 8 || groups.some(g => !/^[0-9a-f]{1,4}$/.test(g))) return client
|
|
70
|
+
return `v6:${groups.slice(0, 4).map(g => g.padStart(4, '0')).join(':')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mints tokens for page instances, with limits per client (the address the request came
|
|
75
|
+
* from, or a user id; IPv6 by /64): at most `perMinute` tokens a minute and `instancesPerHour`
|
|
76
|
+
* distinct page instances an hour, each token valid at least `MIN_TTL_MS` and at most `maxTtlMs`.
|
|
77
|
+
* The relay keeps caps per uid (client/instance) across reconnects, so a client cannot reset
|
|
78
|
+
* its caps by asking for fresh tokens, and cannot open more than `instancesPerHour` identities
|
|
79
|
+
* an hour. At most `maxClients` clients are remembered (the least recently used go first).
|
|
80
|
+
*/
|
|
81
|
+
export const MIN_TTL_MS = 60_000
|
|
82
|
+
export class TokenIssuer {
|
|
83
|
+
perMinute: number
|
|
84
|
+
instancesPerHour: number
|
|
85
|
+
maxTtlMs: number
|
|
86
|
+
maxClients: number
|
|
87
|
+
minTtlMs: number
|
|
88
|
+
clients = new Map<string, { times: number[]; instances: Map<string, number> }>() // least recently used first
|
|
89
|
+
constructor(public secret: string, { perMinute = 60, instancesPerHour = 20, maxTtlMs = 60 * 60_000, maxClients = 10_000, minTtlMs = MIN_TTL_MS } = {}) {
|
|
90
|
+
this.perMinute = perMinute
|
|
91
|
+
this.instancesPerHour = instancesPerHour
|
|
92
|
+
this.maxTtlMs = maxTtlMs
|
|
93
|
+
this.maxClients = maxClients
|
|
94
|
+
this.minTtlMs = Number.isFinite(minTtlMs) && minTtlMs > 0 ? minTtlMs : MIN_TTL_MS // a NaN (an env var that is not a number) would make every token's lifetime NaN; below MIN_TTL_MS only for tests of expiry (test/serve.ts WMD_RELAY_MIN_TTL_MS)
|
|
95
|
+
}
|
|
96
|
+
/** A token for this client's page instance, or {error, status}. A bad request is not counted against the limits. */
|
|
97
|
+
issue(client: string, instance: string, ttlMs: number = this.maxTtlMs, now = Date.now()): { token: string } | { error: string; status: number } {
|
|
98
|
+
if (!ID.test(client)) return { error: 'bad client', status: 400 }
|
|
99
|
+
if (!/^[A-Za-z0-9_-]{8,64}$/.test(instance || '')) return { error: 'instance: 8-64 characters [A-Za-z0-9_-]', status: 400 }
|
|
100
|
+
if (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs)) return { error: 'ttl: a number of milliseconds', status: 400 }
|
|
101
|
+
const key = clientKey(client)
|
|
102
|
+
let c = this.clients.get(key)
|
|
103
|
+
if (c) this.clients.delete(key) // re-inserted below: most recently used last
|
|
104
|
+
else c = { times: [], instances: new Map() }
|
|
105
|
+
this.clients.set(key, c)
|
|
106
|
+
while (this.clients.size > this.maxClients) this.clients.delete(this.clients.keys().next().value as string)
|
|
107
|
+
c.times = c.times.filter(t => now - t < 60_000)
|
|
108
|
+
for (const [k, t] of c.instances) if (now - t > 60 * 60_000) c.instances.delete(k)
|
|
109
|
+
if (c.times.length >= this.perMinute) return { error: `more than ${this.perMinute} tokens a minute`, status: 429 }
|
|
110
|
+
if (!c.instances.has(instance) && c.instances.size >= this.instancesPerHour) return { error: `more than ${this.instancesPerHour} page instances an hour`, status: 429 }
|
|
111
|
+
c.times.push(now)
|
|
112
|
+
if (!c.instances.has(instance)) c.instances.set(instance, now)
|
|
113
|
+
const ttl = Math.min(Math.max(ttlMs, this.minTtlMs), this.maxTtlMs)
|
|
114
|
+
return { token: mintToken(this.secret, { uid: `${key}/${instance}`, grp: key, ttlMs: ttl, now }) }
|
|
115
|
+
}
|
|
116
|
+
}
|