@threadedstack/wmd-web 0.0.0-stage → 0.1.13

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.
Files changed (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
@@ -0,0 +1,387 @@
1
+ // P-B11 (ROADMAP 2.2b): a hashicorp/yamux client, the host side of wmd's control
2
+ // session (internal/engine/control.go: the host is the yamux client, the guest init the
3
+ // server, github.com/hashicorp/yamux v0.1.2 with its default config).
4
+ //
5
+ // Synchronous and transport-free: feed() takes the bytes the guest sent, send() is called
6
+ // with the bytes for the guest, tick() runs the keepalive. The channel service Worker
7
+ // (chan-services.js ControlSession) drives it from its step(); the unit tests drive it
8
+ // against Go's own yamux server.
9
+ //
10
+ // The wire format (yamux spec.md, const.go): a 12-byte header, version 0 | type |
11
+ // flags (2, BE) | stream id (4, BE) | length (4, BE); Data frames carry `length` bytes.
12
+ // Types: 0 Data, 1 Window Update, 2 Ping, 3 Go Away. Flags: SYN 1, ACK 2, FIN 4, RST 8.
13
+ // The client's streams have odd ids, from 1. Every stream starts with a 256 KiB window
14
+ // each way; a sender may not have more unacknowledged bytes in flight.
15
+ //
16
+ // As hashicorp/yamux:
17
+ // - open() sends a Window Update with SYN (delta 0); data may follow at once.
18
+ // - A stream's receive window is given back as the consumer takes the bytes (consume()),
19
+ // in one Window Update once at least half of it is free (Stream.sendWindowUpdate).
20
+ // - closeWrite() sends FIN (after the queued data); a stream is gone once both sides sent
21
+ // FIN, or on RST.
22
+ // - Pings from the guest (SYN) are answered at once (ACK, same opaque value). The guest's
23
+ // server pings every 30 s and closes the session if an answer takes over 10 s
24
+ // (ConnectionWriteTimeout): answering from the service Worker, never the page, keeps a
25
+ // stalled page from losing the session.
26
+ // - The client pings every keepAliveMs (5 s, as the engine); an unanswered ping is only
27
+ // counted (pingTimeouts): a slow emulated guest is not a dead one.
28
+ // - Data past a stream's receive window, a bad version or type, a duplicate SYN: Go Away
29
+ // (protocol error) and the session is closed.
30
+ // - Streams the guest opens (op "edge", unused here) are refused with RST.
31
+
32
+ export const YAMUX_HEADER = 12
33
+ export const T_DATA = 0
34
+ export const T_WINDOW = 1
35
+ export const T_PING = 2
36
+ export const T_GOAWAY = 3
37
+ export const F_SYN = 1
38
+ export const F_ACK = 2
39
+ export const F_FIN = 4
40
+ export const F_RST = 8
41
+ export const INITIAL_WINDOW = 256 * 1024
42
+ /** Go keeps a stream's send window in a uint32: never more credit than that. */
43
+ export const MAX_WINDOW = 0xffffffff
44
+ export const GOAWAY_NORMAL = 0
45
+ export const GOAWAY_PROTO = 1
46
+ export const GOAWAY_INTERNAL = 2
47
+
48
+ /** One yamux header. */
49
+ export function yamuxHeader(type, flags, id, length) {
50
+ const h = new Uint8Array(YAMUX_HEADER)
51
+ const dv = new DataView(h.buffer)
52
+ h[0] = 0
53
+ h[1] = type
54
+ dv.setUint16(2, flags)
55
+ dv.setUint32(4, id)
56
+ dv.setUint32(8, length >>> 0)
57
+ return h
58
+ }
59
+
60
+ const concat = (a, b) => { // one frame: header + payload
61
+ if (!a.length) return b
62
+ const o = new Uint8Array(a.length + b.length)
63
+ o.set(a)
64
+ o.set(b, a.length)
65
+ return o
66
+ }
67
+
68
+ export class YamuxStream {
69
+ constructor(session, id) {
70
+ this.session = session
71
+ this.id = id
72
+ this.sendWindow = INITIAL_WINDOW // bytes we may still send
73
+ this.recvWindow = INITIAL_WINDOW // bytes the guest may still send
74
+ this.unconsumed = 0 // received, not yet consume()d
75
+ this.pending = [] // data waiting for send window
76
+ this.pendingBytes = 0
77
+ this.localFin = false // closeWrite() called
78
+ this.finSent = false
79
+ this.remoteFin = false
80
+ this.reset = false
81
+ this.acked = false
82
+ this.closed = false // both sides done, or reset: removed from the session
83
+ this.bytesIn = 0
84
+ this.bytesOut = 0
85
+ // callbacks
86
+ this.onData = null // (u8) => void
87
+ this.onFin = null // () => void: the guest will send nothing more
88
+ this.onClose = null // (reason|null) => void: the stream is gone (null: both FINs)
89
+ this.onSent = null // (n) => void: n queued bytes went out (the guest's window let them)
90
+ }
91
+
92
+ /** Queue bytes for the guest; sent as the guest's window allows. */
93
+ write(u8) {
94
+ if (this.closed || this.localFin) throw new Error(`yamux stream ${this.id}: write after ${this.closed ? 'close' : 'closeWrite'}`)
95
+ if (!u8.length) return
96
+ this.pending.push(u8)
97
+ this.pendingBytes += u8.length
98
+ this.#flush()
99
+ }
100
+
101
+ /** Half-close: FIN once the queued data is out. */
102
+ closeWrite() {
103
+ if (this.closed || this.localFin) return
104
+ this.localFin = true
105
+ this.#flush()
106
+ }
107
+
108
+ /** Abort the stream (RST). */
109
+ abort(reason = 'reset by the host') {
110
+ if (this.closed) return
111
+ this.session._send(yamuxHeader(T_WINDOW, F_RST, this.id, 0))
112
+ this.reset = true
113
+ this.session._remove(this, reason)
114
+ }
115
+
116
+ /** The consumer took n bytes: give the window back (as hashicorp's Stream.Read). */
117
+ consume(n) {
118
+ if (this.closed || !Number.isFinite(n) || n <= 0) return
119
+ this.unconsumed = Math.max(0, this.unconsumed - n)
120
+ const delta = INITIAL_WINDOW - this.unconsumed - this.recvWindow
121
+ if (delta < INITIAL_WINDOW / 2) return
122
+ this.recvWindow += delta
123
+ this.session._send(yamuxHeader(T_WINDOW, 0, this.id, delta))
124
+ }
125
+
126
+ _flush() {
127
+ this.#flush()
128
+ }
129
+
130
+ #flush() {
131
+ while (this.pending.length && this.sendWindow > 0 && !this.closed) {
132
+ const c = this.pending[0]
133
+ const n = Math.min(c.length, this.sendWindow, 1 << 20)
134
+ const fin = this.localFin && !this.finSent && n === c.length && this.pending.length === 1
135
+ this.session._send(concat(yamuxHeader(T_DATA, fin ? F_FIN : 0, this.id, n), c.subarray(0, n)))
136
+ if (fin) this.finSent = true
137
+ this.sendWindow -= n
138
+ this.bytesOut += n
139
+ this.pendingBytes -= n
140
+ this.onSent?.(n)
141
+ if (n === c.length) this.pending.shift()
142
+ else this.pending[0] = c.subarray(n)
143
+ }
144
+ if (this.localFin && !this.finSent && !this.pending.length && !this.closed) {
145
+ this.finSent = true
146
+ this.session._send(yamuxHeader(T_WINDOW, F_FIN, this.id, 0))
147
+ }
148
+ if (this.finSent && this.remoteFin) this.session._remove(this, null)
149
+ }
150
+
151
+ _flags(flags) {
152
+ if (flags & F_ACK) this.acked = true
153
+ if (flags & F_FIN && !this.remoteFin) {
154
+ this.remoteFin = true
155
+ this.onFin?.()
156
+ if (this.finSent) this.session._remove(this, null)
157
+ }
158
+ if (flags & F_RST) {
159
+ this.reset = true
160
+ this.session._remove(this, 'reset by the guest')
161
+ }
162
+ }
163
+ }
164
+
165
+ export class YamuxClient {
166
+ /**
167
+ * @param {object} o
168
+ * @param {(u8: Uint8Array) => void} o.send bytes for the guest
169
+ * @param {() => number} [o.now] ms clock (performance.now)
170
+ * @param {number} [o.keepAliveMs] client pings (5000, as the engine); 0: none
171
+ * @param {number} [o.pingTimeoutMs] an unanswered ping counts as a timeout after this
172
+ * @param {(reason: string) => void} [o.onClose]
173
+ */
174
+ constructor({ send, now = () => performance.now(), keepAliveMs = 5000, pingTimeoutMs = 10_000, onClose = null } = {}) {
175
+ this.sendFn = send
176
+ this.now = now
177
+ this.keepAliveMs = keepAliveMs
178
+ this.pingTimeoutMs = pingTimeoutMs
179
+ this.onClose = onClose
180
+ this.streams = new Map()
181
+ this.nextId = 1
182
+ this.hdr = new Uint8Array(YAMUX_HEADER) // the header being read
183
+ this.hlen = 0
184
+ this.body = null // the stream whose data frame body is arriving (null: discard it)
185
+ this.bodyFlags = 0
186
+ this.bodyLeft = 0 // bytes of that body still to come
187
+ this.parts = [] // the body's pieces so far
188
+ this.closed = null // the reason, once closed
189
+ this.remoteGoAway = false
190
+ this.lastPingAt = now()
191
+ this.pingId = 0
192
+ this.pings = new Map() // id -> sent at
193
+ this.stats = { pingsSent: 0, pingsAnswered: 0, pingTimeouts: 0, pingRttMs: [], guestPings: 0, refused: 0, discarded: 0, opened: 0, bytesIn: 0, bytesOut: 0 }
194
+ }
195
+
196
+ _send(u8) {
197
+ if (this.closed) return
198
+ this.stats.bytesOut += u8.length
199
+ this.sendFn(u8)
200
+ }
201
+
202
+ /** A new stream (SYN). */
203
+ open() {
204
+ if (this.closed) throw new Error(`yamux session closed: ${this.closed}`)
205
+ if (this.remoteGoAway) throw new Error('yamux: the guest is not accepting streams (go away)')
206
+ const id = this.nextId
207
+ this.nextId += 2
208
+ const s = new YamuxStream(this, id)
209
+ this.streams.set(id, s)
210
+ this.stats.opened++
211
+ this._send(yamuxHeader(T_WINDOW, F_SYN, id, 0))
212
+ return s
213
+ }
214
+
215
+ _remove(s, reason) {
216
+ if (s.closed) return
217
+ s.closed = true
218
+ this.streams.delete(s.id)
219
+ s.onClose?.(reason)
220
+ }
221
+
222
+ /** Keepalive; call often (the service calls it every step). */
223
+ tick() {
224
+ if (this.closed) return
225
+ const t = this.now()
226
+ for (const [id, at] of this.pings) {
227
+ if (t - at > this.pingTimeoutMs) {
228
+ this.pings.delete(id)
229
+ this.stats.pingTimeouts++
230
+ }
231
+ }
232
+ if (this.keepAliveMs > 0 && t - this.lastPingAt >= this.keepAliveMs) {
233
+ this.lastPingAt = t
234
+ const id = this.pingId++ >>> 0
235
+ this.pings.set(id, t)
236
+ this.stats.pingsSent++
237
+ this._send(yamuxHeader(T_PING, F_SYN, 0, id))
238
+ }
239
+ }
240
+
241
+ /** Close the session: Go Away, every stream ends with `reason`. */
242
+ close(reason = 'closed by the host', code = GOAWAY_NORMAL, sendGoAway = true) {
243
+ if (this.closed) return
244
+ if (sendGoAway) this._send(yamuxHeader(T_GOAWAY, 0, 0, code))
245
+ this.closed = reason
246
+ for (const s of [...this.streams.values()]) this._remove(s, reason)
247
+ this.onClose?.(reason)
248
+ }
249
+
250
+ #protoError(why) {
251
+ this.close(`yamux protocol error: ${why}`, GOAWAY_PROTO)
252
+ }
253
+
254
+ /**
255
+ * Bytes from the guest. Linear in the bytes fed, however they are cut: a header is
256
+ * collected in a 12-byte buffer; a data frame's body is kept as the pieces it arrived in
257
+ * (never re-copied per feed) and delivered once whole, which holds at most the stream's
258
+ * receive window (256 KiB); As hashicorp's readData: the length is checked against
259
+ * the stream's receive window when the header arrives (Go Away before any of the body
260
+ * is read), and data for a stream that is gone is discarded as it comes.
261
+ */
262
+ feed(u8) {
263
+ if (this.closed || !u8.length) return
264
+ this.stats.bytesIn += u8.length
265
+ let at = 0
266
+ while (at < u8.length && !this.closed) {
267
+ if (this.bodyLeft > 0) {
268
+ const n = Math.min(this.bodyLeft, u8.length - at)
269
+ if (this.body) this.parts.push(u8.slice(at, at + n)) // at most the stream's window (checked at the header)
270
+ at += n
271
+ this.bodyLeft -= n
272
+ if (this.bodyLeft === 0) this.#bodyDone()
273
+ continue
274
+ }
275
+ const n = Math.min(YAMUX_HEADER - this.hlen, u8.length - at)
276
+ this.hdr.set(u8.subarray(at, at + n), this.hlen)
277
+ this.hlen += n
278
+ at += n
279
+ if (this.hlen < YAMUX_HEADER) break
280
+ this.hlen = 0
281
+ this.#header()
282
+ }
283
+ }
284
+
285
+ /** The header bytes collected so far (a partial header between feeds). */
286
+ get buf() {
287
+ return this.hdr.subarray(0, this.hlen)
288
+ }
289
+
290
+ #header() {
291
+ const h = this.hdr
292
+ const dv = new DataView(h.buffer)
293
+ const ver = h[0]
294
+ const type = h[1]
295
+ const flags = dv.getUint16(2)
296
+ const id = dv.getUint32(4)
297
+ const len = dv.getUint32(8)
298
+ if (ver !== 0) return this.#protoError(`version ${ver}`)
299
+ if (type > T_GOAWAY) return this.#protoError(`message type ${type}`)
300
+ if (type === T_PING) return this.#ping(flags, len)
301
+ if (type === T_GOAWAY) return this.#goAway(len)
302
+ if (flags & F_SYN) {
303
+ // the guest opening a stream (only op "edge" does; not served here): refuse it, and
304
+ // skip any body it sent with the SYN
305
+ if (this.streams.has(id)) return this.#protoError(`duplicate stream ${id}`)
306
+ this.stats.refused++
307
+ this._send(yamuxHeader(T_WINDOW, F_RST, id, 0))
308
+ if (type === T_DATA) this.#discard(len)
309
+ return
310
+ }
311
+ const s = this.streams.get(id)
312
+ if (type === T_WINDOW) {
313
+ if (!s) return // a frame for a stream already gone: dropped, as hashicorp does
314
+ s.sendWindow = Math.min(MAX_WINDOW, s.sendWindow + len) // Go's window is a uint32
315
+ s._flags(flags)
316
+ if (!s.closed) s._flush()
317
+ return
318
+ }
319
+ // data
320
+ if (!s) return this.#discard(len)
321
+ if (len > s.recvWindow) return this.#protoError(`stream ${id}: ${len} bytes over a window of ${s.recvWindow}`)
322
+ s.recvWindow -= len
323
+ s.unconsumed += len
324
+ this.body = s
325
+ this.bodyFlags = flags
326
+ this.bodyLeft = len
327
+ if (len === 0) this.#bodyDone()
328
+ }
329
+
330
+ #discard(len) {
331
+ this.body = null
332
+ this.bodyFlags = 0
333
+ this.bodyLeft = len
334
+ this.stats.discarded += len
335
+ }
336
+
337
+ #bodyDone() {
338
+ const s = this.body
339
+ const parts = this.parts
340
+ this.body = null
341
+ this.parts = []
342
+ if (!s || s.closed) return
343
+ // one delivery per frame, as Go's stream buffers a frame before Read: the window
344
+ // given back by consume() is then counted in whole frames
345
+ const n = parts.reduce((a, p) => a + p.length, 0)
346
+ if (n) {
347
+ let data = parts[0]
348
+ if (parts.length > 1) {
349
+ data = new Uint8Array(n)
350
+ let o = 0
351
+ for (const p of parts) {
352
+ data.set(p, o)
353
+ o += p.length
354
+ }
355
+ }
356
+ s.bytesIn += n
357
+ s.onData?.(data)
358
+ }
359
+ s._flags(this.bodyFlags)
360
+ }
361
+
362
+ #ping(flags, value) {
363
+ if (flags & F_SYN) {
364
+ this.stats.guestPings++
365
+ this._send(yamuxHeader(T_PING, F_ACK, 0, value))
366
+ return
367
+ }
368
+ const at = this.pings.get(value)
369
+ if (at !== undefined) {
370
+ this.pings.delete(value)
371
+ this.stats.pingsAnswered++
372
+ const r = this.stats.pingRttMs
373
+ r.push(this.now() - at)
374
+ if (r.length > 64) r.shift()
375
+ }
376
+ }
377
+
378
+ #goAway(code) {
379
+ if (code === GOAWAY_NORMAL) {
380
+ this.remoteGoAway = true
381
+ return
382
+ }
383
+ this.close(code === GOAWAY_PROTO ? 'the guest reported a yamux protocol error' : `the guest went away (code ${code})`, GOAWAY_NORMAL, false)
384
+ }
385
+
386
+
387
+ }
@@ -0,0 +1,154 @@
1
+ // W63/W66: an image's files, read on the page without a boot. The layers (packs, top first) are opened
2
+ // read-only through the overlay the 9P service serves them with, so whiteouts and opaque directories
3
+ // count as they do in the guest. W63: the image's /etc/passwd and /etc/group, so a caller can apply
4
+ // `USER name`, devcontainer `remoteUser` or `--chown=app:app` (resolveUser). W66: another image's files,
5
+ // for `COPY --from` and multi-stage builds (walk).
6
+
7
+ import { ENOENT, FsError } from './fs/errno.js'
8
+ import { Overlay } from './fs/overlay.js'
9
+ import { Pack, bytesSource } from './fs/pack.js'
10
+
11
+ const S_IFMT = 0o170000
12
+ const TYPES = { 0o100000: 'file', 0o040000: 'dir', 0o120000: 'symlink', 0o020000: 'char', 0o060000: 'block', 0o010000: 'fifo', 0o140000: 'socket' }
13
+
14
+ async function layerBytes(l, i, fetchFn) {
15
+ if (typeof l === 'string') {
16
+ const r = await fetchFn(l)
17
+ if (!r.ok) throw new Error(`layer ${i}: ${l}: HTTP ${r.status}`)
18
+ return new Uint8Array(await r.arrayBuffer())
19
+ }
20
+ if (l instanceof Uint8Array) return l
21
+ if (l instanceof ArrayBuffer || (typeof SharedArrayBuffer !== 'undefined' && l instanceof SharedArrayBuffer)) return new Uint8Array(l)
22
+ if (typeof Blob !== 'undefined' && l instanceof Blob) return new Uint8Array(await l.arrayBuffer())
23
+ throw new TypeError(`layer ${i}: a URL, bytes or a Blob, not ${Object.prototype.toString.call(l)}`)
24
+ }
25
+
26
+ const missing = e => e instanceof FsError && e.errno === ENOENT
27
+
28
+ /**
29
+ * Opens an image's layers: (url | Uint8Array | ArrayBuffer | Blob)[], top first, as bootWmd's 9P service
30
+ * takes them. Each pack is held whole in memory while the result is in use.
31
+ * @returns {Promise<ImageFiles>}
32
+ */
33
+ export async function openImage(layers, { fetch: fetchFn = globalThis.fetch } = {}) {
34
+ const packs = await Promise.all(layers.map(async (l, i) => new Pack(bytesSource(await layerBytes(l, i, fetchFn)), `layer ${i}`)))
35
+ return new ImageFiles(new Overlay({ layers: packs }))
36
+ }
37
+
38
+ export class ImageFiles {
39
+ constructor(fs) {
40
+ this.fs = fs
41
+ }
42
+
43
+ /** A file's bytes (symlinks followed inside the image), or null when it does not exist. Throws for a directory. */
44
+ readFile(path) {
45
+ try {
46
+ return this.fs.readFile(path)
47
+ } catch (e) {
48
+ if (missing(e)) return null
49
+ throw e
50
+ }
51
+ }
52
+
53
+ /** A file's text (UTF-8), or null when it does not exist. */
54
+ readText(path) {
55
+ const b = this.readFile(path)
56
+ return b && new TextDecoder().decode(b)
57
+ }
58
+
59
+ /** {type ('file', 'dir', 'symlink', ...), mode (permission bits), uid, gid, size, mtime (BigInt ns), target?}, or null. */
60
+ stat(path, follow = true) {
61
+ let n
62
+ try {
63
+ n = this.fs.resolve(path, follow)
64
+ } catch (e) {
65
+ if (missing(e)) return null
66
+ throw e
67
+ }
68
+ return this.#info(n)
69
+ }
70
+
71
+ #info(n) {
72
+ const st = { type: TYPES[n.mode & S_IFMT] || 'other', mode: n.mode & 0o7777, uid: n.uid, gid: n.gid, size: this.fs.size(n), mtime: n.mtime }
73
+ if (st.type === 'symlink') st.target = n.target
74
+ return st
75
+ }
76
+
77
+ /**
78
+ * Every entry at and under path (not following symlinks; a symlink at path itself is followed), depth
79
+ * first, names in byte order: {path (relative to path; '' for path itself), ...stat, data? (a file's
80
+ * bytes, unless {data: false})}. Nothing when path does not exist.
81
+ */
82
+ *walk(path, { data = true } = {}) {
83
+ let top
84
+ try {
85
+ top = this.fs.resolve(path, true)
86
+ } catch (e) {
87
+ if (missing(e)) return
88
+ throw e
89
+ }
90
+ const fs = this.fs
91
+ const self = this
92
+ function* visit(n, rel) {
93
+ const e = { path: rel, ...self.#info(n) }
94
+ if (data && e.type === 'file') e.data = fs.read(n, 0, fs.size(n))
95
+ yield e
96
+ if (n.isDir()) for (const [name, c] of fs.list(n)) yield* visit(c, rel ? `${rel}/${name}` : name)
97
+ }
98
+ yield* visit(top, '')
99
+ }
100
+
101
+ /** {users, groups} from the image's /etc/passwd and /etc/group (empty lists when a file is missing). */
102
+ accounts() {
103
+ return { users: parsePasswd(this.readText('/etc/passwd') || ''), groups: parseGroup(this.readText('/etc/group') || '') }
104
+ }
105
+ }
106
+
107
+ /** /etc/passwd lines: [{name, uid, gid, home, shell}] (comments and malformed lines skipped). */
108
+ export function parsePasswd(text) {
109
+ const out = []
110
+ for (const line of text.split('\n')) {
111
+ const f = line.split(':')
112
+ if (f.length < 7 || line.startsWith('#') || !/^\d+$/.test(f[2]) || !/^\d+$/.test(f[3])) continue
113
+ out.push({ name: f[0], uid: Number(f[2]), gid: Number(f[3]), home: f[5], shell: f[6] })
114
+ }
115
+ return out
116
+ }
117
+
118
+ /** /etc/group lines: [{name, gid, members}]. */
119
+ export function parseGroup(text) {
120
+ const out = []
121
+ for (const line of text.split('\n')) {
122
+ const f = line.split(':')
123
+ if (f.length < 4 || line.startsWith('#') || !/^\d+$/.test(f[2])) continue
124
+ out.push({ name: f[0], gid: Number(f[2]), members: f[3] ? f[3].split(',').filter(Boolean) : [] })
125
+ }
126
+ return out
127
+ }
128
+
129
+ /**
130
+ * A Dockerfile `USER` / `--chown` value against an image's accounts, as Docker reads it: "name", "uid",
131
+ * "name:group", "uid:gid" (each side a name or a number). A user alone takes its passwd group (a uid
132
+ * that is not in passwd: group 0). A name that is not in the image throws, as Docker refuses it.
133
+ * @returns {{uid: number, gid: number, name: string|null, home: string|null}}
134
+ */
135
+ export function resolveUser(spec, { users = [], groups = [] } = {}) {
136
+ const s = String(spec).trim()
137
+ if (!s) throw new Error('empty user')
138
+ const colon = s.indexOf(':')
139
+ const u = colon < 0 ? s : s.slice(0, colon)
140
+ const g = colon < 0 ? null : s.slice(colon + 1)
141
+ const numeric = x => /^\d+$/.test(x)
142
+ const pw = numeric(u) ? users.find(x => x.uid === Number(u)) : users.find(x => x.name === u)
143
+ if (!pw && !numeric(u)) throw new Error(`unable to find user ${u}: no matching entries in passwd file`)
144
+ const uid = pw ? pw.uid : Number(u)
145
+ let gid
146
+ if (g === null || g === '') gid = pw ? pw.gid : 0
147
+ else if (numeric(g)) gid = Number(g)
148
+ else {
149
+ const gr = groups.find(x => x.name === g)
150
+ if (!gr) throw new Error(`unable to find group ${g}: no matching entries in group file`)
151
+ gid = gr.gid
152
+ }
153
+ return { uid, gid, name: pw ? pw.name : null, home: pw ? pw.home : null }
154
+ }
@@ -0,0 +1,126 @@
1
+ // P-B10: the guest-facing DHCP server (one static lease, as the engine's netstack gives)
2
+ // and DNS message parsing/encoding (queries are answered through the relay, stack.js).
3
+
4
+ import { rd16, rd32, wr16, wr32 } from './packet.js'
5
+
6
+ const MAGIC = 0x63825363
7
+ export const DHCP = { DISCOVER: 1, OFFER: 2, REQUEST: 3, DECLINE: 4, ACK: 5, NAK: 6, RELEASE: 7, INFORM: 8 }
8
+
9
+ /** Parse a BOOTP/DHCP request; null if it is not one. */
10
+ export function parseDhcp(p) {
11
+ if (p.length < 240 || p[0] !== 1 || p[1] !== 1 || p[2] !== 6 || rd32(p, 236) !== MAGIC) return null
12
+ const m = { xid: rd32(p, 4), flags: rd16(p, 10), ciaddr: rd32(p, 12), chaddr: p.slice(28, 34), type: 0, requested: 0 }
13
+ for (let i = 240; i < p.length; ) {
14
+ const code = p[i]
15
+ if (code === 255) break
16
+ if (code === 0) {
17
+ i++
18
+ continue
19
+ }
20
+ const len = p[i + 1]
21
+ if (i + 2 + len > p.length) break
22
+ if (code === 53 && len === 1) m.type = p[i + 2]
23
+ else if (code === 50 && len === 4) m.requested = rd32(p, i + 2)
24
+ i += 2 + len
25
+ }
26
+ return m
27
+ }
28
+
29
+ /** A DHCP reply (OFFER/ACK) giving `ip` with the router and DNS server at `gw`. */
30
+ export function dhcpReply(req, type, { ip, gw, mask, leaseS = 86400, mtu = 0 }) {
31
+ const opts = [
32
+ [53, [type]],
33
+ [54, u32b(gw)],
34
+ [51, u32b(leaseS)],
35
+ [58, u32b(Math.floor(leaseS / 2))],
36
+ [59, u32b(Math.floor((leaseS * 7) / 8))],
37
+ [1, u32b(mask)],
38
+ [3, u32b(gw)],
39
+ [6, u32b(gw)],
40
+ [28, u32b((ip & mask) | (~mask >>> 0))],
41
+ ]
42
+ if (mtu) opts.push([26, [mtu >> 8, mtu & 255]])
43
+ const olen = opts.reduce((s, [, v]) => s + 2 + v.length, 0) + 1
44
+ const p = new Uint8Array(Math.max(300, 240 + olen))
45
+ p[0] = 2
46
+ p[1] = 1
47
+ p[2] = 6
48
+ wr32(p, 4, req.xid)
49
+ wr16(p, 10, req.flags)
50
+ if (type !== DHCP.NAK && req.type !== DHCP.INFORM) wr32(p, 16, ip)
51
+ if (req.type === DHCP.INFORM) wr32(p, 12, req.ciaddr)
52
+ wr32(p, 20, gw)
53
+ p.set(req.chaddr, 28)
54
+ wr32(p, 236, MAGIC)
55
+ let o = 240
56
+ for (const [c, v] of opts) {
57
+ p[o++] = c
58
+ p[o++] = v.length
59
+ for (const x of v) p[o++] = x
60
+ }
61
+ p[o] = 255
62
+ return p
63
+ }
64
+
65
+ function u32b(v) {
66
+ return [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255]
67
+ }
68
+
69
+ export const QTYPE = { A: 1, AAAA: 28 }
70
+ export const RC = { NOERROR: 0, FORMERR: 1, SERVFAIL: 2, NXDOMAIN: 3, NOTIMP: 4, REFUSED: 5 }
71
+
72
+ /** Parse a DNS query with one question; null if malformed. */
73
+ export function parseDnsQuery(p) {
74
+ if (p.length < 12) return null
75
+ const id = rd16(p, 0)
76
+ const flags = rd16(p, 2)
77
+ if (flags & 0x8000) return null // a response
78
+ const qd = rd16(p, 4)
79
+ const q = { id, flags, opcode: (flags >> 11) & 15, rd: !!(flags & 0x100), qdcount: qd, name: '', qtype: 0, qclass: 0, qend: 12 }
80
+ if (qd !== 1) return q
81
+ const labels = []
82
+ let i = 12
83
+ for (;;) {
84
+ if (i >= p.length) return null
85
+ const len = p[i]
86
+ if (len === 0) {
87
+ i++
88
+ break
89
+ }
90
+ if (len > 63) return null // compression is not used in a question
91
+ if (i + 1 + len > p.length) return null
92
+ labels.push(String.fromCharCode(...p.subarray(i + 1, i + 1 + len)))
93
+ i += 1 + len
94
+ if (i - 12 > 255) return null
95
+ }
96
+ if (i + 4 > p.length) return null
97
+ q.name = labels.join('.')
98
+ q.qtype = rd16(p, i)
99
+ q.qclass = rd16(p, i + 2)
100
+ q.qend = i + 4
101
+ return q
102
+ }
103
+
104
+ /** A response to query q (the request bytes p): rcode and A/AAAA answers (byte arrays). */
105
+ export function dnsResponse(p, q, rcode, answers = [], ttl = 60) {
106
+ const question = q.qdcount === 1 ? p.subarray(12, q.qend) : new Uint8Array(0)
107
+ const alen = answers.reduce((s, a) => s + 12 + a.length, 0)
108
+ const r = new Uint8Array(12 + question.length + alen)
109
+ wr16(r, 0, q.id)
110
+ // QR, the opcode, RD copied, RA; AA off
111
+ wr16(r, 2, 0x8000 | (q.opcode << 11) | (q.rd ? 0x100 : 0) | 0x80 | (rcode & 15))
112
+ wr16(r, 4, q.qdcount === 1 ? 1 : 0)
113
+ wr16(r, 6, answers.length)
114
+ r.set(question, 12)
115
+ let o = 12 + question.length
116
+ for (const a of answers) {
117
+ wr16(r, o, 0xc00c) // the name in the question
118
+ wr16(r, o + 2, a.length === 16 ? QTYPE.AAAA : QTYPE.A)
119
+ wr16(r, o + 4, 1)
120
+ wr32(r, o + 6, ttl)
121
+ wr16(r, o + 10, a.length)
122
+ r.set(a, o + 12)
123
+ o += 12 + a.length
124
+ }
125
+ return r
126
+ }