@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.
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
@@ -0,0 +1,787 @@
1
+ // W59: the relay engine, shared by both wire protocols (v1.ts: wmd-relay.v1, wisp.ts: Wisp v2).
2
+ //
3
+ // The engine owns everything that is not the wire format:
4
+ // - policy (policy.ts): names and addresses checked on the address actually dialled, the
5
+ // allowlist with its per-guest pins, blocked ports
6
+ // - principals (a guest identity) and their caps: connections, connect/DNS/open rates,
7
+ // bandwidth buckets, total bytes; sessions per group; one live session per identity
8
+ // - resolve-and-check, dial, the stream lifecycle and byte accounting
9
+ // - logging (metadata only, never payloads)
10
+ //
11
+ // An adapter owns the wire: it decodes client messages into engine calls, and implements `Wire`
12
+ // for what the engine tells the client. It also owns the flow-control *unit*: the engine counts
13
+ // bytes (download credit, upload bytes written), the adapter turns them into its own messages
14
+ // (v1: byte CREDIT; Wisp: CONTINUE packet counts, the 0xE1 byte credit).
15
+
16
+ import { Resolver } from 'node:dns'
17
+ import { connect as netConnect, type Socket } from 'node:net'
18
+ import { E, MAX_DATA, RCODE, WINDOW } from '../src/net/proto.js'
19
+ import { type Addr, addrKey, addrString, badName, blockedReason, hostAddresses, isLanRange, parseIP } from './policy.ts'
20
+
21
+ // a principal's caps outlive its tokens by at least this long (short-lived tokens must not reset them)
22
+ const PRINCIPAL_FLOOR_MS = 60 * 60_000
23
+
24
+ export type ResolveFn = (name: string) => Promise<{ address: string; ttl: number }[]>
25
+ export type DialFn = (host: string, port: number) => Socket
26
+
27
+ /** What the hosted profile (hosted.ts) asks of the engine; absent outside it. Each method is a hook at one point of the stream lifecycle. */
28
+ export interface Guard {
29
+ /** An extra reason to refuse a dialled address (NAT64, `--deny-cidr`). */
30
+ blockedAddr(a: Addr): string | null
31
+ /** A destination on the operator's deny list. */
32
+ isDeniedDest(host: string, port: number): boolean
33
+ /** Before a connect is resolved: the words to refuse it with, or null. */
34
+ admitConnect(s: Session, host: string, port: number): string | null
35
+ /** Just before the dial: concurrent caps. Sets `st.rel`. */
36
+ openStream(s: Session, st: Stream, host: string, port: number): string | null
37
+ dialFailed(s: Session): void
38
+ dnsAllowed(s: Session): boolean
39
+ dnsClosed?(): string | null // words when lookups are refused too (stop-new, drain, ceiling)
40
+ /** Bytes moved for a session; false when its address group is over its bytes for the hour. */
41
+ bytes(s: Session, n: number): boolean
42
+ limitWhy?(): string // why `bytes` said no
43
+ /** Of n bytes the session's own bucket let through, how many the coarse, origin and relay-wide buckets allow. */
44
+ take(s: Session, dir: 'down' | 'up', n: number): number
45
+ waitMs(s: Session, dir: 'down' | 'up', n: number): number
46
+ }
47
+
48
+ export type EngineOptions = {
49
+ maxConns?: number
50
+ maxSessions?: number
51
+ maxSessionsPerGroup?: number
52
+ opensPerMin?: number
53
+ connectsPerMin?: number
54
+ dnsPerMin?: number
55
+ rateDown?: number
56
+ rateUp?: number
57
+ maxBytes?: number
58
+ allowHosts?: string[]
59
+ selfAddrs?: string[]
60
+ dnsServers?: string[]
61
+ blockedPorts?: number[]
62
+ allowPorts?: number[] // when set, only these destination ports (a stricter deployment)
63
+ guard?: Guard
64
+ allowLan?: boolean // private ranges (10/8, 172.16/12, 192.168/16, fc00::/7) may be dialled; metadata, link-local and this host's LAN addresses stay refused
65
+ allowLoopbackPorts?: number[] // loopback (127/8, ::1) may be dialled on these ports only; never the relay's own port
66
+ ownPort?: () => number // the relay's own port (refused on loopback whatever allowLoopbackPorts says)
67
+ alsoOwnPorts?: () => number[] // listeners of the same program (the --local-tls wss port): refused on loopback too
68
+ connectTimeoutMs?: number
69
+ dnsTimeoutMs?: number
70
+ testRoutes?: Record<string, string>
71
+ testHosts?: Record<string, string>
72
+ resolve4?: ResolveFn
73
+ dial?: DialFn
74
+ log?: (e: Record<string, unknown>) => void
75
+ }
76
+
77
+ export class Bucket {
78
+ tokens: number
79
+ last = performance.now()
80
+ constructor(public rate: number) {
81
+ this.tokens = rate
82
+ }
83
+ /** The smallest take worth a message: 16 KiB, or less for a small request or a small rate (the bucket holds at most `rate`). */
84
+ floor(n: number): number {
85
+ return Math.max(1, Math.min(n, 16 << 10, Math.floor(this.rate)))
86
+ }
87
+ /** Up to n bytes, or 0 when fewer than floor(n) are there (no trickle of tiny messages). */
88
+ take(n: number): number {
89
+ const t = performance.now()
90
+ this.tokens = Math.min(this.rate, this.tokens + ((t - this.last) / 1000) * this.rate)
91
+ this.last = t
92
+ if (this.tokens < this.floor(n)) return 0
93
+ const k = Math.max(0, Math.min(n, Math.floor(this.tokens)))
94
+ this.tokens -= k
95
+ return k
96
+ }
97
+ /** ms until n tokens are there. */
98
+ waitMs(n: number): number {
99
+ return Math.max(1, Math.ceil(((this.floor(n) - this.tokens) / this.rate) * 1000))
100
+ }
101
+ }
102
+
103
+ export class Window {
104
+ times: number[] = []
105
+ constructor(public max: number, public ms: number) {}
106
+ hit(): boolean {
107
+ const t = performance.now()
108
+ while (this.times.length && t - this.times[0] > this.ms) this.times.shift()
109
+ if (this.times.length >= this.max) return false
110
+ this.times.push(t)
111
+ return true
112
+ }
113
+ }
114
+
115
+ export type Stream = {
116
+ id: number
117
+ sock: Socket | null
118
+ connected: boolean
119
+ clientCredit: number // bytes we may send the client
120
+ ourGrant: number // v1: bytes the client may still send us (the v1 adapter's unit)
121
+ outQ: Buffer[]
122
+ outBytes: number
123
+ remoteEnded: boolean // the remote's FIN arrived
124
+ endSent: boolean
125
+ clientEnded: boolean
126
+ closed: boolean
127
+ to: string
128
+ bytesDown: number
129
+ bytesUp: number
130
+ openedAt: number
131
+ w?: any // adapter state for the stream
132
+ rel?: () => void // the guard's release, called once when the stream is gone
133
+ }
134
+
135
+ /** A guest identity: every cap and counter, kept across its WebSocket sessions. */
136
+ export type Principal = {
137
+ uid: string
138
+ grp: string
139
+ connects: Window
140
+ dnsHits: Window
141
+ opens: Window
142
+ down: Bucket
143
+ up: Bucket
144
+ bytes: number
145
+ exp: number // the latest expiry of a token seen for it; the state is dropped after it
146
+ live: Session | null
147
+ pins: Map<string, number> // allowlist: "address:port" -> expiry (ms)
148
+ overBytes: boolean
149
+ }
150
+
151
+ /** What the engine tells the client. The adapter encodes it. */
152
+ export interface Wire {
153
+ /** The stream is open; `detail` is "address:port" dialled. The adapter grants the first upload window (engine.takeUp / engine.owe). */
154
+ connected(s: Session, st: Stream, detail: string): void
155
+ /** The connect failed or was refused before the stream opened. */
156
+ refused(s: Session, id: number, code: number, why: string): void
157
+ data(s: Session, st: Stream, bytes: Uint8Array): void
158
+ /** The remote closed cleanly (FIN) and every byte was sent. */
159
+ end(s: Session, st: Stream): void
160
+ /** The stream failed after it opened: never a clean end. */
161
+ abort(s: Session, st: Stream, code: number, why: string): void
162
+ /** Upload credit: `n` more bytes of the stream may come (out of the up bucket). */
163
+ grant(s: Session, st: Stream, n: number): void
164
+ dnsReply(s: Session, id: number, rcode: number, ttl: number, addrs: Uint8Array[], why: string): void
165
+ /** The stream is done both ways (after end() and the client's end). Optional. */
166
+ done?(s: Session, st: Stream): void
167
+ }
168
+
169
+ export type Session = {
170
+ ws: any
171
+ sid: string
172
+ p: Principal
173
+ wire: Wire
174
+ streams: Map<number, Stream>
175
+ bytes: number // this session's bytes (for the log)
176
+ pumpTimer: ReturnType<typeof setTimeout> | null
177
+ closed: boolean
178
+ expTimer: ReturnType<typeof setTimeout> | null
179
+ grantQueued?: boolean
180
+ /** The download credit a new stream starts with (v1: WINDOW; Wisp without 0xE1: Infinity). */
181
+ window: number
182
+ w?: any // adapter state for the session
183
+ hx?: any // the hosted governor's admission for this session
184
+ allow?: AllowEntry[] // replaces the relay's allowlist for this session (an owner key's)
185
+ }
186
+
187
+ export type AllowEntry = { name: string; ports: Set<number> }
188
+
189
+ /** Allowlist entries: name[:port|port...]; no ports: 80 and 443; a leading "." matches subdomains. */
190
+ export function compileAllow(list: string[] | undefined): AllowEntry[] {
191
+ return (list || []).map(h => h.toLowerCase().trim()).filter(Boolean).map(h => {
192
+ const i = h.indexOf(':')
193
+ const name = i < 0 ? h : h.slice(0, i)
194
+ const ports = i < 0 ? [80, 443] : h.slice(i + 1).split('|').map(Number).filter(n => n > 0 && n < 65536)
195
+ return { name, ports: new Set(ports) }
196
+ })
197
+ }
198
+
199
+ /** Bandwidth buckets shared by several identities (Wisp: one client address, or one owner key). */
200
+ export type Shared = { down: Bucket; up: Bucket }
201
+ export const newShared = (o: { rateDown?: number; rateUp?: number }): Shared => ({ down: new Bucket(o.rateDown ?? 16 << 20), up: new Bucket(o.rateUp ?? 4 << 20) })
202
+
203
+ export type Admission = { ok: true; p: Principal } | { ok: false; status: number; body: string }
204
+
205
+ const ERR_CODE: Record<string, number> = {
206
+ ECONNREFUSED: E.REFUSED, ETIMEDOUT: E.TIMEOUT, ECONNRESET: E.RESET, EPIPE: E.RESET,
207
+ EHOSTUNREACH: E.UNREACHABLE, ENETUNREACH: E.UNREACHABLE, EADDRNOTAVAIL: E.UNREACHABLE,
208
+ }
209
+
210
+ export const codeName = (code: number) => Object.keys(E).find(k => (E as any)[k] === code)?.toLowerCase()
211
+
212
+ export type Engine = ReturnType<typeof createEngine>
213
+
214
+ export function createEngine(o: EngineOptions) {
215
+ const log = o.log || (() => {})
216
+ const maxConns = o.maxConns ?? 64
217
+ const maxSessions = o.maxSessions ?? 100
218
+ const maxBytes = o.maxBytes ?? 4 * 2 ** 30
219
+ const blockedPorts = new Set(o.blockedPorts ?? [25])
220
+ const allowPorts = o.allowPorts?.length ? new Set(o.allowPorts) : null
221
+ const guard = o.guard
222
+ const relStream = (st: Stream) => {
223
+ const r = st.rel
224
+ st.rel = undefined
225
+ r?.()
226
+ }
227
+ const connectTimeoutMs = o.connectTimeoutMs ?? 10_000
228
+ // allowlist entries: name[:port|port...]; no ports: 80 and 443
229
+ const allow = compileAllow(o.allowHosts)
230
+ const maxPerGroup = o.maxSessionsPerGroup ?? 8
231
+ const testRoutes: Record<string, string> = Object.assign(Object.create(null), o.testRoutes) // no prototype keys ("constructor", "__proto__")
232
+ const testHosts: Record<string, string> = Object.assign(Object.create(null), o.testHosts)
233
+ let self = hostAddresses(o.selfAddrs)
234
+ const loopPorts = new Set(o.allowLoopbackPorts ?? [])
235
+ const isLoop = (a: Addr) => (a.v === 4 ? a.b[0] === 127 : a.b.every((x, i) => x === (i === 15 ? 1 : 0)))
236
+ /** blockedReason with the owner's exceptions; `port` undefined is a lookup (the port is checked at the connect). */
237
+ function whyBlocked(a: Addr, port?: number): string | null {
238
+ const extra = o.guard?.blockedAddr(a)
239
+ if (extra) return extra
240
+ const why = blockedReason(a, self)
241
+ if (!why) return null
242
+ if (o.allowLan && why === 'private' && isLanRange(a)) return null
243
+ if (loopPorts.size && isLoop(a) && (why === 'loopback' || why === 'self')) {
244
+ if (port === undefined) return null
245
+ if (port === o.ownPort?.() || o.alsoOwnPorts?.().includes(port)) return `${why} (the relay's own port)`
246
+ if (loopPorts.has(port)) return null
247
+ return `${why} (port ${port} is not in allowLoopbackPorts)`
248
+ }
249
+ return why
250
+ }
251
+ const selfTimer = setInterval(() => (self = hostAddresses(o.selfAddrs)), 30_000)
252
+ const sessions = new Map<string, Session>() // by sid
253
+ const principals = new Map<string, Principal>() // by uid
254
+ const groupLive = new Map<string, number>()
255
+ // a principal's state lives as long as a token for it can: dropped after its latest expiry
256
+ const pruneTimer = setInterval(() => {
257
+ const t = Date.now()
258
+ for (const [uid, p] of principals) {
259
+ if (!p.live && p.exp + PRINCIPAL_FLOOR_MS < t) principals.delete(uid)
260
+ else for (const [k, exp] of p.pins) if (exp < t) p.pins.delete(k)
261
+ }
262
+ }, 60_000)
263
+ function principal(uid: string, grp: string, exp: number, share?: Shared): Principal {
264
+ let p = principals.get(uid)
265
+ if (p) {
266
+ principals.delete(uid) // re-inserted: the map is least recently admitted first
267
+ principals.set(uid, p)
268
+ } else {
269
+ p = {
270
+ uid, grp, connects: new Window(o.connectsPerMin ?? 600, 60_000), dnsHits: new Window(o.dnsPerMin ?? 1200, 60_000),
271
+ opens: new Window(o.opensPerMin ?? 30, 60_000), down: share?.down ?? new Bucket(o.rateDown ?? 16 << 20), up: share?.up ?? new Bucket(o.rateUp ?? 4 << 20),
272
+ bytes: 0, exp, live: null, pins: new Map(), overBytes: false,
273
+ }
274
+ principals.set(uid, p)
275
+ }
276
+ p.exp = Math.max(p.exp, exp)
277
+ return p
278
+ }
279
+ const stats = { sessions: 0, refusedSessions: 0, connects: 0, refused: {} as Record<string, number>, dns: 0, bytesDown: 0, bytesUp: 0 }
280
+
281
+ let resolve4: ResolveFn = o.resolve4 as ResolveFn
282
+ if (!resolve4) {
283
+ const r = new Resolver({ timeout: o.dnsTimeoutMs ?? 3000, tries: 2 })
284
+ if (o.dnsServers?.length) r.setServers(o.dnsServers)
285
+ resolve4 = name =>
286
+ new Promise((res, rej) => r.resolve4(name, { ttl: true }, (err, addrs) => (err ? rej(err) : res(addrs as { address: string; ttl: number }[]))))
287
+ }
288
+ const dial: DialFn = o.dial || ((host, port) => netConnect({ host, port, allowHalfOpen: true, noDelay: true } as any))
289
+
290
+ // a session may carry its own allowlist (an owner key's), which replaces the relay's
291
+ const listFor = (s: Session) => s.allow ?? allow
292
+ const entryFor = (A: AllowEntry[], name: string) =>
293
+ A.find(a => (a.name.startsWith('.') ? name.endsWith(a.name) || name === a.name.slice(1) : name === a.name))
294
+ const allowed = (A: AllowEntry[], name: string) => !A.length || !!entryFor(A, name)
295
+
296
+ function refuse(s: Session, id: number, code: number, why: string, to: string) {
297
+ const name = codeName(code)!
298
+ stats.refused[name] = (stats.refused[name] || 0) + 1
299
+ log({ ev: 'refused', sid: s.sid, id, to, code: name, why })
300
+ if (!s.closed) s.wire.refused(s, id, code, why)
301
+ }
302
+
303
+ /** The address to dial for a name: resolved here, filtered by the policy. */
304
+ async function resolveChecked(s: Session, name: string, port?: number): Promise<{ addrs: { a: Addr; ttl: number }[]; rcode: number; why: string; code: number }> {
305
+ const n = name.toLowerCase().replace(/\.$/, '')
306
+ const A = listFor(s)
307
+ const bad = badName(n)
308
+ if (bad) return { addrs: [], rcode: RCODE.REFUSED, why: `name refused: ${bad}`, code: E.BAD }
309
+ if (testHosts[n]) return { addrs: [{ a: parseIP(testHosts[n])!, ttl: 60 }], rcode: RCODE.NOERROR, why: '', code: E.OK } // test only
310
+ if (!allowed(A, n)) return { addrs: [], rcode: RCODE.REFUSED, why: 'name not on the allowlist', code: E.NOT_ALLOWED }
311
+ let list: { address: string; ttl: number }[]
312
+ {
313
+ try {
314
+ list = await resolve4(n)
315
+ } catch (e: any) {
316
+ const code = e?.code || ''
317
+ if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') return { addrs: [], rcode: code === 'ENODATA' ? RCODE.NOERROR : RCODE.NXDOMAIN, why: code, code: E.DNS }
318
+ return { addrs: [], rcode: RCODE.SERVFAIL, why: `resolver: ${code || e?.message}`, code: E.DNS }
319
+ }
320
+ }
321
+ const out: { a: Addr; ttl: number }[] = []
322
+ const blocked: string[] = []
323
+ for (const x of list) {
324
+ const a = parseIP(x.address)
325
+ if (!a || a.v !== 4) continue
326
+ const why = whyBlocked(a, port)
327
+ if (why) blocked.push(`${x.address} (${why})`)
328
+ else out.push({ a, ttl: x.ttl })
329
+ }
330
+ if (!out.length && blocked.length) return { addrs: [], rcode: RCODE.REFUSED, why: `resolves only to refused addresses: ${blocked.join(', ')}`, code: E.BLOCKED }
331
+ if (A.length) {
332
+ const ports = entryFor(A, n)!.ports
333
+ const exp = Date.now() + Math.max(maxTtl(out), 300) * 1000
334
+ for (const x of out) for (const port of ports) s.p.pins.set(`${addrKey(x.a)}:${port}`, exp)
335
+ }
336
+ return { addrs: out, rcode: RCODE.NOERROR, why: blocked.length ? `refused: ${blocked.join(', ')}` : '', code: out.length ? E.OK : E.DNS }
337
+ }
338
+
339
+ function maxTtl(out: { ttl: number }[]) {
340
+ return out.reduce((m, x) => Math.max(m, x.ttl), 0)
341
+ }
342
+
343
+ async function dns(s: Session, id: number, name: string, qtype: number) {
344
+ stats.dns++
345
+ if (!s.p.dnsHits.hit()) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], 'dns rate limit')
346
+ const closed = guard?.dnsClosed?.()
347
+ if (closed) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], closed)
348
+ if (guard && !guard.dnsAllowed(s)) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], 'dns rate limit for this address')
349
+ if (qtype !== 1) return s.wire.dnsReply(s, id, RCODE.NOERROR, 60, [], 'only A')
350
+ let r: Awaited<ReturnType<typeof resolveChecked>>
351
+ try {
352
+ r = await resolveChecked(s, name)
353
+ } catch (e: any) {
354
+ log({ ev: 'error', sid: s.sid, error: String(e) })
355
+ return s.wire.dnsReply(s, id, RCODE.SERVFAIL, 0, [], 'lookup failed')
356
+ }
357
+ if (r.rcode !== RCODE.NOERROR || r.why) log({ ev: 'dns', sid: s.sid, name, rcode: r.rcode, why: r.why })
358
+ const ttl = r.addrs.length ? Math.min(...r.addrs.map(x => x.ttl)) : 30
359
+ if (!s.closed) s.wire.dnsReply(s, id, r.rcode, ttl, r.addrs.slice(0, 16).map(x => x.a.b), r.why)
360
+ }
361
+
362
+ async function connect(s: Session, id: number, host: string, port: number) {
363
+ const to = `${host}:${port}`
364
+ const A = listFor(s)
365
+ if (s.streams.has(id)) return refuse(s, id, E.BAD, 'stream id in use', to)
366
+ if (!port) return refuse(s, id, E.BAD, 'port 0', to)
367
+ if (blockedPorts.has(port)) return refuse(s, id, E.PORT, `port ${port} is blocked`, to)
368
+ if (allowPorts && !allowPorts.has(port)) return refuse(s, id, E.PORT, `port ${port} is not allowed on this relay`, to)
369
+ if (s.streams.size >= maxConns) return refuse(s, id, E.LIMIT, `more than ${maxConns} connections at once`, to)
370
+ if (!s.p.connects.hit()) return refuse(s, id, E.LIMIT, 'connect rate limit', to)
371
+ if (guard?.isDeniedDest(host, port)) return refuse(s, id, E.BLOCKED, 'this destination is blocked by the operator', to)
372
+ const held = guard?.admitConnect(s, host, port)
373
+ if (held) return refuse(s, id, E.LIMIT, held, to)
374
+ const st: Stream = {
375
+ id, sock: null, connected: false, clientCredit: s.window, ourGrant: 0, outQ: [], outBytes: 0, remoteEnded: false, endSent: false,
376
+ clientEnded: false, closed: false, to, bytesDown: 0, bytesUp: 0, openedAt: performance.now(),
377
+ }
378
+ s.streams.set(id, st) // reserve the id while resolving
379
+ let addr = parseIP(host)
380
+ let dialHost: string
381
+ let dialPort = port
382
+ if (addr) {
383
+ const key = addrKey(addr)
384
+ const route = testRoutes[`${key}:${port}`]
385
+ const why = route ? null : whyBlocked(addr, port)
386
+ if (why) {
387
+ s.streams.delete(id)
388
+ return refuse(s, id, E.BLOCKED, `${key} is ${why}`, to)
389
+ }
390
+ if (A.length && !route) {
391
+ const exp = s.p.pins.get(`${key}:${port}`)
392
+ if (!exp || exp < Date.now()) {
393
+ s.streams.delete(id)
394
+ return refuse(s, id, E.NOT_ALLOWED, `${key}:${port} was not resolved from an allowed name (for this port) by this guest`, to)
395
+ }
396
+ }
397
+ if (route) {
398
+ const i = route.lastIndexOf(':')
399
+ dialHost = route.slice(0, i)
400
+ dialPort = Number(route.slice(i + 1))
401
+ } else dialHost = addrString(addr)
402
+ } else {
403
+ let r: Awaited<ReturnType<typeof resolveChecked>>
404
+ try {
405
+ r = await resolveChecked(s, host, port)
406
+ } catch (e: any) {
407
+ // whatever fails, the stream id is freed and the guest gets an answer
408
+ if (!st.closed && !s.closed) {
409
+ s.streams.delete(id)
410
+ log({ ev: 'error', sid: s.sid, error: String(e) })
411
+ refuse(s, id, E.DNS, 'lookup failed', to)
412
+ }
413
+ return
414
+ }
415
+ if (st.closed || s.closed) return
416
+ if (r.addrs.length && A.length && !testHosts[host.toLowerCase().replace(/\.$/, '')] && !entryFor(A, host.toLowerCase().replace(/\.$/, ''))?.ports.has(port)) {
417
+ s.streams.delete(id)
418
+ return refuse(s, id, E.NOT_ALLOWED, `port ${port} is not allowed for ${host}`, to)
419
+ }
420
+ if (!r.addrs.length) {
421
+ s.streams.delete(id)
422
+ if (r.code === E.DNS) guard?.dialFailed(s)
423
+ return refuse(s, id, r.code || E.DNS, r.why || 'no address', to)
424
+ }
425
+ addr = r.addrs[0].a
426
+ const route = testRoutes[`${addrKey(addr)}:${port}`]
427
+ if (route) {
428
+ const i = route.lastIndexOf(':')
429
+ dialHost = route.slice(0, i)
430
+ dialPort = Number(route.slice(i + 1))
431
+ } else dialHost = addrString(addr) // exactly the address that was checked: no second lookup
432
+ }
433
+ if (st.closed || s.closed) return
434
+ const full = guard?.openStream(s, st, host, port)
435
+ if (full) {
436
+ s.streams.delete(id)
437
+ return refuse(s, id, E.LIMIT, full, to)
438
+ }
439
+ stats.connects++
440
+ let sock: Socket
441
+ try {
442
+ sock = dial(dialHost, dialPort)
443
+ } catch (e: any) {
444
+ relStream(st)
445
+ guard?.dialFailed(s)
446
+ s.streams.delete(id)
447
+ return refuse(s, id, E.UNREACHABLE, e?.message || 'dial failed', to)
448
+ }
449
+ st.sock = sock
450
+ const timer = setTimeout(() => {
451
+ if (!st.connected) {
452
+ sock.destroy()
453
+ fail(s, st, E.TIMEOUT, `no connection in ${connectTimeoutMs} ms`)
454
+ }
455
+ }, connectTimeoutMs)
456
+ sock.on('connect', () => {
457
+ clearTimeout(timer)
458
+ if (st.closed) return sock.destroy()
459
+ st.connected = true
460
+ log({ ev: 'connect', sid: s.sid, id, to, via: `${dialHost}:${dialPort}`, ms: Math.round(performance.now() - st.openedAt) })
461
+ if (!s.closed) s.wire.connected(s, st, `${addrKey(addr!)}:${port}`)
462
+ })
463
+ sock.on('data', (b: Buffer) => {
464
+ if (st.closed) return
465
+ st.outQ.push(b)
466
+ st.outBytes += b.length
467
+ pump(s, st)
468
+ })
469
+ sock.on('end', () => {
470
+ st.remoteEnded = true
471
+ pump(s, st)
472
+ })
473
+ sock.on('error', (e: any) => {
474
+ clearTimeout(timer)
475
+ fail(s, st, ERR_CODE[e?.code] ?? (st.connected ? E.RESET : E.UNREACHABLE), e?.code || e?.message || 'socket error')
476
+ })
477
+ sock.on('close', () => {
478
+ clearTimeout(timer)
479
+ if (st.closed) return
480
+ // closed without an error: clean only if the remote's FIN came and everything was sent
481
+ if (!st.connected) return fail(s, st, E.UNREACHABLE, 'closed before connecting')
482
+ if (st.remoteEnded) {
483
+ st.sock = null
484
+ pump(s, st)
485
+ return
486
+ }
487
+ fail(s, st, E.RESET, 'closed')
488
+ })
489
+ }
490
+
491
+ /** connect(), with any failure before the dial answered as a refusal (the stream id is freed). */
492
+ function connectSafe(s: Session, id: number, host: string, port: number) {
493
+ connect(s, id, host, port).catch(e => {
494
+ log({ ev: 'error', sid: s.sid, error: String(e) })
495
+ const st = s.streams.get(id) // a failure before the dial: free the id and answer
496
+ if (st && !st.sock && !st.connected && !st.closed) {
497
+ s.streams.delete(id)
498
+ refuse(s, id, E.UNREACHABLE, 'connect failed', `${host}:${port}`)
499
+ }
500
+ })
501
+ }
502
+
503
+ /** Send the remote's bytes as credit, bandwidth and the WebSocket allow; end() after the last. */
504
+ function pump(s: Session, st: Stream) {
505
+ if (st.closed || s.closed) return
506
+ while (st.outBytes > 0 && st.clientCredit > 0) {
507
+ if (s.ws.getBufferedAmount?.() > 8 << 20) break // the drain handler pumps again
508
+ const want = Math.min(st.outBytes, st.clientCredit, MAX_DATA)
509
+ let n = s.p.down.take(want)
510
+ if (!n) {
511
+ schedulePump(s, s.p.down.waitMs(want))
512
+ break
513
+ }
514
+ if (guard) {
515
+ const k = guard.take(s, 'down', n)
516
+ if (k < n) s.p.down.tokens = Math.min(s.p.down.rate, s.p.down.tokens + n - k)
517
+ n = k
518
+ if (!n) {
519
+ schedulePump(s, guard.waitMs(s, 'down', want))
520
+ break
521
+ }
522
+ }
523
+ const out = Buffer.allocUnsafe(n)
524
+ let o = 0
525
+ while (o < n) {
526
+ const h = st.outQ[0]
527
+ const k = Math.min(h.length, n - o)
528
+ h.copy(out, o, 0, k)
529
+ o += k
530
+ if (k === h.length) st.outQ.shift()
531
+ else st.outQ[0] = h.subarray(k)
532
+ }
533
+ st.outBytes -= n
534
+ st.clientCredit -= n
535
+ st.bytesDown += n
536
+ stats.bytesDown += n
537
+ s.bytes += n
538
+ s.p.bytes += n
539
+ s.wire.data(s, st, out)
540
+ if (overBytes(s) || overHour(s, n)) return
541
+ }
542
+ if (st.sock) {
543
+ if (st.outBytes > 0) st.sock.pause()
544
+ else if (!st.remoteEnded) st.sock.resume()
545
+ }
546
+ if (st.remoteEnded && st.outBytes === 0 && !st.endSent) {
547
+ st.endSent = true
548
+ s.wire.end(s, st)
549
+ maybeDone(s, st)
550
+ }
551
+ }
552
+
553
+ function pumpAll(s: Session) {
554
+ for (const st of s.streams.values()) pump(s, st)
555
+ }
556
+
557
+ function schedulePump(s: Session, ms: number) {
558
+ if (s.pumpTimer) return
559
+ s.pumpTimer = setTimeout(() => {
560
+ s.pumpTimer = null
561
+ for (const st of s.streams.values()) pump(s, st)
562
+ grantPending(s)
563
+ }, ms)
564
+ }
565
+
566
+ /** The hosted profile's per-hour bytes for the session's address group: past them the session's streams end. */
567
+ function overHour(s: Session, n: number): boolean {
568
+ if (!guard || guard.bytes(s, n)) return false
569
+ const why = guard.limitWhy?.() ?? 'this address has used its bytes for this hour'
570
+ log({ ev: 'limit', sid: s.sid, why })
571
+ for (const st of [...s.streams.values()]) fail(s, st, E.LIMIT, why)
572
+ return true
573
+ }
574
+
575
+ function overBytes(s: Session): boolean {
576
+ if (s.p.bytes <= maxBytes) return false
577
+ s.p.overBytes = true // the identity stays over its cap: later sessions are refused
578
+ log({ ev: 'limit', sid: s.sid, why: 'session byte limit' })
579
+ for (const st of [...s.streams.values()]) fail(s, st, E.LIMIT, 'session byte limit')
580
+ s.ws.close(4008, 'session byte limit')
581
+ return true
582
+ }
583
+
584
+ // upload credit waiting for bandwidth
585
+ const owed = new Map<Session, Map<number, number>>()
586
+ function owe(s: Session, id: number, n: number, now = true) {
587
+ let m = owed.get(s)
588
+ if (!m) owed.set(s, (m = new Map()))
589
+ m.set(id, (m.get(id) || 0) + n)
590
+ if (now) grantPending(s)
591
+ }
592
+
593
+ function grantPending(s: Session) {
594
+ const m = owed.get(s)
595
+ if (!m) return
596
+ for (const [id, n] of m) {
597
+ const st = s.streams.get(id)
598
+ if (!st || st.closed) {
599
+ m.delete(id)
600
+ continue
601
+ }
602
+ let k = s.p.up.take(n)
603
+ if (k && guard) {
604
+ const g = guard.take(s, 'up', k)
605
+ if (g < k) s.p.up.tokens = Math.min(s.p.up.rate, s.p.up.tokens + k - g)
606
+ k = g
607
+ }
608
+ if (k) s.wire.grant(s, st, k)
609
+ if (k === n) m.delete(id)
610
+ else {
611
+ m.set(id, n - k)
612
+ schedulePump(s, Math.max(s.p.up.waitMs(n - k), guard ? guard.waitMs(s, 'up', n - k) : 1))
613
+ }
614
+ }
615
+ }
616
+
617
+ /** Upload bytes for a connected stream (the adapter has checked its flow-control unit). */
618
+ function write(s: Session, st: Stream, data: Uint8Array) {
619
+ st.bytesUp += data.length
620
+ stats.bytesUp += data.length
621
+ s.bytes += data.length
622
+ s.p.bytes += data.length
623
+ if (overBytes(s) || overHour(s, data.length)) return
624
+ const n = data.length
625
+ st.sock!.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength), () => {
626
+ if (st.closed) return
627
+ owe(s, st.id, n, false)
628
+ // one grant per stream for all the writes that finished in this turn, not one per write
629
+ if (!s.grantQueued) {
630
+ s.grantQueued = true
631
+ setImmediate(() => {
632
+ s.grantQueued = false
633
+ grantPending(s)
634
+ })
635
+ }
636
+ })
637
+ }
638
+
639
+ function fail(s: Session, st: Stream, code: number, why: string) {
640
+ if (st.closed) return
641
+ st.closed = true
642
+ relStream(st)
643
+ s.streams.delete(st.id)
644
+ st.sock?.destroy()
645
+ if (!st.connected && guard && (code === E.REFUSED || code === E.UNREACHABLE || code === E.TIMEOUT)) guard.dialFailed(s)
646
+ if (!st.connected) return refuse(s, st.id, code, why, st.to)
647
+ log({ ev: 'abort', sid: s.sid, id: st.id, to: st.to, code: codeName(code), why, down: st.bytesDown, up: st.bytesUp })
648
+ if (!s.closed) s.wire.abort(s, st, code, why)
649
+ }
650
+
651
+ function maybeDone(s: Session, st: Stream) {
652
+ if (st.endSent && st.clientEnded && !st.closed) {
653
+ st.closed = true
654
+ relStream(st)
655
+ s.streams.delete(st.id)
656
+ st.sock?.end()
657
+ log({ ev: 'done', sid: s.sid, id: st.id, to: st.to, down: st.bytesDown, up: st.bytesUp })
658
+ s.wire.done?.(s, st)
659
+ }
660
+ }
661
+
662
+ /** The client half-closed the stream (the guest sent FIN). */
663
+ function clientEnd(s: Session, id: number) {
664
+ const st = s.streams.get(id)
665
+ if (st && st.connected && !st.clientEnded) {
666
+ st.clientEnded = true
667
+ st.sock?.end()
668
+ maybeDone(s, st)
669
+ }
670
+ }
671
+
672
+ /** The client reset the stream. */
673
+ function clientAbort(s: Session, id: number) {
674
+ const st = s.streams.get(id)
675
+ if (st) {
676
+ st.closed = true
677
+ relStream(st)
678
+ s.streams.delete(id)
679
+ st.sock?.destroy()
680
+ log({ ev: 'client-abort', sid: s.sid, id, to: st.to, down: st.bytesDown, up: st.bytesUp })
681
+ }
682
+ }
683
+
684
+ /** The client may take n more download bytes on the stream. */
685
+ function credit(s: Session, id: number, n: number) {
686
+ const st = s.streams.get(id)
687
+ if (st) {
688
+ st.clientCredit = Math.min(st.clientCredit + n, 0xffffffff)
689
+ pump(s, st)
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Session admission for an identity, after the adapter's own checks (origin, auth). Counts the
695
+ * open against the identity's rate. On success the caller upgrades and then calls open().
696
+ */
697
+ function admit(uid: string, grp: string, exp: number, extra: Record<string, unknown> = {}, share?: Shared): Admission {
698
+ const known = principals.get(uid)
699
+ const replaces = !!known?.live
700
+ if (sessions.size >= maxSessions && !replaces) {
701
+ stats.refusedSessions++
702
+ return { ok: false, status: 503, body: 'too many sessions\n' }
703
+ }
704
+ if ((groupLive.get(grp) || 0) >= maxPerGroup && !replaces) {
705
+ stats.refusedSessions++
706
+ log({ ev: 'session-refused', why: 'group session limit', grp, ...extra })
707
+ return { ok: false, status: 429, body: `more than ${maxPerGroup} sessions for this client\n` }
708
+ }
709
+ const p = principal(uid, grp, exp, share)
710
+ if (p.overBytes) {
711
+ stats.refusedSessions++
712
+ return { ok: false, status: 429, body: 'byte limit reached for this guest\n' }
713
+ }
714
+ if (!p.opens.hit()) {
715
+ stats.refusedSessions++
716
+ log({ ev: 'session-refused', why: 'open rate limit', uid, ...extra })
717
+ return { ok: false, status: 429, body: 'too many connections for this guest; wait\n' }
718
+ }
719
+ return { ok: true, p }
720
+ }
721
+
722
+ function refusedSession(why: string, fields: Record<string, unknown> = {}) {
723
+ stats.refusedSessions++
724
+ if (why) log({ ev: 'session-refused', why, ...fields })
725
+ }
726
+
727
+ /** A WebSocket opened for an admitted identity. Returns null when the sid is already live. */
728
+ function open(ws: any, sid: string, p: Principal, exp: number, wire: Wire, window: number, logFields: Record<string, unknown> = {}): Session | null {
729
+ if (sessions.has(sid)) {
730
+ ws.close(4009, 'session already connected')
731
+ return null
732
+ }
733
+ // one live session per guest identity: a newer one (a reconnect the old side has not noticed) replaces it
734
+ if (p.live) {
735
+ const old = p.live
736
+ old.ws.close(4010, 'replaced by a newer session of this guest')
737
+ closeSession(old, 'replaced by a newer session')
738
+ }
739
+ const s: Session = { ws, sid, p, wire, streams: new Map(), bytes: 0, pumpTimer: null, closed: false, expTimer: null, window }
740
+ p.live = s
741
+ groupLive.set(p.grp, (groupLive.get(p.grp) || 0) + 1)
742
+ if (Number.isFinite(exp)) s.expTimer = setTimeout(() => ws.close(4001, 'token expired'), Math.max(0, exp - Date.now()))
743
+ sessions.set(sid, s)
744
+ stats.sessions++
745
+ log({ ev: 'session', sid, uid: p.uid, grp: p.grp, ...logFields })
746
+ return s
747
+ }
748
+
749
+ function closeSession(s: Session, why: string) {
750
+ if (s.closed) return
751
+ s.closed = true
752
+ if (s.pumpTimer) clearTimeout(s.pumpTimer)
753
+ if (s.expTimer) clearTimeout(s.expTimer)
754
+ for (const st of s.streams.values()) {
755
+ st.closed = true
756
+ relStream(st)
757
+ st.sock?.destroy()
758
+ }
759
+ s.streams.clear()
760
+ owed.delete(s)
761
+ if (sessions.get(s.sid) === s) sessions.delete(s.sid)
762
+ if (s.p.live === s) s.p.live = null
763
+ const g = (groupLive.get(s.p.grp) || 1) - 1
764
+ if (g > 0) groupLive.set(s.p.grp, g)
765
+ else groupLive.delete(s.p.grp)
766
+ log({ ev: 'session-end', sid: s.sid, why, bytes: s.bytes })
767
+ }
768
+
769
+ function limits(p: Principal, A: AllowEntry[] = allow) {
770
+ return {
771
+ maxConns, window: WINDOW, rateDown: p.down.rate, rateUp: p.up.rate, bytesLeft: Math.max(0, maxBytes - p.bytes),
772
+ allowHosts: A.length ? A.map(a => `${a.name}:${[...a.ports].join('|')}`) : null,
773
+ }
774
+ }
775
+
776
+ return {
777
+ log, stats, sessions, principals,
778
+ hasSession: (sid: string) => sessions.has(sid),
779
+ admit, refusedSession, open, closeSession, limits,
780
+ connect, connectSafe, refuse, dns, write, credit, clientEnd, clientAbort, fail, owe, pump, pumpAll, grantPending,
781
+ stop() {
782
+ clearInterval(selfTimer)
783
+ clearInterval(pruneTimer)
784
+ for (const s of sessions.values()) closeSession(s, 'relay stopping')
785
+ },
786
+ }
787
+ }