@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/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 limits: sockets in their handshake per address, a handshake deadline, failed keys
9
- // throttled per address.
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 (120)
47
- maxPrincipals?: number // identities remembered (10000)
48
- preAuthPerAddr?: number // sockets in their handshake per address (8)
49
- handshakeMs?: number // (10000)
50
- streamIdleMs?: number // a stream with no bytes either way this long is closed, 0x47 (30 min)
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 ?? 10_000
122
- const streamIdleMs = w.streamIdleMs ?? 30 * 60_000
123
+ const handshakeMs = w.handshakeMs ?? 0
124
+ const streamIdleMs = w.streamIdleMs ?? 0
123
125
  const preAuth = w.preAuthPerAddr ?? 8
124
- const maxPrincipals = w.maxPrincipals ?? 10_000
125
- const instancesPerHour = w.instancesPerHour ?? 120
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
- w.hosted
143
- ? `wmd hosted relay${w.version ? ` ${w.version}` : ''}: the operator sees destination names, DNS names and plain-http content (TLS traffic is not visible); ` +
144
- `abuse contact ${w.hosted.contact}; ${w.hosted.limitsLine()}; blocked ports: ${(o.blockedPorts ?? [25]).join(', ') || 'none'}`
145
- :
146
- `wmd-relay${w.version ? ` ${w.version}` : ''}: up to ${o.maxConns ?? 64} streams per guest; ${o.rateDown ?? 16 << 20} B/s down and ` +
147
- `${o.rateUp ?? 4 << 20} B/s up; blocked ports: ${(o.blockedPorts ?? [25]).join(', ') || 'none'}; private, loopback and metadata addresses are refused`
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
- const n = inHandshake.get(addr) || 0
261
- if (n >= preAuth) return refuse(429, 'too many connections opening from this address; wait\n', 'pre-auth limit', { addr })
262
- inHandshake.set(addr, n + 1)
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: true }
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
- d.hsTimer = setTimeout(() => {
314
- if (d.session) return
315
- log({ ev: 'session-refused', proto: 'wisp', why: 'handshake timeout', addr: d.addr })
316
- ws.close(1008, 'handshake timeout')
317
- }, handshakeMs)
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 closed = w.hosted.notAccepting()
380
- if (closed) return rejectHs(ws, CLOSE.THROTTLED, closed)
381
- adm = w.hosted.admitSession({ ip: d.ip, grp: d.grp, origin: d.origin, fresh, end: words => ws.close(4011, words.slice(0, 120)) })
382
- if (!adm.ok) return rejectHs(ws, CLOSE.THROTTLED, adm.why)
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: (s, id, code) => send(s, closePacket(id, reasonFor(code))),
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: (s, st, code) => send(s, closePacket(st.id, st.w?.idle ? CLOSE.DATA_TIMEOUT : reasonFor(code))),
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) for (const st of s.streams.values()) ws.send(closePacket(st.id, CLOSE.UNKNOWN))
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
- * @returns {{files: Record<string, string|Uint8Array|{data, mode}>, spec: object}}
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
- return { files, spec }
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'
@@ -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
- // 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)
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
- return this.#failed(authFail.words, st, authFail.reason)
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
- this.nextAt = this.now() + this.backoff
338
- this.log(`net: ${why}; next try in ${this.backoff} ms`)
339
- this.#status(state, reason, why)
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 words = closeWords(m.reason)
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)