@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.
- 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
package/src/host/ctl.js
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
// P-B11 (ROADMAP 2.2b): wmd's control protocol, the page's half
|
|
2
|
+
// (internal/engine/ctlproto.go, control.go; the guest's guest/container2wasm/cmd/init
|
|
3
|
+
// control.go, procs.go).
|
|
4
|
+
//
|
|
5
|
+
// One yamux stream per operation (the yamux client runs in the channel service Worker,
|
|
6
|
+
// yamux.js; this module sees each stream as bytes in and out):
|
|
7
|
+
// 1. The host writes one JSON request line: {"op":"run"|"exec"|"kill"|"attach"|"procs"|
|
|
8
|
+
// "pause"|"resume"|"ps"|"stats"|"step"|"resync", "id", "args", "env", "cwd", "user",
|
|
9
|
+
// "tty", "sig", "all", "ps_args", "now", "seed" (base64 of 32 bytes, Go's []byte),
|
|
10
|
+
// "p9tag", "p9opts", "ckpt", ...}; empty fields are left out (Go's omitempty).
|
|
11
|
+
// 2. run / exec / step: frames both ways, id[1] | length[4] big-endian | payload; ids 0
|
|
12
|
+
// control (JSON {"resize":[rows,cols]} or {"close_stdin":true}), 1 stdin, 2 stdout,
|
|
13
|
+
// 3 stderr, 4 exit (4-byte BE code, optionally 4 more bytes of flags, bit 0 "synced").
|
|
14
|
+
// The host sends close_stdin at once when there is no stdin (control.go).
|
|
15
|
+
// 3. attach: one JSON line ({"ok","error","state","code","buffered","dropped"}), then the
|
|
16
|
+
// frames of the process (the ring it kept while detached first), as for run/exec.
|
|
17
|
+
// 4. Other ops: one JSON response line {"ok","error","exit_code","titles","rows","stats","ids"}.
|
|
18
|
+
// A stream that ends without an exit frame is exit 125 (control.go runProcessExit).
|
|
19
|
+
|
|
20
|
+
export const STREAM_CONTROL = 0
|
|
21
|
+
export const STREAM_STDIN = 1
|
|
22
|
+
export const STREAM_STDOUT = 2
|
|
23
|
+
export const STREAM_STDERR = 3
|
|
24
|
+
export const STREAM_EXIT = 4
|
|
25
|
+
export const EXIT_SYNCED = 1
|
|
26
|
+
/** readFrame's sanity limit (ctlproto.go). */
|
|
27
|
+
export const MAX_CTL_FRAME = 10 * 1024 * 1024
|
|
28
|
+
export const MAIN_PROC_ID = 'main'
|
|
29
|
+
|
|
30
|
+
const enc = new TextEncoder()
|
|
31
|
+
|
|
32
|
+
/** One frame: id | len BE | payload. */
|
|
33
|
+
export function encodeFrame(id, payload = new Uint8Array(0)) {
|
|
34
|
+
const p = typeof payload === 'string' ? enc.encode(payload) : payload
|
|
35
|
+
const f = new Uint8Array(5 + p.length)
|
|
36
|
+
f[0] = id
|
|
37
|
+
new DataView(f.buffer).setUint32(1, p.length)
|
|
38
|
+
f.set(p, 5)
|
|
39
|
+
return f
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The exit frame's payload: {code, synced} (ctlproto.go parseExitFrame). */
|
|
43
|
+
export function parseExitFrame(p) {
|
|
44
|
+
if (p.length < 4) return { code: 125, synced: false }
|
|
45
|
+
const dv = new DataView(p.buffer, p.byteOffset, p.length)
|
|
46
|
+
return { code: dv.getInt32(0), synced: p.length >= 8 && (dv.getUint32(4) & EXIT_SYNCED) !== 0 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** base64 of bytes (Go encodes []byte in JSON as standard base64). */
|
|
50
|
+
export function base64(u8) {
|
|
51
|
+
let s = ''
|
|
52
|
+
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i])
|
|
53
|
+
return btoa(s)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The request line, with Go's omitempty: empty strings, false, 0 and empty lists are left out. */
|
|
57
|
+
export function requestLine(req) {
|
|
58
|
+
const o = {}
|
|
59
|
+
for (const [k, v] of Object.entries(req)) {
|
|
60
|
+
if (v === undefined || v === null || v === '' || v === false || v === 0) continue
|
|
61
|
+
if (Array.isArray(v) && !v.length) continue
|
|
62
|
+
o[k] = v instanceof Uint8Array ? base64(v) : v
|
|
63
|
+
}
|
|
64
|
+
if (!o.op) throw new Error('control request without op')
|
|
65
|
+
return enc.encode(JSON.stringify(o) + '\n')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the engine adds to a run request (run.go fillRunRequest): the host clock, 32 bytes
|
|
70
|
+
* of entropy (crypto.getRandomValues: the guest has no entropy device, P-B3 problem 2),
|
|
71
|
+
* the share's 9P tag and the guest's mount options. The browser mounts with cache=mmap
|
|
72
|
+
* (p9mount.js), not the native engine's cache=loose.
|
|
73
|
+
*/
|
|
74
|
+
export function fillRunRequest(req, { p9opts, now = Date.now(), random = globalThis.crypto } = {}) {
|
|
75
|
+
if (!p9opts) throw new Error('fillRunRequest: p9opts is required (the init refuses a first run without it)')
|
|
76
|
+
const seed = new Uint8Array(32)
|
|
77
|
+
random.getRandomValues(seed)
|
|
78
|
+
return { ...req, now: Math.floor(now / 1000), seed, p9tag: 'wmdroot', p9opts }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cat(a, b) {
|
|
82
|
+
const o = new Uint8Array(a.length + b.length)
|
|
83
|
+
o.set(a)
|
|
84
|
+
o.set(b, a.length)
|
|
85
|
+
return o
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Bytes kept as chunks: appending costs no copy, only taking does (no O(n^2) on small pieces). */
|
|
89
|
+
class ByteQueue {
|
|
90
|
+
constructor() {
|
|
91
|
+
this.chunks = []
|
|
92
|
+
this.off = 0 // into chunks[0]
|
|
93
|
+
this.length = 0
|
|
94
|
+
}
|
|
95
|
+
push(u8) {
|
|
96
|
+
if (u8.length) {
|
|
97
|
+
this.chunks.push(u8)
|
|
98
|
+
this.length += u8.length
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The first n bytes (n <= length), copied; removed when take is true. */
|
|
102
|
+
read(n, take) {
|
|
103
|
+
const out = new Uint8Array(n)
|
|
104
|
+
let got = 0
|
|
105
|
+
let i = 0
|
|
106
|
+
let off = this.off
|
|
107
|
+
while (got < n) {
|
|
108
|
+
const c = this.chunks[i]
|
|
109
|
+
const k = Math.min(n - got, c.length - off)
|
|
110
|
+
out.set(c.subarray(off, off + k), got)
|
|
111
|
+
got += k
|
|
112
|
+
off += k
|
|
113
|
+
if (off === c.length) {
|
|
114
|
+
i++
|
|
115
|
+
off = 0
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (take) {
|
|
119
|
+
this.chunks.splice(0, i)
|
|
120
|
+
this.off = off
|
|
121
|
+
this.length -= n
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Splits a byte stream into frames. Throws on a frame over MAX_CTL_FRAME. */
|
|
128
|
+
export class FrameReader {
|
|
129
|
+
constructor() {
|
|
130
|
+
this.q = new ByteQueue()
|
|
131
|
+
this.need = -1 // payload length of the frame whose header is read, or -1
|
|
132
|
+
this.id = 0
|
|
133
|
+
}
|
|
134
|
+
/** Returns the whole frames now available: [{id, payload}]. */
|
|
135
|
+
push(u8) {
|
|
136
|
+
this.q.push(u8)
|
|
137
|
+
const out = []
|
|
138
|
+
for (;;) {
|
|
139
|
+
if (this.need < 0) {
|
|
140
|
+
if (this.q.length < 5) break
|
|
141
|
+
const h = this.q.read(5, true)
|
|
142
|
+
const n = new DataView(h.buffer).getUint32(1)
|
|
143
|
+
if (n > MAX_CTL_FRAME) throw new Error(`control frame too large: ${n} bytes`)
|
|
144
|
+
this.id = h[0]
|
|
145
|
+
this.need = n
|
|
146
|
+
}
|
|
147
|
+
if (this.q.length < this.need) break
|
|
148
|
+
out.push({ id: this.id, payload: this.q.read(this.need, true) })
|
|
149
|
+
this.need = -1
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Splits off the first line (for response and attach lines). */
|
|
156
|
+
export class LineReader {
|
|
157
|
+
constructor(max = 1 << 20) {
|
|
158
|
+
this.buf = new Uint8Array(0)
|
|
159
|
+
this.max = max
|
|
160
|
+
}
|
|
161
|
+
/** Returns {line, rest} once a newline came, else null. */
|
|
162
|
+
push(u8) {
|
|
163
|
+
this.buf = this.buf.length ? cat(this.buf, u8) : u8
|
|
164
|
+
const nl = this.buf.indexOf(10)
|
|
165
|
+
if (nl < 0) {
|
|
166
|
+
if (this.buf.length > this.max) throw new Error('control: response line too long')
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
const line = new TextDecoder().decode(this.buf.subarray(0, nl))
|
|
170
|
+
const rest = this.buf.slice(nl + 1)
|
|
171
|
+
this.buf = new Uint8Array(0)
|
|
172
|
+
return { line, rest }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// The client. transport: {post(msg)}; the boot wires post() to the channel service
|
|
178
|
+
// Worker and calls client.onEvents(evs) with what the service sends:
|
|
179
|
+
// {ev:'session', up, epoch, why?} the guest opened (or lost) the control port
|
|
180
|
+
// {ev:'data', k, bytes} bytes on stream k (the page acks them)
|
|
181
|
+
// {ev:'fin', k} the guest closed its side of stream k
|
|
182
|
+
// {ev:'closed', k, why} stream k is gone (why null: both sides closed)
|
|
183
|
+
// Messages to the service ({t:'ctl', ...}):
|
|
184
|
+
// {op:'open', k, bytes} {op:'write', k, bytes} {op:'close', k} (FIN)
|
|
185
|
+
// {op:'reset', k} {op:'ack', k, n}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** Bytes the page may have posted to a stream that the service has not sent on yet
|
|
189
|
+
* (stdin backpressure): past it, CtlProcess.write refuses and writeAsync waits. 4 MiB of
|
|
190
|
+
* data plus room for the request line and frame headers, so one write of up to 4 MiB
|
|
191
|
+
* goes whole to an idle stream. */
|
|
192
|
+
export const STREAM_CREDIT = (4 << 20) + (64 << 10)
|
|
193
|
+
/** Streams the page has open at once; more wait their turn. The guest's yamux server
|
|
194
|
+
* resets streams it cannot queue for accept (AcceptBacklog 256). */
|
|
195
|
+
export const MAX_OPEN_STREAMS = 128
|
|
196
|
+
|
|
197
|
+
export class CtlStream {
|
|
198
|
+
constructor(client, k) {
|
|
199
|
+
this.client = client
|
|
200
|
+
this.k = k
|
|
201
|
+
this.onBytes = null
|
|
202
|
+
this.onFin = null
|
|
203
|
+
this.ended = false // closed (both sides) or reset
|
|
204
|
+
this.why = null
|
|
205
|
+
this.opened = false // the open has been posted (MAX_OPEN_STREAMS)
|
|
206
|
+
this.queued = [] // messages before the open went out
|
|
207
|
+
this.inflight = 0 // bytes posted and not yet sent on by the service
|
|
208
|
+
this.gotBytes = 0
|
|
209
|
+
this.creditWaiters = []
|
|
210
|
+
let resolve
|
|
211
|
+
this.closed = new Promise(r => (resolve = r))
|
|
212
|
+
this._resolve = resolve
|
|
213
|
+
}
|
|
214
|
+
_post(m) {
|
|
215
|
+
if (this.opened) this.client.transport.post(m)
|
|
216
|
+
else this.queued.push(m)
|
|
217
|
+
}
|
|
218
|
+
/** Posts u8 (counted against STREAM_CREDIT). False once the stream has ended. */
|
|
219
|
+
write(u8) {
|
|
220
|
+
if (this.ended) return false
|
|
221
|
+
this.inflight += u8.length
|
|
222
|
+
this._post({ t: 'ctl', op: 'write', k: this.k, bytes: u8 })
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
/** Bytes that may be posted now without passing STREAM_CREDIT. */
|
|
226
|
+
credit() {
|
|
227
|
+
return Math.max(0, STREAM_CREDIT - this.inflight)
|
|
228
|
+
}
|
|
229
|
+
/** Resolves when credit() >= n (or at once if the stream ended). */
|
|
230
|
+
waitCredit(n) {
|
|
231
|
+
if (this.ended || this.credit() >= Math.min(n, STREAM_CREDIT)) return Promise.resolve()
|
|
232
|
+
return new Promise(r => this.creditWaiters.push({ n: Math.min(n, STREAM_CREDIT), r }))
|
|
233
|
+
}
|
|
234
|
+
_sent(n) {
|
|
235
|
+
this.inflight = Math.max(0, this.inflight - n)
|
|
236
|
+
this._wake()
|
|
237
|
+
}
|
|
238
|
+
_wake() {
|
|
239
|
+
const w = this.creditWaiters
|
|
240
|
+
this.creditWaiters = []
|
|
241
|
+
for (const x of w) {
|
|
242
|
+
if (this.ended || this.credit() >= x.n) x.r()
|
|
243
|
+
else this.creditWaiters.push(x)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
closeWrite() {
|
|
247
|
+
if (!this.ended) this._post({ t: 'ctl', op: 'close', k: this.k })
|
|
248
|
+
}
|
|
249
|
+
reset() {
|
|
250
|
+
if (!this.ended) this._post({ t: 'ctl', op: 'reset', k: this.k })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A run, exec or attach: stdout/stderr callbacks, stdin, and `exited`
|
|
256
|
+
* ({code, synced, error?}). As control.go runProcessExit: a stream that ends without an
|
|
257
|
+
* exit frame gives 125 and the error.
|
|
258
|
+
*
|
|
259
|
+
* stdin: write() takes bytes only while the stream's credit allows (STREAM_CREDIT posted
|
|
260
|
+
* and not yet sent on to the guest) and returns false otherwise, sending nothing;
|
|
261
|
+
* writeAsync() queues and resolves once its bytes are posted, waiting for credit as the
|
|
262
|
+
* guest's yamux window lets the service send. Nothing is queued without bound.
|
|
263
|
+
*/
|
|
264
|
+
export class CtlProcess {
|
|
265
|
+
constructor(stream, { onStdout, onStderr, onControl, op = '', mapExec = false } = {}) {
|
|
266
|
+
this.stream = stream
|
|
267
|
+
this.onStdout = onStdout
|
|
268
|
+
this.onStderr = onStderr
|
|
269
|
+
this.onControl = onControl
|
|
270
|
+
this.op = op
|
|
271
|
+
this.mapExec = mapExec
|
|
272
|
+
this.frames = new FrameReader()
|
|
273
|
+
this.exit = null
|
|
274
|
+
this.stdinClosed = false
|
|
275
|
+
this.closeAfterQueue = false
|
|
276
|
+
this.queue = [] // writeAsync data waiting for credit, in order
|
|
277
|
+
this.pumping = false
|
|
278
|
+
this.bytes = { stdout: 0, stderr: 0, stdin: 0 }
|
|
279
|
+
this.errTail = '' // the last stderr text, for exec failures crun reports (mapExec)
|
|
280
|
+
this.sawStdout = false
|
|
281
|
+
let resolve
|
|
282
|
+
this.exited = new Promise(r => (resolve = r))
|
|
283
|
+
this._resolve = resolve
|
|
284
|
+
}
|
|
285
|
+
_frames(u8) {
|
|
286
|
+
let frames
|
|
287
|
+
try {
|
|
288
|
+
frames = this.frames.push(u8)
|
|
289
|
+
} catch (e) {
|
|
290
|
+
this.stream.reset()
|
|
291
|
+
this._end(e.message)
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
for (const f of frames) {
|
|
295
|
+
if (f.id === STREAM_STDOUT) {
|
|
296
|
+
this.bytes.stdout += f.payload.length
|
|
297
|
+
this.sawStdout = true
|
|
298
|
+
this.onStdout?.(f.payload)
|
|
299
|
+
} else if (f.id === STREAM_STDERR) {
|
|
300
|
+
this.bytes.stderr += f.payload.length
|
|
301
|
+
let p = f.payload
|
|
302
|
+
if (this.mapExec && !this.sawStdout) {
|
|
303
|
+
const text = new TextDecoder().decode(p)
|
|
304
|
+
this.errTail = (this.errTail + text).slice(-8192)
|
|
305
|
+
const clean = cleanRuntimeText(text)
|
|
306
|
+
if (clean !== text) p = enc.encode(clean)
|
|
307
|
+
}
|
|
308
|
+
this.onStderr?.(p)
|
|
309
|
+
} else if (f.id === STREAM_EXIT) {
|
|
310
|
+
if (!this.exit) {
|
|
311
|
+
this.exit = parseExitFrame(f.payload)
|
|
312
|
+
if (this.mapExec && this.exit.code === 1 && !this.sawStdout) {
|
|
313
|
+
// as the init does for run (control.go crunExecStatus), which it does not for exec
|
|
314
|
+
const code = crunExecStatus(this.errTail)
|
|
315
|
+
if (code) {
|
|
316
|
+
this.exit.code = code
|
|
317
|
+
this.exit.error = cleanRuntimeText(this.errTail.trim().split('\n').at(-1) || '')
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.stream.closeWrite() // nothing more to say; the guest closes its side
|
|
321
|
+
this._resolve({ ...this.exit })
|
|
322
|
+
}
|
|
323
|
+
} else if (f.id === STREAM_CONTROL) this.onControl?.(f.payload)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
_end(why) {
|
|
327
|
+
if (this.exit) return
|
|
328
|
+
this.exit = { code: 125, synced: false, error: why || 'process stream closed' }
|
|
329
|
+
this._resolve({ ...this.exit })
|
|
330
|
+
this.stream._wake()
|
|
331
|
+
}
|
|
332
|
+
#frame(p) {
|
|
333
|
+
return encodeFrame(STREAM_STDIN, p)
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Bytes (or text) for stdin, now: true when all of it was posted (in frames of at most
|
|
337
|
+
* 1 MiB), false (nothing sent) when the credit does not allow it, writeAsync data is
|
|
338
|
+
* still waiting, stdin is closed or the process has ended.
|
|
339
|
+
*/
|
|
340
|
+
write(data) {
|
|
341
|
+
if (this.stdinClosed || this.closeAfterQueue || this.exit || this.queue.length) return false
|
|
342
|
+
const p = typeof data === 'string' ? enc.encode(data) : data
|
|
343
|
+
const need = p.length + 5 * Math.ceil(p.length / (1 << 20))
|
|
344
|
+
if (need > this.stream.credit()) return false
|
|
345
|
+
this.bytes.stdin += p.length
|
|
346
|
+
for (let o = 0; o < p.length; o += 1 << 20) this.stream.write(this.#frame(p.subarray(o, Math.min(p.length, o + (1 << 20)))))
|
|
347
|
+
return true
|
|
348
|
+
}
|
|
349
|
+
/** Queues bytes (or text) for stdin; resolves true once all are posted, false if the process ended first. */
|
|
350
|
+
writeAsync(data) {
|
|
351
|
+
const p = typeof data === 'string' ? enc.encode(data) : data
|
|
352
|
+
if (this.stdinClosed || this.closeAfterQueue || this.exit) return Promise.resolve(false)
|
|
353
|
+
return new Promise(resolve => {
|
|
354
|
+
this.queue.push({ p, off: 0, resolve })
|
|
355
|
+
this.#pump()
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
async #pump() {
|
|
359
|
+
if (this.pumping) return
|
|
360
|
+
this.pumping = true
|
|
361
|
+
while (this.queue.length) {
|
|
362
|
+
const item = this.queue[0]
|
|
363
|
+
if (this.exit || this.stream.ended) {
|
|
364
|
+
for (const q of this.queue.splice(0)) q.resolve(false)
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
const chunk = Math.min(item.p.length - item.off, 1 << 20)
|
|
368
|
+
await this.stream.waitCredit(chunk + 5)
|
|
369
|
+
if (this.exit || this.stream.ended) continue
|
|
370
|
+
const n = Math.min(chunk, Math.max(1, this.stream.credit() - 5))
|
|
371
|
+
this.stream.write(this.#frame(item.p.subarray(item.off, item.off + n)))
|
|
372
|
+
this.bytes.stdin += n
|
|
373
|
+
item.off += n
|
|
374
|
+
if (item.off >= item.p.length) {
|
|
375
|
+
this.queue.shift()
|
|
376
|
+
item.resolve(true)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
this.pumping = false
|
|
380
|
+
if (this.closeAfterQueue && !this.queue.length) {
|
|
381
|
+
this.closeAfterQueue = false
|
|
382
|
+
this.#closeNow()
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Resolves when every writeAsync byte has been posted. */
|
|
386
|
+
drained() {
|
|
387
|
+
if (!this.queue.length) return Promise.resolve()
|
|
388
|
+
return new Promise(r => this.queue[this.queue.length - 1].resolve = (orig => v => (orig(v), r()))(this.queue[this.queue.length - 1].resolve))
|
|
389
|
+
}
|
|
390
|
+
/** close_stdin, after any writeAsync data still queued. */
|
|
391
|
+
closeStdin() {
|
|
392
|
+
if (this.stdinClosed || this.exit) return
|
|
393
|
+
if (this.queue.length) {
|
|
394
|
+
this.closeAfterQueue = true
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
this.#closeNow()
|
|
398
|
+
}
|
|
399
|
+
#closeNow() {
|
|
400
|
+
if (this.stdinClosed || this.exit) return
|
|
401
|
+
this.stdinClosed = true
|
|
402
|
+
this.stream.write(encodeFrame(STREAM_CONTROL, JSON.stringify({ close_stdin: true })))
|
|
403
|
+
}
|
|
404
|
+
resize(rows, cols) {
|
|
405
|
+
if (!this.exit) this.stream.write(encodeFrame(STREAM_CONTROL, JSON.stringify({ resize: [rows, cols] })))
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The init's name for the container (crun's id); never shown to the user. */
|
|
410
|
+
const CRUN_ID = 'foo'
|
|
411
|
+
|
|
412
|
+
/** crun's messages with the internal container id taken out. */
|
|
413
|
+
export function cleanRuntimeText(t) {
|
|
414
|
+
return t
|
|
415
|
+
.replaceAll(`the container \`${CRUN_ID}\``, 'the container')
|
|
416
|
+
.replaceAll(`container \`${CRUN_ID}\``, 'the container')
|
|
417
|
+
.replaceAll(`\`/run/crun/${CRUN_ID}/config.json\``, "the container's state")
|
|
418
|
+
.replaceAll(`/run/crun/${CRUN_ID}/`, '')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The init's crunExecStatus (control.go): 127 not found, 126 not executable, else 0. */
|
|
422
|
+
export function crunExecStatus(log) {
|
|
423
|
+
for (const line of log.split('\n')) {
|
|
424
|
+
const l = line.replace(/^[^:]*crun: /, '').replace(/^Error: /, '')
|
|
425
|
+
if (/executable file `.*` not found/.test(l)) return 127
|
|
426
|
+
if (/the path `.*` exists but it is not executable/.test(l)) return 126
|
|
427
|
+
if (/exec container process `.*`: Permission denied$/.test(l)) return 126
|
|
428
|
+
if (/exec container process `.*`: No such file or directory$/.test(l)) return 127
|
|
429
|
+
}
|
|
430
|
+
return 0
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export class ControlClient {
|
|
434
|
+
constructor(transport) {
|
|
435
|
+
this.transport = transport
|
|
436
|
+
this.streams = new Map()
|
|
437
|
+
this.nextK = 1
|
|
438
|
+
this.session = { up: false, epoch: 0, why: null }
|
|
439
|
+
this.waiters = []
|
|
440
|
+
this.acks = new Map()
|
|
441
|
+
this.waitingOpens = [] // streams past MAX_OPEN_STREAMS, in order
|
|
442
|
+
this.openNow = 0
|
|
443
|
+
this.ids = new Set() // run/exec ids of this page's processes that have not exited
|
|
444
|
+
this.stats = { opened: 0, bytesIn: 0, sessionUpAt: 0, queuedOpens: 0 }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
onEvents(evs) {
|
|
448
|
+
for (const e of evs) this.#event(e)
|
|
449
|
+
this.#flushAcks()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#event(e) {
|
|
453
|
+
if (e.ev === 'session') {
|
|
454
|
+
this.session = { up: e.up, epoch: e.epoch, why: e.why || null }
|
|
455
|
+
if (e.up) {
|
|
456
|
+
if (!this.stats.sessionUpAt) this.stats.sessionUpAt = performance.now()
|
|
457
|
+
const w = this.waiters
|
|
458
|
+
this.waiters = []
|
|
459
|
+
for (const x of w) {
|
|
460
|
+
clearTimeout(x.timer)
|
|
461
|
+
x.resolve()
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
const s = this.streams.get(e.k)
|
|
467
|
+
if (!s) return
|
|
468
|
+
if (e.ev === 'data') {
|
|
469
|
+
const u8 = new Uint8Array(e.bytes)
|
|
470
|
+
this.stats.bytesIn += u8.length
|
|
471
|
+
s.gotBytes += u8.length
|
|
472
|
+
s.onBytes?.(u8)
|
|
473
|
+
// acked once delivered: a page that stops serving events stops the guest (backpressure)
|
|
474
|
+
this.acks.set(e.k, (this.acks.get(e.k) || 0) + u8.length)
|
|
475
|
+
} else if (e.ev === 'sent') s._sent(e.n)
|
|
476
|
+
else if (e.ev === 'fin') s.onFin?.()
|
|
477
|
+
else if (e.ev === 'closed') {
|
|
478
|
+
s.ended = true
|
|
479
|
+
s.why = e.why === 'reset by the guest' && !s.gotBytes
|
|
480
|
+
? 'the guest refused the stream before any reply (reset: its yamux server queues at most 256 streams waiting to be accepted)'
|
|
481
|
+
: e.why
|
|
482
|
+
this.streams.delete(e.k)
|
|
483
|
+
this.acks.delete(e.k)
|
|
484
|
+
if (s.opened) {
|
|
485
|
+
this.openNow--
|
|
486
|
+
this.#openNext()
|
|
487
|
+
}
|
|
488
|
+
s._wake()
|
|
489
|
+
s._resolve(s.why)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#openNext() {
|
|
494
|
+
while (this.waitingOpens.length && this.openNow < MAX_OPEN_STREAMS) this.#postOpen(this.waitingOpens.shift())
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#postOpen({ s, line }) {
|
|
498
|
+
this.openNow++
|
|
499
|
+
s.opened = true
|
|
500
|
+
s.inflight += line.length
|
|
501
|
+
this.transport.post({ t: 'ctl', op: 'open', k: s.k, bytes: line })
|
|
502
|
+
for (const m of s.queued.splice(0)) this.transport.post(m)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#flushAcks() {
|
|
506
|
+
for (const [k, n] of this.acks) this.transport.post({ t: 'ctl', op: 'ack', k, n })
|
|
507
|
+
this.acks.clear()
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Resolves once the guest has opened the control port (the yamux session is up). Rejects at once, with the
|
|
512
|
+
* reason, when the machine has ended or its channel service has died (fail()): a wait on a machine that is
|
|
513
|
+
* gone must not run out its timeout.
|
|
514
|
+
*/
|
|
515
|
+
waitSession(timeoutMs = 600_000) {
|
|
516
|
+
if (this.session.up) return Promise.resolve()
|
|
517
|
+
if (this.failure) return Promise.reject(new Error(this.failure))
|
|
518
|
+
return new Promise((resolve, reject) => {
|
|
519
|
+
const t = setTimeout(() => {
|
|
520
|
+
this.waiters = this.waiters.filter(w => w.timer !== t)
|
|
521
|
+
reject(new Error(`the guest did not open the control channel within ${timeoutMs} ms`))
|
|
522
|
+
}, timeoutMs)
|
|
523
|
+
this.waiters.push({ timer: t, resolve, reject })
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The machine is over (it exited, trapped, or the channel service died): every wait rejects with `message` now. */
|
|
528
|
+
fail(message) {
|
|
529
|
+
if (this.failure) return
|
|
530
|
+
this.failure = message
|
|
531
|
+
const w = this.waiters
|
|
532
|
+
this.waiters = []
|
|
533
|
+
for (const x of w) {
|
|
534
|
+
clearTimeout(x.timer)
|
|
535
|
+
x.reject(new Error(message))
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** A stream with the request line sent. */
|
|
540
|
+
openStream(req) {
|
|
541
|
+
const line = requestLine(req)
|
|
542
|
+
const k = this.nextK++
|
|
543
|
+
const s = new CtlStream(this, k)
|
|
544
|
+
this.streams.set(k, s)
|
|
545
|
+
this.stats.opened++
|
|
546
|
+
if (this.openNow < MAX_OPEN_STREAMS) this.#postOpen({ s, line })
|
|
547
|
+
else {
|
|
548
|
+
this.stats.queuedOpens++
|
|
549
|
+
this.waitingOpens.push({ s, line })
|
|
550
|
+
}
|
|
551
|
+
return s
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** A stream that ends at once with `why` (a request refused on the page). */
|
|
555
|
+
#refused(why) {
|
|
556
|
+
const s = new CtlStream(this, this.nextK++)
|
|
557
|
+
s.ended = true
|
|
558
|
+
s.why = why
|
|
559
|
+
queueMicrotask(() => s._resolve(why))
|
|
560
|
+
return s
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** run / exec / step: a CtlProcess. stdin: false sends close_stdin at once (control.go). */
|
|
564
|
+
process(req, { stdin = false, ...handlers } = {}) {
|
|
565
|
+
const bad = checkProcessRequest(req, this.ids)
|
|
566
|
+
const s = bad ? this.#refused(bad) : this.openStream(req)
|
|
567
|
+
const p = new CtlProcess(s, { ...handlers, op: req.op, mapExec: req.op === 'exec' })
|
|
568
|
+
if (!bad && req.id && (req.op === 'exec' || req.op === 'run')) {
|
|
569
|
+
this.ids.add(req.id)
|
|
570
|
+
p.exited.then(() => this.ids.delete(req.id))
|
|
571
|
+
}
|
|
572
|
+
s.onBytes = u8 => p._frames(u8)
|
|
573
|
+
s.closed.then(why => p._end(why || 'process stream closed'))
|
|
574
|
+
if (!stdin && !bad) p.closeStdin()
|
|
575
|
+
return p
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
run(req, o) {
|
|
579
|
+
return this.process({ ...req, op: 'run' }, o)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** op "exec" (runc exec in the container). */
|
|
583
|
+
startExec(req, o) {
|
|
584
|
+
return this.process({ ...req, op: 'exec' }, o)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** attach (W3.4): {reply, proc}; proc is null when the reply is an error. */
|
|
588
|
+
attach(id, handlers = {}) {
|
|
589
|
+
const s = this.openStream({ op: 'attach', id })
|
|
590
|
+
const lines = new LineReader()
|
|
591
|
+
return new Promise((resolve, reject) => {
|
|
592
|
+
let proc = null
|
|
593
|
+
s.onBytes = u8 => {
|
|
594
|
+
if (proc) return proc._frames(u8)
|
|
595
|
+
let got
|
|
596
|
+
try {
|
|
597
|
+
got = lines.push(u8)
|
|
598
|
+
} catch (e) {
|
|
599
|
+
s.reset()
|
|
600
|
+
return reject(e)
|
|
601
|
+
}
|
|
602
|
+
if (!got) return
|
|
603
|
+
let reply
|
|
604
|
+
try {
|
|
605
|
+
reply = JSON.parse(got.line)
|
|
606
|
+
} catch {
|
|
607
|
+
s.reset()
|
|
608
|
+
return reject(new Error(`attach ${id}: bad reply ${JSON.stringify(got.line.slice(0, 200))}`))
|
|
609
|
+
}
|
|
610
|
+
if (!reply.ok) {
|
|
611
|
+
s.closeWrite()
|
|
612
|
+
return resolve({ reply, proc: null })
|
|
613
|
+
}
|
|
614
|
+
proc = new CtlProcess(s, handlers)
|
|
615
|
+
s.closed.then(why => proc._end(why || 'process stream closed'))
|
|
616
|
+
resolve({ reply, proc })
|
|
617
|
+
if (got.rest.length) proc._frames(got.rest)
|
|
618
|
+
}
|
|
619
|
+
s.closed.then(why => {
|
|
620
|
+
if (!proc) reject(new Error(`attach ${id}: the stream closed before the reply${why ? `: ${why}` : ''}`))
|
|
621
|
+
})
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** kill / procs / pause / resume / ps / stats: the JSON response. */
|
|
626
|
+
simple(req) {
|
|
627
|
+
const s = this.openStream(req)
|
|
628
|
+
const lines = new LineReader()
|
|
629
|
+
return new Promise((resolve, reject) => {
|
|
630
|
+
let done = false
|
|
631
|
+
s.onBytes = u8 => {
|
|
632
|
+
if (done) return
|
|
633
|
+
let got
|
|
634
|
+
try {
|
|
635
|
+
got = lines.push(u8)
|
|
636
|
+
} catch (e) {
|
|
637
|
+
done = true
|
|
638
|
+
s.reset()
|
|
639
|
+
return reject(e)
|
|
640
|
+
}
|
|
641
|
+
if (!got) return
|
|
642
|
+
done = true
|
|
643
|
+
s.closeWrite()
|
|
644
|
+
try {
|
|
645
|
+
resolve(JSON.parse(got.line))
|
|
646
|
+
} catch {
|
|
647
|
+
reject(new Error(`control ${req.op}: bad response ${JSON.stringify(got.line.slice(0, 200))}`))
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
s.closed.then(why => {
|
|
651
|
+
if (!done) reject(new Error(`control ${req.op}: the stream closed before a response${why ? `: ${why}` : ''}`))
|
|
652
|
+
})
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
kill({ id, sig = '', all = false } = {}) {
|
|
657
|
+
return this.simple({ op: 'kill', id, sig, all })
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
procs() {
|
|
661
|
+
return this.simple({ op: 'procs' })
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Why a run/exec request cannot go to the guest, or null. The init would answer some of
|
|
667
|
+
* these with a bare 125 (a NUL in an argument or env entry: Go's exec refuses it) or run
|
|
668
|
+
* them anyway (an id already in use by a process of this page).
|
|
669
|
+
*/
|
|
670
|
+
export function checkProcessRequest(req, liveIds = new Set()) {
|
|
671
|
+
if ((req.op === 'exec' || req.op === 'run') && (!Array.isArray(req.args) || !req.args.length) && req.op === 'exec') {
|
|
672
|
+
return 'exec: no command (args is empty)'
|
|
673
|
+
}
|
|
674
|
+
for (const [what, list] of [['argument', req.args || []], ['environment entry', req.env || []]]) {
|
|
675
|
+
for (let i = 0; i < list.length; i++) {
|
|
676
|
+
if (typeof list[i] !== 'string') return `${req.op}: ${what} ${i} is not a string`
|
|
677
|
+
if (list[i].includes('\0')) return `${req.op}: ${what} ${i} contains a NUL byte, which a process cannot be given (${JSON.stringify(list[i].slice(0, 40))})`
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (req.cwd && req.cwd.includes('\0')) return `${req.op}: the working directory contains a NUL byte`
|
|
681
|
+
if (req.id && liveIds.has(req.id)) return `${req.op}: the process id ${JSON.stringify(req.id)} is already in use by a running process`
|
|
682
|
+
return null
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// The bundle: the OCI runtime spec as the engine writes it (internal/engine/ocispec.go
|
|
687
|
+
// buildSpec, bundle.go writeBundle), for the guest init's config/config.json.
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
export const GUEST_9P_MOUNT = '/run/wmd9p'
|
|
691
|
+
export const DEFAULT_CAPS = [
|
|
692
|
+
'CAP_CHOWN', 'CAP_DAC_OVERRIDE', 'CAP_FSETID', 'CAP_FOWNER', 'CAP_MKNOD', 'CAP_NET_RAW', 'CAP_SETGID',
|
|
693
|
+
'CAP_SETUID', 'CAP_SETFCAP', 'CAP_SETPCAP', 'CAP_NET_BIND_SERVICE', 'CAP_SYS_CHROOT', 'CAP_KILL', 'CAP_AUDIT_WRITE',
|
|
694
|
+
]
|
|
695
|
+
const MASKED = ['/proc/acpi', '/proc/asound', '/proc/kcore', '/proc/keys', '/proc/latency_stats', '/proc/timer_list', '/proc/timer_stats', '/proc/sched_debug', '/proc/scsi', '/sys/firmware']
|
|
696
|
+
const READONLY = ['/proc/bus', '/proc/fs', '/proc/irq', '/proc/sys', '/proc/sysrq-trigger']
|
|
697
|
+
const ETC_FILES = ['hosts', 'resolv.conf', 'hostname']
|
|
698
|
+
|
|
699
|
+
function defaultMounts() {
|
|
700
|
+
return [
|
|
701
|
+
{ destination: '/proc', type: 'proc', source: 'proc', options: ['nosuid', 'noexec', 'nodev'] },
|
|
702
|
+
{ destination: '/dev', type: 'tmpfs', source: 'tmpfs', options: ['nosuid', 'strictatime', 'mode=755', 'size=65536k'] },
|
|
703
|
+
{ destination: '/dev/pts', type: 'devpts', source: 'devpts', options: ['nosuid', 'noexec', 'newinstance', 'ptmxmode=0666', 'mode=0620', 'gid=5'] },
|
|
704
|
+
{ destination: '/dev/shm', type: 'tmpfs', source: 'shm', options: ['nosuid', 'noexec', 'nodev', 'mode=1777', 'size=65536k'] },
|
|
705
|
+
{ destination: '/dev/mqueue', type: 'mqueue', source: 'mqueue', options: ['nosuid', 'noexec', 'nodev'] },
|
|
706
|
+
{ destination: '/sys', type: 'sysfs', source: 'sysfs', options: ['nosuid', 'noexec', 'nodev', 'ro'] },
|
|
707
|
+
]
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* The 9P tree's files for a container (paths relative to the share root, beside the
|
|
712
|
+
* rootfs/ the packs give): bundle/config/config.json, bundle/config/imageconfig.json,
|
|
713
|
+
* bundle/etc/{hosts,resolv.conf,hostname}, and one share per mount (vol0, vol1, ...)
|
|
714
|
+
* holding the mount's files.
|
|
715
|
+
*
|
|
716
|
+
* @param {object} o
|
|
717
|
+
* @param {string[]} o.args process args
|
|
718
|
+
* @param {string[]} [o.env]
|
|
719
|
+
* @param {string} [o.cwd] default "/"
|
|
720
|
+
* @param {{uid:number, gid:number, additionalGids?:number[]}} [o.user] numeric (the engine resolves names on the host)
|
|
721
|
+
* @param {boolean} [o.terminal]
|
|
722
|
+
* @param {string} [o.hostname] default "localhost"
|
|
723
|
+
* @param {boolean} [o.networkDisabled] no address line in /etc/hosts, empty resolv.conf (default true: no network yet)
|
|
724
|
+
* @param {{destination:string, files:Record<string,{data:Uint8Array|string, mode?:number}|string>, file?:string, readOnly?:boolean}[]} [o.mounts]
|
|
725
|
+
* each mount is a share vol<i> with these files; `file` binds only that file of the share
|
|
726
|
+
* @param {string} [o.arch] for imageconfig.json (default arm64)
|
|
727
|
+
* @returns {{files: Record<string, string|Uint8Array|{data, mode}>, spec: object}}
|
|
728
|
+
*/
|
|
729
|
+
export function containerBundle(o) {
|
|
730
|
+
const cwd = o.cwd || '/'
|
|
731
|
+
const user = o.user || { uid: 0, gid: 0 }
|
|
732
|
+
const hostname = o.hostname || 'localhost'
|
|
733
|
+
const spec = {
|
|
734
|
+
ociVersion: '1.0.2',
|
|
735
|
+
process: {
|
|
736
|
+
terminal: !!o.terminal,
|
|
737
|
+
user: { uid: user.uid, gid: user.gid, ...(user.additionalGids?.length ? { additionalGids: user.additionalGids } : {}) },
|
|
738
|
+
args: [...o.args],
|
|
739
|
+
env: [...(o.env || [])],
|
|
740
|
+
cwd,
|
|
741
|
+
capabilities: { bounding: DEFAULT_CAPS, effective: DEFAULT_CAPS, permitted: DEFAULT_CAPS },
|
|
742
|
+
rlimits: [{ type: 'RLIMIT_NOFILE', hard: 1 << 20, soft: 1 << 20 }],
|
|
743
|
+
oomScoreAdj: 0,
|
|
744
|
+
},
|
|
745
|
+
root: { path: `${GUEST_9P_MOUNT}/rootfs`, readonly: false },
|
|
746
|
+
hostname,
|
|
747
|
+
mounts: defaultMounts(),
|
|
748
|
+
linux: {
|
|
749
|
+
namespaces: [{ type: 'pid' }, { type: 'ipc' }, { type: 'uts' }, { type: 'mount' }],
|
|
750
|
+
maskedPaths: MASKED,
|
|
751
|
+
readonlyPaths: READONLY,
|
|
752
|
+
resources: { devices: [{ allow: false, access: 'rwm' }] },
|
|
753
|
+
},
|
|
754
|
+
}
|
|
755
|
+
for (const name of ETC_FILES) {
|
|
756
|
+
spec.mounts.push({ destination: `/etc/${name}`, type: 'bind', source: `${GUEST_9P_MOUNT}/bundle/etc/${name}`, options: ['rbind', 'rprivate', 'rw'] })
|
|
757
|
+
}
|
|
758
|
+
const files = {}
|
|
759
|
+
;(o.mounts || []).forEach((m, i) => {
|
|
760
|
+
const share = `vol${i}`
|
|
761
|
+
const entries = Object.entries(m.files || {})
|
|
762
|
+
for (const [rel, f] of entries) files[`${share}/${rel}`] = f
|
|
763
|
+
if (!entries.length) files[`${share}/.keep`] = '' // the share's directory
|
|
764
|
+
let src = `${GUEST_9P_MOUNT}/${share}`
|
|
765
|
+
if (m.file) src += `/${m.file}`
|
|
766
|
+
spec.mounts.push({ destination: m.destination, type: 'bind', source: src, options: ['rbind', 'rprivate', m.readOnly ? 'ro' : 'rw'] })
|
|
767
|
+
})
|
|
768
|
+
const networkDisabled = o.networkDisabled ?? true
|
|
769
|
+
let hosts = '127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost ip6-loopback\nfe00::\tip6-localnet\nff00::\tip6-mcastprefix\nff02::1\tip6-allnodes\nff02::2\tip6-allrouters\n'
|
|
770
|
+
if (!networkDisabled) hosts += `${o.ip || '10.88.0.2'}\t${hostname}\n`
|
|
771
|
+
files['bundle/etc/hosts'] = hosts
|
|
772
|
+
files['bundle/etc/resolv.conf'] = networkDisabled ? '' : `nameserver ${o.gateway || '10.88.0.1'}\n`
|
|
773
|
+
files['bundle/etc/hostname'] = `${hostname}\n`
|
|
774
|
+
files['bundle/config/config.json'] = JSON.stringify(spec, null, 2) + '\n'
|
|
775
|
+
files['bundle/config/imageconfig.json'] =
|
|
776
|
+
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 }
|
|
778
|
+
}
|