@threadedstack/wmd-web 0.0.0-stage → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- package/wmdpack.wasm +0 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// W59: the browser side of Wisp (wisp.js), a transport for the network Worker with the same
|
|
2
|
+
// interface as RelayClient (relay-client.js): the stack (stack.js) calls connect / data / end /
|
|
3
|
+
// abort / credit / dns and is called back with onConnected, onRelayData, onRelayEnd,
|
|
4
|
+
// onRelayAbort, onRelayCredit, onDnsReply, onRelayDown. NetStack does not change; the Wisp
|
|
5
|
+
// specifics live here:
|
|
6
|
+
//
|
|
7
|
+
// - upload: Wisp counts packets, not bytes. The stack gets WINDOW bytes of credit when a stream
|
|
8
|
+
// opens and gets back each packet's bytes when the packet leaves here; packets past the
|
|
9
|
+
// stream's counter wait in its queue until a CONTINUE resets the counter (plan §3).
|
|
10
|
+
// - download: with wmd-credit (0xE1) the stack's credit goes to the relay as CREDIT; without it
|
|
11
|
+
// the relay sends as fast as it reads and a stream holding more than DOWN_CAP undelivered
|
|
12
|
+
// bytes (or the session, SESSION_CAP) is reset with a log line.
|
|
13
|
+
// - half-close: with wmd-halfclose (0xE2) a guest FIN is SHUTDOWN; without it the FIN is held
|
|
14
|
+
// until the remote closes. A remote SHUTDOWN, or CLOSE 0x02, is a FIN; every other CLOSE is a reset.
|
|
15
|
+
// - names: the guest's lookups get placeholder addresses from 198.18.0.0/15; a connect to one
|
|
16
|
+
// is a CONNECT to the name (plan §4). With wmd-dns (0xE4) the relay answers first, so
|
|
17
|
+
// NXDOMAIN and refusals reach the guest; without it every valid name gets a placeholder.
|
|
18
|
+
// - status: onStatus({state, relay, reason, words}) for the page (plan §6).
|
|
19
|
+
//
|
|
20
|
+
// A drop of the WebSocket fails every stream (onRelayDown), as with v1.
|
|
21
|
+
|
|
22
|
+
import { E, RCODE as V1_RCODE, WINDOW } from './proto.js'
|
|
23
|
+
import {
|
|
24
|
+
CLOSE, EXT, PT, RCODE, SUBPROTOCOL, ClientHandshake, answerAddrs, closePacket, closeWords, connectPacket, creditPacket,
|
|
25
|
+
dataPacket, ext, lookupPacket, parse, shutdownPacket,
|
|
26
|
+
} from './wisp.js'
|
|
27
|
+
|
|
28
|
+
/** `relay: {url: 'local'}`: a `wmd-relay` started with no arguments on this computer. */
|
|
29
|
+
export const LOCAL_RELAY_URL = 'ws://127.0.0.1:8780/wisp/'
|
|
30
|
+
/** `relay: {url: 'hosted'}`: the public relay we run (`wmd-relay --hosted`, docs/relay-hosted.md). A page that uses it
|
|
31
|
+
needs `wss://relay.threadedstack.com` in its CSP connect-src. Set to '' and 'hosted' is unreachable, in words. */
|
|
32
|
+
export const HOSTED_RELAY_URL = 'wss://relay.threadedstack.com/wisp/'
|
|
33
|
+
|
|
34
|
+
const BACKOFF_MIN_MS = 250
|
|
35
|
+
const BACKOFF_MAX_MS = 8000
|
|
36
|
+
const CLOSE_MAINTENANCE = 1001 // a hosted relay's drain (WebSocket 1001 "the relay is closed for maintenance")
|
|
37
|
+
const CLOSE_DENIED = 4011 // a hosted relay's operator denied this address or page origin
|
|
38
|
+
const OPEN_TIMEOUT_MS = 10_000
|
|
39
|
+
const QUEUE_MAX_MS = 8000
|
|
40
|
+
export const DOWN_CAP = 64 << 20 // bytes delivered and not yet taken by the guest, per stream, without wmd-credit
|
|
41
|
+
export const SESSION_CAP = 256 << 20 // the same, for every stream together
|
|
42
|
+
export const NAME_CAP = 65536 // placeholder addresses per machine; never recycled
|
|
43
|
+
const PH_BASE = (198 << 24) | (18 << 16) // 198.18.0.0/15
|
|
44
|
+
const PH_MASK = 0xfffe0000
|
|
45
|
+
const NAME_TTL = 60
|
|
46
|
+
const CREDIT_SLACK = 64 << 10 // bytes a relay with wmd-credit may run past the credit it was given before it is cut off
|
|
47
|
+
const LOOKUPS_MAX = 1024 // lookups waiting for the relay's answer
|
|
48
|
+
const MOTD_MAX = 400 // characters of the relay's message shown in the status
|
|
49
|
+
const MALFORMED_LOGGED = 8 // malformed packets logged in full; after that every 1000th
|
|
50
|
+
const DEGRADED = { [EXT.CONFIRM]: 'open confirmation', [EXT.WMD_CREDIT]: 'download flow control', [EXT.WMD_HALFCLOSE]: 'half-close', [EXT.WMD_DNS]: 'name lookups' }
|
|
51
|
+
|
|
52
|
+
/** The URL a relay config names ('local', 'hosted', or a ws(s) URL); '' when none is configured. */
|
|
53
|
+
export function relayUrl(url) {
|
|
54
|
+
if (url === 'local') return LOCAL_RELAY_URL
|
|
55
|
+
if (url === 'hosted') return HOSTED_RELAY_URL
|
|
56
|
+
return url || ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ipU32 = (s) => {
|
|
60
|
+
const p = s.split('.')
|
|
61
|
+
if (p.length !== 4) return -1
|
|
62
|
+
let v = 0
|
|
63
|
+
for (const x of p) {
|
|
64
|
+
if (!/^\d{1,3}$/.test(x) || +x > 255) return -1
|
|
65
|
+
v = v * 256 + +x
|
|
66
|
+
}
|
|
67
|
+
return v >>> 0
|
|
68
|
+
}
|
|
69
|
+
const u32Ip = (v) => `${v >>> 24}.${(v >>> 16) & 255}.${(v >>> 8) & 255}.${v & 255}`
|
|
70
|
+
export const isPlaceholder = (ip) => {
|
|
71
|
+
const v = ipU32(ip)
|
|
72
|
+
return v >= 0 && ((v & PH_MASK) >>> 0) === PH_BASE >>> 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A host name the guest could look up (RFC 1123 labels); a bad one is NXDOMAIN without asking anyone. */
|
|
76
|
+
export function validName(n) {
|
|
77
|
+
if (!n || n.length > 253 || n.includes('..')) return false
|
|
78
|
+
return n.split('.').every((l) => /^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$/i.test(l))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A Wisp CLOSE reason as the stack's E code. */
|
|
82
|
+
function eCode(reason) {
|
|
83
|
+
switch (reason) {
|
|
84
|
+
case CLOSE.INVALID: return E.BAD
|
|
85
|
+
case CLOSE.UNREACHABLE: return E.UNREACHABLE
|
|
86
|
+
case CLOSE.TIMEOUT: case CLOSE.DATA_TIMEOUT: return E.TIMEOUT
|
|
87
|
+
case CLOSE.REFUSED: return E.REFUSED
|
|
88
|
+
case CLOSE.BLOCKED: return E.BLOCKED
|
|
89
|
+
case CLOSE.THROTTLED: return E.LIMIT
|
|
90
|
+
case CLOSE.AUTH_BAD_PASSWORD: case CLOSE.AUTH_BAD_SIGNATURE: case CLOSE.AUTH_REQUIRED: return E.GONE
|
|
91
|
+
default: return E.RESET
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const KNOWN_RCODES = new Set([RCODE.SERVFAIL, RCODE.NXDOMAIN, RCODE.REFUSED]) // any other rcode from a relay is a failure of the lookup
|
|
96
|
+
|
|
97
|
+
const hexBytes = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16))
|
|
98
|
+
|
|
99
|
+
export class WispClient {
|
|
100
|
+
/**
|
|
101
|
+
* @param {object} o
|
|
102
|
+
* @param {string} o.url ws(s)://host:port/wisp/, 'local' or 'hosted'
|
|
103
|
+
* @param {string} [o.token] bundled relay: the session token (subprotocol "t.<token>")
|
|
104
|
+
* @param {string} [o.tokenUrl] fetch a fresh token before each open (JSON {token}), with ?instance=
|
|
105
|
+
* @param {{id: string, secret: string}} [o.key] an owner key (Wisp password auth)
|
|
106
|
+
* @param {string} [o.instance] this machine's id, 32 hex digits (kept across restores; plan §3)
|
|
107
|
+
* @param {[string, string][]} [o.names] the placeholder map so far (name, address), from the page
|
|
108
|
+
* @param {(name: string, ip: string) => void} [o.onName] a new placeholder (the page keeps the map)
|
|
109
|
+
* @param {(s: object) => void} [o.onStatus]
|
|
110
|
+
* @param {typeof WebSocket} [o.WebSocket]
|
|
111
|
+
* @param {(line: string) => void} [o.log]
|
|
112
|
+
*/
|
|
113
|
+
constructor(o) {
|
|
114
|
+
this.url = relayUrl(o.url)
|
|
115
|
+
this.configured = o.url
|
|
116
|
+
this.token = o.token || ''
|
|
117
|
+
this.tokenUrl = o.tokenUrl || ''
|
|
118
|
+
this.key = o.key || null
|
|
119
|
+
this.instance = /^[0-9a-f]{32}$/.test(o.instance || '') ? o.instance : Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, '0')).join('')
|
|
120
|
+
this.WS = o.WebSocket || globalThis.WebSocket
|
|
121
|
+
this.log = o.log || (() => {})
|
|
122
|
+
this.now = o.now || (() => performance.now())
|
|
123
|
+
this.openTimeoutMs = o.openTimeoutMs || OPEN_TIMEOUT_MS
|
|
124
|
+
this.onName = o.onName || (() => {})
|
|
125
|
+
this.onStatus = o.onStatus || (() => {})
|
|
126
|
+
this.stack = null
|
|
127
|
+
this.ws = null
|
|
128
|
+
this.up = false
|
|
129
|
+
this.connecting = false
|
|
130
|
+
this.queue = [] // {kind: 'connect' | 'dns', id, ..., at} waiting for the socket
|
|
131
|
+
this.backoff = 0
|
|
132
|
+
this.nextAt = 0
|
|
133
|
+
this.timer = null
|
|
134
|
+
this.hs = null // the handshake's result: {version, buffer, server, common, motd}
|
|
135
|
+
this.server = null // the last relay's extensions (known after a first handshake)
|
|
136
|
+
this.streams = new Map() // id -> {confirmed, counter, held: [], ended, shut, owed}
|
|
137
|
+
this.held = 0 // bytes delivered without wmd-credit and not yet taken, all streams
|
|
138
|
+
this.names = new Map() // name -> ip
|
|
139
|
+
this.ips = new Map() // ip -> name
|
|
140
|
+
this.nextName = 1
|
|
141
|
+
this.lookups = new Map() // id -> {name, at} sent to the relay, not yet answered
|
|
142
|
+
for (const [n, ip] of o.names || []) if (this.names.size < NAME_CAP) this.#learn(n, ip)
|
|
143
|
+
this.status = null
|
|
144
|
+
this.stats = { opens: 0, openFailures: 0, drops: 0, sent: 0, received: 0, lastClose: null, lastError: null, v1Fallbacks: 0, downCapResets: 0, creditViolations: 0, malformed: 0, unknownPlaceholders: 0 }
|
|
145
|
+
if (!this.url) this.#status('unreachable', CLOSE.UNKNOWN, o.url === 'hosted' ? 'no hosted relay is configured' : 'no relay URL is configured')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
attach(stack) {
|
|
149
|
+
this.stack = stack
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#status(state, reason, words) {
|
|
153
|
+
let relay = ''
|
|
154
|
+
try {
|
|
155
|
+
relay = this.url ? new URL(this.url).origin.replace(/^http/, 'ws') : ''
|
|
156
|
+
} catch {}
|
|
157
|
+
this.status = { state, relay, reason: reason ?? 0, words: words || '' }
|
|
158
|
+
this.onStatus(this.status)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#learn(name, ip) {
|
|
162
|
+
// only an address of 198.18.0.0/15 that no other name has is a placeholder (a bad entry would move nextName out of range)
|
|
163
|
+
if (typeof name !== 'string' || typeof ip !== 'string' || !isPlaceholder(ip) || this.ips.has(ip) || this.names.has(name)) return
|
|
164
|
+
this.names.set(name, ip)
|
|
165
|
+
this.ips.set(ip, name)
|
|
166
|
+
const v = ipU32(ip)
|
|
167
|
+
if (v >= 0) this.nextName = Math.max(this.nextName, ((v - PH_BASE) >>> 0) + 1)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The placeholder for a name (made now if new); null when every address is used. */
|
|
171
|
+
#placeholder(name) {
|
|
172
|
+
let ip = this.names.get(name)
|
|
173
|
+
if (ip) return ip
|
|
174
|
+
if (this.names.size >= NAME_CAP || this.nextName >= 1 << 17) {
|
|
175
|
+
if (!this.namesFull) {
|
|
176
|
+
this.namesFull = true
|
|
177
|
+
this.log(`net: ${NAME_CAP} names looked up: no addresses left for new names until the machine restarts`)
|
|
178
|
+
this.#status('degraded', 0, `${NAME_CAP} names looked up: new names fail until the machine restarts`)
|
|
179
|
+
}
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
ip = u32Ip((PH_BASE + this.nextName++) >>> 0)
|
|
183
|
+
this.#learn(name, ip)
|
|
184
|
+
this.onName(name, ip)
|
|
185
|
+
return ip
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The placeholder map, for a checkpoint. */
|
|
189
|
+
nameList() {
|
|
190
|
+
return [...this.names]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#raw(b) {
|
|
194
|
+
this.stats.sent += b.length
|
|
195
|
+
this.ws.send(b)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Open the socket if it is not open (now, or when the backoff allows). */
|
|
199
|
+
wake() {
|
|
200
|
+
if (!this.url || this.up || this.connecting || this.timer) return
|
|
201
|
+
const wait = this.nextAt - this.now()
|
|
202
|
+
if (wait > 0) {
|
|
203
|
+
this.timer = setTimeout(() => {
|
|
204
|
+
this.timer = null
|
|
205
|
+
this.#open()
|
|
206
|
+
}, wait)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
this.#open()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #open() {
|
|
213
|
+
if (this.up || this.connecting) return
|
|
214
|
+
this.connecting = true
|
|
215
|
+
this.#status('connecting')
|
|
216
|
+
let token = this.token
|
|
217
|
+
try {
|
|
218
|
+
if (this.tokenUrl) {
|
|
219
|
+
const u = new URL(this.tokenUrl, globalThis.location?.href)
|
|
220
|
+
u.searchParams.set('instance', this.instance)
|
|
221
|
+
const r = await fetch(u, { cache: 'no-store', signal: AbortSignal.timeout(this.openTimeoutMs) })
|
|
222
|
+
if (!r.ok) throw new Error(`token: GET ${this.tokenUrl}: ${r.status}`)
|
|
223
|
+
token = (await r.json()).token
|
|
224
|
+
if (token !== undefined && typeof token !== 'string') throw new Error(`token: GET ${this.tokenUrl}: the answer has no text token`)
|
|
225
|
+
}
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return this.#failed(e.message)
|
|
228
|
+
}
|
|
229
|
+
// B8: offer wisp-v2; a server that fails the WebSocket handshake for it gets one retry as
|
|
230
|
+
// Wisp v1 (no subprotocol). A token needs its subprotocol, so there is no retry with one.
|
|
231
|
+
this.#socket(token ? [SUBPROTOCOL, `t.${token}`] : [SUBPROTOCOL], !token)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#socket(protocols, mayRetry) {
|
|
235
|
+
let ws
|
|
236
|
+
try {
|
|
237
|
+
ws = protocols.length ? new this.WS(this.url, protocols) : new this.WS(this.url)
|
|
238
|
+
} catch (e) {
|
|
239
|
+
return this.#failed(e.message)
|
|
240
|
+
}
|
|
241
|
+
ws.binaryType = 'arraybuffer'
|
|
242
|
+
this.ws = ws
|
|
243
|
+
let opened = false
|
|
244
|
+
const hs = new ClientHandshake({
|
|
245
|
+
exts: [ext.confirm(), ext.wmdCredit(WINDOW), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance(hexBytes(this.instance))],
|
|
246
|
+
password: this.key ? { user: this.key.id, pass: this.key.secret } : null,
|
|
247
|
+
})
|
|
248
|
+
const openTimer = setTimeout(() => !this.up && this.ws === ws && ws.close(), this.openTimeoutMs)
|
|
249
|
+
let authFail = null
|
|
250
|
+
ws.onopen = () => {
|
|
251
|
+
if (this.ws === ws) opened = true
|
|
252
|
+
}
|
|
253
|
+
ws.onmessage = (e) => {
|
|
254
|
+
if (this.ws !== ws || typeof e.data === 'string') return
|
|
255
|
+
const u8 = new Uint8Array(e.data)
|
|
256
|
+
this.stats.received += u8.length
|
|
257
|
+
if (!this.up) {
|
|
258
|
+
const r = hs.step(parse(u8))
|
|
259
|
+
for (const b of r.send) this.#raw(b)
|
|
260
|
+
if (r.fail) {
|
|
261
|
+
authFail = r.fail
|
|
262
|
+
ws.close(1000, 'handshake failed')
|
|
263
|
+
} else if (r.done) {
|
|
264
|
+
clearTimeout(openTimer)
|
|
265
|
+
this.#ready(r.done)
|
|
266
|
+
}
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
this.#dispatch(parse(u8))
|
|
270
|
+
}
|
|
271
|
+
ws.onerror = () => {
|
|
272
|
+
this.stats.lastError = 'websocket error'
|
|
273
|
+
}
|
|
274
|
+
ws.onclose = (e) => {
|
|
275
|
+
clearTimeout(openTimer)
|
|
276
|
+
if (this.ws !== ws) return
|
|
277
|
+
const wasUp = this.up
|
|
278
|
+
this.ws = null
|
|
279
|
+
this.up = false
|
|
280
|
+
this.stats.lastClose = { code: e.code, reason: e.reason, at: Math.round(this.now()) }
|
|
281
|
+
if (wasUp) {
|
|
282
|
+
this.stats.drops++
|
|
283
|
+
this.streams.clear()
|
|
284
|
+
this.lookups.clear() // the stack fails its lookups in onRelayDown
|
|
285
|
+
this.held = 0
|
|
286
|
+
// a relay that closed us on purpose (maintenance, 1001; denied by its operator, 4011) is not back in a moment: wait the longest
|
|
287
|
+
this.backoff = e.code === CLOSE_MAINTENANCE || e.code === CLOSE_DENIED ? BACKOFF_MAX_MS : BACKOFF_MIN_MS
|
|
288
|
+
this.nextAt = this.now() + this.backoff
|
|
289
|
+
const why = `relay connection closed (${e.code}${e.reason ? ` ${e.reason}` : ''})`
|
|
290
|
+
this.log(`net: the relay connection closed (${e.code}${e.reason ? ` ${e.reason}` : ''}): every connection is reset`)
|
|
291
|
+
this.#status('down', 0, why)
|
|
292
|
+
this.stack?.onRelayDown(why)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
if (!opened && mayRetry) {
|
|
296
|
+
this.stats.v1Fallbacks++
|
|
297
|
+
this.log('net: the relay refused the wisp-v2 subprotocol; trying Wisp v1')
|
|
298
|
+
return this.#socket([], false)
|
|
299
|
+
}
|
|
300
|
+
if (authFail) {
|
|
301
|
+
const st = ['auth-required', 'auth-refused', 'version'].includes(authFail.state) ? authFail.state : 'unreachable'
|
|
302
|
+
this.backoff = BACKOFF_MAX_MS // a wrong or missing key does not fix itself: no tight loop
|
|
303
|
+
return this.#failed(authFail.words, st, authFail.reason)
|
|
304
|
+
}
|
|
305
|
+
this.#failed(`could not connect to the relay (${e.code}${e.reason ? ` ${e.reason}` : ''})`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#ready(done) {
|
|
310
|
+
this.connecting = false
|
|
311
|
+
this.up = true
|
|
312
|
+
this.backoff = 0
|
|
313
|
+
this.stats.opens++
|
|
314
|
+
this.hs = done
|
|
315
|
+
this.server = done.common
|
|
316
|
+
const motd = (done.motd || '').slice(0, MOTD_MAX)
|
|
317
|
+
const missing = Object.keys(DEGRADED).map(Number).filter((id) => !done.common.has(id))
|
|
318
|
+
if (missing.length) {
|
|
319
|
+
const words = `this relay does not support ${missing.map((id) => DEGRADED[id]).join(', ')} (Wisp ${done.version}${motd ? `; ${motd}` : ''})`
|
|
320
|
+
this.log(`net: ${words}`)
|
|
321
|
+
this.#status('degraded', 0, words)
|
|
322
|
+
} else this.#status('connected', 0, motd)
|
|
323
|
+
const q = this.queue
|
|
324
|
+
this.queue = []
|
|
325
|
+
for (const x of q) {
|
|
326
|
+
if (x.kind === 'connect') this.#connectNow(x.id, x.host, x.port)
|
|
327
|
+
else this.#dnsNow(x.id, x.name)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#failed(why, state = 'unreachable', reason = 0) {
|
|
332
|
+
this.connecting = false
|
|
333
|
+
this.ws = null
|
|
334
|
+
this.stats.openFailures++
|
|
335
|
+
this.stats.lastError = why
|
|
336
|
+
this.backoff = Math.min(Math.max(this.backoff * 2, BACKOFF_MIN_MS), BACKOFF_MAX_MS)
|
|
337
|
+
this.nextAt = this.now() + this.backoff
|
|
338
|
+
this.log(`net: ${why}; next try in ${this.backoff} ms`)
|
|
339
|
+
this.#status(state, reason, why)
|
|
340
|
+
const t = this.now()
|
|
341
|
+
const keep = []
|
|
342
|
+
const hopeless = state !== 'unreachable' // a missing or wrong key, or another protocol version, does not fix itself by waiting
|
|
343
|
+
for (const x of this.queue) {
|
|
344
|
+
if (!hopeless && t - x.at < QUEUE_MAX_MS) keep.push(x)
|
|
345
|
+
else if (x.kind === 'connect') this.stack?.onConnected(x.id, E.GONE, 0, why)
|
|
346
|
+
else this.stack?.onDnsReply(x.id, V1_RCODE.SERVFAIL, 0, [], why)
|
|
347
|
+
}
|
|
348
|
+
this.queue = keep
|
|
349
|
+
if (keep.length) this.wake()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---- packets from the relay ----
|
|
353
|
+
|
|
354
|
+
#dispatch(m) {
|
|
355
|
+
const s = this.stack
|
|
356
|
+
if (!s || m.error) {
|
|
357
|
+
if (m.error && (this.stats.malformed++ < MALFORMED_LOGGED || this.stats.malformed % 1000 === 0)) this.log(`net: a malformed packet from the relay (${m.error}); ignored (${this.stats.malformed} so far)`)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
switch (m.type) {
|
|
361
|
+
case PT.CONTINUE: {
|
|
362
|
+
const st = this.streams.get(m.id)
|
|
363
|
+
if (!st) return
|
|
364
|
+
st.counter = m.n
|
|
365
|
+
if (!st.confirmed) {
|
|
366
|
+
st.confirmed = true
|
|
367
|
+
s.onConnected(m.id, E.OK, WINDOW, '')
|
|
368
|
+
}
|
|
369
|
+
this.#flush(m.id, st)
|
|
370
|
+
break
|
|
371
|
+
}
|
|
372
|
+
case PT.DATA: {
|
|
373
|
+
const st = this.streams.get(m.id)
|
|
374
|
+
if (!st || !m.payload.length) return
|
|
375
|
+
if (!st.confirmed) return this.#violation(m.id, st, E.BAD, 'data before the connection was confirmed')
|
|
376
|
+
if (this.server.has(EXT.WMD_CREDIT)) {
|
|
377
|
+
st.allow -= m.payload.length // the relay may send what the guest has taken so far, plus the window it was given
|
|
378
|
+
if (st.allow < -CREDIT_SLACK) return this.#violation(m.id, st, E.LIMIT, 'it sent more than the credit it was given')
|
|
379
|
+
} else {
|
|
380
|
+
st.owed += m.payload.length
|
|
381
|
+
this.held += m.payload.length
|
|
382
|
+
if (st.owed > DOWN_CAP || this.held > SESSION_CAP) {
|
|
383
|
+
this.stats.downCapResets++
|
|
384
|
+
const why = st.owed > DOWN_CAP ? `more than ${DOWN_CAP >> 20} MiB waiting for the guest on one connection` : `more than ${SESSION_CAP >> 20} MiB waiting for the guest`
|
|
385
|
+
this.log(`net: stream ${m.id}: ${why} (this relay has no download flow control): reset`)
|
|
386
|
+
this.#forget(m.id, st)
|
|
387
|
+
this.#raw(closePacket(m.id, CLOSE.CLIENT_ERROR))
|
|
388
|
+
s.onRelayAbort(m.id, E.LIMIT, why)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
s.onRelayData(m.id, m.payload.slice())
|
|
393
|
+
break
|
|
394
|
+
}
|
|
395
|
+
case PT.SHUTDOWN: {
|
|
396
|
+
const st = this.streams.get(m.id)
|
|
397
|
+
if (!st || st.ended) return
|
|
398
|
+
st.ended = true
|
|
399
|
+
s.onRelayEnd(m.id)
|
|
400
|
+
break
|
|
401
|
+
}
|
|
402
|
+
case PT.CLOSE: {
|
|
403
|
+
const st = this.streams.get(m.id)
|
|
404
|
+
if (!st) return
|
|
405
|
+
this.#forget(m.id, st)
|
|
406
|
+
const words = closeWords(m.reason)
|
|
407
|
+
if (!st.confirmed) {
|
|
408
|
+
// unconfirmed (open confirmation) or opened on trust (no confirmation): a refused SYN either way
|
|
409
|
+
if (this.server.has(EXT.CONFIRM)) return s.onConnected(m.id, m.reason === CLOSE.VOLUNTARY ? E.RESET : eCode(m.reason), 0, words)
|
|
410
|
+
}
|
|
411
|
+
if (m.reason === CLOSE.VOLUNTARY) {
|
|
412
|
+
if (!st.ended) s.onRelayEnd(m.id) // the remote's FIN after its last byte
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
s.onRelayAbort(m.id, eCode(m.reason), words)
|
|
416
|
+
break
|
|
417
|
+
}
|
|
418
|
+
case PT.ANSWER: {
|
|
419
|
+
const d = this.lookups.get(m.id)
|
|
420
|
+
if (!d) return
|
|
421
|
+
this.lookups.delete(m.id)
|
|
422
|
+
const { addrs, reason } = answerAddrs(m, 1)
|
|
423
|
+
if (m.rcode !== RCODE.NOERROR) return s.onDnsReply(m.id, KNOWN_RCODES.has(m.rcode) ? m.rcode : RCODE.SERVFAIL, m.ttl, [], reason)
|
|
424
|
+
if (!addrs.length) return s.onDnsReply(m.id, RCODE.NOERROR, m.ttl, [], reason)
|
|
425
|
+
this.#answer(m.id, d.name, Math.min(m.ttl, NAME_TTL))
|
|
426
|
+
break
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#answer(id, name, ttl) {
|
|
432
|
+
const ip = this.#placeholder(name)
|
|
433
|
+
if (!ip) return this.stack.onDnsReply(id, V1_RCODE.SERVFAIL, 0, [], 'no addresses left for new names')
|
|
434
|
+
const v = ipU32(ip)
|
|
435
|
+
this.stack.onDnsReply(id, RCODE.NOERROR, ttl, [Uint8Array.of(v >>> 24, (v >>> 16) & 255, (v >>> 8) & 255, v & 255)], '')
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The relay broke the flow rules for a stream: reset it, in words. */
|
|
439
|
+
#violation(id, st, code, what) {
|
|
440
|
+
this.stats.creditViolations++
|
|
441
|
+
const why = `the relay broke the flow rules: ${what}`
|
|
442
|
+
this.log(`net: stream ${id}: ${why}: reset`)
|
|
443
|
+
this.#forget(id, st)
|
|
444
|
+
this.#raw(closePacket(id, CLOSE.CLIENT_ERROR))
|
|
445
|
+
this.stack.onRelayAbort(id, code, why)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#forget(id, st) {
|
|
449
|
+
this.streams.delete(id)
|
|
450
|
+
this.held -= st.owed
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Send a stream's held packets while its counter allows; the stack gets their bytes back as credit. */
|
|
454
|
+
#flush(id, st) {
|
|
455
|
+
while (st.held.length && st.counter > 0) {
|
|
456
|
+
const b = st.held.shift()
|
|
457
|
+
st.counter--
|
|
458
|
+
this.#raw(dataPacket(id, b))
|
|
459
|
+
this.stack.onRelayCredit(id, b.length)
|
|
460
|
+
}
|
|
461
|
+
if (!st.held.length && st.shut && !st.shutSent) {
|
|
462
|
+
st.shutSent = true
|
|
463
|
+
this.#raw(shutdownPacket(id))
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#connectNow(id, host, port) {
|
|
468
|
+
if (this.streams.has(id)) return
|
|
469
|
+
const st = { confirmed: false, counter: this.server.has(EXT.CONFIRM) ? 0 : this.hs.buffer, allow: WINDOW, held: [], ended: false, shut: false, shutSent: false, owed: 0 }
|
|
470
|
+
this.streams.set(id, st)
|
|
471
|
+
this.#raw(connectPacket(id, host, port))
|
|
472
|
+
if (!this.server.has(EXT.CONFIRM)) {
|
|
473
|
+
// no open confirmation: the stream is open on trust; a failure comes as a CLOSE (a RST after open)
|
|
474
|
+
st.confirmed = true
|
|
475
|
+
queueMicrotask(() => this.streams.get(id) === st && this.stack?.onConnected(id, E.OK, WINDOW, ''))
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#dnsNow(id, name) {
|
|
480
|
+
if (!this.server.has(EXT.WMD_DNS)) return this.#answer(id, name, NAME_TTL)
|
|
481
|
+
if (this.lookups.size >= LOOKUPS_MAX) this.lookups.delete(this.lookups.keys().next().value) // the oldest: the stack gave up on it long ago
|
|
482
|
+
this.lookups.set(id, { name, at: this.now() })
|
|
483
|
+
this.#raw(lookupPacket(id, name, 1))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---- the stack's interface ----
|
|
487
|
+
|
|
488
|
+
connect(id, host, port) {
|
|
489
|
+
if (isPlaceholder(host)) {
|
|
490
|
+
const name = this.ips.get(host)
|
|
491
|
+
if (!name) {
|
|
492
|
+
this.stats.unknownPlaceholders++
|
|
493
|
+
const why = `${host} is a name placeholder this machine does not know (from before a restore without its names?)`
|
|
494
|
+
this.log(`net: ${why}: reset`)
|
|
495
|
+
return queueMicrotask(() => this.stack?.onConnected(id, E.UNREACHABLE, 0, why))
|
|
496
|
+
}
|
|
497
|
+
host = name
|
|
498
|
+
}
|
|
499
|
+
if (this.up) return this.#connectNow(id, host, port)
|
|
500
|
+
if (!this.url) return queueMicrotask(() => this.stack?.onConnected(id, E.UNREACHABLE, 0, this.status.words))
|
|
501
|
+
this.queue.push({ kind: 'connect', id, host, port, at: this.now() })
|
|
502
|
+
this.wake()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
data(id, u8) {
|
|
506
|
+
const st = this.up && this.streams.get(id)
|
|
507
|
+
if (!st) return
|
|
508
|
+
st.held.push(u8)
|
|
509
|
+
this.#flush(id, st)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
end(id) {
|
|
513
|
+
const st = this.up && this.streams.get(id)
|
|
514
|
+
if (!st || st.shut) return
|
|
515
|
+
if (!this.server.has(EXT.WMD_HALFCLOSE)) return // held until the remote closes (stated, plan §3)
|
|
516
|
+
st.shut = true
|
|
517
|
+
this.#flush(id, st)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
abort(id) {
|
|
521
|
+
const st = this.up && this.streams.get(id)
|
|
522
|
+
if (!st) return
|
|
523
|
+
this.#forget(id, st)
|
|
524
|
+
this.#raw(closePacket(id, CLOSE.VOLUNTARY))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The guest took n bytes: the relay may send n more (wmd-credit), or they are no longer held here. */
|
|
528
|
+
credit(id, n) {
|
|
529
|
+
const st = this.up && this.streams.get(id)
|
|
530
|
+
if (!st || n <= 0) return
|
|
531
|
+
if (this.server.has(EXT.WMD_CREDIT)) {
|
|
532
|
+
st.allow += n
|
|
533
|
+
return this.#raw(creditPacket(id, n))
|
|
534
|
+
}
|
|
535
|
+
const k = Math.min(n, st.owed)
|
|
536
|
+
st.owed -= k
|
|
537
|
+
this.held -= k
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** A stream that ended cleanly on both sides (the stack's FIN exchange is done). */
|
|
541
|
+
closed(id) {
|
|
542
|
+
const st = this.streams.get(id)
|
|
543
|
+
if (st) this.#forget(id, st)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
dns(id, name) {
|
|
547
|
+
name = String(name).toLowerCase().replace(/\.$/, '')
|
|
548
|
+
if (!validName(name)) return queueMicrotask(() => this.stack?.onDnsReply(id, RCODE.NXDOMAIN, 0, [], 'not a valid host name'))
|
|
549
|
+
if (this.names.has(name)) return queueMicrotask(() => this.stack && this.#answer(id, name, NAME_TTL))
|
|
550
|
+
if (this.up) return this.#dnsNow(id, name)
|
|
551
|
+
// a relay known to lack wmd-dns: answer without waking it (plan §4)
|
|
552
|
+
if (this.server && !this.server.has(EXT.WMD_DNS)) return queueMicrotask(() => this.stack && this.#answer(id, name, NAME_TTL))
|
|
553
|
+
if (!this.url) return queueMicrotask(() => this.stack?.onDnsReply(id, V1_RCODE.SERVFAIL, 0, [], this.status.words))
|
|
554
|
+
this.queue.push({ kind: 'dns', id, name, at: this.now() })
|
|
555
|
+
this.wake()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Test hook: drop the WebSocket as a network failure would. */
|
|
559
|
+
drop() {
|
|
560
|
+
this.ws?.close(4000, 'dropped by the page (test hook)')
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
snapshot() {
|
|
564
|
+
return {
|
|
565
|
+
protocol: 'wisp', url: this.url, up: this.up, connecting: this.connecting, status: this.status,
|
|
566
|
+
version: this.hs?.version ?? null, extensions: this.server ? [...this.server] : null, motd: this.hs?.motd ?? '',
|
|
567
|
+
streams: this.streams.size, names: this.names.size, ...this.stats,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|