@threadedstack/wmd-web 0.1.13 → 0.1.14

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 CHANGED
@@ -2600,3 +2600,14 @@ engines' memory64 limit), passes `WMD_RAM_FLAT=1` to vm.wasm and hands the memor
2600
2600
  Measured in Chromium (page/b7.js, `ram=`): 4096 shows `Mem: 3976`; 8192 with 4 vCPUs held 5366 MB in six
2601
2601
  processes with every byte checked; the same work at 1536 (small) and 4096 (flat) took 35 s and 39 s.
2602
2602
  Tests: `test/unit/ram-mem.test.js` (bun has no memory64: the helper's part runs under node).
2603
+
2604
+ ## D-544: `--init` and keep-running containers (2026-11-12)
2605
+
2606
+ `containerBundle({ ..., init: true })` runs the command under tini at `/sbin/docker-init`, as `docker run --init`
2607
+ does. `containerBundle({ ..., keepRunning: true, stopSignal })` keeps the container up after its command ends;
2608
+ send its `run` fields (`{ command: true, stop_signal }`) with the main process's run request and pass `onCommand`
2609
+ to hear `{ state: 'running' | 'exited' | 'none', code }`. `control.startCommand({ args, env, cwd, user })` starts a
2610
+ command in a keep-running container that has none running (browser only: for a base snapshot made with `args: []`).
2611
+ After a restore, attach the main process again with `onCommand`: the guest sends the restored machine's state.
2612
+ Guide: `../docs/keep-running.md`. Tests: `test/unit/ctl-keep-running.test.js`, `test/b9/b9keep.spec.js`
2613
+ (`bunx playwright test -c test/b9 b9keep`, under the boot lock; 4 tests, passing in Chromium, Firefox and WebKit).
package/RELEASE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wmd-web",
3
- "version": "0.3.1",
4
- "commit": "23bf0595198cec6c2027017049b2caa39753efc2",
3
+ "version": "0.3.2",
4
+ "commit": "1fe3dd6c164b913eecd21d64097b285c51fc1401",
5
5
  "previous": "wmd-web-v0.1.0",
6
6
  "decisions": [
7
7
  "D-486",
@@ -18,7 +18,9 @@
18
18
  "D-531",
19
19
  "D-533",
20
20
  "D-539",
21
- "D-542"
21
+ "D-542",
22
+ "D-544",
23
+ "D-545"
22
24
  ],
23
25
  "vmSources": "f638fbdada4ccae26c12334b851e12244c1985d8",
24
26
  "guest": {
@@ -37,22 +39,22 @@
37
39
  "bytes": 38828040
38
40
  },
39
41
  "initramfs.cpio.gz": {
40
- "sha256": "38101c6b9bd0f8ec8f1b0aea99046d80d36669052794fa8e0368b6a1743285c1",
41
- "bytes": 4667433
42
+ "sha256": "a2260db90e5d8acdf12a2123f696b4512a1465856d5653e20a85504069a3480c",
43
+ "bytes": 4685323
42
44
  }
43
45
  }
44
46
  },
45
47
  "files": {
46
- "README.md": "e26f6e48262e55f34f7bb2c3e5b4a91163aac356e3f07b5f82202b6ca16e10a3",
48
+ "README.md": "b9edbf3db9576f7016df93dee8e2b197071655518f90363fe2dafe2a97b03a3d",
47
49
  "export-page/coi-sw.js": "0300e670b413cb2e0aae35843fc583546bbf6687df971ab6df41cfa3b0d494d7",
48
50
  "export-page/index.html": "f5450c89e63601bbbbffef421f03491b231d1f143070012c1c5315d4b0059f43",
49
51
  "export-page/run.css": "b57adf1e76553549c1f89ff157b7973409dedc21889cd3932948cd05b51adc6f",
50
52
  "export-page/run.js": "717636a632a40469106ddf1383e198eaac5d83651b5866d0699ec04b82462b7f",
51
53
  "guest/arm64/Image": "a6b3209cf69d5bc1dacf0f3ed67f92c535dbb4108d67b47091c2aef87c6d7b04",
52
54
  "guest/arm64/Image-4k": "8ff6e338a0b42d9f774b35423e0e7f9d7f779c635446e8de2a3beb83d3518f8a",
53
- "guest/arm64/initramfs.cpio.gz": "38101c6b9bd0f8ec8f1b0aea99046d80d36669052794fa8e0368b6a1743285c1",
55
+ "guest/arm64/initramfs.cpio.gz": "a2260db90e5d8acdf12a2123f696b4512a1465856d5653e20a85504069a3480c",
54
56
  "guest/arm64/vm.wasm": "f7affc474658fdfdc50e96c29a224eed1520ea69b426d6427fbc058eb141091a",
55
- "package.json": "5ec889bd829e3e0a021e042e59eaf4f832280ac2135ce8ef160582173aa9dce3",
57
+ "package.json": "17aa3a8406e8d2d85b647c426438d3fd75edcb59d83e257882227e7b93cccbc0",
56
58
  "src/base.js": "982f8e46dbb727ec542a494ebdb884ccd19a31ff4e091425b2d1fc01b0d4754a",
57
59
  "src/boot.js": "114f9cf0561bca17b042c8db2e72e6984f7a55fd8d19700730895935f226cc77",
58
60
  "src/calib-worker.js": "3d610591a1b3b58f59c3a2ab457f43c90727d95973cd43055b3b73b76180611d",
@@ -79,7 +81,7 @@
79
81
  "src/host/ckpt-save.js": "e222571033ae74a4439596c5e90e03b2aa9a5516665dd290f8dd243712294523",
80
82
  "src/host/ckpt.js": "17c3e3f39c7e4ab9b51a4d121d1017d943257f95357330d8f21140011562d0db",
81
83
  "src/host/clock.js": "aba6af3a8b1a985ae340892222e4b482d33afe20f5c8b3a4c7403ce87d1e247c",
82
- "src/host/ctl.js": "a2293b8cb53463323999e0d74277eeab6ebc580625db81b324ecbe2e07ddd98d",
84
+ "src/host/ctl.js": "654c55c64efa3df4df8b7b28024a10bd0e7ed3afb89004f8a583b9e62f17f58f",
83
85
  "src/host/errno.js": "26021d85962084be2c43c98fb3accafc2a8f11fbf3c8a5b8d484bd6a088ae4db",
84
86
  "src/host/fs.js": "d6753f74db35b7b01baa8709fc7fdcdfc8d638dfe42f454b0a833b22111e8f4a",
85
87
  "src/host/gcnudge.js": "4a6569cb73ae24a11fc4ac8c467380bd9efba0bb5fa9867b797bc4f153ab15ab",
@@ -107,9 +109,9 @@
107
109
  "src/net/relay-client.js": "4101de76dc0251db46839dc786cce677e4020795adf022bdce14bcf7edf8f864",
108
110
  "src/net/stack.js": "cd451cce7130f08e59cc19c961d2a5fca8416e577a40df1419ab6d5046018891",
109
111
  "src/net/switch.js": "bb047fb1ceeb10a84b5475916ead916362accf0e6f42a86561653dfad0c6c50d",
110
- "src/net/wisp-client.js": "51207a1fab6f0d2dc4cada17ecdc202db2ffa95f16c318459be79c0abb919faf",
112
+ "src/net/wisp-client.js": "653961c4b735f2f74e5ea89e75c387f15ec8b8d9ed4c45d06c7d6b2a04968d42",
111
113
  "src/net/wisp-transport.js": "2b71201f3c686078c867e8995bf5c1cd6d47a0d415f476f3a33fbd5729a55f74",
112
- "src/net/wisp.js": "f5740bd349cca6ca5bbb03ef392a0b2ce9f23c0ec8e07abe3286329fa209118d",
114
+ "src/net/wisp.js": "06c50159f416d40a912fe2cce4806388b938f2ab0220b2ee6da890630ce3d113",
113
115
  "src/registry/client.js": "fd38550e9afd82c5cb498513d245c33357a71697b3b2e6dd78538934bcc9d879",
114
116
  "src/registry/converter.js": "1fb8294fd5275f6772c27130cc9ba70bd46fafaf440884d42448036618229f37",
115
117
  "src/registry/hub.js": "22323e0f799a7078843514c9f87bbcdaa4173fd991ad4e059377befa773762d4",
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadedstack/wmd-web",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "license": "MIT",
5
5
  "homepage": "https://github.com/threadedstack/wmd-releases",
6
6
  "repository": {
@@ -9,9 +9,9 @@
9
9
  },
10
10
  "description": "Browser host for wmd's guest vm.wasm (Web Workers + shared memory + WASI preview1)",
11
11
  "wmd": {
12
- "tag": "v0.1.13",
13
- "commit": "23bf0595198cec6c2027017049b2caa39753efc2",
14
- "web": "0.3.1"
12
+ "tag": "v0.1.14",
13
+ "commit": "1fe3dd6c164b913eecd21d64097b285c51fc1401",
14
+ "web": "0.3.2"
15
15
  },
16
16
  "type": "module",
17
17
  "main": "./src/boot.js",
package/relay/engine.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  // The engine owns everything that is not the wire format:
4
4
  // - policy (policy.ts): names and addresses checked on the address actually dialled, the
5
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
6
+ // - principals (a guest identity) and their caps, each off unless set (plan §16): connections,
7
+ // connect/DNS/open rates, bandwidth buckets, total bytes; sessions per group; one live session per identity
8
8
  // - resolve-and-check, dial, the stream lifecycle and byte accounting
9
9
  // - logging (metadata only, never payloads)
10
10
  //
@@ -24,25 +24,31 @@ const PRINCIPAL_FLOOR_MS = 60 * 60_000
24
24
  export type ResolveFn = (name: string) => Promise<{ address: string; ttl: number }[]>
25
25
  export type DialFn = (host: string, port: number) => Socket
26
26
 
27
+ /** A refusal as the page hears it (wmd-reason): the rule's name, words, and when it lifts (ms since 1970, 0 = no time). */
28
+ export type Why = { rule: string; words: string; until?: number }
29
+
27
30
  /** 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
31
  export interface Guard {
29
32
  /** An extra reason to refuse a dialled address (NAT64, `--deny-cidr`). */
30
33
  blockedAddr(a: Addr): string | null
31
34
  /** A destination on the operator's deny list. */
32
35
  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. */
36
+ /** Before a connect is resolved: a refusal, a wait before the dial, or null. */
37
+ admitConnect(s: Session, host: string, port: number): Why | { waitMs: number } | null
38
+ /** Just before the dial: a socket for it. Sets `st.rel`. */
39
+ openStream(s: Session, st: Stream, host: string, port: number): Why | null
40
+ /** A dial failed after `ms` (refused, no route, timed out, or a name that does not exist). */
41
+ dialFailed(s: Session, ms: number): void
42
+ dialOk?(s: Session): void
43
+ dnsClosed?(): string | null // words when lookups are refused too (stop-new, drain, the month cap)
44
+ /** Bytes moved for a session; false when its streams must end (limitWhy says why). */
41
45
  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. */
46
+ limitWhy?(): Why
47
+ /** Of n bytes the session's own bucket let through, how many the guard's slowdowns allow. */
44
48
  take(s: Session, dir: 'down' | 'up', n: number): number
45
49
  waitMs(s: Session, dir: 'down' | 'up', n: number): number
50
+ /** The session's WebSocket is backed up. */
51
+ backpressure?(s: Session): void
46
52
  }
47
53
 
48
54
  export type EngineOptions = {
@@ -74,6 +80,7 @@ export type EngineOptions = {
74
80
  log?: (e: Record<string, unknown>) => void
75
81
  }
76
82
 
83
+ /** A token bucket of `rate` bytes a second, holding at most `rate`. Infinity is no limit. */
77
84
  export class Bucket {
78
85
  tokens: number
79
86
  last = performance.now()
@@ -86,6 +93,7 @@ export class Bucket {
86
93
  }
87
94
  /** Up to n bytes, or 0 when fewer than floor(n) are there (no trickle of tiny messages). */
88
95
  take(n: number): number {
96
+ if (this.rate === Infinity) return n
89
97
  const t = performance.now()
90
98
  this.tokens = Math.min(this.rate, this.tokens + ((t - this.last) / 1000) * this.rate)
91
99
  this.last = t
@@ -96,6 +104,7 @@ export class Bucket {
96
104
  }
97
105
  /** ms until n tokens are there. */
98
106
  waitMs(n: number): number {
107
+ if (this.rate === Infinity) return 1
99
108
  return Math.max(1, Math.ceil(((this.floor(n) - this.tokens) / this.rate) * 1000))
100
109
  }
101
110
  }
@@ -104,6 +113,7 @@ export class Window {
104
113
  times: number[] = []
105
114
  constructor(public max: number, public ms: number) {}
106
115
  hit(): boolean {
116
+ if (this.max === Infinity) return true
107
117
  const t = performance.now()
108
118
  while (this.times.length && t - this.times[0] > this.ms) this.times.shift()
109
119
  if (this.times.length >= this.max) return false
@@ -113,6 +123,7 @@ export class Window {
113
123
  }
114
124
 
115
125
  export type Stream = {
126
+ site?: string // the name the guest looked up for this address (siteOf), or the host it asked for
116
127
  id: number
117
128
  sock: Socket | null
118
129
  connected: boolean
@@ -128,6 +139,7 @@ export type Stream = {
128
139
  bytesDown: number
129
140
  bytesUp: number
130
141
  openedAt: number
142
+ dialAt?: number // when the lookup or dial began (after any wait)
131
143
  w?: any // adapter state for the stream
132
144
  rel?: () => void // the guard's release, called once when the stream is gone
133
145
  }
@@ -152,13 +164,13 @@ export type Principal = {
152
164
  export interface Wire {
153
165
  /** The stream is open; `detail` is "address:port" dialled. The adapter grants the first upload window (engine.takeUp / engine.owe). */
154
166
  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
167
+ /** The connect failed or was refused before the stream opened; `rule` and `until` when a rule refused it. */
168
+ refused(s: Session, id: number, code: number, why: string, rule?: string, until?: number): void
157
169
  data(s: Session, st: Stream, bytes: Uint8Array): void
158
170
  /** The remote closed cleanly (FIN) and every byte was sent. */
159
171
  end(s: Session, st: Stream): void
160
172
  /** The stream failed after it opened: never a clean end. */
161
- abort(s: Session, st: Stream, code: number, why: string): void
173
+ abort(s: Session, st: Stream, code: number, why: string, rule?: string, until?: number): void
162
174
  /** Upload credit: `n` more bytes of the stream may come (out of the up bucket). */
163
175
  grant(s: Session, st: Stream, n: number): void
164
176
  dnsReply(s: Session, id: number, rcode: number, ttl: number, addrs: Uint8Array[], why: string): void
@@ -167,6 +179,7 @@ export interface Wire {
167
179
  }
168
180
 
169
181
  export type Session = {
182
+ names?: Map<string, { name: string; exp: number }> // looked-up name by address, until the answer's TTL (the sites rule and the deny list by name)
170
183
  ws: any
171
184
  sid: string
172
185
  p: Principal
@@ -198,9 +211,9 @@ export function compileAllow(list: string[] | undefined): AllowEntry[] {
198
211
 
199
212
  /** Bandwidth buckets shared by several identities (Wisp: one client address, or one owner key). */
200
213
  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) })
214
+ export const newShared = (o: { rateDown?: number; rateUp?: number }): Shared => ({ down: new Bucket(o.rateDown ?? Infinity), up: new Bucket(o.rateUp ?? Infinity) })
202
215
 
203
- export type Admission = { ok: true; p: Principal } | { ok: false; status: number; body: string }
216
+ export type Admission = { ok: true; p: Principal } | { ok: false; status: number; body: string; rule: string }
204
217
 
205
218
  const ERR_CODE: Record<string, number> = {
206
219
  ECONNREFUSED: E.REFUSED, ETIMEDOUT: E.TIMEOUT, ECONNRESET: E.RESET, EPIPE: E.RESET,
@@ -213,10 +226,10 @@ export type Engine = ReturnType<typeof createEngine>
213
226
 
214
227
  export function createEngine(o: EngineOptions) {
215
228
  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])
229
+ const maxConns = o.maxConns ?? Infinity
230
+ const maxSessions = o.maxSessions ?? Infinity
231
+ const maxBytes = o.maxBytes ?? Infinity
232
+ const blockedPorts = new Set(o.blockedPorts ?? [])
220
233
  const allowPorts = o.allowPorts?.length ? new Set(o.allowPorts) : null
221
234
  const guard = o.guard
222
235
  const relStream = (st: Stream) => {
@@ -227,7 +240,7 @@ export function createEngine(o: EngineOptions) {
227
240
  const connectTimeoutMs = o.connectTimeoutMs ?? 10_000
228
241
  // allowlist entries: name[:port|port...]; no ports: 80 and 443
229
242
  const allow = compileAllow(o.allowHosts)
230
- const maxPerGroup = o.maxSessionsPerGroup ?? 8
243
+ const maxPerGroup = o.maxSessionsPerGroup ?? Infinity
231
244
  const testRoutes: Record<string, string> = Object.assign(Object.create(null), o.testRoutes) // no prototype keys ("constructor", "__proto__")
232
245
  const testHosts: Record<string, string> = Object.assign(Object.create(null), o.testHosts)
233
246
  let self = hostAddresses(o.selfAddrs)
@@ -267,8 +280,8 @@ export function createEngine(o: EngineOptions) {
267
280
  principals.set(uid, p)
268
281
  } else {
269
282
  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),
283
+ uid, grp, connects: new Window(o.connectsPerMin ?? Infinity, 60_000), dnsHits: new Window(o.dnsPerMin ?? Infinity, 60_000),
284
+ opens: new Window(o.opensPerMin ?? Infinity, 60_000), down: share?.down ?? new Bucket(o.rateDown ?? Infinity), up: share?.up ?? new Bucket(o.rateUp ?? Infinity),
272
285
  bytes: 0, exp, live: null, pins: new Map(), overBytes: false,
273
286
  }
274
287
  principals.set(uid, p)
@@ -293,12 +306,13 @@ export function createEngine(o: EngineOptions) {
293
306
  A.find(a => (a.name.startsWith('.') ? name.endsWith(a.name) || name === a.name.slice(1) : name === a.name))
294
307
  const allowed = (A: AllowEntry[], name: string) => !A.length || !!entryFor(A, name)
295
308
 
296
- function refuse(s: Session, id: number, code: number, why: string, to: string) {
309
+ function refuse(s: Session, id: number, code: number, why: string, to: string, rule?: string, until?: number) {
297
310
  const name = codeName(code)!
298
311
  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)
312
+ log({ ev: 'refused', sid: s.sid, id, to, code: name, why, ...(rule ? { rule } : {}) })
313
+ if (!s.closed) s.wire.refused(s, id, code, why, rule, until)
301
314
  }
315
+ const ruleOf = (code: number) => (code === E.BLOCKED ? 'blocked-address' : code === E.NOT_ALLOWED ? 'allowHosts' : undefined)
302
316
 
303
317
  /** The address to dial for a name: resolved here, filtered by the policy. */
304
318
  async function resolveChecked(s: Session, name: string, port?: number): Promise<{ addrs: { a: Addr; ttl: number }[]; rcode: number; why: string; code: number }> {
@@ -342,10 +356,9 @@ export function createEngine(o: EngineOptions) {
342
356
 
343
357
  async function dns(s: Session, id: number, name: string, qtype: number) {
344
358
  stats.dns++
345
- if (!s.p.dnsHits.hit()) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], 'dns rate limit')
359
+ if (!s.p.dnsHits.hit()) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], 'dns rate limit (dnsPerMin)')
346
360
  const closed = guard?.dnsClosed?.()
347
361
  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
362
  if (qtype !== 1) return s.wire.dnsReply(s, id, RCODE.NOERROR, 60, [], 'only A')
350
363
  let r: Awaited<ReturnType<typeof resolveChecked>>
351
364
  try {
@@ -356,26 +369,49 @@ export function createEngine(o: EngineOptions) {
356
369
  }
357
370
  if (r.rcode !== RCODE.NOERROR || r.why) log({ ev: 'dns', sid: s.sid, name, rcode: r.rcode, why: r.why })
358
371
  const ttl = r.addrs.length ? Math.min(...r.addrs.map(x => x.ttl)) : 30
372
+ if (r.addrs.length) remember(s, name, r.addrs.slice(0, 16), ttl)
359
373
  if (!s.closed) s.wire.dnsReply(s, id, r.rcode, ttl, r.addrs.slice(0, 16).map(x => x.a.b), r.why)
360
374
  }
361
375
 
376
+ /** The names this session looked up, by the address each gave, for as long as the answer's DNS lifetime (TTL). A guest
377
+ connects to the address its lookup returned; the sites rule and the deny list by name go by the name it asked for. */
378
+ function remember(s: Session, name: string, addrs: { a: Addr }[], ttl: number) {
379
+ const now = Date.now()
380
+ const m = (s.names ??= new Map())
381
+ for (const [k, v] of m) if (v.exp <= now) m.delete(k)
382
+ const n = name.toLowerCase().replace(/\.$/, '')
383
+ for (const x of addrs) m.set(addrKey(x.a), { name: n, exp: now + ttl * 1000 })
384
+ }
385
+ /** The name a connect to `host` stands for: the looked-up name of that address, else `host` itself. */
386
+ function siteOf(s: Session, host: string): string {
387
+ const a = s.names && parseIP(host)
388
+ const hit = a ? s.names!.get(addrKey(a)) : undefined
389
+ return hit && hit.exp > Date.now() ? hit.name : host
390
+ }
391
+
362
392
  async function connect(s: Session, id: number, host: string, port: number) {
363
393
  const to = `${host}:${port}`
364
394
  const A = listFor(s)
365
395
  if (s.streams.has(id)) return refuse(s, id, E.BAD, 'stream id in use', to)
366
396
  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)
397
+ if (blockedPorts.has(port)) return refuse(s, id, E.PORT, `port ${port} is blocked`, to, 'blocked-port')
398
+ if (allowPorts && !allowPorts.has(port)) return refuse(s, id, E.PORT, `port ${port} is not allowed on this relay`, to, 'allowPorts')
399
+ if (s.streams.size >= maxConns) return refuse(s, id, E.LIMIT, `more than ${maxConns} connections at once`, to, 'maxConns')
400
+ if (!s.p.connects.hit()) return refuse(s, id, E.LIMIT, 'connect rate limit', to, 'connectsPerMin')
401
+ const site = siteOf(s, host)
402
+ if (guard?.isDeniedDest(host, port) || (site !== host && guard?.isDeniedDest(site, port))) return refuse(s, id, E.BLOCKED, 'this destination is blocked by the operator', to, 'denied-destination')
403
+ const held = guard?.admitConnect(s, site, port)
404
+ if (held && 'rule' in held) return refuse(s, id, E.LIMIT, held.words, to, held.rule, held.until)
374
405
  const st: Stream = {
375
406
  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(),
407
+ clientEnded: false, closed: false, to, site, bytesDown: 0, bytesUp: 0, openedAt: performance.now(),
377
408
  }
378
409
  s.streams.set(id, st) // reserve the id while resolving
410
+ if (held?.waitMs) {
411
+ await new Promise(r => setTimeout(r, held.waitMs))
412
+ if (st.closed || s.closed) return
413
+ }
414
+ st.dialAt = performance.now()
379
415
  let addr = parseIP(host)
380
416
  let dialHost: string
381
417
  let dialPort = port
@@ -385,13 +421,13 @@ export function createEngine(o: EngineOptions) {
385
421
  const why = route ? null : whyBlocked(addr, port)
386
422
  if (why) {
387
423
  s.streams.delete(id)
388
- return refuse(s, id, E.BLOCKED, `${key} is ${why}`, to)
424
+ return refuse(s, id, E.BLOCKED, `${key} is ${why}`, to, 'blocked-address')
389
425
  }
390
426
  if (A.length && !route) {
391
427
  const exp = s.p.pins.get(`${key}:${port}`)
392
428
  if (!exp || exp < Date.now()) {
393
429
  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)
430
+ return refuse(s, id, E.NOT_ALLOWED, `${key}:${port} was not resolved from an allowed name (for this port) by this guest`, to, 'allowHosts')
395
431
  }
396
432
  }
397
433
  if (route) {
@@ -415,12 +451,12 @@ export function createEngine(o: EngineOptions) {
415
451
  if (st.closed || s.closed) return
416
452
  if (r.addrs.length && A.length && !testHosts[host.toLowerCase().replace(/\.$/, '')] && !entryFor(A, host.toLowerCase().replace(/\.$/, ''))?.ports.has(port)) {
417
453
  s.streams.delete(id)
418
- return refuse(s, id, E.NOT_ALLOWED, `port ${port} is not allowed for ${host}`, to)
454
+ return refuse(s, id, E.NOT_ALLOWED, `port ${port} is not allowed for ${host}`, to, 'allowHosts')
419
455
  }
420
456
  if (!r.addrs.length) {
421
457
  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)
458
+ if (r.code === E.DNS && r.rcode === RCODE.NXDOMAIN) guard?.dialFailed(s, performance.now() - st.dialAt!)
459
+ return refuse(s, id, r.code || E.DNS, r.why || 'no address', to, ruleOf(r.code))
424
460
  }
425
461
  addr = r.addrs[0].a
426
462
  const route = testRoutes[`${addrKey(addr)}:${port}`]
@@ -434,7 +470,7 @@ export function createEngine(o: EngineOptions) {
434
470
  const full = guard?.openStream(s, st, host, port)
435
471
  if (full) {
436
472
  s.streams.delete(id)
437
- return refuse(s, id, E.LIMIT, full, to)
473
+ return refuse(s, id, E.LIMIT, full.words, to, full.rule, full.until)
438
474
  }
439
475
  stats.connects++
440
476
  let sock: Socket
@@ -442,7 +478,7 @@ export function createEngine(o: EngineOptions) {
442
478
  sock = dial(dialHost, dialPort)
443
479
  } catch (e: any) {
444
480
  relStream(st)
445
- guard?.dialFailed(s)
481
+ guard?.dialFailed(s, performance.now() - st.dialAt!)
446
482
  s.streams.delete(id)
447
483
  return refuse(s, id, E.UNREACHABLE, e?.message || 'dial failed', to)
448
484
  }
@@ -457,6 +493,7 @@ export function createEngine(o: EngineOptions) {
457
493
  clearTimeout(timer)
458
494
  if (st.closed) return sock.destroy()
459
495
  st.connected = true
496
+ guard?.dialOk?.(s)
460
497
  log({ ev: 'connect', sid: s.sid, id, to, via: `${dialHost}:${dialPort}`, ms: Math.round(performance.now() - st.openedAt) })
461
498
  if (!s.closed) s.wire.connected(s, st, `${addrKey(addr!)}:${port}`)
462
499
  })
@@ -504,7 +541,10 @@ export function createEngine(o: EngineOptions) {
504
541
  function pump(s: Session, st: Stream) {
505
542
  if (st.closed || s.closed) return
506
543
  while (st.outBytes > 0 && st.clientCredit > 0) {
507
- if (s.ws.getBufferedAmount?.() > 8 << 20) break // the drain handler pumps again
544
+ if (s.ws.getBufferedAmount?.() > 8 << 20) {
545
+ guard?.backpressure?.(s)
546
+ break // the drain handler pumps again
547
+ }
508
548
  const want = Math.min(st.outBytes, st.clientCredit, MAX_DATA)
509
549
  let n = s.p.down.take(want)
510
550
  if (!n) {
@@ -563,20 +603,20 @@ export function createEngine(o: EngineOptions) {
563
603
  }, ms)
564
604
  }
565
605
 
566
- /** The hosted profile's per-hour bytes for the session's address group: past them the session's streams end. */
606
+ /** The guard's bytes (the hosted month): when it says no, the session's streams end. */
567
607
  function overHour(s: Session, n: number): boolean {
568
608
  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)
609
+ const why = guard.limitWhy?.() ?? { rule: 'limit', words: 'the relay limited this session' }
610
+ log({ ev: 'limit', sid: s.sid, why: why.words, rule: why.rule })
611
+ for (const st of [...s.streams.values()]) fail(s, st, E.LIMIT, why.words, why.rule, why.until)
572
612
  return true
573
613
  }
574
614
 
575
615
  function overBytes(s: Session): boolean {
576
616
  if (s.p.bytes <= maxBytes) return false
577
617
  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')
618
+ log({ ev: 'limit', sid: s.sid, why: 'session byte limit', rule: 'maxBytes' })
619
+ for (const st of [...s.streams.values()]) fail(s, st, E.LIMIT, 'session byte limit', 'maxBytes')
580
620
  s.ws.close(4008, 'session byte limit')
581
621
  return true
582
622
  }
@@ -636,16 +676,16 @@ export function createEngine(o: EngineOptions) {
636
676
  })
637
677
  }
638
678
 
639
- function fail(s: Session, st: Stream, code: number, why: string) {
679
+ function fail(s: Session, st: Stream, code: number, why: string, rule?: string, until?: number) {
640
680
  if (st.closed) return
641
681
  st.closed = true
642
682
  relStream(st)
643
683
  s.streams.delete(st.id)
644
684
  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)
685
+ if (!st.connected && guard && st.sock && (code === E.REFUSED || code === E.UNREACHABLE || code === E.TIMEOUT)) guard.dialFailed(s, performance.now() - (st.dialAt ?? st.openedAt))
686
+ if (!st.connected) return refuse(s, st.id, code, why, st.to, rule, until)
687
+ log({ ev: 'abort', sid: s.sid, id: st.id, to: st.to, code: codeName(code), why, down: st.bytesDown, up: st.bytesUp, ...(rule ? { rule } : {}) })
688
+ if (!s.closed) s.wire.abort(s, st, code, why, rule, until)
649
689
  }
650
690
 
651
691
  function maybeDone(s: Session, st: Stream) {
@@ -699,22 +739,22 @@ export function createEngine(o: EngineOptions) {
699
739
  const replaces = !!known?.live
700
740
  if (sessions.size >= maxSessions && !replaces) {
701
741
  stats.refusedSessions++
702
- return { ok: false, status: 503, body: 'too many sessions\n' }
742
+ return { ok: false, status: 503, body: 'too many sessions\n', rule: 'maxSessions' }
703
743
  }
704
744
  if ((groupLive.get(grp) || 0) >= maxPerGroup && !replaces) {
705
745
  stats.refusedSessions++
706
746
  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` }
747
+ return { ok: false, status: 429, body: `more than ${maxPerGroup} sessions for this client\n`, rule: 'maxSessionsPerGroup' }
708
748
  }
709
749
  const p = principal(uid, grp, exp, share)
710
750
  if (p.overBytes) {
711
751
  stats.refusedSessions++
712
- return { ok: false, status: 429, body: 'byte limit reached for this guest\n' }
752
+ return { ok: false, status: 429, body: 'byte limit reached for this guest\n', rule: 'maxBytes' }
713
753
  }
714
754
  if (!p.opens.hit()) {
715
755
  stats.refusedSessions++
716
756
  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' }
757
+ return { ok: false, status: 429, body: 'too many connections for this guest; wait\n', rule: 'opensPerMin' }
718
758
  }
719
759
  return { ok: true, p }
720
760
  }
@@ -766,9 +806,11 @@ export function createEngine(o: EngineOptions) {
766
806
  log({ ev: 'session-end', sid: s.sid, why, bytes: s.bytes })
767
807
  }
768
808
 
809
+ /** The v1 hello's limits; null where there is none. */
769
810
  function limits(p: Principal, A: AllowEntry[] = allow) {
811
+ const n = (v: number) => (Number.isFinite(v) ? v : null)
770
812
  return {
771
- maxConns, window: WINDOW, rateDown: p.down.rate, rateUp: p.up.rate, bytesLeft: Math.max(0, maxBytes - p.bytes),
813
+ maxConns: n(maxConns), window: WINDOW, rateDown: n(p.down.rate), rateUp: n(p.up.rate), bytesLeft: n(Math.max(0, maxBytes - p.bytes)),
772
814
  allowHosts: A.length ? A.map(a => `${a.name}:${[...a.ports].join('|')}`) : null,
773
815
  }
774
816
  }