@rallycry/conveyor-k3 0.1.10 → 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/dist/cli.js +12935 -8559
- package/dist/index.d.ts +267 -85
- package/dist/index.js +1853 -364
- package/dist/vm-qemu.js +408 -89
- package/package.json +1 -1
package/dist/vm-qemu.js
CHANGED
|
@@ -7,9 +7,127 @@ import { dirname, join as join5 } from "path";
|
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { createHash } from "crypto";
|
|
9
9
|
|
|
10
|
+
// src/vm/capacity.ts
|
|
11
|
+
var GIB = 1024 ** 3;
|
|
12
|
+
var MIB = 1024 ** 2;
|
|
13
|
+
var CONTROL_RELAY_SLOTS = 6;
|
|
14
|
+
var CAPACITY_BOUNDS = {
|
|
15
|
+
relaySlots: { min: 16, max: 192, flag: "relay-slots" },
|
|
16
|
+
relayMaxGiB: { min: 1, max: 16, flag: "relay-max-gib" },
|
|
17
|
+
diskMiBps: { min: 25, max: 2e3, flag: "disk-mbps" },
|
|
18
|
+
diskIops: { min: 500, max: 5e4, flag: "disk-iops" }
|
|
19
|
+
};
|
|
20
|
+
var LEGACY_CAPACITY_LIMITS = Object.freeze({
|
|
21
|
+
schema: 1,
|
|
22
|
+
profile: "default",
|
|
23
|
+
relaySlots: 16,
|
|
24
|
+
reservedRelaySlots: 0,
|
|
25
|
+
relayMaxBytes: GIB,
|
|
26
|
+
diskBytesPerSecond: 100 * MIB,
|
|
27
|
+
diskIops: 2e3
|
|
28
|
+
});
|
|
29
|
+
var KEYS = Object.keys(CAPACITY_BOUNDS);
|
|
30
|
+
var PROFILES = /* @__PURE__ */ new Set(["default", "server", "custom"]);
|
|
31
|
+
function parseCapacityLimits(value) {
|
|
32
|
+
if (value === void 0) return { ...LEGACY_CAPACITY_LIMITS };
|
|
33
|
+
const limits = typeof value === "object" && value !== null ? value : {};
|
|
34
|
+
const within = (key, min, max) => Number.isSafeInteger(limits[key]) && limits[key] >= min && limits[key] <= max;
|
|
35
|
+
const bounds = CAPACITY_BOUNDS;
|
|
36
|
+
if (limits.schema !== 1 || !PROFILES.has(limits.profile) || !within("relaySlots", bounds.relaySlots.min, bounds.relaySlots.max) || limits.reservedRelaySlots !== 0 && limits.reservedRelaySlots !== CONTROL_RELAY_SLOTS || !within("relayMaxBytes", bounds.relayMaxGiB.min * GIB, bounds.relayMaxGiB.max * GIB) || !within("diskBytesPerSecond", bounds.diskMiBps.min * MIB, bounds.diskMiBps.max * MIB) || !within("diskIops", bounds.diskIops.min, bounds.diskIops.max))
|
|
37
|
+
throw new Error("The VM capacity limits in the host network policy are invalid.");
|
|
38
|
+
return {
|
|
39
|
+
schema: 1,
|
|
40
|
+
profile: limits.profile,
|
|
41
|
+
relaySlots: limits.relaySlots,
|
|
42
|
+
reservedRelaySlots: limits.reservedRelaySlots,
|
|
43
|
+
relayMaxBytes: limits.relayMaxBytes,
|
|
44
|
+
diskBytesPerSecond: limits.diskBytesPerSecond,
|
|
45
|
+
diskIops: limits.diskIops
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function guardProcessLimit(relaySlots) {
|
|
49
|
+
return 48 + relaySlots;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/vm/instance-name.ts
|
|
53
|
+
var VM_NAME = "conveyor";
|
|
54
|
+
var DEFAULT_INSTANCE = "default";
|
|
55
|
+
var NAMED_INSTANCE = /^(?=.{1,12}$)[a-z](?:-?[a-z0-9])*$/;
|
|
56
|
+
function isNamedInstance(value) {
|
|
57
|
+
return typeof value === "string" && NAMED_INSTANCE.test(value) && value !== DEFAULT_INSTANCE && value !== VM_NAME;
|
|
58
|
+
}
|
|
59
|
+
function vmNameFor(instance) {
|
|
60
|
+
return instance === DEFAULT_INSTANCE ? VM_NAME : instance;
|
|
61
|
+
}
|
|
62
|
+
function stateInstance(value) {
|
|
63
|
+
if (value === void 0) return DEFAULT_INSTANCE;
|
|
64
|
+
if (!isNamedInstance(value)) throw new Error("Invalid VM instance in host state");
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
10
68
|
// src/vm/network.ts
|
|
11
69
|
import { networkInterfaces } from "os";
|
|
12
70
|
import { resolve4 as systemResolve4, resolveSrv as systemResolveSrv } from "dns/promises";
|
|
71
|
+
|
|
72
|
+
// src/vm/endpoints.ts
|
|
73
|
+
var DEFAULT_ENDPOINTS = [
|
|
74
|
+
"archive.ubuntu.com",
|
|
75
|
+
"security.ubuntu.com",
|
|
76
|
+
"ports.ubuntu.com",
|
|
77
|
+
"registry.npmjs.org",
|
|
78
|
+
// Font Awesome Pro tarballs through Conveyor's Artifact Registry cache.
|
|
79
|
+
"us-central1-npm.pkg.dev",
|
|
80
|
+
// Tiptap Pro packages, which every claudespace bake installs.
|
|
81
|
+
"registry.tiptap.dev",
|
|
82
|
+
// The Python venv a pod builds at runtime.
|
|
83
|
+
"pypi.org",
|
|
84
|
+
"files.pythonhosted.org",
|
|
85
|
+
"bun.sh",
|
|
86
|
+
"get.k3s.io",
|
|
87
|
+
"update.k3s.io",
|
|
88
|
+
"github.com",
|
|
89
|
+
"api.github.com",
|
|
90
|
+
"raw.githubusercontent.com",
|
|
91
|
+
"release-assets.githubusercontent.com",
|
|
92
|
+
"objects.githubusercontent.com",
|
|
93
|
+
"ghcr.io",
|
|
94
|
+
"pkg-containers.githubusercontent.com",
|
|
95
|
+
// The crane image that shadow bakes' state-bake and publish Jobs run. gcr.io
|
|
96
|
+
// serves its blobs from the same host (/artifacts-downloads), on Google's
|
|
97
|
+
// shared frontend addresses like us-central1-npm.pkg.dev.
|
|
98
|
+
"gcr.io",
|
|
99
|
+
// The first shadow bake on a VM builds the toolchain base inside it; base
|
|
100
|
+
// reuse never crosses machines. These are the hosts that build downloads
|
|
101
|
+
// from. Several sit on shared CDN addresses.
|
|
102
|
+
// Plain HTTP too: the first apt-get runs before ca-certificates exists.
|
|
103
|
+
"deb.debian.org",
|
|
104
|
+
"www.postgresql.org",
|
|
105
|
+
"apt.postgresql.org",
|
|
106
|
+
"cli.github.com",
|
|
107
|
+
"johnvansickle.com",
|
|
108
|
+
"astral.sh",
|
|
109
|
+
// uv downloads CPython from here.
|
|
110
|
+
"releases.astral.sh",
|
|
111
|
+
"opencode.ai",
|
|
112
|
+
"cdn.playwright.dev",
|
|
113
|
+
"playwright.download.prss.microsoft.com",
|
|
114
|
+
"registry-1.docker.io",
|
|
115
|
+
"auth.docker.io",
|
|
116
|
+
"production.cloudflare.docker.com",
|
|
117
|
+
"production.cloudfront.docker.com",
|
|
118
|
+
"api.cloudflare.com",
|
|
119
|
+
"conveyor.rallycryapp.com",
|
|
120
|
+
"conveyor-api.rallycryapp.com",
|
|
121
|
+
"api.conveyor.rallycryapp.com",
|
|
122
|
+
"api.anthropic.com",
|
|
123
|
+
"api.openai.com",
|
|
124
|
+
"chatgpt.com"
|
|
125
|
+
].map((host) => ({
|
|
126
|
+
host,
|
|
127
|
+
ports: host.endsWith(".ubuntu.com") || host === "deb.debian.org" ? [80, 443] : [443]
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// src/vm/network.ts
|
|
13
131
|
var WORKER_SUBNET = "192.168.5.0/24";
|
|
14
132
|
var WORKER_IP = "192.168.5.15";
|
|
15
133
|
var MAX_NETWORK_FORWARDS = 100;
|
|
@@ -50,6 +168,159 @@ function isGloballyRoutableIPv4(address) {
|
|
|
50
168
|
].some(([base, mask]) => inCidr(value, parseIPv4(base), mask));
|
|
51
169
|
}
|
|
52
170
|
|
|
171
|
+
// src/vm/public-web.ts
|
|
172
|
+
import { resolve4 } from "dns/promises";
|
|
173
|
+
import { networkInterfaces as networkInterfaces2 } from "os";
|
|
174
|
+
var PUBLIC_WEB_IP = "192.168.5.250";
|
|
175
|
+
var PUBLIC_WEB_PORT = 3128;
|
|
176
|
+
var PUBLIC_WEB_URL = `http://${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}`;
|
|
177
|
+
async function publicWebAddress(host, lookup = resolve4, interfaces = networkInterfaces2) {
|
|
178
|
+
if (!/^[a-z0-9.-]+$/i.test(host) || host.length > 253)
|
|
179
|
+
throw new Error("Invalid public destination");
|
|
180
|
+
const own = new Set(
|
|
181
|
+
Object.values(interfaces()).flatMap((items) => items?.map((i) => i.address) ?? [])
|
|
182
|
+
);
|
|
183
|
+
const answers = /^\d+\.\d+\.\d+\.\d+$/.test(host) ? [host] : await lookup(host);
|
|
184
|
+
if (!answers.length || answers.some((ip) => !isGloballyRoutableIPv4(ip) || own.has(ip)))
|
|
185
|
+
throw new Error("Host, LAN and reserved destinations are blocked");
|
|
186
|
+
const address = answers[0];
|
|
187
|
+
if (!address) throw new Error("No public DNS answer");
|
|
188
|
+
return address;
|
|
189
|
+
}
|
|
190
|
+
function publicWebRequest(header) {
|
|
191
|
+
const lines = header.split("\r\n");
|
|
192
|
+
const match = /^(CONNECT|GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ([^\s]+) HTTP\/1\.[01]$/.exec(
|
|
193
|
+
lines.shift() ?? ""
|
|
194
|
+
);
|
|
195
|
+
if (!match) throw new Error("Unsupported proxy request");
|
|
196
|
+
const connect = match[1] === "CONNECT";
|
|
197
|
+
const target = match[2] ?? "";
|
|
198
|
+
if (connect && !/^[a-z0-9.-]+:(80|443)$/i.test(target))
|
|
199
|
+
throw new Error("Invalid CONNECT destination");
|
|
200
|
+
const { url, port } = proxyUrl(target, connect);
|
|
201
|
+
for (const line of lines.filter(Boolean)) {
|
|
202
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e]*$/.test(line))
|
|
203
|
+
throw new Error("Invalid proxy header");
|
|
204
|
+
}
|
|
205
|
+
const headers = lines.filter(
|
|
206
|
+
(line) => line && !/^(host|connection|proxy-connection|proxy-authorization):/i.test(line)
|
|
207
|
+
);
|
|
208
|
+
return {
|
|
209
|
+
host: url.hostname,
|
|
210
|
+
port,
|
|
211
|
+
connect,
|
|
212
|
+
forward: connect ? "" : `${match[1]} ${url.pathname}${url.search} HTTP/1.1\r
|
|
213
|
+
Host: ${url.host}\r
|
|
214
|
+
Connection: close\r
|
|
215
|
+
${headers.length ? headers.join("\r\n") + "\r\n" : ""}\r
|
|
216
|
+
`
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function readPublicWebRequest(input) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
let data = Buffer.alloc(0);
|
|
222
|
+
const cleanup = () => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
input.pause();
|
|
225
|
+
input.removeListener("data", receive);
|
|
226
|
+
input.removeListener("end", closed);
|
|
227
|
+
input.removeListener("error", failed);
|
|
228
|
+
};
|
|
229
|
+
const failed = (error) => {
|
|
230
|
+
cleanup();
|
|
231
|
+
reject(error);
|
|
232
|
+
};
|
|
233
|
+
const closed = () => failed(new Error("Incomplete proxy request"));
|
|
234
|
+
const timer = setTimeout(() => failed(new Error("Proxy handshake timed out")), 1e4);
|
|
235
|
+
const receive = (chunk) => {
|
|
236
|
+
data = Buffer.concat([data, chunk]);
|
|
237
|
+
const end = data.indexOf("\r\n\r\n");
|
|
238
|
+
if (end < 0 && data.length > 16384 || end > 16384)
|
|
239
|
+
return failed(new Error("Proxy header too large"));
|
|
240
|
+
if (end < 0) return;
|
|
241
|
+
cleanup();
|
|
242
|
+
try {
|
|
243
|
+
const request = publicWebRequest(data.subarray(0, end).toString("latin1"));
|
|
244
|
+
const remaining = Buffer.concat([Buffer.from(request.forward), data.subarray(end + 4)]);
|
|
245
|
+
if (remaining.length) input.unshift(remaining);
|
|
246
|
+
resolve(request);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
reject(error);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
input.on("data", receive);
|
|
252
|
+
input.once("end", closed);
|
|
253
|
+
input.once("error", failed);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function proxyUrl(target, connect) {
|
|
257
|
+
const url = new URL(connect ? `https://${target}` : target);
|
|
258
|
+
if (url.username || url.password || url.hash || !connect && url.protocol !== "http:")
|
|
259
|
+
throw new Error("Invalid proxy URL");
|
|
260
|
+
const port = Number(url.port || (connect ? 443 : 80));
|
|
261
|
+
if (port !== 80 && port !== 443) throw new Error("Only public web ports 80 and 443 are allowed");
|
|
262
|
+
return { url, port };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/vm/qemu-forwards.ts
|
|
266
|
+
var ENDPOINT_IP = /^192\.168\.5\.(?:1\d\d)$/;
|
|
267
|
+
var CONTROL_ALIAS_IP = /^192\.168\.5\.24[0-7]$/;
|
|
268
|
+
var PINNED_IPV4 = /^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/;
|
|
269
|
+
function assertPort(port) {
|
|
270
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
271
|
+
throw new Error("Invalid guest forwarding port");
|
|
272
|
+
}
|
|
273
|
+
function validateForwards(forwards) {
|
|
274
|
+
const seen = /* @__PURE__ */ new Set();
|
|
275
|
+
for (const forward of forwards) {
|
|
276
|
+
assertPort(forward.port);
|
|
277
|
+
const key = `${forward.guestIp}:${forward.port}`;
|
|
278
|
+
if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
|
|
279
|
+
seen.add(key);
|
|
280
|
+
if (!isGloballyRoutableIPv4(forward.address) || !PINNED_IPV4.test(forward.address))
|
|
281
|
+
throw new Error("Invalid pinned forwarding address");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function forwardsOf(entries, guestIps, control) {
|
|
285
|
+
return entries.flatMap((host) => {
|
|
286
|
+
if (!guestIps.test(host.guestIp) || control && host.reserve !== "control")
|
|
287
|
+
throw new Error("Invalid pinned forwarding address");
|
|
288
|
+
return host.ports.map((port) => ({
|
|
289
|
+
guestIp: host.guestIp,
|
|
290
|
+
address: host.address,
|
|
291
|
+
port,
|
|
292
|
+
...host.reserve === "control" ? { reserve: "control" } : {}
|
|
293
|
+
}));
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function qemuForwards(network) {
|
|
297
|
+
const forwards = [
|
|
298
|
+
...forwardsOf(network.hosts, ENDPOINT_IP, false),
|
|
299
|
+
...forwardsOf(network.control ?? [], CONTROL_ALIAS_IP, true)
|
|
300
|
+
];
|
|
301
|
+
if (forwards.length > MAX_NETWORK_FORWARDS) throw new Error("Too many QEMU forwarding rules");
|
|
302
|
+
validateForwards(forwards);
|
|
303
|
+
return forwards;
|
|
304
|
+
}
|
|
305
|
+
function restrictedUsernet(network, forwards, ncPath) {
|
|
306
|
+
const guestForwards = forwards.map(
|
|
307
|
+
(forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}${forward.reserve === "control" ? " control" : ""}`
|
|
308
|
+
);
|
|
309
|
+
return [
|
|
310
|
+
"user",
|
|
311
|
+
"id=net0",
|
|
312
|
+
`net=${WORKER_SUBNET}`,
|
|
313
|
+
`dhcpstart=${WORKER_IP}`,
|
|
314
|
+
"restrict=on",
|
|
315
|
+
"ipv6=off",
|
|
316
|
+
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
317
|
+
...guestForwards,
|
|
318
|
+
...network.publicWeb === true ? [
|
|
319
|
+
`guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
|
|
320
|
+
] : []
|
|
321
|
+
].join(",");
|
|
322
|
+
}
|
|
323
|
+
|
|
53
324
|
// src/vm/qemu-policy.ts
|
|
54
325
|
var ALLOWED_PROBES = new Set(
|
|
55
326
|
[
|
|
@@ -103,9 +374,7 @@ var REQUIRED_CONTROLS = [
|
|
|
103
374
|
"drive:cidata"
|
|
104
375
|
];
|
|
105
376
|
var DISALLOWED_CONTROLS = /* @__PURE__ */ new Set(["-sandbox", "-no-user-config", "-nodefaults"]);
|
|
106
|
-
|
|
107
|
-
var BOOT_DISK_IOPS = "2000";
|
|
108
|
-
function assertPort(port, field) {
|
|
377
|
+
function assertPort2(port, field) {
|
|
109
378
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid ${field}`);
|
|
110
379
|
}
|
|
111
380
|
function isProbe(args) {
|
|
@@ -226,10 +495,11 @@ function validateDrive(value, context) {
|
|
|
226
495
|
}
|
|
227
496
|
assertFields(fields, { file: /.+/, if: "none", discard: "on", id: "boot-disk" }, "boot drive");
|
|
228
497
|
assertInstancePath(path, "disk", context);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
498
|
+
const disk = context.disk ?? {
|
|
499
|
+
bps: LEGACY_CAPACITY_LIMITS.diskBytesPerSecond,
|
|
500
|
+
iops: LEGACY_CAPACITY_LIMITS.diskIops
|
|
232
501
|
};
|
|
502
|
+
return { kind: "boot", value: `${value},bps=${disk.bps},iops=${disk.iops}` };
|
|
233
503
|
}
|
|
234
504
|
if (fields.get("id") === "cdrom0") {
|
|
235
505
|
assertFields(
|
|
@@ -274,35 +544,6 @@ function parseOriginalUsernet(value, sshPort) {
|
|
|
274
544
|
"Lima QEMU user network"
|
|
275
545
|
);
|
|
276
546
|
}
|
|
277
|
-
function restrictedUsernet(network, forwards, ncPath) {
|
|
278
|
-
const guestForwards = forwards.map(
|
|
279
|
-
(forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}`
|
|
280
|
-
);
|
|
281
|
-
return [
|
|
282
|
-
"user",
|
|
283
|
-
"id=net0",
|
|
284
|
-
`net=${WORKER_SUBNET}`,
|
|
285
|
-
`dhcpstart=${WORKER_IP}`,
|
|
286
|
-
"restrict=on",
|
|
287
|
-
"ipv6=off",
|
|
288
|
-
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
289
|
-
...guestForwards
|
|
290
|
-
].join(",");
|
|
291
|
-
}
|
|
292
|
-
function validateForwards(forwards) {
|
|
293
|
-
const seen = /* @__PURE__ */ new Set();
|
|
294
|
-
for (const forward of forwards) {
|
|
295
|
-
assertPort(forward.port, "guest forwarding port");
|
|
296
|
-
const key = `${forward.guestIp}:${forward.port}`;
|
|
297
|
-
if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
|
|
298
|
-
seen.add(key);
|
|
299
|
-
if (!/^192\.168\.5\.(?:1\d\d)$/.test(forward.guestIp) || !isGloballyRoutableIPv4(forward.address) || !/^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/.test(
|
|
300
|
-
forward.address
|
|
301
|
-
)) {
|
|
302
|
-
throw new Error("Invalid pinned forwarding address");
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
547
|
function fixedControl(control, expected) {
|
|
307
548
|
return (value) => {
|
|
308
549
|
if (value !== expected) throw new Error(`Unexpected QEMU ${control}`);
|
|
@@ -335,7 +576,7 @@ var SIMPLE_VALIDATORS = {
|
|
|
335
576
|
"-parallel": fixedControl("-parallel", "none"),
|
|
336
577
|
"-serial": fixedControl("serial", "chardev:char-serial"),
|
|
337
578
|
"-qmp": fixedControl("qmp", "chardev:char-qmp"),
|
|
338
|
-
"-name": fixedControl("name",
|
|
579
|
+
"-name": (value, { vmName = VM_NAME }) => fixedControl("name", `lima-${vmName}`)(value, {}),
|
|
339
580
|
"-pidfile": (value, context) => {
|
|
340
581
|
assertInstancePath(value, "qemu.pid", context);
|
|
341
582
|
return { value, control: "pidfile" };
|
|
@@ -406,15 +647,16 @@ function assertCompleteProfile(state) {
|
|
|
406
647
|
throw new Error("QEMU PCI serial device and chardev must appear together");
|
|
407
648
|
}
|
|
408
649
|
}
|
|
409
|
-
function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc",
|
|
650
|
+
function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", hostContext = {}) {
|
|
410
651
|
if (isProbe(args)) return [...args];
|
|
411
|
-
|
|
652
|
+
assertPort2(network.sshPort, "SSH port");
|
|
412
653
|
if (!TRUSTED_NC_PATH.test(ncPath)) throw new Error("Invalid netcat path");
|
|
413
|
-
const forwards = network
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
654
|
+
const forwards = qemuForwards(network);
|
|
655
|
+
const limits = parseCapacityLimits(network.limits);
|
|
656
|
+
const context = {
|
|
657
|
+
...hostContext,
|
|
658
|
+
disk: { bps: limits.diskBytesPerSecond, iops: limits.diskIops }
|
|
659
|
+
};
|
|
418
660
|
const output = [];
|
|
419
661
|
const state = {
|
|
420
662
|
controls: /* @__PURE__ */ new Set(),
|
|
@@ -470,12 +712,12 @@ import { execFileSync } from "child_process";
|
|
|
470
712
|
import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
471
713
|
import { join } from "path";
|
|
472
714
|
import { Socket } from "net";
|
|
473
|
-
var
|
|
474
|
-
var MAX_RELAY_BYTES = 1024 * 1024 * 1024;
|
|
715
|
+
var SLOT_CEILING = CAPACITY_BOUNDS.relaySlots.max;
|
|
475
716
|
var RELAY_CONNECT_MS = 6e4;
|
|
476
|
-
var RELAY_IDLE_MS =
|
|
717
|
+
var RELAY_IDLE_MS = 15 * 6e4;
|
|
477
718
|
var GUARD_FRESH_MS = 6e3;
|
|
478
719
|
var RELAY_PREFIX = "--conveyor-vm-relay";
|
|
720
|
+
var RELAY_FLAGS = ["-4", "-n", "-w", "60"];
|
|
479
721
|
function validPid(value) {
|
|
480
722
|
return Number.isInteger(value) && value > 1 && value <= 4194304;
|
|
481
723
|
}
|
|
@@ -526,11 +768,13 @@ function readNetwork(root) {
|
|
|
526
768
|
if (typeof parsed !== "object" || parsed === null)
|
|
527
769
|
throw new Error("VM relay network policy is invalid");
|
|
528
770
|
const network = parsed;
|
|
529
|
-
if (!Array.isArray(network.hosts)
|
|
530
|
-
|
|
771
|
+
if (!Array.isArray(network.hosts) || network.control !== void 0 && !Array.isArray(network.control))
|
|
772
|
+
throw new Error("VM relay network policy is invalid");
|
|
773
|
+
return { ...network, limits: parseCapacityLimits(network.limits) };
|
|
531
774
|
}
|
|
532
|
-
function assertApprovedDestination(
|
|
533
|
-
const
|
|
775
|
+
function assertApprovedDestination(network, invocation) {
|
|
776
|
+
const entries = invocation.reserve === "control" ? [...network.hosts, ...network.control ?? []].filter((host) => host?.reserve === "control") : network.hosts;
|
|
777
|
+
const allowed = entries.some(
|
|
534
778
|
(host) => host && host.address === invocation.address && Array.isArray(host.ports) && host.ports.includes(invocation.port)
|
|
535
779
|
);
|
|
536
780
|
if (!allowed) throw new Error("VM relay destination is not in the host network policy");
|
|
@@ -541,9 +785,10 @@ function recordPath(root, pid) {
|
|
|
541
785
|
function slotPath(root, slot) {
|
|
542
786
|
return join(relayDirectory(root), `slot-${slot}`);
|
|
543
787
|
}
|
|
544
|
-
function claimSlot(root) {
|
|
788
|
+
function claimSlot(root, limits, reserve) {
|
|
545
789
|
safeDirectory(relayDirectory(root));
|
|
546
|
-
|
|
790
|
+
const first = reserve === "control" ? 0 : limits.reservedRelaySlots;
|
|
791
|
+
for (let slot = first; slot < limits.relaySlots; slot += 1) {
|
|
547
792
|
try {
|
|
548
793
|
mkdirSync(slotPath(root, slot), { mode: 448 });
|
|
549
794
|
return slot;
|
|
@@ -551,10 +796,10 @@ function claimSlot(root) {
|
|
|
551
796
|
if (error.code !== "EEXIST") throw error;
|
|
552
797
|
}
|
|
553
798
|
}
|
|
554
|
-
throw new Error(`VM relay limit reached (${
|
|
799
|
+
throw new Error(`VM relay limit reached (${limits.relaySlots})`);
|
|
555
800
|
}
|
|
556
|
-
function registerRelay(root, runId) {
|
|
557
|
-
const slot = claimSlot(root);
|
|
801
|
+
function registerRelay(root, runId, limits, reserve) {
|
|
802
|
+
const slot = claimSlot(root, limits, reserve);
|
|
558
803
|
const record = {
|
|
559
804
|
schema: 1,
|
|
560
805
|
pid: process.pid,
|
|
@@ -584,15 +829,20 @@ function unregisterRelay(root, record) {
|
|
|
584
829
|
}
|
|
585
830
|
function parseRelayInvocation(args) {
|
|
586
831
|
if (args[0] !== RELAY_PREFIX) return null;
|
|
587
|
-
|
|
832
|
+
const control = args.length === 8 && args[7] === "control";
|
|
833
|
+
if (args.length !== 7 && !control || RELAY_FLAGS.some((flag, index) => args[index + 1] !== flag)) {
|
|
588
834
|
throw new Error("Invalid VM relay invocation");
|
|
589
835
|
}
|
|
590
836
|
const address = args[5] ?? "";
|
|
591
837
|
const port = Number(args[6]);
|
|
592
|
-
|
|
838
|
+
const publicWeb = !control && address === "public-web" && port === PUBLIC_WEB_PORT;
|
|
839
|
+
if (!(isGloballyRoutableIPv4(address) || publicWeb) || !validPort(port)) {
|
|
593
840
|
throw new Error("Invalid VM relay destination");
|
|
594
841
|
}
|
|
595
|
-
return { address, port };
|
|
842
|
+
return { address, port, ...control ? { reserve: "control" } : {} };
|
|
843
|
+
}
|
|
844
|
+
function validPort(port) {
|
|
845
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
596
846
|
}
|
|
597
847
|
function parsePs(output) {
|
|
598
848
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -613,7 +863,7 @@ function parsePs(output) {
|
|
|
613
863
|
}
|
|
614
864
|
function listedRecords(root, runId) {
|
|
615
865
|
const records = [];
|
|
616
|
-
for (let slot = 0; slot <
|
|
866
|
+
for (let slot = 0; slot < SLOT_CEILING; slot += 1) {
|
|
617
867
|
try {
|
|
618
868
|
const record = JSON.parse(
|
|
619
869
|
readFileSync(join(slotPath(root, slot), "owner.json"), "utf8")
|
|
@@ -668,21 +918,58 @@ function currentGuardStillValid(root, runId) {
|
|
|
668
918
|
function runRelay(root, invocation, io) {
|
|
669
919
|
return Promise.resolve().then(() => runRelayNow(root, invocation, io));
|
|
670
920
|
}
|
|
671
|
-
function runRelayNow(root, invocation, io) {
|
|
921
|
+
async function runRelayNow(root, invocation, io) {
|
|
672
922
|
const guard = readLiveGuard(root);
|
|
673
|
-
|
|
674
|
-
const
|
|
923
|
+
const network = readNetwork(root);
|
|
924
|
+
const publicWeb = invocation.address === "public-web" && invocation.port === PUBLIC_WEB_PORT;
|
|
925
|
+
if (publicWeb) {
|
|
926
|
+
if (network.publicWeb !== true) throw new Error("Public web access is not enabled");
|
|
927
|
+
} else assertApprovedDestination(network, invocation);
|
|
928
|
+
const record = registerRelay(root, guard.runId, network.limits, invocation.reserve);
|
|
929
|
+
const streams = io ?? { socket: new Socket(), input: process.stdin, output: process.stdout };
|
|
930
|
+
let connectedResponse = false;
|
|
931
|
+
if (publicWeb) {
|
|
932
|
+
const releaseHandshake = handshakeExitCleanup(root, record);
|
|
933
|
+
let timeout;
|
|
934
|
+
try {
|
|
935
|
+
const request = await readPublicWebRequest(streams.input);
|
|
936
|
+
const address = await Promise.race([
|
|
937
|
+
publicWebAddress(request.host),
|
|
938
|
+
new Promise((_, reject) => {
|
|
939
|
+
timeout = setTimeout(() => reject(new Error("DNS lookup timed out")), 5e3);
|
|
940
|
+
})
|
|
941
|
+
]);
|
|
942
|
+
if (!currentGuardStillValid(root, record.runId))
|
|
943
|
+
throw new Error("Resource guard is no longer healthy");
|
|
944
|
+
invocation = { address, port: request.port };
|
|
945
|
+
connectedResponse = request.connect;
|
|
946
|
+
} catch {
|
|
947
|
+
unregisterRelay(root, record);
|
|
948
|
+
streams.output.write(
|
|
949
|
+
"HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
|
|
950
|
+
);
|
|
951
|
+
streams.input.destroy();
|
|
952
|
+
streams.socket.destroy();
|
|
953
|
+
return;
|
|
954
|
+
} finally {
|
|
955
|
+
if (timeout) clearTimeout(timeout);
|
|
956
|
+
releaseHandshake();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return transportRelay(root, invocation, streams, record, {
|
|
960
|
+
connectedResponse,
|
|
961
|
+
maxBytes: network.limits.relayMaxBytes
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
function transportRelay(root, invocation, streams, record, options) {
|
|
965
|
+
const { connectedResponse, maxBytes } = options;
|
|
675
966
|
return new Promise((resolve) => {
|
|
676
967
|
let finished = false;
|
|
677
968
|
let sent = 0;
|
|
678
969
|
let received = 0;
|
|
679
970
|
let guardTimer;
|
|
680
971
|
let connectTimer;
|
|
681
|
-
const { socket, input, output } =
|
|
682
|
-
socket: new Socket(),
|
|
683
|
-
input: process.stdin,
|
|
684
|
-
output: process.stdout
|
|
685
|
-
};
|
|
972
|
+
const { socket, input, output } = streams;
|
|
686
973
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
687
974
|
const cleanup = () => {
|
|
688
975
|
try {
|
|
@@ -709,11 +996,12 @@ function runRelayNow(root, invocation, io) {
|
|
|
709
996
|
};
|
|
710
997
|
process.once("exit", cleanup);
|
|
711
998
|
for (const signal of signals) process.once(signal, terminated);
|
|
712
|
-
const exceedsLimit = (total) => total >
|
|
999
|
+
const exceedsLimit = (total) => total > maxBytes;
|
|
713
1000
|
socket.setTimeout(RELAY_IDLE_MS, () => finish(new Error("VM relay idle timeout")));
|
|
714
1001
|
socket.once("connect", () => {
|
|
715
1002
|
if (connectTimer) clearTimeout(connectTimer);
|
|
716
1003
|
connectTimer = void 0;
|
|
1004
|
+
if (connectedResponse) output.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
717
1005
|
});
|
|
718
1006
|
socket.on("error", () => finish(new Error("VM relay connection failed")));
|
|
719
1007
|
socket.on("close", () => finish());
|
|
@@ -741,29 +1029,56 @@ function runRelayNow(root, invocation, io) {
|
|
|
741
1029
|
RELAY_CONNECT_MS
|
|
742
1030
|
);
|
|
743
1031
|
socket.connect(invocation.port, invocation.address);
|
|
1032
|
+
input.resume();
|
|
744
1033
|
});
|
|
745
1034
|
}
|
|
1035
|
+
function handshakeExitCleanup(root, record) {
|
|
1036
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
1037
|
+
const cleanup = () => {
|
|
1038
|
+
try {
|
|
1039
|
+
unregisterRelay(root, record);
|
|
1040
|
+
} catch {
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
const release = () => {
|
|
1044
|
+
process.removeListener("exit", cleanup);
|
|
1045
|
+
for (const signal of signals) process.removeListener(signal, terminated);
|
|
1046
|
+
};
|
|
1047
|
+
const terminated = (signal) => {
|
|
1048
|
+
cleanup();
|
|
1049
|
+
release();
|
|
1050
|
+
process.kill(process.pid, signal);
|
|
1051
|
+
};
|
|
1052
|
+
process.once("exit", cleanup);
|
|
1053
|
+
for (const signal of signals) process.once(signal, terminated);
|
|
1054
|
+
return release;
|
|
1055
|
+
}
|
|
746
1056
|
|
|
747
1057
|
// src/vm/memory-budget.ts
|
|
748
|
-
|
|
749
|
-
|
|
1058
|
+
var RELAY_RSS_ALLOWANCE_MIB = 128;
|
|
1059
|
+
var BASE_RELAY_SLOTS = 16;
|
|
1060
|
+
function relayAllowanceGiB(relaySlots = BASE_RELAY_SLOTS) {
|
|
1061
|
+
return Math.max(0, relaySlots - BASE_RELAY_SLOTS) * RELAY_RSS_ALLOWANCE_MIB / 1024;
|
|
1062
|
+
}
|
|
1063
|
+
function vmHostMemoryGiB(guestGiB, relaySlots = BASE_RELAY_SLOTS) {
|
|
1064
|
+
return guestGiB + Math.max(1, guestGiB / 4) + relayAllowanceGiB(relaySlots);
|
|
750
1065
|
}
|
|
751
1066
|
|
|
752
1067
|
// src/vm/resource-guard.ts
|
|
753
|
-
var
|
|
1068
|
+
var GIB2 = 1024 ** 3;
|
|
754
1069
|
var GUARD_INTERVAL_MS = 2e3;
|
|
755
|
-
function guardLimits(resources) {
|
|
1070
|
+
function guardLimits(resources, capacity = LEGACY_CAPACITY_LIMITS) {
|
|
756
1071
|
if (!Number.isInteger(resources.memoryGiB) || resources.memoryGiB < 2 || resources.memoryGiB > 1024 || !Number.isInteger(resources.diskGiB) || resources.diskGiB < 20 || resources.diskGiB > 512) {
|
|
757
1072
|
throw new Error("Invalid resource limits in host state");
|
|
758
1073
|
}
|
|
759
1074
|
return {
|
|
760
|
-
minFreeBytes: 10 *
|
|
1075
|
+
minFreeBytes: 10 * GIB2,
|
|
761
1076
|
// A 24 GiB Mac guest exceeded 27 GiB during image unpacking. Allow 25%
|
|
762
1077
|
// overhead (at least 1 GiB); admission reserves host RAM using this same cap.
|
|
763
|
-
maxRssKiB: vmHostMemoryGiB(resources.memoryGiB) * 1024 ** 2,
|
|
764
|
-
maxProcesses:
|
|
1078
|
+
maxRssKiB: vmHostMemoryGiB(resources.memoryGiB, capacity.relaySlots) * 1024 ** 2,
|
|
1079
|
+
maxProcesses: guardProcessLimit(capacity.relaySlots),
|
|
765
1080
|
maxOutputBytes: 1024 * 1024,
|
|
766
|
-
maxDiskBytes: (resources.diskGiB + 1) *
|
|
1081
|
+
maxDiskBytes: (resources.diskGiB + 1) * GIB2,
|
|
767
1082
|
maxLogBytes: 8 * 1024 ** 2
|
|
768
1083
|
};
|
|
769
1084
|
}
|
|
@@ -815,20 +1130,19 @@ function hostPs(args) {
|
|
|
815
1130
|
env: { PATH: "/usr/bin:/bin", LANG: "C" }
|
|
816
1131
|
});
|
|
817
1132
|
}
|
|
818
|
-
function sampleResources(root, qemuPid, runId) {
|
|
1133
|
+
function sampleResources(root, instanceDir, qemuPid, runId, ps = hostPs) {
|
|
819
1134
|
const fs = statfsSync(root);
|
|
820
|
-
const
|
|
821
|
-
const
|
|
822
|
-
const relays = sampleRelayProcesses(root, runId, hostPs(["-axo", "pid=,pgid=,rss=,command="]));
|
|
1135
|
+
const processes = parseProcessGroup(ps(["-axo", "pgid=,pid=,rss="]), qemuPid);
|
|
1136
|
+
const relays = sampleRelayProcesses(root, runId, ps(["-axo", "pid=,pgid=,rss=,command="]));
|
|
823
1137
|
return {
|
|
824
1138
|
freeBytes: fs.bavail * fs.bsize,
|
|
825
1139
|
rssKiB: processes.rssKiB + relays.reduce((total, relay) => total + relay.rssKiB, 0),
|
|
826
1140
|
processes: processes.processes + relays.length,
|
|
827
1141
|
relayProcesses: relays.length,
|
|
828
|
-
diskBytes: regularFileSize(join2(
|
|
1142
|
+
diskBytes: regularFileSize(join2(instanceDir, "disk")),
|
|
829
1143
|
largestLogBytes: Math.max(
|
|
830
1144
|
...["ha.stdout.log", "ha.stderr.log", "serial.log", "serialp.log", "serialv.log"].map(
|
|
831
|
-
(file) => regularFileSize(join2(
|
|
1145
|
+
(file) => regularFileSize(join2(instanceDir, file))
|
|
832
1146
|
)
|
|
833
1147
|
)
|
|
834
1148
|
};
|
|
@@ -843,7 +1157,7 @@ function writeGuardEvidence(root, evidence) {
|
|
|
843
1157
|
rmSync2(temporary, { force: true });
|
|
844
1158
|
}
|
|
845
1159
|
}
|
|
846
|
-
function startResourceGuard(root, identity, limits, stop) {
|
|
1160
|
+
function startResourceGuard(root, identity, limits, stop, instanceDir) {
|
|
847
1161
|
const evidence = {
|
|
848
1162
|
schema: 1,
|
|
849
1163
|
...identity,
|
|
@@ -868,7 +1182,7 @@ function startResourceGuard(root, identity, limits, stop) {
|
|
|
868
1182
|
function check() {
|
|
869
1183
|
if (finished) return;
|
|
870
1184
|
try {
|
|
871
|
-
evidence.observed = sampleResources(root, identity.qemuPid, identity.runId);
|
|
1185
|
+
evidence.observed = sampleResources(root, instanceDir, identity.qemuPid, identity.runId);
|
|
872
1186
|
const violation = resourceViolation(evidence.observed, limits);
|
|
873
1187
|
if (violation) throw new Error(violation);
|
|
874
1188
|
evidence.lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1071,9 +1385,9 @@ function attachBoundedOutput(child, limit, terminate) {
|
|
|
1071
1385
|
child.stdout?.on("data", consume);
|
|
1072
1386
|
child.stderr?.on("data", consume);
|
|
1073
1387
|
}
|
|
1074
|
-
function runGuardedQemu(root, qemu, args, networkHash, resources) {
|
|
1388
|
+
function runGuardedQemu(root, qemu, args, networkHash, resources, instanceDir, capacity) {
|
|
1075
1389
|
const runId = startIdentity(root);
|
|
1076
|
-
const limits = guardLimits(resources);
|
|
1390
|
+
const limits = guardLimits(resources, parseCapacityLimits(capacity));
|
|
1077
1391
|
const hardenedArgs = [...args, "-no-user-config", "-nodefaults"];
|
|
1078
1392
|
const child = spawn2(qemu, hardenedArgs, {
|
|
1079
1393
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -1131,7 +1445,7 @@ function runGuardedQemu(root, qemu, args, networkHash, resources) {
|
|
|
1131
1445
|
JSON.stringify({ ...identity, args: hardenedArgs, startedAt: (/* @__PURE__ */ new Date()).toISOString() }),
|
|
1132
1446
|
{ mode: 384 }
|
|
1133
1447
|
);
|
|
1134
|
-
guard = startResourceGuard(root, identity, limits, terminate);
|
|
1448
|
+
guard = startResourceGuard(root, identity, limits, terminate, instanceDir);
|
|
1135
1449
|
} catch {
|
|
1136
1450
|
terminate("failed to record or start host resource supervision");
|
|
1137
1451
|
}
|
|
@@ -1162,6 +1476,8 @@ function main() {
|
|
|
1162
1476
|
if (state.schema !== 1 || !state.qemu.startsWith("/") || state.qemu.includes("wrapper")) {
|
|
1163
1477
|
throw new Error("Invalid QEMU host state");
|
|
1164
1478
|
}
|
|
1479
|
+
const vmName = vmNameFor(stateInstance(state.instance));
|
|
1480
|
+
const instanceDir = join5(root, "lima", vmName);
|
|
1165
1481
|
const networkText = readFileSync4(join5(root, "network.json"), "utf8");
|
|
1166
1482
|
const network = JSON.parse(networkText);
|
|
1167
1483
|
const args = process.argv.slice(2);
|
|
@@ -1170,7 +1486,8 @@ function main() {
|
|
|
1170
1486
|
original?.match(/hostfwd=tcp:127\.0\.0\.1:(\d+)-(?:192\.168\.5\.15)?:22/)?.[1] ?? 0
|
|
1171
1487
|
);
|
|
1172
1488
|
const rewritten = rewriteQemuArgs(args, { ...network, sshPort }, join5(root, "qemu-relay"), {
|
|
1173
|
-
instanceDir
|
|
1489
|
+
instanceDir,
|
|
1490
|
+
vmName
|
|
1174
1491
|
});
|
|
1175
1492
|
if (original) {
|
|
1176
1493
|
const expected = process.platform === "darwin" ? "hvf" : "kvm";
|
|
@@ -1182,7 +1499,9 @@ function main() {
|
|
|
1182
1499
|
state.qemu,
|
|
1183
1500
|
rewritten,
|
|
1184
1501
|
createHash("sha256").update(networkText).digest("hex"),
|
|
1185
|
-
state.resources
|
|
1502
|
+
state.resources,
|
|
1503
|
+
instanceDir,
|
|
1504
|
+
network.limits
|
|
1186
1505
|
);
|
|
1187
1506
|
return;
|
|
1188
1507
|
}
|