@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 +11 -0
- package/RELEASE.json +13 -11
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/package.json +4 -4
- package/relay/engine.ts +103 -61
- package/relay/hosted.ts +437 -392
- package/relay/main.ts +61 -33
- package/relay/relay.ts +37 -25
- package/relay/wisp.ts +111 -51
- package/src/host/ctl.js +45 -4
- package/src/net/wisp-client.js +71 -17
- package/src/net/wisp.js +52 -6
- package/wmdpack.wasm +0 -0
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.
|
|
4
|
-
"commit": "
|
|
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": "
|
|
41
|
-
"bytes":
|
|
42
|
+
"sha256": "a2260db90e5d8acdf12a2123f696b4512a1465856d5653e20a85504069a3480c",
|
|
43
|
+
"bytes": 4685323
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
},
|
|
45
47
|
"files": {
|
|
46
|
-
"README.md": "
|
|
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": "
|
|
55
|
+
"guest/arm64/initramfs.cpio.gz": "a2260db90e5d8acdf12a2123f696b4512a1465856d5653e20a85504069a3480c",
|
|
54
56
|
"guest/arm64/vm.wasm": "f7affc474658fdfdc50e96c29a224eed1520ea69b426d6427fbc058eb141091a",
|
|
55
|
-
"package.json": "
|
|
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": "
|
|
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": "
|
|
112
|
+
"src/net/wisp-client.js": "653961c4b735f2f74e5ea89e75c387f15ec8b8d9ed4c45d06c7d6b2a04968d42",
|
|
111
113
|
"src/net/wisp-transport.js": "2b71201f3c686078c867e8995bf5c1cd6d47a0d415f476f3a33fbd5729a55f74",
|
|
112
|
-
"src/net/wisp.js": "
|
|
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.
|
|
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
|
-
"commit": "
|
|
14
|
-
"web": "0.3.
|
|
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,
|
|
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:
|
|
34
|
-
admitConnect(s: Session, host: string, port: number):
|
|
35
|
-
/** Just before the dial:
|
|
36
|
-
openStream(s: Session, st: Stream, host: string, port: number):
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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?():
|
|
43
|
-
/** Of n bytes the session's own bucket let through, how many the
|
|
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 ??
|
|
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 ??
|
|
217
|
-
const maxSessions = o.maxSessions ??
|
|
218
|
-
const maxBytes = o.maxBytes ??
|
|
219
|
-
const blockedPorts = new Set(o.blockedPorts ?? [
|
|
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 ??
|
|
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 ??
|
|
271
|
-
opens: new Window(o.opensPerMin ??
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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)
|
|
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
|
|
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?.() ??
|
|
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
|
}
|