@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/relay/wisp.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// - open: no key (the standalone relay's default); identity = the client's instance id (0xE7)
|
|
6
6
|
// plus its address group, bandwidth shared per address
|
|
7
7
|
// - keys (optional): Wisp password auth, scrypt hashes; a relay with keys refuses keyless clients
|
|
8
|
-
// Pre-auth
|
|
9
|
-
//
|
|
8
|
+
// Pre-auth: wrong keys are throttled per address, and at most `preAuthPerAddr` key checks (a scrypt each) run at once
|
|
9
|
+
// per address. A handshake deadline, an idle-stream close and the per-address identity rate are off unless set
|
|
10
|
+
// (plan §16). A hosted relay refuses after the handshake, so the page hears why (wmd-reason, 0xE9).
|
|
10
11
|
//
|
|
11
12
|
// Flow control: upload is Wisp's CONTINUE (packets), released as the bytes reach the remote and
|
|
12
13
|
// the up bucket allows (so the bucket delays CONTINUE); download is wmd-credit (0xE1) bytes when
|
|
@@ -15,11 +16,11 @@
|
|
|
15
16
|
import { randomBytes } from 'node:crypto'
|
|
16
17
|
import { E } from '../src/net/proto.js'
|
|
17
18
|
import {
|
|
18
|
-
CLOSE, EXT, PT, STREAM, SUBPROTOCOL, ServerHandshake, answerPacket, closePacket, closeWords, continuePacket,
|
|
19
|
-
dataPacket, ext, parse, shutdownPacket,
|
|
19
|
+
CLOSE, EXT, PT, REASON_KIND, STREAM, SUBPROTOCOL, ServerHandshake, answerPacket, closePacket, closeWords, continuePacket,
|
|
20
|
+
dataPacket, ext, parse, reasonPacket, shutdownPacket,
|
|
20
21
|
} from '../src/net/wisp.js'
|
|
21
22
|
import { type AllowEntry, type Engine, type Principal, type Session, type Shared, type Stream, Window, type Wire, compileAllow, newShared } from './engine.ts'
|
|
22
|
-
import { type Governor, resolveClient } from './hosted.ts'
|
|
23
|
+
import { type Governor, type Why, resolveClient } from './hosted.ts'
|
|
23
24
|
import { type OwnerKey, verifySecret } from './keys.ts'
|
|
24
25
|
import type { Cidr } from './policy.ts'
|
|
25
26
|
import { clientKey, verifyToken } from './token.ts'
|
|
@@ -43,11 +44,11 @@ export type WispOptions = {
|
|
|
43
44
|
// so one stream that keeps the packet rule never reaches it); past it the stream that sent more gets 0x49
|
|
44
45
|
maxHeldTotal?: number // upload bytes held by the whole relay (512 MiB); past it, DATA from a group over its fair share gets 0x49
|
|
45
46
|
maxGroupHeldBytes?: number // upload bytes held by one address group or key, all its sessions (2 x the session cap)
|
|
46
|
-
instancesPerHour?: number // new identities per address (or key) an hour (
|
|
47
|
-
maxPrincipals?: number // identities remembered (
|
|
48
|
-
preAuthPerAddr?: number //
|
|
49
|
-
handshakeMs?: number // (
|
|
50
|
-
streamIdleMs?: number // a stream with no bytes either way this long is closed, 0x47 (
|
|
47
|
+
instancesPerHour?: number // new identities per address (or key) an hour (no limit)
|
|
48
|
+
maxPrincipals?: number // identities remembered (no limit)
|
|
49
|
+
preAuthPerAddr?: number // key checks (scrypt) at once per address (8)
|
|
50
|
+
handshakeMs?: number // a socket that has not finished its handshake this long is closed (no limit)
|
|
51
|
+
streamIdleMs?: number // a stream with no bytes either way this long is closed, 0x47 (no limit)
|
|
51
52
|
version?: string // the relay's version, in the MOTD
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -81,6 +82,7 @@ type SessionW = {
|
|
|
81
82
|
engine: Engine
|
|
82
83
|
v2: boolean
|
|
83
84
|
confirm: boolean
|
|
85
|
+
reason: boolean
|
|
84
86
|
credit: boolean
|
|
85
87
|
halfclose: boolean
|
|
86
88
|
dns: boolean
|
|
@@ -118,11 +120,12 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
118
120
|
heldSwept = n
|
|
119
121
|
groupHeld = g
|
|
120
122
|
}, 1000)
|
|
121
|
-
const handshakeMs = w.handshakeMs ??
|
|
122
|
-
const streamIdleMs = w.streamIdleMs ??
|
|
123
|
+
const handshakeMs = w.handshakeMs ?? 0
|
|
124
|
+
const streamIdleMs = w.streamIdleMs ?? 0
|
|
123
125
|
const preAuth = w.preAuthPerAddr ?? 8
|
|
124
|
-
const maxPrincipals = w.maxPrincipals ??
|
|
125
|
-
const instancesPerHour = w.instancesPerHour ??
|
|
126
|
+
const maxPrincipals = w.maxPrincipals ?? Infinity
|
|
127
|
+
const instancesPerHour = w.instancesPerHour ?? Infinity
|
|
128
|
+
const handshaking = new Set<any>() // sockets that have not finished their handshake (rule G closes the oldest first)
|
|
126
129
|
const inHandshake = new Map<string, number>()
|
|
127
130
|
const failed = new Map<string, { fails: number; until: number; at: number }>()
|
|
128
131
|
const newIds = new Map<string, Window>() // per address or key: new identities this hour
|
|
@@ -138,16 +141,22 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
138
141
|
const log = engine.log
|
|
139
142
|
const stats = engine.stats
|
|
140
143
|
|
|
141
|
-
const motd = () =>
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
const motd = () => {
|
|
145
|
+
const ports = `blocked ports: ${(o.blockedPorts ?? []).join(', ') || 'none'}`
|
|
146
|
+
if (w.hosted) {
|
|
147
|
+
return `wmd hosted relay${w.version ? ` ${w.version}` : ''}: the operator sees destination names, DNS names and plain-http content (TLS traffic is not visible); ` +
|
|
148
|
+
`abuse contact ${w.hosted.contact}; ${w.hosted.limitsLine()}; ${ports}`
|
|
149
|
+
}
|
|
150
|
+
const set = [
|
|
151
|
+
o.maxConns !== undefined && `up to ${o.maxConns} streams per guest`,
|
|
152
|
+
o.rateDown !== undefined && `${o.rateDown} B/s down`,
|
|
153
|
+
o.rateUp !== undefined && `${o.rateUp} B/s up`,
|
|
154
|
+
].filter(Boolean)
|
|
155
|
+
return `wmd-relay${w.version ? ` ${w.version}` : ''}: ${set.length ? `${set.join('; ')}; ` : ''}${ports}; private, loopback and metadata addresses are refused`
|
|
156
|
+
}
|
|
148
157
|
|
|
149
158
|
function serverExts(mode: Mode) {
|
|
150
|
-
const e = [ext.confirm(), ext.motd(motd()), ext.wmdCredit(0), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance()]
|
|
159
|
+
const e = [ext.confirm(), ext.motd(motd()), ext.wmdCredit(0), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance(), ext.wmdReason()]
|
|
151
160
|
if (mode === 'key-pending') e.push(ext.passwordServer(true))
|
|
152
161
|
return e
|
|
153
162
|
}
|
|
@@ -182,12 +191,6 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
182
191
|
const origin = req.headers.get('origin') || ''
|
|
183
192
|
// before any table or the log sees it: no real page origin is this long
|
|
184
193
|
if (origin.length > 256) return refuse(400, 'the Origin header is longer than 256 characters\n', 'origin too long', { addr })
|
|
185
|
-
if (w.hosted) {
|
|
186
|
-
const denied = w.hosted.deniedClient(ip, origin)
|
|
187
|
-
if (denied) return refuse(403, `${denied}\n`, 'denied', { addr, origin })
|
|
188
|
-
const closed = w.hosted.notAccepting()
|
|
189
|
-
if (closed) return refuse(503, `${closed}\n`, 'not accepting', { addr })
|
|
190
|
-
}
|
|
191
194
|
const t = failed.get(addr)
|
|
192
195
|
if (t && t.until > Date.now()) return refuse(429, 'too many wrong keys from this address; wait\n', 'key throttle', { addr }) // set only after ADDR_FAILS
|
|
193
196
|
if (open && w.checkHost && !LOOPBACK_HOST.test(req.headers.get('host') || '')) {
|
|
@@ -257,15 +260,22 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
257
260
|
} else {
|
|
258
261
|
return refuse(401, 'no token\n', 'no token', { origin })
|
|
259
262
|
}
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
+
let pre = false
|
|
264
|
+
if (mode === 'key-pending') {
|
|
265
|
+
const n = inHandshake.get(addr) || 0
|
|
266
|
+
if (n >= preAuth) return refuse(429, 'too many connections opening from this address; wait\n', 'pre-auth limit', { addr })
|
|
267
|
+
inHandshake.set(addr, n + 1)
|
|
268
|
+
pre = true
|
|
269
|
+
}
|
|
270
|
+
// hosted: the socket counts from now (rule E); a refusal waits for the handshake, so the page hears why (rule I)
|
|
271
|
+
const full = w.hosted?.claimHandshake() ?? null
|
|
263
272
|
const headers: Record<string, string> = {}
|
|
264
273
|
if (v2 && protos.length) headers['Sec-WebSocket-Protocol'] = protos.includes(SUBPROTOCOL) ? SUBPROTOCOL : protos[0]
|
|
265
|
-
const data = { kind: 'wisp', v2, mode, addr, ip, sid, uid, grp, exp, p, key, origin, hs: null as any, hsTimer: null as any, pre:
|
|
274
|
+
const data = { kind: 'wisp', v2, mode, addr, ip, sid, uid, grp, exp, p, key, origin, hs: null as any, hsTimer: null as any, pre, claimed: !!w.hosted && !full, refuse: full as Why | null, at: performance.now() }
|
|
266
275
|
const ok = srv.upgrade(req, Object.keys(headers).length ? { data, headers } : { data }) // Bun refuses an empty headers object
|
|
267
276
|
if (!ok) {
|
|
268
277
|
release(data)
|
|
278
|
+
unclaim(data)
|
|
269
279
|
return reply('upgrade failed\n', 400)
|
|
270
280
|
}
|
|
271
281
|
return undefined
|
|
@@ -291,6 +301,13 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
291
301
|
}
|
|
292
302
|
const keyThrottled = (addr: string, id: string) => (failed.get(`${addr}|${id}`)?.until ?? 0) > Date.now()
|
|
293
303
|
|
|
304
|
+
/** A hosted socket's count, given back when it closes before a session took it over. */
|
|
305
|
+
function unclaim(d: any) {
|
|
306
|
+
if (!d.claimed) return
|
|
307
|
+
d.claimed = false
|
|
308
|
+
w.hosted?.unclaim()
|
|
309
|
+
}
|
|
310
|
+
|
|
294
311
|
/** The socket left its handshake (done or gone): it no longer counts against the pre-auth cap. */
|
|
295
312
|
function release(d: any) {
|
|
296
313
|
if (!d.pre) return
|
|
@@ -310,24 +327,40 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
310
327
|
const r = d.hs.start()
|
|
311
328
|
sendAll(ws, r.send)
|
|
312
329
|
if (r.done) return finish(ws, null)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
330
|
+
handshaking.add(ws)
|
|
331
|
+
if (handshakeMs > 0) {
|
|
332
|
+
d.hsTimer = setTimeout(() => {
|
|
333
|
+
if (d.session) return
|
|
334
|
+
log({ ev: 'session-refused', proto: 'wisp', why: 'handshake timeout', addr: d.addr })
|
|
335
|
+
ws.close(1008, 'handshake timeout')
|
|
336
|
+
}, handshakeMs)
|
|
337
|
+
}
|
|
318
338
|
}
|
|
319
339
|
|
|
320
|
-
function rejectHs(ws: any, reason: number, why: string) {
|
|
340
|
+
function rejectHs(ws: any, reason: number, why: string, rule?: string) {
|
|
321
341
|
const d = ws.data
|
|
322
|
-
log({ ev: 'session-refused', proto: 'wisp', why, addr: d.addr })
|
|
342
|
+
log({ ev: 'session-refused', proto: 'wisp', why, addr: d.addr, ...(rule ? { rule } : {}) })
|
|
323
343
|
stats.refusedSessions++
|
|
324
344
|
sendAll(ws, d.hs.reject(reason))
|
|
325
345
|
ws.close(1008, closeWords(reason).slice(0, 120))
|
|
326
346
|
}
|
|
327
347
|
|
|
348
|
+
/** A rule refused the session (hosted): REASON when the client lists wmd-reason, then CLOSE on stream 0, then 4011 in words. */
|
|
349
|
+
function refuseHs(ws: any, client: any, why: Why) {
|
|
350
|
+
const d = ws.data
|
|
351
|
+
log({ ev: 'session-refused', proto: 'wisp', why: why.words, rule: why.rule, addr: d.addr, origin: d.origin })
|
|
352
|
+
stats.refusedSessions++
|
|
353
|
+
const code = /^(denied-|not-allowed-site)/.test(why.rule) ? CLOSE.BLOCKED : CLOSE.THROTTLED
|
|
354
|
+
if (client?.common?.has(EXT.WMD_REASON)) ws.send(reasonPacket(0, REASON_KIND.REFUSED, why.rule, why.until ?? 0, why.words))
|
|
355
|
+
if (d.v2) sendAll(ws, d.hs.reject(code))
|
|
356
|
+
ws.close(4011, why.words.slice(0, 120))
|
|
357
|
+
}
|
|
358
|
+
|
|
328
359
|
async function finish(ws: any, client: any) {
|
|
329
360
|
const d = ws.data
|
|
361
|
+
handshaking.delete(ws)
|
|
330
362
|
if (d.hsTimer) clearTimeout(d.hsTimer)
|
|
363
|
+
if (d.refuse) return refuseHs(ws, client, d.refuse)
|
|
331
364
|
let share: Shared | undefined
|
|
332
365
|
let allow: AllowEntry[] | undefined
|
|
333
366
|
let fresh = false
|
|
@@ -363,23 +396,30 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
363
396
|
if (fresh && engine.principals.size >= maxPrincipals) {
|
|
364
397
|
// least recently admitted identity with no live session goes first (plan 5: LRU); refuse only when all are live
|
|
365
398
|
for (const [k, q] of engine.principals) if (!q.live) { engine.principals.delete(k); break }
|
|
366
|
-
if (engine.principals.size >= maxPrincipals) return rejectHs(ws, CLOSE.THROTTLED, 'too many identities remembered')
|
|
399
|
+
if (engine.principals.size >= maxPrincipals) return rejectHs(ws, CLOSE.THROTTLED, 'too many identities remembered', 'maxPrincipals')
|
|
367
400
|
}
|
|
368
|
-
if (fresh) {
|
|
401
|
+
if (fresh && instancesPerHour !== Infinity) {
|
|
369
402
|
let win = newIds.get(d.grp)
|
|
370
403
|
if (!win) newIds.set(d.grp, (win = new Window(instancesPerHour, 60 * 60_000)))
|
|
371
|
-
if (!win.hit()) return rejectHs(ws, CLOSE.THROTTLED, `more than ${instancesPerHour} new instances this hour from ${d.grp}
|
|
404
|
+
if (!win.hit()) return rejectHs(ws, CLOSE.THROTTLED, `more than ${instancesPerHour} new instances this hour from ${d.grp}`, 'instancesPerHour')
|
|
372
405
|
}
|
|
373
406
|
const a = engine.admit(d.uid, d.grp, Date.now(), { proto: 'wisp' }, share)
|
|
374
|
-
if (!a.ok) return rejectHs(ws, CLOSE.THROTTLED, a.body.trim())
|
|
407
|
+
if (!a.ok) return rejectHs(ws, CLOSE.THROTTLED, a.body.trim(), a.rule)
|
|
375
408
|
d.p = a.p
|
|
376
409
|
}
|
|
377
410
|
let adm: ReturnType<Governor['admitSession']> | null = null
|
|
378
411
|
if (w.hosted) {
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
412
|
+
const reason = !!client?.common?.has(EXT.WMD_REASON)
|
|
413
|
+
const tell = (kind: number, rule: string, until: number, words: string) => {
|
|
414
|
+
if (reason && ws.readyState === 1) ws.send(reasonPacket(0, kind, rule, until, words))
|
|
415
|
+
}
|
|
416
|
+
const end = (why: Why) => {
|
|
417
|
+
tell(REASON_KIND.REFUSED, why.rule, why.until ?? 0, why.words)
|
|
418
|
+
ws.close(4011, why.words.slice(0, 120))
|
|
419
|
+
}
|
|
420
|
+
adm = w.hosted.admitSession({ ip: d.ip, grp: d.grp, origin: d.origin, end, tell, claimed: d.claimed })
|
|
421
|
+
if (!adm.ok) return refuseHs(ws, client, adm)
|
|
422
|
+
d.claimed = false // the session holds the socket's count now
|
|
383
423
|
d.rel = adm.adm.release
|
|
384
424
|
}
|
|
385
425
|
const common: Set<number> = client?.common ?? new Set()
|
|
@@ -390,7 +430,7 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
390
430
|
if (adm?.ok) s.hx = adm.adm
|
|
391
431
|
s.allow = allow
|
|
392
432
|
const sw: SessionW = {
|
|
393
|
-
engine, v2: d.v2, confirm: common.has(EXT.CONFIRM), credit: common.has(EXT.WMD_CREDIT), halfclose: common.has(EXT.WMD_HALFCLOSE),
|
|
433
|
+
engine, v2: d.v2, confirm: common.has(EXT.CONFIRM), reason: common.has(EXT.WMD_REASON), credit: common.has(EXT.WMD_CREDIT), halfclose: common.has(EXT.WMD_HALFCLOSE),
|
|
394
434
|
dns: common.has(EXT.WMD_DNS), buffer, idleTimer: null, unknownLogged: false,
|
|
395
435
|
}
|
|
396
436
|
s.w = sw
|
|
@@ -437,7 +477,10 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
437
477
|
if (q) for (const b of q) writeUp(s, st, x, b)
|
|
438
478
|
if (x.shutPending && !st.closed) engine.clientEnd(s, st.id)
|
|
439
479
|
},
|
|
440
|
-
refused
|
|
480
|
+
refused(s, id, code, why, rule, until) {
|
|
481
|
+
if (rule && (s.w as SessionW)?.reason) send(s, reasonPacket(id, REASON_KIND.REFUSED, rule, until ?? 0, why))
|
|
482
|
+
send(s, closePacket(id, reasonFor(code)))
|
|
483
|
+
},
|
|
441
484
|
data(s, st, bytes) {
|
|
442
485
|
sw(st).last = performance.now()
|
|
443
486
|
send(s, dataPacket(st.id, bytes))
|
|
@@ -446,7 +489,10 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
446
489
|
if ((s.w as SessionW).halfclose) send(s, shutdownPacket(st.id))
|
|
447
490
|
else engine.clientEnd(s, st.id) // no half-close: the stream is over once the remote's bytes are all sent
|
|
448
491
|
},
|
|
449
|
-
abort
|
|
492
|
+
abort(s, st, code, why, rule, until) {
|
|
493
|
+
if (rule && (s.w as SessionW)?.reason) send(s, reasonPacket(st.id, REASON_KIND.REFUSED, rule, until ?? 0, why))
|
|
494
|
+
send(s, closePacket(st.id, st.w?.idle ? CLOSE.DATA_TIMEOUT : reasonFor(code)))
|
|
495
|
+
},
|
|
450
496
|
grant(s, st, n) {
|
|
451
497
|
const x = sw(st)
|
|
452
498
|
x.released += n
|
|
@@ -584,8 +630,10 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
584
630
|
|
|
585
631
|
function close(ws: any, code: number, reason: string) {
|
|
586
632
|
const d = ws.data
|
|
633
|
+
handshaking.delete(ws)
|
|
587
634
|
if (d.hsTimer) clearTimeout(d.hsTimer)
|
|
588
635
|
release(d)
|
|
636
|
+
unclaim(d)
|
|
589
637
|
d.rel?.()
|
|
590
638
|
const s: Session | undefined = d.session
|
|
591
639
|
if (!s) return
|
|
@@ -600,9 +648,21 @@ export function createWisp(engine: Engine, o: { secret?: string; port: () => num
|
|
|
600
648
|
/** Maintenance: every stream gets CLOSE 0x01 (the spec has no maintenance code), then the socket closes 1001 in words. */
|
|
601
649
|
drain(ws: any) {
|
|
602
650
|
const s: Session | undefined = ws.data.session
|
|
603
|
-
if (s && !s.closed)
|
|
651
|
+
if (s && !s.closed) {
|
|
652
|
+
if ((s.w as SessionW)?.reason) ws.send(reasonPacket(0, REASON_KIND.REFUSED, 'draining', 0, DRAIN_WORDS))
|
|
653
|
+
for (const st of s.streams.values()) ws.send(closePacket(st.id, CLOSE.UNKNOWN))
|
|
654
|
+
}
|
|
604
655
|
ws.close(1001, DRAIN_WORDS)
|
|
605
656
|
},
|
|
657
|
+
/** Rule G: the sockets still in their handshake, oldest first. */
|
|
658
|
+
handshakes: () => [...handshaking],
|
|
659
|
+
/** Rule G: close one, at once; its count is given back now. */
|
|
660
|
+
closeHandshake(ws: any) {
|
|
661
|
+
handshaking.delete(ws)
|
|
662
|
+
unclaim(ws.data)
|
|
663
|
+
log({ ev: 'session-refused', proto: 'wisp', why: 'closed unfinished to free a socket', rule: 'busy', addr: ws.data.addr })
|
|
664
|
+
ws.close(1013, 'the relay is full')
|
|
665
|
+
},
|
|
606
666
|
fetch,
|
|
607
667
|
open: open_,
|
|
608
668
|
message,
|
package/src/host/ctl.js
CHANGED
|
@@ -22,6 +22,8 @@ export const STREAM_STDIN = 1
|
|
|
22
22
|
export const STREAM_STDOUT = 2
|
|
23
23
|
export const STREAM_STDERR = 3
|
|
24
24
|
export const STREAM_EXIT = 4
|
|
25
|
+
/** A keep-running container's command: {state: 'running'|'exited'|'none', code} (control.go streamCommand). */
|
|
26
|
+
export const STREAM_COMMAND = 5
|
|
25
27
|
export const EXIT_SYNCED = 1
|
|
26
28
|
/** readFrame's sanity limit (ctlproto.go). */
|
|
27
29
|
export const MAX_CTL_FRAME = 10 * 1024 * 1024
|
|
@@ -262,8 +264,11 @@ export class CtlStream {
|
|
|
262
264
|
* guest's yamux window lets the service send. Nothing is queued without bound.
|
|
263
265
|
*/
|
|
264
266
|
export class CtlProcess {
|
|
265
|
-
constructor(stream, { onStdout, onStderr, onControl, op = '', mapExec = false } = {}) {
|
|
267
|
+
constructor(stream, { onStdout, onStderr, onControl, onCommand, op = '', mapExec = false } = {}) {
|
|
266
268
|
this.stream = stream
|
|
269
|
+
this.onCommand = onCommand
|
|
270
|
+
/** The last report of a keep-running container's command ({state, code}), or null. */
|
|
271
|
+
this.command = null
|
|
267
272
|
this.onStdout = onStdout
|
|
268
273
|
this.onStderr = onStderr
|
|
269
274
|
this.onControl = onControl
|
|
@@ -320,6 +325,17 @@ export class CtlProcess {
|
|
|
320
325
|
this.stream.closeWrite() // nothing more to say; the guest closes its side
|
|
321
326
|
this._resolve({ ...this.exit })
|
|
322
327
|
}
|
|
328
|
+
} else if (f.id === STREAM_COMMAND) {
|
|
329
|
+
let st
|
|
330
|
+
try {
|
|
331
|
+
st = JSON.parse(new TextDecoder().decode(f.payload))
|
|
332
|
+
} catch {
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
// the guest sends the last state again after every attach (a rewind lands here): report changes only
|
|
336
|
+
if (this.command && this.command.state === st.state && this.command.code === st.code) continue
|
|
337
|
+
this.command = { state: st.state, code: st.code | 0 }
|
|
338
|
+
this.onCommand?.({ ...this.command })
|
|
323
339
|
} else if (f.id === STREAM_CONTROL) this.onControl?.(f.payload)
|
|
324
340
|
}
|
|
325
341
|
}
|
|
@@ -653,6 +669,17 @@ export class ControlClient {
|
|
|
653
669
|
})
|
|
654
670
|
}
|
|
655
671
|
|
|
672
|
+
/**
|
|
673
|
+
* Starts the command of a keep-running container whose init has none running (browser only:
|
|
674
|
+
* a base snapshot is made with args [] and the page's command is started after the restore).
|
|
675
|
+
* user is "uid:gid" or a name from the image. Resolves to the guest's reply ({ok} or {error}).
|
|
676
|
+
*/
|
|
677
|
+
startCommand({ args, env = [], cwd = '', user = '' } = {}) {
|
|
678
|
+
const bad = checkProcessRequest({ op: 'exec', args, env, cwd })
|
|
679
|
+
if (bad) return Promise.resolve({ ok: false, error: bad.replace(/^exec:/, 'command:') })
|
|
680
|
+
return this.simple({ op: 'command', args, env, cwd, user })
|
|
681
|
+
}
|
|
682
|
+
|
|
656
683
|
kill({ id, sig = '', all = false } = {}) {
|
|
657
684
|
return this.simple({ op: 'kill', id, sig, all })
|
|
658
685
|
}
|
|
@@ -724,7 +751,13 @@ function defaultMounts() {
|
|
|
724
751
|
* @param {{destination:string, files:Record<string,{data:Uint8Array|string, mode?:number}|string>, file?:string, readOnly?:boolean}[]} [o.mounts]
|
|
725
752
|
* each mount is a share vol<i> with these files; `file` binds only that file of the share
|
|
726
753
|
* @param {string} [o.arch] for imageconfig.json (default arm64)
|
|
727
|
-
* @
|
|
754
|
+
* @param {boolean} [o.init] docker run --init: tini at /sbin/docker-init runs the command
|
|
755
|
+
* @param {boolean} [o.keepRunning] wmd's keep-running init at /sbin/docker-init: the container stays up
|
|
756
|
+
* after its command ends; args may be [] (nothing runs until ControlClient.startCommand). Send the
|
|
757
|
+
* run request with command: true and stop_signal (see runOptions).
|
|
758
|
+
* @param {string} [o.stopSignal] the image's STOPSIGNAL (default SIGTERM), for runOptions
|
|
759
|
+
* @returns {{files: Record<string, string|Uint8Array|{data, mode}>, spec: object, run: object}} run: fields to add to
|
|
760
|
+
* the run request ({command, stop_signal} for keepRunning, else {})
|
|
728
761
|
*/
|
|
729
762
|
export function containerBundle(o) {
|
|
730
763
|
const cwd = o.cwd || '/'
|
|
@@ -735,7 +768,7 @@ export function containerBundle(o) {
|
|
|
735
768
|
process: {
|
|
736
769
|
terminal: !!o.terminal,
|
|
737
770
|
user: { uid: user.uid, gid: user.gid, ...(user.additionalGids?.length ? { additionalGids: user.additionalGids } : {}) },
|
|
738
|
-
args: [...o.args],
|
|
771
|
+
args: o.init || o.keepRunning ? [DOCKER_INIT, '--', ...o.args] : [...o.args],
|
|
739
772
|
env: [...(o.env || [])],
|
|
740
773
|
cwd,
|
|
741
774
|
capabilities: { bounding: DEFAULT_CAPS, effective: DEFAULT_CAPS, permitted: DEFAULT_CAPS },
|
|
@@ -752,6 +785,10 @@ export function containerBundle(o) {
|
|
|
752
785
|
resources: { devices: [{ allow: false, access: 'rwm' }] },
|
|
753
786
|
},
|
|
754
787
|
}
|
|
788
|
+
if (o.init || o.keepRunning) {
|
|
789
|
+
// as dockerd binds its docker-init (internal/engine/ocispec.go): the guest's tini, or its own init for keep-running
|
|
790
|
+
spec.mounts.push({ destination: DOCKER_INIT, type: 'bind', source: o.keepRunning ? '/sbin/init' : '/sbin/tini', options: ['bind', 'ro'] })
|
|
791
|
+
}
|
|
755
792
|
for (const name of ETC_FILES) {
|
|
756
793
|
spec.mounts.push({ destination: `/etc/${name}`, type: 'bind', source: `${GUEST_9P_MOUNT}/bundle/etc/${name}`, options: ['rbind', 'rprivate', 'rw'] })
|
|
757
794
|
}
|
|
@@ -774,5 +811,9 @@ export function containerBundle(o) {
|
|
|
774
811
|
files['bundle/config/config.json'] = JSON.stringify(spec, null, 2) + '\n'
|
|
775
812
|
files['bundle/config/imageconfig.json'] =
|
|
776
813
|
JSON.stringify({ architecture: o.arch || 'arm64', os: 'linux', config: { Env: [...(o.env || [])], Cmd: [...o.args], WorkingDir: cwd }, rootfs: { type: 'layers', diff_ids: null } }, null, 2) + '\n'
|
|
777
|
-
|
|
814
|
+
const run = o.keepRunning ? { command: true, stop_signal: o.stopSignal || 'SIGTERM' } : {}
|
|
815
|
+
return { files, spec, run }
|
|
778
816
|
}
|
|
817
|
+
|
|
818
|
+
/** Where dockerd puts the init of docker run --init. */
|
|
819
|
+
export const DOCKER_INIT = '/sbin/docker-init'
|
package/src/net/wisp-client.js
CHANGED
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
// - names: the guest's lookups get placeholder addresses from 198.18.0.0/15; a connect to one
|
|
16
16
|
// is a CONNECT to the name (plan §4). With wmd-dns (0xE4) the relay answers first, so
|
|
17
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).
|
|
18
|
+
// - status: onStatus({state, relay, reason, words, rule?, until?}) for the page (plan §6). With wmd-reason (0xE9) a
|
|
19
|
+
// relay says which rule refused or slowed the page and when it lifts (until: ms since 1970, 0 = no time): state
|
|
20
|
+
// 'limited' for a refusal, 'slowed' for a slowdown (plan §16, rule I).
|
|
19
21
|
//
|
|
20
22
|
// A drop of the WebSocket fails every stream (onRelayDown), as with v1.
|
|
21
23
|
|
|
22
24
|
import { E, RCODE as V1_RCODE, WINDOW } from './proto.js'
|
|
23
25
|
import {
|
|
24
|
-
CLOSE, EXT, PT, RCODE, SUBPROTOCOL, ClientHandshake, answerAddrs, closePacket, closeWords, connectPacket, creditPacket,
|
|
26
|
+
CLOSE, EXT, PT, RCODE, REASON_KIND, SUBPROTOCOL, ClientHandshake, answerAddrs, closePacket, closeWords, connectPacket, creditPacket,
|
|
25
27
|
dataPacket, ext, lookupPacket, parse, shutdownPacket,
|
|
26
28
|
} from './wisp.js'
|
|
27
29
|
|
|
@@ -141,6 +143,10 @@ export class WispClient {
|
|
|
141
143
|
this.lookups = new Map() // id -> {name, at} sent to the relay, not yet answered
|
|
142
144
|
for (const [n, ip] of o.names || []) if (this.names.size < NAME_CAP) this.#learn(n, ip)
|
|
143
145
|
this.status = null
|
|
146
|
+
this.readyStatus = null // the status the open connection had before a 'limited' or 'slowed' one
|
|
147
|
+
this.whys = new Map() // stream id -> {rule, until, words}: the REASON before its CLOSE
|
|
148
|
+
this.closeWhy = null // the REASON on stream 0 before the relay closes the session
|
|
149
|
+
this.liftTimer = null
|
|
144
150
|
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
151
|
if (!this.url) this.#status('unreachable', CLOSE.UNKNOWN, o.url === 'hosted' ? 'no hosted relay is configured' : 'no relay URL is configured')
|
|
146
152
|
}
|
|
@@ -149,15 +155,33 @@ export class WispClient {
|
|
|
149
155
|
this.stack = stack
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
#status(state, reason, words) {
|
|
158
|
+
#status(state, reason, words, why) {
|
|
153
159
|
let relay = ''
|
|
154
160
|
try {
|
|
155
161
|
relay = this.url ? new URL(this.url).origin.replace(/^http/, 'ws') : ''
|
|
156
162
|
} catch {}
|
|
157
163
|
this.status = { state, relay, reason: reason ?? 0, words: words || '' }
|
|
164
|
+
if (why) Object.assign(this.status, { rule: why.rule, until: why.until || 0 })
|
|
158
165
|
this.onStatus(this.status)
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
/** A rule refused or slowed this page while the connection stays up; the ready status comes back when it lifts. */
|
|
169
|
+
#limited(state, why) {
|
|
170
|
+
clearTimeout(this.liftTimer)
|
|
171
|
+
this.#status(state, 0, why.words, why)
|
|
172
|
+
const wait = why.until ? why.until - Date.now() : 0
|
|
173
|
+
if (wait > 0) this.liftTimer = setTimeout(() => this.#lift(), wait)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#lift() {
|
|
177
|
+
clearTimeout(this.liftTimer)
|
|
178
|
+
this.liftTimer = null
|
|
179
|
+
if (this.up && this.readyStatus && (this.status?.state === 'limited' || this.status?.state === 'slowed')) {
|
|
180
|
+
const r = this.readyStatus
|
|
181
|
+
this.#status(r.state, r.reason, r.words)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
161
185
|
#learn(name, ip) {
|
|
162
186
|
// 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
187
|
if (typeof name !== 'string' || typeof ip !== 'string' || !isPlaceholder(ip) || this.ips.has(ip) || this.names.has(name)) return
|
|
@@ -242,7 +266,7 @@ export class WispClient {
|
|
|
242
266
|
this.ws = ws
|
|
243
267
|
let opened = false
|
|
244
268
|
const hs = new ClientHandshake({
|
|
245
|
-
exts: [ext.confirm(), ext.wmdCredit(WINDOW), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance(hexBytes(this.instance))],
|
|
269
|
+
exts: [ext.confirm(), ext.wmdCredit(WINDOW), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance(hexBytes(this.instance)), ext.wmdReason()],
|
|
246
270
|
password: this.key ? { user: this.key.id, pass: this.key.secret } : null,
|
|
247
271
|
})
|
|
248
272
|
const openTimer = setTimeout(() => !this.up && this.ws === ws && ws.close(), this.openTimeoutMs)
|
|
@@ -281,14 +305,20 @@ export class WispClient {
|
|
|
281
305
|
if (wasUp) {
|
|
282
306
|
this.stats.drops++
|
|
283
307
|
this.streams.clear()
|
|
308
|
+
this.whys.clear()
|
|
284
309
|
this.lookups.clear() // the stack fails its lookups in onRelayDown
|
|
285
310
|
this.held = 0
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
this.
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
this
|
|
311
|
+
clearTimeout(this.liftTimer)
|
|
312
|
+
const told = this.closeWhy
|
|
313
|
+
this.closeWhy = null
|
|
314
|
+
// a relay that closed us on purpose (maintenance, 1001; a rule or its operator, 4011) is not back in a moment: wait
|
|
315
|
+
// until the rule lifts, or the longest
|
|
316
|
+
this.backoff = e.code === CLOSE_MAINTENANCE || e.code === CLOSE_DENIED || told ? BACKOFF_MAX_MS : BACKOFF_MIN_MS
|
|
317
|
+
this.nextAt = this.now() + Math.max(this.backoff, told?.until ? told.until - Date.now() : 0)
|
|
318
|
+
const why = told?.words || `relay connection closed (${e.code}${e.reason ? ` ${e.reason}` : ''})`
|
|
319
|
+
this.log(`net: the relay connection closed (${e.code}${e.reason ? ` ${e.reason}` : ''}${told ? `; ${told.rule}` : ''}): every connection is reset`)
|
|
320
|
+
if (told) this.#status('limited', 0, why, told)
|
|
321
|
+
else this.#status('down', 0, why)
|
|
292
322
|
this.stack?.onRelayDown(why)
|
|
293
323
|
return
|
|
294
324
|
}
|
|
@@ -298,9 +328,10 @@ export class WispClient {
|
|
|
298
328
|
return this.#socket([], false)
|
|
299
329
|
}
|
|
300
330
|
if (authFail) {
|
|
301
|
-
const st = ['auth-required', 'auth-refused', 'version'].includes(authFail.state) ? authFail.state : 'unreachable'
|
|
331
|
+
const st = ['auth-required', 'auth-refused', 'version', 'limited'].includes(authFail.state) ? authFail.state : 'unreachable'
|
|
302
332
|
this.backoff = BACKOFF_MAX_MS // a wrong or missing key does not fix itself: no tight loop
|
|
303
|
-
|
|
333
|
+
const why = authFail.rule ? { rule: authFail.rule, until: authFail.until, words: authFail.words } : null
|
|
334
|
+
return this.#failed(authFail.words, st, authFail.reason, why)
|
|
304
335
|
}
|
|
305
336
|
this.#failed(`could not connect to the relay (${e.code}${e.reason ? ` ${e.reason}` : ''})`)
|
|
306
337
|
}
|
|
@@ -320,6 +351,7 @@ export class WispClient {
|
|
|
320
351
|
this.log(`net: ${words}`)
|
|
321
352
|
this.#status('degraded', 0, words)
|
|
322
353
|
} else this.#status('connected', 0, motd)
|
|
354
|
+
this.readyStatus = this.status
|
|
323
355
|
const q = this.queue
|
|
324
356
|
this.queue = []
|
|
325
357
|
for (const x of q) {
|
|
@@ -328,15 +360,16 @@ export class WispClient {
|
|
|
328
360
|
}
|
|
329
361
|
}
|
|
330
362
|
|
|
331
|
-
#failed(why, state = 'unreachable', reason = 0) {
|
|
363
|
+
#failed(why, state = 'unreachable', reason = 0, rule = null) {
|
|
332
364
|
this.connecting = false
|
|
333
365
|
this.ws = null
|
|
334
366
|
this.stats.openFailures++
|
|
335
367
|
this.stats.lastError = why
|
|
336
368
|
this.backoff = Math.min(Math.max(this.backoff * 2, BACKOFF_MIN_MS), BACKOFF_MAX_MS)
|
|
337
|
-
|
|
338
|
-
this.
|
|
339
|
-
this
|
|
369
|
+
const wait = Math.max(this.backoff, rule?.until ? rule.until - Date.now() : 0) // a rule that names its end: not before it
|
|
370
|
+
this.nextAt = this.now() + wait
|
|
371
|
+
this.log(`net: ${why}${rule ? ` (${rule.rule})` : ''}; next try in ${Math.round(wait)} ms`)
|
|
372
|
+
this.#status(state, reason, why, rule)
|
|
340
373
|
const t = this.now()
|
|
341
374
|
const keep = []
|
|
342
375
|
const hopeless = state !== 'unreachable' // a missing or wrong key, or another protocol version, does not fix itself by waiting
|
|
@@ -351,7 +384,21 @@ export class WispClient {
|
|
|
351
384
|
|
|
352
385
|
// ---- packets from the relay ----
|
|
353
386
|
|
|
387
|
+
/** Rule I: a REASON. On a stream it gives that stream's CLOSE its words; on 0 it is the whole session's state. */
|
|
388
|
+
#reason(m) {
|
|
389
|
+
if (!this.server?.has(EXT.WMD_REASON)) return
|
|
390
|
+
const why = { rule: m.rule, until: m.until, words: m.words }
|
|
391
|
+
if (m.id !== 0) {
|
|
392
|
+
if (m.kind === REASON_KIND.REFUSED && this.streams.has(m.id)) this.whys.set(m.id, why)
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
if (m.kind === REASON_KIND.SLOWED) return this.#limited('slowed', why)
|
|
396
|
+
if (m.kind === REASON_KIND.CLEARED) return this.status?.state === 'slowed' && this.status.rule === m.rule && this.#lift()
|
|
397
|
+
if (m.kind === REASON_KIND.REFUSED) this.closeWhy = why // the relay closes the session next
|
|
398
|
+
}
|
|
399
|
+
|
|
354
400
|
#dispatch(m) {
|
|
401
|
+
if (!m.error && m.type === PT.REASON) return this.#reason(m) // about the session too, so not tied to an attached stack
|
|
355
402
|
const s = this.stack
|
|
356
403
|
if (!s || m.error) {
|
|
357
404
|
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)`)
|
|
@@ -364,6 +411,7 @@ export class WispClient {
|
|
|
364
411
|
st.counter = m.n
|
|
365
412
|
if (!st.confirmed) {
|
|
366
413
|
st.confirmed = true
|
|
414
|
+
if (this.status?.state === 'limited' && !(this.status.until > Date.now())) this.#lift()
|
|
367
415
|
s.onConnected(m.id, E.OK, WINDOW, '')
|
|
368
416
|
}
|
|
369
417
|
this.#flush(m.id, st)
|
|
@@ -403,7 +451,13 @@ export class WispClient {
|
|
|
403
451
|
const st = this.streams.get(m.id)
|
|
404
452
|
if (!st) return
|
|
405
453
|
this.#forget(m.id, st)
|
|
406
|
-
const
|
|
454
|
+
const told = this.whys.get(m.id)
|
|
455
|
+
this.whys.delete(m.id)
|
|
456
|
+
if (told) {
|
|
457
|
+
this.log(`net: stream ${m.id}: ${told.rule}: ${told.words}`)
|
|
458
|
+
this.#limited('limited', told)
|
|
459
|
+
}
|
|
460
|
+
const words = told?.words || closeWords(m.reason)
|
|
407
461
|
if (!st.confirmed) {
|
|
408
462
|
// unconfirmed (open confirmation) or opened on trust (no confirmation): a refused SYN either way
|
|
409
463
|
if (this.server.has(EXT.CONFIRM)) return s.onConnected(m.id, m.reason === CLOSE.VOLUNTARY ? E.RESET : eCode(m.reason), 0, words)
|