@threadedstack/wmd-web 0.0.0-stage → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- package/wmdpack.wasm +0 -0
|
@@ -0,0 +1,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
|
+
}
|