@threadedstack/wmd-web 0.0.0-stage → 0.1.12

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.
Files changed (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
package/relay/wisp.ts ADDED
@@ -0,0 +1,615 @@
1
+ // W59: the Wisp v2 adapter (src/net/wisp.js, docs/wisp-wmd-extensions.md) over the relay engine.
2
+ //
3
+ // Who may use it (plan §5):
4
+ // - token: the HMAC `t.<token>` subprotocol, as wmd-relay.v1 (wmn's bundled relay)
5
+ // - open: no key (the standalone relay's default); identity = the client's instance id (0xE7)
6
+ // plus its address group, bandwidth shared per address
7
+ // - keys (optional): Wisp password auth, scrypt hashes; a relay with keys refuses keyless clients
8
+ // Pre-auth limits: sockets in their handshake per address, a handshake deadline, failed keys
9
+ // throttled per address.
10
+ //
11
+ // Flow control: upload is Wisp's CONTINUE (packets), released as the bytes reach the remote and
12
+ // the up bucket allows (so the bucket delays CONTINUE); download is wmd-credit (0xE1) bytes when
13
+ // the client lists it, otherwise WebSocket backpressure only.
14
+
15
+ import { randomBytes } from 'node:crypto'
16
+ import { E } from '../src/net/proto.js'
17
+ import {
18
+ CLOSE, EXT, PT, STREAM, SUBPROTOCOL, ServerHandshake, answerPacket, closePacket, closeWords, continuePacket,
19
+ dataPacket, ext, parse, shutdownPacket,
20
+ } from '../src/net/wisp.js'
21
+ import { type AllowEntry, type Engine, type Principal, type Session, type Shared, type Stream, Window, type Wire, compileAllow, newShared } from './engine.ts'
22
+ import { type Governor, resolveClient } from './hosted.ts'
23
+ import { type OwnerKey, verifySecret } from './keys.ts'
24
+ import type { Cidr } from './policy.ts'
25
+ import { clientKey, verifyToken } from './token.ts'
26
+
27
+ export type WispOptions = {
28
+ enabled?: boolean
29
+ path?: string // URL prefix, ends with "/" (default "/wisp/")
30
+ open?: boolean // serve clients that bring no token and no key (the standalone default)
31
+ keys?: OwnerKey[] // when set, a client needs one of them (or a token)
32
+ prefixKeys?: boolean // Wisp v1 clients may put a key in the URL: /wisp/<id>.<secret>/
33
+ origins?: string[] // page origins allowed (default: any); a request without Origin is refused when set
34
+ checkHost?: boolean // the Host header must be a loopback name with the port (a loopback bind, open mode)
35
+ trustProxy?: boolean // caps per X-Forwarded-For client (the last entry, added by the trusted proxy)
36
+ proxy?: { trusted: Cidr[]; header: string } // hosted: only these peers are proxies; the header is walked from the right (hosted.ts resolveClient)
37
+ hosted?: Governor // the hosted profile's limits, lists and state (plan §15)
38
+ buffer?: number // CONTINUE: packets buffered per stream (32)
39
+ maxPayload?: number // largest WebSocket frame (1 MiB + 1 KiB)
40
+ maxHeldBytes?: number // upload bytes held per stream before CONTINUE is withheld (4 MiB). Never a close by itself: a client
41
+ // that keeps the packet rule may hold 2 x buffer packets of up to maxPayload each (the session cap bounds that)
42
+ maxSessionHeldBytes?: number // upload bytes held for one session, all streams (default: 2 x buffer x maxPayload, at least 64 MiB,
43
+ // so one stream that keeps the packet rule never reaches it); past it the stream that sent more gets 0x49
44
+ maxHeldTotal?: number // upload bytes held by the whole relay (512 MiB); past it, DATA from a group over its fair share gets 0x49
45
+ maxGroupHeldBytes?: number // upload bytes held by one address group or key, all its sessions (2 x the session cap)
46
+ instancesPerHour?: number // new identities per address (or key) an hour (120)
47
+ maxPrincipals?: number // identities remembered (10000)
48
+ preAuthPerAddr?: number // sockets in their handshake per address (8)
49
+ handshakeMs?: number // (10000)
50
+ streamIdleMs?: number // a stream with no bytes either way this long is closed, 0x47 (30 min)
51
+ version?: string // the relay's version, in the MOTD
52
+ }
53
+
54
+ type Mode = 'token' | 'open' | 'key' | 'key-pending'
55
+
56
+ const REASON: Record<number, number> = {
57
+ [E.BLOCKED]: CLOSE.BLOCKED, [E.NOT_ALLOWED]: CLOSE.BLOCKED, [E.PORT]: CLOSE.BLOCKED, [E.REFUSED]: CLOSE.REFUSED,
58
+ [E.TIMEOUT]: CLOSE.TIMEOUT, [E.DNS]: CLOSE.UNREACHABLE, [E.UNREACHABLE]: CLOSE.UNREACHABLE, [E.LIMIT]: CLOSE.THROTTLED,
59
+ [E.BAD]: CLOSE.INVALID, [E.RESET]: CLOSE.NETWORK, [E.GONE]: CLOSE.NETWORK,
60
+ }
61
+ const reasonFor = (code: number) => REASON[code] ?? CLOSE.UNKNOWN
62
+
63
+ export const DRAIN_WORDS = 'the relay is closed for maintenance'
64
+ const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i
65
+ // a key id that does not exist is still checked against this, so the time does not tell
66
+ const DUMMY_HASH = 'scrypt:16384:8:1:AAAAAAAAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
67
+
68
+ type StreamW = {
69
+ pending: Uint8Array[] | null // DATA that came before the remote connected
70
+ sizes: number[] // sizes of packets written to the remote, not yet released
71
+ released: number // bytes released by the engine, not yet a whole packet
72
+ held: number // packets received and not yet released
73
+ heldBytes: number
74
+ drained: number // packets released since the last CONTINUE
75
+ last: number // the last time a byte moved either way
76
+ idle: boolean
77
+ shutPending: boolean // SHUTDOWN before the remote connected
78
+ }
79
+
80
+ type SessionW = {
81
+ engine: Engine
82
+ v2: boolean
83
+ confirm: boolean
84
+ credit: boolean
85
+ halfclose: boolean
86
+ dns: boolean
87
+ buffer: number
88
+ idleTimer: ReturnType<typeof setInterval> | null
89
+ unknownLogged: boolean
90
+ }
91
+
92
+ export function createWisp(engine: Engine, o: { secret?: string; port: () => number; maxConns?: number; rateDown?: number; rateUp?: number; blockedPorts?: number[]; wisp: WispOptions }) {
93
+ const w = o.wisp
94
+ const path = w.path ?? '/wisp/'
95
+ const keys = w.keys ?? []
96
+ const open = !!w.open && !keys.length
97
+ const buffer = w.buffer ?? 32
98
+ const maxPayload = w.maxPayload ?? (1 << 20) + 1024
99
+ const maxHeld = w.maxHeldBytes ?? 4 << 20
100
+ const maxSessHeld = w.maxSessionHeldBytes ?? Math.max(64 << 20, 2 * buffer * maxPayload)
101
+ const maxGroupHeld = w.maxGroupHeldBytes ?? 2 * maxSessHeld
102
+ const maxHeldAll = w.maxHeldTotal ?? 512 << 20
103
+ // The relay's and each group's held bytes at the last count, plus what came since (they decrease only at the next
104
+ // count, so they never go negative and only overestimate). When the relay is over its total, only a group holding
105
+ // more than its fair share (the total over the groups holding bytes) is refused: one party cannot fill the relay
106
+ // and break everyone else's uploads.
107
+ let heldSwept = 0
108
+ let groupHeld = new Map<string, number>()
109
+ const heldCount = setInterval(() => {
110
+ let n = 0
111
+ const g = new Map<string, number>()
112
+ for (const s of engine.sessions.values()) {
113
+ let k = 0
114
+ for (const st of s.streams.values()) k += st.w?.heldBytes || 0
115
+ if (k) g.set(s.p.grp, (g.get(s.p.grp) || 0) + k)
116
+ n += k
117
+ }
118
+ heldSwept = n
119
+ groupHeld = g
120
+ }, 1000)
121
+ const handshakeMs = w.handshakeMs ?? 10_000
122
+ const streamIdleMs = w.streamIdleMs ?? 30 * 60_000
123
+ const preAuth = w.preAuthPerAddr ?? 8
124
+ const maxPrincipals = w.maxPrincipals ?? 10_000
125
+ const instancesPerHour = w.instancesPerHour ?? 120
126
+ const inHandshake = new Map<string, number>()
127
+ const failed = new Map<string, { fails: number; until: number; at: number }>()
128
+ const newIds = new Map<string, Window>() // per address or key: new identities this hour
129
+ const shared = new Map<string, Shared>() // per address or key: bandwidth
130
+ const sweep = setInterval(() => {
131
+ const t = Date.now()
132
+ for (const [k, f] of failed) if (Math.max(f.until, f.at) + 10 * 60_000 < t) failed.delete(k)
133
+ for (const [k, win] of newIds) if (!win.times.length || performance.now() - win.times[win.times.length - 1] > win.ms) newIds.delete(k)
134
+ const groups = new Set<string>()
135
+ for (const p of engine.principals.values()) groups.add(p.grp)
136
+ for (const k of shared.keys()) if (!groups.has(k)) shared.delete(k)
137
+ }, 60_000)
138
+ const log = engine.log
139
+ const stats = engine.stats
140
+
141
+ const motd = () =>
142
+ w.hosted
143
+ ? `wmd hosted relay${w.version ? ` ${w.version}` : ''}: the operator sees destination names, DNS names and plain-http content (TLS traffic is not visible); ` +
144
+ `abuse contact ${w.hosted.contact}; ${w.hosted.limitsLine()}; blocked ports: ${(o.blockedPorts ?? [25]).join(', ') || 'none'}`
145
+ :
146
+ `wmd-relay${w.version ? ` ${w.version}` : ''}: up to ${o.maxConns ?? 64} streams per guest; ${o.rateDown ?? 16 << 20} B/s down and ` +
147
+ `${o.rateUp ?? 4 << 20} B/s up; blocked ports: ${(o.blockedPorts ?? [25]).join(', ') || 'none'}; private, loopback and metadata addresses are refused`
148
+
149
+ function serverExts(mode: Mode) {
150
+ const e = [ext.confirm(), ext.motd(motd()), ext.wmdCredit(0), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance()]
151
+ if (mode === 'key-pending') e.push(ext.passwordServer(true))
152
+ return e
153
+ }
154
+
155
+ /** The client's address as text (the peer, or behind trusted proxies the client the proxy saw), or a refusal. */
156
+ function clientIp(req: Request, srv: any): string | Response {
157
+ const peer = srv.requestIP(req)?.address || 'unknown'
158
+ if (w.proxy) {
159
+ const r = resolveClient(peer, req.headers.get(w.proxy.header), w.proxy.trusted, w.proxy.header)
160
+ if ('error' in r) return refuse(400, `${r.error}\n`, 'proxy header', { peer })
161
+ return r.ip
162
+ }
163
+ if (w.trustProxy) {
164
+ const xff = (req.headers.get('x-forwarded-for') || '').split(',').map(x => x.trim()).filter(Boolean)
165
+ if (xff.length) return xff[xff.length - 1]
166
+ }
167
+ return peer
168
+ }
169
+
170
+ /** A refusal body; on a hosted relay it always names the abuse contact. */
171
+ const reply = (body: string, status: number) => new Response(w.hosted ? body + w.hosted.contactLine : body, { status })
172
+
173
+ function refuse(status: number, body: string, why: string, fields: Record<string, unknown>) {
174
+ engine.refusedSession(why, { proto: 'wisp', ...fields })
175
+ return reply(body, status)
176
+ }
177
+
178
+ async function fetch(req: Request, srv: any, url: URL): Promise<Response | undefined> {
179
+ const ip = clientIp(req, srv)
180
+ if (typeof ip !== 'string') return ip
181
+ const addr = clientKey(ip)
182
+ const origin = req.headers.get('origin') || ''
183
+ // before any table or the log sees it: no real page origin is this long
184
+ if (origin.length > 256) return refuse(400, 'the Origin header is longer than 256 characters\n', 'origin too long', { addr })
185
+ if (w.hosted) {
186
+ const denied = w.hosted.deniedClient(ip, origin)
187
+ if (denied) return refuse(403, `${denied}\n`, 'denied', { addr, origin })
188
+ const closed = w.hosted.notAccepting()
189
+ if (closed) return refuse(503, `${closed}\n`, 'not accepting', { addr })
190
+ }
191
+ const t = failed.get(addr)
192
+ if (t && t.until > Date.now()) return refuse(429, 'too many wrong keys from this address; wait\n', 'key throttle', { addr }) // set only after ADDR_FAILS
193
+ if (open && w.checkHost && !LOOPBACK_HOST.test(req.headers.get('host') || '')) {
194
+ return refuse(403, 'this relay answers only to localhost, 127.0.0.1 or [::1] (a check against DNS rebinding)\n', 'host not loopback', { addr, host: req.headers.get('host') })
195
+ }
196
+ if (w.origins?.length && !w.origins.includes(origin)) return refuse(403, 'origin not allowed\n', 'origin not allowed', { addr, origin })
197
+ const header = req.headers.get('sec-websocket-protocol')
198
+ const protos = (header || '').split(',').map(x => x.trim()).filter(Boolean)
199
+ if (protos.includes('wmd-relay.v1') && !protos.includes(SUBPROTOCOL)) {
200
+ return reply(`this is the Wisp endpoint (${path}); wmd-relay.v1 is at /relay\n`, 400)
201
+ }
202
+ const v2 = header !== null // the spec: v2 only when the header is there
203
+ const rest = url.pathname.slice(path.length)
204
+ let mode: Mode
205
+ let key: OwnerKey | null = null
206
+ let sid = randomBytes(9).toString('base64url')
207
+ let uid = ''
208
+ let grp = ''
209
+ let exp = Infinity
210
+ let p: Principal | null = null
211
+ const tok = protos.find(x => x.startsWith('t.'))
212
+ if (tok && o.secret) {
213
+ const info = verifyToken(o.secret, tok.slice(2))
214
+ if (typeof info === 'string') return refuse(401, `${info}\n`, info, { origin })
215
+ if (engine.hasSession(info.sid)) {
216
+ stats.refusedSessions++
217
+ return reply('session already connected\n', 409)
218
+ }
219
+ mode = 'token'
220
+ sid = info.sid
221
+ uid = info.uid || info.sid
222
+ grp = info.grp || uid
223
+ exp = info.exp
224
+ const a = engine.admit(uid, grp, exp, { proto: 'wisp' })
225
+ if (!a.ok) return reply(a.body, a.status)
226
+ p = a.p
227
+ } else if (rest) {
228
+ // a key in the URL, for Wisp v1 clients (off unless the owner enables it)
229
+ const m = rest.match(/^([^./]{1,64})\.([^/]{1,256})\/$/)
230
+ if (!w.prefixKeys || !m) return reply('not found\n', 404)
231
+ if (keyThrottled(addr, m[1])) return refuse(429, 'too many wrong secrets for this key from this address; wait\n', 'key throttle', { addr, key: m[1] })
232
+ // a check costs a scrypt: at most preAuth of them at once per address, so a burst cannot test many secrets before
233
+ // the throttle (below) is set
234
+ const busy = inHandshake.get(addr) || 0
235
+ if (busy >= preAuth) return refuse(429, 'too many connections opening from this address; wait\n', 'pre-auth limit', { addr })
236
+ inHandshake.set(addr, busy + 1)
237
+ const k = keys.find(x => x.id === m[1])
238
+ let ok = false
239
+ try {
240
+ ok = await verifySecret(m[2], k?.hash ?? DUMMY_HASH)
241
+ } finally {
242
+ release({ pre: true, addr })
243
+ }
244
+ if (!ok || !k) {
245
+ noteFailure(addr, m[1])
246
+ return refuse(401, 'wrong key\n', 'wrong key', { addr, key: m[1] })
247
+ }
248
+ failed.delete(`${addr}|${m[1]}`)
249
+ if (k.origins?.length && !k.origins.includes(origin)) return refuse(403, 'origin not allowed for this key\n', 'origin not allowed for key', { addr, key: k.id, origin })
250
+ mode = 'key'
251
+ key = k
252
+ } else if (keys.length) {
253
+ if (!v2) return refuse(401, `this relay needs a key; a Wisp v1 client can put it in the URL (${path}<id>.<secret>/) when the owner allows it\n`, 'no key (v1)', { addr })
254
+ mode = 'key-pending'
255
+ } else if (open) {
256
+ mode = 'open'
257
+ } else {
258
+ return refuse(401, 'no token\n', 'no token', { origin })
259
+ }
260
+ const n = inHandshake.get(addr) || 0
261
+ if (n >= preAuth) return refuse(429, 'too many connections opening from this address; wait\n', 'pre-auth limit', { addr })
262
+ inHandshake.set(addr, n + 1)
263
+ const headers: Record<string, string> = {}
264
+ if (v2 && protos.length) headers['Sec-WebSocket-Protocol'] = protos.includes(SUBPROTOCOL) ? SUBPROTOCOL : protos[0]
265
+ const data = { kind: 'wisp', v2, mode, addr, ip, sid, uid, grp, exp, p, key, origin, hs: null as any, hsTimer: null as any, pre: true }
266
+ const ok = srv.upgrade(req, Object.keys(headers).length ? { data, headers } : { data }) // Bun refuses an empty headers object
267
+ if (!ok) {
268
+ release(data)
269
+ return reply('upgrade failed\n', 400)
270
+ }
271
+ return undefined
272
+ }
273
+
274
+ // Wrong keys are throttled per (address, key id), so one wrong guess does not lock out the right key from the same
275
+ // address (a NAT, a proxy); the whole address is throttled only after ADDR_FAILS wrong keys (guessing ids).
276
+ const ADDR_FAILS = 20
277
+ const KEY_FREE_FAILS = 3
278
+ function noteFailure(addr: string, id: string) {
279
+ const now = Date.now()
280
+ const f = failed.get(`${addr}|${id}`) || { fails: 0, until: 0, at: 0 }
281
+ f.fails++
282
+ f.at = now
283
+ // a typo or two goes through at once; from the fourth wrong secret on, 1 s doubling to 60 s
284
+ if (f.fails > KEY_FREE_FAILS) f.until = now + Math.min(60_000, 1000 * 2 ** (f.fails - KEY_FREE_FAILS - 1))
285
+ failed.set(`${addr}|${id}`, f)
286
+ const a = failed.get(addr) || { fails: 0, until: 0, at: 0 }
287
+ a.fails++
288
+ a.at = now
289
+ if (a.fails >= ADDR_FAILS) a.until = now + Math.min(60_000, 1000 * 2 ** (a.fails - ADDR_FAILS))
290
+ failed.set(addr, a)
291
+ }
292
+ const keyThrottled = (addr: string, id: string) => (failed.get(`${addr}|${id}`)?.until ?? 0) > Date.now()
293
+
294
+ /** The socket left its handshake (done or gone): it no longer counts against the pre-auth cap. */
295
+ function release(d: any) {
296
+ if (!d.pre) return
297
+ d.pre = false
298
+ const n = (inHandshake.get(d.addr) || 1) - 1
299
+ if (n > 0) inHandshake.set(d.addr, n)
300
+ else inHandshake.delete(d.addr)
301
+ }
302
+
303
+ function sendAll(ws: any, pkts: Uint8Array[]) {
304
+ for (const b of pkts) ws.send(b)
305
+ }
306
+
307
+ function open_(ws: any) {
308
+ const d = ws.data
309
+ d.hs = new ServerHandshake({ v2: d.v2, exts: serverExts(d.mode), buffer })
310
+ const r = d.hs.start()
311
+ sendAll(ws, r.send)
312
+ if (r.done) return finish(ws, null)
313
+ d.hsTimer = setTimeout(() => {
314
+ if (d.session) return
315
+ log({ ev: 'session-refused', proto: 'wisp', why: 'handshake timeout', addr: d.addr })
316
+ ws.close(1008, 'handshake timeout')
317
+ }, handshakeMs)
318
+ }
319
+
320
+ function rejectHs(ws: any, reason: number, why: string) {
321
+ const d = ws.data
322
+ log({ ev: 'session-refused', proto: 'wisp', why, addr: d.addr })
323
+ stats.refusedSessions++
324
+ sendAll(ws, d.hs.reject(reason))
325
+ ws.close(1008, closeWords(reason).slice(0, 120))
326
+ }
327
+
328
+ async function finish(ws: any, client: any) {
329
+ const d = ws.data
330
+ if (d.hsTimer) clearTimeout(d.hsTimer)
331
+ let share: Shared | undefined
332
+ let allow: AllowEntry[] | undefined
333
+ let fresh = false
334
+ const inst = client?.instance ? Buffer.from(client.instance).toString('hex') : randomBytes(8).toString('hex')
335
+ if (d.mode === 'key-pending') {
336
+ const pw = client?.password
337
+ if (pw && keyThrottled(d.addr, pw.user)) return rejectHs(ws, CLOSE.AUTH_BAD_PASSWORD, `key throttle ${String(pw.user).slice(0, 64)}`)
338
+ const k = pw ? keys.find(x => x.id === pw.user) : undefined
339
+ const ok = pw ? await verifySecret(pw.pass, k?.hash ?? DUMMY_HASH) : false
340
+ if (ws.readyState !== 1) return
341
+ if (!ok || !k) {
342
+ noteFailure(d.addr, pw ? String(pw.user) : '')
343
+ return rejectHs(ws, CLOSE.AUTH_BAD_PASSWORD, `wrong key${pw ? ` ${String(pw.user).slice(0, 64)}` : ''}`)
344
+ }
345
+ failed.delete(`${d.addr}|${pw.user}`)
346
+ if (k.origins?.length && !k.origins.includes(d.origin)) return rejectHs(ws, CLOSE.AUTH_BAD_PASSWORD, `origin not allowed for key ${k.id}`)
347
+ d.key = k
348
+ d.mode = 'key'
349
+ }
350
+ if (d.mode === 'key') {
351
+ const k: OwnerKey = d.key
352
+ d.grp = `key:${k.id}`
353
+ d.uid = `key:${k.id}/${inst}`
354
+ share = sharedFor(d.grp, k)
355
+ if (k.allowHosts?.length) allow = compileAllow(k.allowHosts)
356
+ } else if (d.mode === 'open') {
357
+ d.grp = d.addr
358
+ d.uid = `${d.addr}/${inst}`
359
+ share = sharedFor(d.grp)
360
+ }
361
+ if (d.mode !== 'token') {
362
+ fresh = !engine.principals.has(d.uid)
363
+ if (fresh && engine.principals.size >= maxPrincipals) {
364
+ // least recently admitted identity with no live session goes first (plan 5: LRU); refuse only when all are live
365
+ for (const [k, q] of engine.principals) if (!q.live) { engine.principals.delete(k); break }
366
+ if (engine.principals.size >= maxPrincipals) return rejectHs(ws, CLOSE.THROTTLED, 'too many identities remembered')
367
+ }
368
+ if (fresh) {
369
+ let win = newIds.get(d.grp)
370
+ if (!win) newIds.set(d.grp, (win = new Window(instancesPerHour, 60 * 60_000)))
371
+ if (!win.hit()) return rejectHs(ws, CLOSE.THROTTLED, `more than ${instancesPerHour} new instances this hour from ${d.grp}`)
372
+ }
373
+ const a = engine.admit(d.uid, d.grp, Date.now(), { proto: 'wisp' }, share)
374
+ if (!a.ok) return rejectHs(ws, CLOSE.THROTTLED, a.body.trim())
375
+ d.p = a.p
376
+ }
377
+ let adm: ReturnType<Governor['admitSession']> | null = null
378
+ if (w.hosted) {
379
+ const closed = w.hosted.notAccepting()
380
+ if (closed) return rejectHs(ws, CLOSE.THROTTLED, closed)
381
+ adm = w.hosted.admitSession({ ip: d.ip, grp: d.grp, origin: d.origin, fresh, end: words => ws.close(4011, words.slice(0, 120)) })
382
+ if (!adm.ok) return rejectHs(ws, CLOSE.THROTTLED, adm.why)
383
+ d.rel = adm.adm.release
384
+ }
385
+ const common: Set<number> = client?.common ?? new Set()
386
+ const window = common.has(EXT.WMD_CREDIT) ? client.window : Infinity
387
+ const s = engine.open(ws, d.sid, d.p, d.exp, wire, window, { proto: 'wisp', mode: d.mode, v: d.v2 ? 2 : 1, inst: client?.instance ? inst : undefined, addr: d.addr, ...(w.hosted ? { origin: d.origin } : {}) })
388
+ release(d)
389
+ if (!s) return d.rel?.()
390
+ if (adm?.ok) s.hx = adm.adm
391
+ s.allow = allow
392
+ const sw: SessionW = {
393
+ engine, v2: d.v2, confirm: common.has(EXT.CONFIRM), credit: common.has(EXT.WMD_CREDIT), halfclose: common.has(EXT.WMD_HALFCLOSE),
394
+ dns: common.has(EXT.WMD_DNS), buffer, idleTimer: null, unknownLogged: false,
395
+ }
396
+ s.w = sw
397
+ d.session = s
398
+ if (streamIdleMs > 0) sw.idleTimer = setInterval(() => idleSweep(s), Math.min(60_000, Math.max(20, streamIdleMs / 4)))
399
+ if (d.v2) sendAll(ws, d.hs.accept())
400
+ }
401
+
402
+ function sharedFor(k: string, key?: OwnerKey): Shared {
403
+ let sh = shared.get(k)
404
+ if (!sh) shared.set(k, (sh = newShared({ rateDown: key?.rateDown ?? o.rateDown, rateUp: key?.rateUp ?? o.rateUp })))
405
+ return sh
406
+ }
407
+
408
+ function idleSweep(s: Session) {
409
+ const t = performance.now()
410
+ for (const st of [...s.streams.values()]) {
411
+ const x = sw(st)
412
+ if (st.connected && t - x.last > streamIdleMs) {
413
+ x.idle = true
414
+ engine.fail(s, st, E.TIMEOUT, `no bytes for ${Math.round(streamIdleMs / 1000)} s`)
415
+ }
416
+ }
417
+ }
418
+
419
+ function sw(st: Stream): StreamW {
420
+ return (st.w ||= { pending: null, sizes: [], released: 0, held: 0, heldBytes: 0, drained: 0, last: performance.now(), idle: false, shutPending: false })
421
+ }
422
+
423
+ function send(s: Session, b: Uint8Array) {
424
+ if (!s.closed) s.ws.send(b)
425
+ }
426
+
427
+ /** Room in the stream's buffer, for a CONTINUE. */
428
+ const room = (s: Session, x: StreamW) => Math.max(0, (s.w as SessionW).buffer - x.held)
429
+
430
+ const wire: Wire = {
431
+ connected(s, st) {
432
+ const x = sw(st)
433
+ x.last = performance.now()
434
+ if ((s.w as SessionW).confirm) send(s, continuePacket(st.id, room(s, x)))
435
+ const q = x.pending
436
+ x.pending = null
437
+ if (q) for (const b of q) writeUp(s, st, x, b)
438
+ if (x.shutPending && !st.closed) engine.clientEnd(s, st.id)
439
+ },
440
+ refused: (s, id, code) => send(s, closePacket(id, reasonFor(code))),
441
+ data(s, st, bytes) {
442
+ sw(st).last = performance.now()
443
+ send(s, dataPacket(st.id, bytes))
444
+ },
445
+ end(s, st) {
446
+ if ((s.w as SessionW).halfclose) send(s, shutdownPacket(st.id))
447
+ else engine.clientEnd(s, st.id) // no half-close: the stream is over once the remote's bytes are all sent
448
+ },
449
+ abort: (s, st, code) => send(s, closePacket(st.id, st.w?.idle ? CLOSE.DATA_TIMEOUT : reasonFor(code))),
450
+ grant(s, st, n) {
451
+ const x = sw(st)
452
+ x.released += n
453
+ while (x.sizes.length && x.released >= x.sizes[0]) {
454
+ const k = x.sizes.shift()!
455
+ x.released -= k
456
+ x.held--
457
+ x.heldBytes -= k
458
+ x.drained++
459
+ // released bytes leave the relay's and the group's counts at once (not at the next recount), so a fast honest
460
+ // uploader whose bytes are sent as they arrive never meets a cap that its real held bytes are far from
461
+ heldSwept = Math.max(0, heldSwept - k)
462
+ const gh = (groupHeld.get(s.p.grp) || 0) - k
463
+ if (gh > 0) groupHeld.set(s.p.grp, gh)
464
+ else groupHeld.delete(s.p.grp)
465
+ }
466
+ maybeContinue(s, st, x)
467
+ },
468
+ dnsReply: (s, id, rcode, ttl, addrs, why) => send(s, answerPacket(id, rcode, ttl, addrs, why)),
469
+ done: (s, st) => send(s, closePacket(st.id, CLOSE.VOLUNTARY)),
470
+ }
471
+
472
+ function maybeContinue(s: Session, st: Stream, x: StreamW) {
473
+ const b = (s.w as SessionW).buffer
474
+ if (x.drained >= Math.max(1, b >> 1) && x.heldBytes <= maxHeld && !st.closed) {
475
+ x.drained = 0
476
+ send(s, continuePacket(st.id, room(s, x)))
477
+ }
478
+ }
479
+
480
+ function writeUp(s: Session, st: Stream, x: StreamW, b: Uint8Array) {
481
+ if (b.length === 0) {
482
+ // nothing to write: released at once (a zero-byte write would never be credited)
483
+ x.held--
484
+ x.drained++
485
+ return maybeContinue(s, st, x)
486
+ }
487
+ x.sizes.push(b.length)
488
+ engine.write(s, st, b)
489
+ }
490
+
491
+ function message(ws: any, raw: any) {
492
+ const d = ws.data
493
+ if (typeof raw === 'string') return ws.close(1003, 'binary packets only')
494
+ const u8: Uint8Array = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
495
+ if (u8.length > maxPayload) return ws.close(1009, 'packet too large')
496
+ const m: any = parse(u8)
497
+ if (!d.session) {
498
+ if (!d.hs || d.hs.state !== 'wait-info') return // a packet while the key is being checked: dropped, the client waits for CONTINUE
499
+ const r = d.hs.step(m)
500
+ if (r.error) {
501
+ log({ ev: 'session-refused', proto: 'wisp', why: `handshake: ${r.error}`, addr: d.addr })
502
+ stats.refusedSessions++
503
+ sendAll(ws, r.send)
504
+ return ws.close(1008, 'handshake failed')
505
+ }
506
+ if (r.client) finish(ws, r.client).catch(e => {
507
+ log({ ev: 'error', proto: 'wisp', error: String(e) })
508
+ ws.close(1011, 'relay error')
509
+ })
510
+ return
511
+ }
512
+ const s: Session = d.session
513
+ const x = s.w as SessionW
514
+ if (m.error) return ws.close(1002, `malformed packet: ${m.error}`.slice(0, 120))
515
+ switch (m.type) {
516
+ case PT.CONNECT: {
517
+ const to = `${m.host}:${m.port}`
518
+ if (m.id === 0) {
519
+ log({ ev: 'refused', sid: s.sid, id: 0, to, code: 'bad', why: 'stream id 0 is reserved' })
520
+ return ws.close(1002, 'stream id 0 is reserved for the handshake')
521
+ }
522
+ if (m.streamType !== STREAM.TCP) return engine.refuse(s, m.id, E.BAD, m.streamType === STREAM.UDP ? 'UDP streams are not supported' : `stream type ${m.streamType}`, to)
523
+ const dup = s.streams.get(m.id)
524
+ if (dup) return engine.fail(s, dup, E.BAD, 'stream id in use') // the client must not keep believing in a stream the server dropped (or the reverse)
525
+ engine.connectSafe(s, m.id, m.host, m.port)
526
+ return
527
+ }
528
+ case PT.DATA: {
529
+ const st = s.streams.get(m.id)
530
+ if (!st || st.closed) return // gone: its CLOSE is on the way
531
+ const sx = sw(st)
532
+ if (st.clientEnded || st.w?.shutPending) return engine.fail(s, st, E.BAD, 'data after SHUTDOWN')
533
+ sx.held++
534
+ sx.heldBytes += m.payload.length
535
+ sx.last = performance.now()
536
+ if (sx.held > 2 * x.buffer) return engine.fail(s, st, E.LIMIT, `more than ${2 * x.buffer} packets past the CONTINUE window`)
537
+ let tot = 0
538
+ for (const t of s.streams.values()) tot += t.w?.heldBytes || 0
539
+ if (tot > maxSessHeld) return engine.fail(s, st, E.LIMIT, `more than ${maxSessHeld} bytes held for this session`)
540
+ heldSwept += m.payload.length
541
+ const gh = (groupHeld.get(s.p.grp) || 0) + m.payload.length
542
+ groupHeld.set(s.p.grp, gh)
543
+ if (gh > maxGroupHeld) return engine.fail(s, st, E.LIMIT, `more than ${maxGroupHeld} bytes held for this address or key`)
544
+ if (heldSwept > maxHeldAll && gh > maxHeldAll / Math.max(1, groupHeld.size)) {
545
+ return engine.fail(s, st, E.LIMIT, 'the relay is holding too much unsent data; try again shortly')
546
+ }
547
+ if (!st.connected || !st.sock) {
548
+ ;(sx.pending ||= []).push(m.payload.slice())
549
+ return
550
+ }
551
+ writeUp(s, st, sx, m.payload)
552
+ return
553
+ }
554
+ case PT.CLOSE:
555
+ engine.clientAbort(s, m.id)
556
+ return
557
+ case PT.SHUTDOWN: {
558
+ if (!x.halfclose) return unknown(s, m.type)
559
+ const st = s.streams.get(m.id)
560
+ if (!st) return
561
+ if (!st.connected) sw(st).shutPending = true
562
+ else engine.clientEnd(s, m.id)
563
+ return
564
+ }
565
+ case PT.CREDIT:
566
+ if (!x.credit) return unknown(s, m.type)
567
+ engine.credit(s, m.id, m.n)
568
+ return
569
+ case PT.LOOKUP:
570
+ if (!x.dns) return unknown(s, m.type)
571
+ engine.dns(s, m.id, m.name, m.qtype).catch(e => log({ ev: 'error', sid: s.sid, error: String(e) }))
572
+ return
573
+ default:
574
+ return unknown(s, m.type)
575
+ }
576
+ }
577
+
578
+ function unknown(s: Session, type: number) {
579
+ const x = s.w as SessionW
580
+ if (x.unknownLogged) return
581
+ x.unknownLogged = true
582
+ log({ ev: 'ignored', sid: s.sid, why: `packet type 0x${type.toString(16)} not used on this session` })
583
+ }
584
+
585
+ function close(ws: any, code: number, reason: string) {
586
+ const d = ws.data
587
+ if (d.hsTimer) clearTimeout(d.hsTimer)
588
+ release(d)
589
+ d.rel?.()
590
+ const s: Session | undefined = d.session
591
+ if (!s) return
592
+ const x = s.w as SessionW
593
+ if (x?.idleTimer) clearInterval(x.idleTimer)
594
+ engine.closeSession(s, `websocket closed ${code} ${reason || ''}`.trim())
595
+ }
596
+
597
+ return {
598
+ path,
599
+ maxPayload,
600
+ /** Maintenance: every stream gets CLOSE 0x01 (the spec has no maintenance code), then the socket closes 1001 in words. */
601
+ drain(ws: any) {
602
+ const s: Session | undefined = ws.data.session
603
+ if (s && !s.closed) for (const st of s.streams.values()) ws.send(closePacket(st.id, CLOSE.UNKNOWN))
604
+ ws.close(1001, DRAIN_WORDS)
605
+ },
606
+ fetch,
607
+ open: open_,
608
+ message,
609
+ close,
610
+ stop: () => {
611
+ clearInterval(sweep)
612
+ clearInterval(heldCount)
613
+ },
614
+ }
615
+ }