@ghalex/kvbox-server 0.1.4 → 0.3.0
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/kvbox-server.js +459 -169
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
package/dist/kvbox-server.js
CHANGED
|
@@ -35,9 +35,14 @@ function paths(home = kvboxHome()) {
|
|
|
35
35
|
serverSock: join(home, "server.sock"),
|
|
36
36
|
serverPid: join(home, "server.pid"),
|
|
37
37
|
serverLog: join(home, "server.log"),
|
|
38
|
-
|
|
38
|
+
/** The CLI's login to the control plane (`kvbox login`): url, bearer token, user. Mode 600. */
|
|
39
|
+
cli: join(home, "cli.json"),
|
|
39
40
|
ssh: join(home, "ssh"),
|
|
40
41
|
sshKey: join(home, "ssh", "id_ed25519"),
|
|
42
|
+
/** The host's self-signed TLS identity for the control plane (`kvbox-server join`): pinned there by fingerprint. */
|
|
43
|
+
tls: join(home, "tls"),
|
|
44
|
+
tlsKey: join(home, "tls", "key.pem"),
|
|
45
|
+
tlsCert: join(home, "tls", "cert.pem"),
|
|
41
46
|
bin: join(home, "bin"),
|
|
42
47
|
firecracker: join(home, "bin", "firecracker"),
|
|
43
48
|
/** Firecracker's snapshot-editor, from the same release: merges a diff snapshot onto a full memory image. */
|
|
@@ -91,7 +96,7 @@ function paths(home = kvboxHome()) {
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
// src/core/config.ts
|
|
94
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
99
|
+
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
|
95
100
|
import { z } from "zod";
|
|
96
101
|
|
|
97
102
|
// ../shared/src/sizes.ts
|
|
@@ -112,6 +117,7 @@ function shown(ref) {
|
|
|
112
117
|
return ref.display ?? ref.name;
|
|
113
118
|
}
|
|
114
119
|
var LOCAL_OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
|
|
120
|
+
var REALM_RE = /^[A-Za-z0-9.-]{1,128}$/;
|
|
115
121
|
var OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}(@[A-Za-z0-9.-]{1,128})?$/;
|
|
116
122
|
function localOwner() {
|
|
117
123
|
try {
|
|
@@ -122,6 +128,15 @@ function localOwner() {
|
|
|
122
128
|
const env = process.env["USER"];
|
|
123
129
|
return env !== void 0 && LOCAL_OWNER_RE.test(env) ? env : "kvbox";
|
|
124
130
|
}
|
|
131
|
+
function inScope(owner, scope) {
|
|
132
|
+
if (scope === null) return true;
|
|
133
|
+
if (typeof scope === "string") return owner === scope;
|
|
134
|
+
return realmOf(owner) === scope.realm;
|
|
135
|
+
}
|
|
136
|
+
function realmOf(owner) {
|
|
137
|
+
const at = owner.indexOf("@");
|
|
138
|
+
return at === -1 ? null : owner.slice(at + 1);
|
|
139
|
+
}
|
|
125
140
|
function parseRef(text, caller) {
|
|
126
141
|
const slash = text.indexOf("/");
|
|
127
142
|
if (slash === -1) return { owner: caller, name: text, display: text };
|
|
@@ -130,6 +145,10 @@ function parseRef(text, caller) {
|
|
|
130
145
|
if (!OWNER_RE.test(owner) || name.length === 0 || name.includes("/")) {
|
|
131
146
|
throw new KvboxError(`invalid box reference "${text}": use <name> or <owner>/<name>`);
|
|
132
147
|
}
|
|
148
|
+
const realm = realmOf(caller);
|
|
149
|
+
if (realm !== null && realmOf(owner) !== realm) {
|
|
150
|
+
throw new KvboxError(`invalid box reference "${text}": ${owner} is not in your realm`);
|
|
151
|
+
}
|
|
133
152
|
return { owner, name, display: owner === caller ? name : text };
|
|
134
153
|
}
|
|
135
154
|
function refKey(ref) {
|
|
@@ -143,6 +162,16 @@ var PricingSchema = z.object({
|
|
|
143
162
|
ramGibHour: z.number().nonnegative(),
|
|
144
163
|
diskGibHour: z.number().nonnegative()
|
|
145
164
|
});
|
|
165
|
+
var ControlPlaneSchema = z.object({
|
|
166
|
+
url: z.string().url(),
|
|
167
|
+
/** The realm name: boxes the control plane creates are owned by `<user>@<name>`, a directory name on disk. */
|
|
168
|
+
name: z.string().regex(REALM_RE),
|
|
169
|
+
/** Minted at join; every HTTPS call from the control plane carries it as a bearer. */
|
|
170
|
+
secret: z.string().min(1),
|
|
171
|
+
/** The address the control plane reaches this host at. */
|
|
172
|
+
advertise: z.string().min(1),
|
|
173
|
+
port: z.number().int().positive().default(7777)
|
|
174
|
+
});
|
|
146
175
|
var ConfigSchema = z.object({
|
|
147
176
|
kernel: z.string(),
|
|
148
177
|
rootfs: z.string(),
|
|
@@ -155,8 +184,17 @@ var ConfigSchema = z.object({
|
|
|
155
184
|
defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE),
|
|
156
185
|
/** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
|
|
157
186
|
owner: z.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
|
|
158
|
-
pricing: PricingSchema.optional()
|
|
187
|
+
pricing: PricingSchema.optional(),
|
|
188
|
+
/** Absent until the host joins a control plane; then the server also listens on HTTPS. */
|
|
189
|
+
controlPlane: ControlPlaneSchema.optional()
|
|
159
190
|
});
|
|
191
|
+
async function saveConfig(cfg, p = paths()) {
|
|
192
|
+
const parsed = ConfigSchema.parse(cfg);
|
|
193
|
+
const tmp = `${p.config}.tmp`;
|
|
194
|
+
await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", { mode: 384 });
|
|
195
|
+
await chmod(tmp, 384);
|
|
196
|
+
await rename(tmp, p.config);
|
|
197
|
+
}
|
|
160
198
|
function defaultConfig(p = paths()) {
|
|
161
199
|
return ConfigSchema.parse({
|
|
162
200
|
kernel: p.kernel,
|
|
@@ -180,9 +218,152 @@ async function loadConfig(p = paths()) {
|
|
|
180
218
|
}
|
|
181
219
|
}
|
|
182
220
|
|
|
183
|
-
// src/core/
|
|
184
|
-
import {
|
|
185
|
-
import {
|
|
221
|
+
// src/core/controlPlane.ts
|
|
222
|
+
import { X509Certificate } from "node:crypto";
|
|
223
|
+
import { chmod as chmod2, mkdir, readFile as readFile2 } from "node:fs/promises";
|
|
224
|
+
import { hostname as osHostname } from "node:os";
|
|
225
|
+
import { request } from "undici";
|
|
226
|
+
|
|
227
|
+
// ../shared/src/api.ts
|
|
228
|
+
import { z as z2 } from "zod";
|
|
229
|
+
var SizeSchema = z2.enum(SIZE_NAMES);
|
|
230
|
+
var BoxStatusSchema = z2.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
|
|
231
|
+
var MachineSchema = z2.object({
|
|
232
|
+
/** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
|
|
233
|
+
id: z2.string().optional(),
|
|
234
|
+
name: z2.string(),
|
|
235
|
+
/** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
|
|
236
|
+
owner: z2.string(),
|
|
237
|
+
ip: z2.string(),
|
|
238
|
+
mac: z2.string(),
|
|
239
|
+
pid: z2.number().int().nullable(),
|
|
240
|
+
vcpu: z2.number().int(),
|
|
241
|
+
memMib: z2.number().int(),
|
|
242
|
+
diskBytes: z2.number().int(),
|
|
243
|
+
// the box's own writable disk, as the guest sees it
|
|
244
|
+
diskUsedBytes: z2.number().int(),
|
|
245
|
+
// what that sparse file really occupies on the host
|
|
246
|
+
base: z2.string().nullable(),
|
|
247
|
+
// base image version the box runs on (null: pre-overlay box with its own copy)
|
|
248
|
+
createdAt: z2.string(),
|
|
249
|
+
source: z2.string(),
|
|
250
|
+
// "base" or "snapshot:<name>"
|
|
251
|
+
status: BoxStatusSchema
|
|
252
|
+
});
|
|
253
|
+
var CreateMachineRequest = z2.object({
|
|
254
|
+
name: z2.string().optional(),
|
|
255
|
+
/** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
|
|
256
|
+
owner: z2.string().optional(),
|
|
257
|
+
size: SizeSchema.optional(),
|
|
258
|
+
from: z2.string().optional(),
|
|
259
|
+
force: z2.boolean().optional()
|
|
260
|
+
});
|
|
261
|
+
var RescaleMachineRequest = z2.object({
|
|
262
|
+
size: SizeSchema.optional(),
|
|
263
|
+
vcpu: z2.number().int().min(1).max(64).optional(),
|
|
264
|
+
memMib: z2.number().int().min(256).optional(),
|
|
265
|
+
diskGib: z2.number().int().min(1).optional()
|
|
266
|
+
});
|
|
267
|
+
var SnapshotSchema = z2.object({
|
|
268
|
+
name: z2.string(),
|
|
269
|
+
owner: z2.string(),
|
|
270
|
+
source: z2.string(),
|
|
271
|
+
// the box it was taken from, as owner/name
|
|
272
|
+
base: z2.string(),
|
|
273
|
+
// base image version the overlay stacks on; a restore needs the same one
|
|
274
|
+
vcpu: z2.number().int(),
|
|
275
|
+
memMib: z2.number().int(),
|
|
276
|
+
sizeBytes: z2.number().int(),
|
|
277
|
+
// what vmstate + mem + overlay really occupy on the host
|
|
278
|
+
createdAt: z2.string()
|
|
279
|
+
});
|
|
280
|
+
var CreateSnapshotRequest = z2.object({
|
|
281
|
+
box: z2.string(),
|
|
282
|
+
name: z2.string()
|
|
283
|
+
});
|
|
284
|
+
var CapacitySchema = z2.object({
|
|
285
|
+
hostMemMib: z2.number().int(),
|
|
286
|
+
hostCores: z2.number().int(),
|
|
287
|
+
reservedMemMib: z2.number().int(),
|
|
288
|
+
reservedVcpu: z2.number().int(),
|
|
289
|
+
running: z2.number().int()
|
|
290
|
+
});
|
|
291
|
+
var HealthSchema = z2.object({
|
|
292
|
+
ok: z2.literal(true),
|
|
293
|
+
version: z2.string(),
|
|
294
|
+
pid: z2.number().int(),
|
|
295
|
+
/** Base image version new boxes get (null when the host has no usable base image yet); absent from older servers. */
|
|
296
|
+
base: z2.string().nullable().optional(),
|
|
297
|
+
/** Owner that boxes made over the local socket get (the Unix user running the server); absent from older servers. */
|
|
298
|
+
owner: z2.string().optional()
|
|
299
|
+
});
|
|
300
|
+
var MachineUsageSchema = z2.object({
|
|
301
|
+
name: z2.string(),
|
|
302
|
+
owner: z2.string(),
|
|
303
|
+
status: BoxStatusSchema,
|
|
304
|
+
window: z2.object({ from: z2.string(), to: z2.string() }),
|
|
305
|
+
/** standby and hibernated default to 0 so a newer CLI reads an older server's answer. */
|
|
306
|
+
seconds: z2.object({
|
|
307
|
+
running: z2.number().int(),
|
|
308
|
+
standby: z2.number().int().default(0),
|
|
309
|
+
hibernated: z2.number().int().default(0),
|
|
310
|
+
stopped: z2.number().int()
|
|
311
|
+
}),
|
|
312
|
+
vcpuHours: z2.number(),
|
|
313
|
+
ramGibHours: z2.number(),
|
|
314
|
+
diskGibHours: z2.number(),
|
|
315
|
+
cost: z2.object({ amount: z2.number(), currency: z2.string() }).optional(),
|
|
316
|
+
/** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
|
|
317
|
+
estimatedStops: z2.number().int(),
|
|
318
|
+
historyFrom: z2.string().nullable()
|
|
319
|
+
});
|
|
320
|
+
var ApiErrorSchema = z2.object({
|
|
321
|
+
error: z2.string()
|
|
322
|
+
});
|
|
323
|
+
var CloudUserSchema = z2.object({
|
|
324
|
+
/** The handle: what hosts file boxes under (`<username>@<control plane>`). Lowercase, no `@` or `/`. */
|
|
325
|
+
username: z2.string(),
|
|
326
|
+
/** Display name, free text ("Alexandru Ghiura"). */
|
|
327
|
+
name: z2.string(),
|
|
328
|
+
/** Login identity, unique. */
|
|
329
|
+
email: z2.string(),
|
|
330
|
+
role: z2.enum(["admin", "user"]),
|
|
331
|
+
createdAt: z2.string()
|
|
332
|
+
});
|
|
333
|
+
var LoginRequestSchema = z2.object({
|
|
334
|
+
email: z2.string().min(1),
|
|
335
|
+
password: z2.string().min(1),
|
|
336
|
+
publicKey: z2.string().optional()
|
|
337
|
+
});
|
|
338
|
+
var SignupRequestSchema = z2.object({
|
|
339
|
+
username: z2.string().min(1),
|
|
340
|
+
name: z2.string().min(1),
|
|
341
|
+
email: z2.string().min(1),
|
|
342
|
+
password: z2.string().min(1)
|
|
343
|
+
});
|
|
344
|
+
var LoginResponseSchema = z2.object({
|
|
345
|
+
token: z2.string(),
|
|
346
|
+
user: CloudUserSchema
|
|
347
|
+
});
|
|
348
|
+
var HostJoinRequestSchema = z2.object({
|
|
349
|
+
token: z2.string().min(1),
|
|
350
|
+
name: z2.string().min(1),
|
|
351
|
+
url: z2.string().url(),
|
|
352
|
+
fingerprint: z2.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
|
|
353
|
+
publicAddress: z2.string().min(1)
|
|
354
|
+
});
|
|
355
|
+
var HostJoinResponseSchema = z2.object({
|
|
356
|
+
hostSecret: z2.string(),
|
|
357
|
+
controlPlane: z2.object({ name: z2.string() })
|
|
358
|
+
});
|
|
359
|
+
var CloudHostSchema = z2.object({
|
|
360
|
+
name: z2.string(),
|
|
361
|
+
url: z2.string(),
|
|
362
|
+
publicAddress: z2.string(),
|
|
363
|
+
joinedAt: z2.string(),
|
|
364
|
+
lastSeenAt: z2.string().nullable(),
|
|
365
|
+
capacity: CapacitySchema.nullable()
|
|
366
|
+
});
|
|
186
367
|
|
|
187
368
|
// src/core/exec.ts
|
|
188
369
|
import { execFile } from "node:child_process";
|
|
@@ -208,7 +389,127 @@ async function run(argv, opts = {}) {
|
|
|
208
389
|
}
|
|
209
390
|
}
|
|
210
391
|
|
|
392
|
+
// src/core/controlPlane.ts
|
|
393
|
+
var DEFAULT_HTTPS_PORT = 7777;
|
|
394
|
+
async function ensureTlsCert(p = paths()) {
|
|
395
|
+
const have = await readFile2(p.tlsCert, "utf8").catch(() => null);
|
|
396
|
+
const haveKey = await readFile2(p.tlsKey).then(() => true, () => false);
|
|
397
|
+
if (have === null || !haveKey) {
|
|
398
|
+
await mkdir(p.tls, { recursive: true, mode: 448 });
|
|
399
|
+
try {
|
|
400
|
+
await run([
|
|
401
|
+
"openssl",
|
|
402
|
+
"req",
|
|
403
|
+
"-x509",
|
|
404
|
+
"-newkey",
|
|
405
|
+
"ec",
|
|
406
|
+
"-pkeyopt",
|
|
407
|
+
"ec_paramgen_curve:prime256v1",
|
|
408
|
+
"-nodes",
|
|
409
|
+
"-keyout",
|
|
410
|
+
p.tlsKey,
|
|
411
|
+
"-out",
|
|
412
|
+
p.tlsCert,
|
|
413
|
+
"-subj",
|
|
414
|
+
"/CN=kvbox-host",
|
|
415
|
+
"-days",
|
|
416
|
+
"3650"
|
|
417
|
+
]);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
throw new KvboxError(`could not make the host's certificate (is openssl installed?): ${err.message}`);
|
|
420
|
+
}
|
|
421
|
+
await chmod2(p.tlsKey, 384);
|
|
422
|
+
}
|
|
423
|
+
const pem = await readFile2(p.tlsCert, "utf8");
|
|
424
|
+
return { fingerprint: new X509Certificate(pem).fingerprint256 };
|
|
425
|
+
}
|
|
426
|
+
function isPrivateAddress(ip) {
|
|
427
|
+
const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ip);
|
|
428
|
+
if (!m) return false;
|
|
429
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
430
|
+
return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
|
|
431
|
+
}
|
|
432
|
+
async function defaultAdvertise() {
|
|
433
|
+
const { stdout } = await run(["ip", "-4", "route", "get", "1.1.1.1"]).catch((err) => {
|
|
434
|
+
throw new KvboxError(`cannot tell this host's address (${err.message}); pass --advertise <host-or-ip>`);
|
|
435
|
+
});
|
|
436
|
+
const m = /\bsrc\s+(\S+)/.exec(stdout);
|
|
437
|
+
if (!m) throw new KvboxError("cannot tell this host's address from its routes; pass --advertise <host-or-ip>");
|
|
438
|
+
return m[1];
|
|
439
|
+
}
|
|
440
|
+
async function joinControlPlane(opts, p = paths(), guessAddress = defaultAdvertise) {
|
|
441
|
+
const url = opts.url.replace(/\/+$/, "");
|
|
442
|
+
const port = opts.port ?? DEFAULT_HTTPS_PORT;
|
|
443
|
+
const cfg = await loadConfig(p);
|
|
444
|
+
if (cfg.controlPlane) throw new KvboxError(`this host has already joined ${cfg.controlPlane.name} (${cfg.controlPlane.url}); run kvbox-server leave first`);
|
|
445
|
+
let advertise = opts.advertise;
|
|
446
|
+
if (advertise === void 0) {
|
|
447
|
+
advertise = await guessAddress();
|
|
448
|
+
if (isPrivateAddress(advertise)) {
|
|
449
|
+
throw new KvboxError(
|
|
450
|
+
`${advertise} is a private address: a control plane on the internet cannot reach this host there.
|
|
451
|
+
Pass --advertise <public host or ip>, or --advertise ${advertise} if the control plane is on the same network.`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const name = opts.name ?? osHostname();
|
|
456
|
+
const { fingerprint } = await ensureTlsCert(p);
|
|
457
|
+
const hostUrl = `https://${advertise}:${port}`;
|
|
458
|
+
const body = { token: opts.token, name, url: hostUrl, fingerprint, publicAddress: advertise };
|
|
459
|
+
const res = await request(`${url}/hosts/join`, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "content-type": "application/json" },
|
|
462
|
+
body: JSON.stringify(body),
|
|
463
|
+
headersTimeout: 1e4,
|
|
464
|
+
bodyTimeout: 1e4
|
|
465
|
+
}).catch((err) => {
|
|
466
|
+
throw new KvboxError(`cannot reach ${url} (${err.message})`);
|
|
467
|
+
});
|
|
468
|
+
const text = await res.body.text();
|
|
469
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
470
|
+
throw new KvboxError(errorIn(text) ?? `control plane answered ${res.statusCode}`);
|
|
471
|
+
}
|
|
472
|
+
const parsed = HostJoinResponseSchema.safeParse(jsonOrNull(text));
|
|
473
|
+
if (!parsed.success) throw new KvboxError(`${url} did not answer like a kvbox control plane`);
|
|
474
|
+
if (!REALM_RE.test(parsed.data.controlPlane.name)) {
|
|
475
|
+
throw new KvboxError(`${url} calls itself "${parsed.data.controlPlane.name}", which cannot name a realm (letters, digits, '.', '-')`);
|
|
476
|
+
}
|
|
477
|
+
await saveConfig({ ...cfg, controlPlane: { url, name: parsed.data.controlPlane.name, secret: parsed.data.hostSecret, advertise, port } }, p);
|
|
478
|
+
return { hostName: name, controlPlane: parsed.data.controlPlane.name, hostUrl };
|
|
479
|
+
}
|
|
480
|
+
async function leaveControlPlane(p = paths()) {
|
|
481
|
+
const cfg = await loadConfig(p);
|
|
482
|
+
const cp = cfg.controlPlane;
|
|
483
|
+
if (!cp) throw new KvboxError("this host has not joined a control plane");
|
|
484
|
+
const told = await request(`${cp.url}/hosts/self`, {
|
|
485
|
+
method: "DELETE",
|
|
486
|
+
headers: { authorization: `Bearer ${cp.secret}` },
|
|
487
|
+
headersTimeout: 3e3,
|
|
488
|
+
bodyTimeout: 3e3
|
|
489
|
+
}).then(async (res) => {
|
|
490
|
+
await res.body.dump();
|
|
491
|
+
return res.statusCode >= 200 && res.statusCode < 300;
|
|
492
|
+
}).catch(() => false);
|
|
493
|
+
const { controlPlane: _gone, ...rest } = cfg;
|
|
494
|
+
await saveConfig(rest, p);
|
|
495
|
+
return { controlPlane: cp.name, told };
|
|
496
|
+
}
|
|
497
|
+
function jsonOrNull(text) {
|
|
498
|
+
try {
|
|
499
|
+
return JSON.parse(text);
|
|
500
|
+
} catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function errorIn(text) {
|
|
505
|
+
const v = jsonOrNull(text);
|
|
506
|
+
if (v && typeof v === "object" && typeof v.error === "string") return v.error;
|
|
507
|
+
return void 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
211
510
|
// src/core/network.ts
|
|
511
|
+
import { existsSync } from "node:fs";
|
|
512
|
+
import { fileURLToPath } from "node:url";
|
|
212
513
|
var TAP_PREFIX = "kv-";
|
|
213
514
|
var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
|
|
214
515
|
function tapName(box) {
|
|
@@ -274,41 +575,42 @@ function macFor(ip) {
|
|
|
274
575
|
}
|
|
275
576
|
|
|
276
577
|
// src/listen.ts
|
|
277
|
-
import { rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
578
|
+
import { readFile as readFile6, rm as rm3, writeFile as writeFile5 } from "node:fs/promises";
|
|
579
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
278
580
|
import { createAdaptorServer } from "@hono/node-server";
|
|
279
581
|
|
|
280
582
|
// src/core/state.ts
|
|
281
583
|
import { randomBytes } from "node:crypto";
|
|
282
|
-
import { mkdir, readFile as
|
|
283
|
-
import { z as
|
|
284
|
-
var MachineMetaSchema =
|
|
584
|
+
import { mkdir as mkdir2, readFile as readFile3, readdir, writeFile as writeFile2 } from "node:fs/promises";
|
|
585
|
+
import { z as z3 } from "zod";
|
|
586
|
+
var MachineMetaSchema = z3.object({
|
|
285
587
|
/**
|
|
286
588
|
* Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is
|
|
287
589
|
* `kv-<id>`. Absent only on a box that predates ids and is still running on its `kv-<name>` tap:
|
|
288
590
|
* `migrateBoxIds` gives stopped ones theirs at server start, a running one gets it when it next starts.
|
|
289
591
|
*/
|
|
290
|
-
id:
|
|
291
|
-
name:
|
|
592
|
+
id: z3.string().optional(),
|
|
593
|
+
name: z3.string(),
|
|
292
594
|
/** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
|
|
293
|
-
owner:
|
|
294
|
-
ip:
|
|
295
|
-
mac:
|
|
296
|
-
pid:
|
|
595
|
+
owner: z3.string(),
|
|
596
|
+
ip: z3.string(),
|
|
597
|
+
mac: z3.string(),
|
|
598
|
+
pid: z3.number().int().nullable(),
|
|
297
599
|
// null: stopped on purpose, disk kept
|
|
298
|
-
vcpu:
|
|
299
|
-
memMib:
|
|
300
|
-
createdAt:
|
|
301
|
-
source:
|
|
600
|
+
vcpu: z3.number().int(),
|
|
601
|
+
memMib: z3.number().int(),
|
|
602
|
+
createdAt: z3.string(),
|
|
603
|
+
source: z3.string(),
|
|
302
604
|
/** Base image version (assets/rootfs-<version>.ext4) the box overlays. Absent: a pre-overlay box with its own disk.ext4. */
|
|
303
|
-
base:
|
|
605
|
+
base: z3.string().optional(),
|
|
304
606
|
/**
|
|
305
607
|
* Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
|
|
306
608
|
* each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
|
|
307
609
|
* is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
|
|
308
610
|
*/
|
|
309
|
-
memSnapshots:
|
|
611
|
+
memSnapshots: z3.number().int().optional(),
|
|
310
612
|
/** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
|
|
311
|
-
paused:
|
|
613
|
+
paused: z3.boolean().optional()
|
|
312
614
|
});
|
|
313
615
|
function newBoxId(taken) {
|
|
314
616
|
for (; ; ) {
|
|
@@ -316,25 +618,25 @@ function newBoxId(taken) {
|
|
|
316
618
|
if (!taken.has(id)) return id;
|
|
317
619
|
}
|
|
318
620
|
}
|
|
319
|
-
var SnapshotMetaSchema =
|
|
320
|
-
name:
|
|
321
|
-
owner:
|
|
621
|
+
var SnapshotMetaSchema = z3.object({
|
|
622
|
+
name: z3.string(),
|
|
623
|
+
owner: z3.string(),
|
|
322
624
|
/** The box it was taken from, as owner/name. */
|
|
323
|
-
source:
|
|
324
|
-
base:
|
|
325
|
-
vcpu:
|
|
326
|
-
memMib:
|
|
327
|
-
sizeBytes:
|
|
328
|
-
createdAt:
|
|
625
|
+
source: z3.string(),
|
|
626
|
+
base: z3.string(),
|
|
627
|
+
vcpu: z3.number().int(),
|
|
628
|
+
memMib: z3.number().int(),
|
|
629
|
+
sizeBytes: z3.number().int(),
|
|
630
|
+
createdAt: z3.string()
|
|
329
631
|
});
|
|
330
632
|
async function ensureLayout(p = paths()) {
|
|
331
633
|
for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
|
|
332
|
-
await
|
|
634
|
+
await mkdir2(dir, { recursive: true, mode: 448 });
|
|
333
635
|
}
|
|
334
636
|
return p;
|
|
335
637
|
}
|
|
336
638
|
async function readJson(file, schema) {
|
|
337
|
-
return schema.parse(JSON.parse(await
|
|
639
|
+
return schema.parse(JSON.parse(await readFile3(file, "utf8")));
|
|
338
640
|
}
|
|
339
641
|
async function listDirs(dir) {
|
|
340
642
|
try {
|
|
@@ -350,7 +652,7 @@ async function readMachineMeta(ref, p = paths()) {
|
|
|
350
652
|
}
|
|
351
653
|
async function writeMachineMeta(meta, p = paths()) {
|
|
352
654
|
const m = p.machine(meta.owner, meta.name);
|
|
353
|
-
await
|
|
655
|
+
await mkdir2(m.dir, { recursive: true });
|
|
354
656
|
await writeFile2(m.meta, JSON.stringify(meta, null, 2) + "\n");
|
|
355
657
|
}
|
|
356
658
|
async function listMachineRefs(p = paths()) {
|
|
@@ -361,7 +663,7 @@ async function readSnapshotMeta(ref, p = paths()) {
|
|
|
361
663
|
}
|
|
362
664
|
async function writeSnapshotMeta(meta, p = paths()) {
|
|
363
665
|
const s = p.snapshot(meta.owner, meta.name);
|
|
364
|
-
await
|
|
666
|
+
await mkdir2(s.dir, { recursive: true });
|
|
365
667
|
await writeFile2(s.meta, JSON.stringify(meta, null, 2) + "\n");
|
|
366
668
|
}
|
|
367
669
|
async function listSnapshotRefs(p = paths()) {
|
|
@@ -388,7 +690,7 @@ function pidAlive(pid) {
|
|
|
388
690
|
|
|
389
691
|
// src/core/migrate.ts
|
|
390
692
|
import { existsSync as existsSync2 } from "node:fs";
|
|
391
|
-
import { mkdir as
|
|
693
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rename as rename2, writeFile as writeFile3 } from "node:fs/promises";
|
|
392
694
|
import { join as join2 } from "node:path";
|
|
393
695
|
|
|
394
696
|
// src/core/db.ts
|
|
@@ -491,8 +793,8 @@ async function migrateOwnerLayout(owner, p = paths()) {
|
|
|
491
793
|
// a restored box names its snapshot: that moved under the same owner
|
|
492
794
|
source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
|
|
493
795
|
}));
|
|
494
|
-
await
|
|
495
|
-
await
|
|
796
|
+
await mkdir3(p.ownerMachines(owner), { recursive: true, mode: 448 });
|
|
797
|
+
await rename2(from, to);
|
|
496
798
|
done.boxes.push(name);
|
|
497
799
|
}
|
|
498
800
|
for (const name of await oldDepthEntries(p.snapshots)) {
|
|
@@ -507,8 +809,8 @@ async function migrateOwnerLayout(owner, p = paths()) {
|
|
|
507
809
|
owner,
|
|
508
810
|
source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
|
|
509
811
|
}));
|
|
510
|
-
await
|
|
511
|
-
await
|
|
812
|
+
await mkdir3(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
|
|
813
|
+
await rename2(from, to);
|
|
512
814
|
done.snapshots.push(name);
|
|
513
815
|
}
|
|
514
816
|
return done;
|
|
@@ -524,7 +826,7 @@ async function oldDepthEntries(root) {
|
|
|
524
826
|
return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
|
|
525
827
|
}
|
|
526
828
|
async function patchJson(file, patch) {
|
|
527
|
-
const raw = JSON.parse(await
|
|
829
|
+
const raw = JSON.parse(await readFile4(file, "utf8"));
|
|
528
830
|
await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
|
|
529
831
|
}
|
|
530
832
|
async function migrateBoxIds(p = paths()) {
|
|
@@ -560,7 +862,7 @@ async function adoptBoxId(meta, taken, p = paths()) {
|
|
|
560
862
|
// src/core/machines.ts
|
|
561
863
|
import { spawn } from "node:child_process";
|
|
562
864
|
import { closeSync as closeSync2, existsSync as existsSync3, openSync as openSync2, statSync } from "node:fs";
|
|
563
|
-
import { link, mkdir as
|
|
865
|
+
import { link, mkdir as mkdir4, readFile as readFile5, realpath, rm, truncate, writeFile as writeFile4 } from "node:fs/promises";
|
|
564
866
|
import { basename } from "node:path";
|
|
565
867
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
566
868
|
|
|
@@ -616,8 +918,12 @@ function setIdentity(udsPath, id, timeoutMs) {
|
|
|
616
918
|
|
|
617
919
|
// src/core/capacity.ts
|
|
618
920
|
import { cpus, totalmem } from "node:os";
|
|
921
|
+
|
|
922
|
+
// ../shared/src/capacity.ts
|
|
619
923
|
var MEM_HEADROOM_MIB = 2048;
|
|
620
924
|
var VCPU_OVERSUBSCRIPTION = 2;
|
|
925
|
+
|
|
926
|
+
// src/core/capacity.ts
|
|
621
927
|
function hostResources() {
|
|
622
928
|
return { memMib: Math.floor(totalmem() / 1024 ** 2), cores: cpus().length };
|
|
623
929
|
}
|
|
@@ -675,7 +981,7 @@ async function assertRoomFor(vcpu, memMib, force, p = paths()) {
|
|
|
675
981
|
}
|
|
676
982
|
|
|
677
983
|
// src/core/firecracker.ts
|
|
678
|
-
import { Agent, request } from "undici";
|
|
984
|
+
import { Agent, request as request2 } from "undici";
|
|
679
985
|
var FirecrackerClient = class {
|
|
680
986
|
constructor(socketPath) {
|
|
681
987
|
this.socketPath = socketPath;
|
|
@@ -684,7 +990,7 @@ var FirecrackerClient = class {
|
|
|
684
990
|
socketPath;
|
|
685
991
|
agent;
|
|
686
992
|
async call(method, path, body) {
|
|
687
|
-
const res = await
|
|
993
|
+
const res = await request2(`http://localhost${path}`, {
|
|
688
994
|
dispatcher: this.agent,
|
|
689
995
|
method,
|
|
690
996
|
headers: body === void 0 ? {} : { "content-type": "application/json" },
|
|
@@ -877,8 +1183,8 @@ async function readAllMeta(p) {
|
|
|
877
1183
|
}
|
|
878
1184
|
return metas;
|
|
879
1185
|
}
|
|
880
|
-
async function listMachines(
|
|
881
|
-
return (await readAllMeta(p)).filter((m) =>
|
|
1186
|
+
async function listMachines(scope, p = paths()) {
|
|
1187
|
+
return (await readAllMeta(p)).filter((m) => inScope(m.owner, scope)).map((m) => toMachine(m, p));
|
|
882
1188
|
}
|
|
883
1189
|
async function getMachine(ref, p = paths()) {
|
|
884
1190
|
return toMachine(await readMeta(ref, p), p);
|
|
@@ -944,7 +1250,7 @@ async function waitFor(pred, timeoutMs, stepMs = 50) {
|
|
|
944
1250
|
}
|
|
945
1251
|
async function tailOf(file, lines = 5) {
|
|
946
1252
|
try {
|
|
947
|
-
return (await
|
|
1253
|
+
return (await readFile5(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
|
|
948
1254
|
} catch {
|
|
949
1255
|
return "";
|
|
950
1256
|
}
|
|
@@ -1043,11 +1349,11 @@ async function currentBaseVersion(p = paths()) {
|
|
|
1043
1349
|
return (await currentBase(await loadConfig(p), p)).version;
|
|
1044
1350
|
}
|
|
1045
1351
|
async function namesOverlayAbsolutely(vmstate) {
|
|
1046
|
-
return (await
|
|
1352
|
+
return (await readFile5(vmstate)).includes(`/${OVERLAY_REL}`);
|
|
1047
1353
|
}
|
|
1048
1354
|
async function readPubkeyB64(p) {
|
|
1049
1355
|
try {
|
|
1050
|
-
return Buffer.from((await
|
|
1356
|
+
return Buffer.from((await readFile5(`${p.sshKey}.pub`, "utf8")).trim()).toString("base64");
|
|
1051
1357
|
} catch {
|
|
1052
1358
|
throw new KvboxError(`SSH key missing at ${p.sshKey}.pub; run install-server.sh`);
|
|
1053
1359
|
}
|
|
@@ -1075,7 +1381,7 @@ async function createMachine(req, owner, p = paths()) {
|
|
|
1075
1381
|
const base = await currentBase(cfg, p);
|
|
1076
1382
|
await assertOverlayCapable(base.file, base.version);
|
|
1077
1383
|
await ensureHostNetwork(cfg);
|
|
1078
|
-
await
|
|
1384
|
+
await mkdir4(m.dir, { recursive: true, mode: 448 });
|
|
1079
1385
|
let tapCreated = false;
|
|
1080
1386
|
try {
|
|
1081
1387
|
await createOverlay(m.overlay, size.diskGib);
|
|
@@ -1123,7 +1429,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
|
|
|
1123
1429
|
const m = p.machine(owner, name);
|
|
1124
1430
|
const s = p.snapshot(from.owner, from.name);
|
|
1125
1431
|
await ensureHostNetwork(cfg);
|
|
1126
|
-
await
|
|
1432
|
+
await mkdir4(m.dir, { recursive: true, mode: 448 });
|
|
1127
1433
|
let tapCreated = false;
|
|
1128
1434
|
let pid;
|
|
1129
1435
|
try {
|
|
@@ -1281,7 +1587,7 @@ async function hibernateMachine(ref, p = paths()) {
|
|
|
1281
1587
|
if (meta.base === void 0) throw new KvboxError(`box ${shown(ref)} was made before overlays and runs on its own disk copy; it cannot be hibernated`);
|
|
1282
1588
|
const m = p.machine(ref.owner, ref.name);
|
|
1283
1589
|
await rm(m.hibernateDir, { recursive: true, force: true });
|
|
1284
|
-
await
|
|
1590
|
+
await mkdir4(m.hibernateDir, { recursive: true, mode: 448 });
|
|
1285
1591
|
const fc = new FirecrackerClient(m.sock);
|
|
1286
1592
|
try {
|
|
1287
1593
|
try {
|
|
@@ -1416,6 +1722,10 @@ async function destroyMachine(ref, p = paths()) {
|
|
|
1416
1722
|
|
|
1417
1723
|
// src/core/usage.ts
|
|
1418
1724
|
var HOUR_MS = 36e5;
|
|
1725
|
+
function ledgerState(status) {
|
|
1726
|
+
if (status === "unknown") throw new Error("a box on this host cannot be in state unknown");
|
|
1727
|
+
return status;
|
|
1728
|
+
}
|
|
1419
1729
|
var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
|
|
1420
1730
|
function computeUsage(events, w) {
|
|
1421
1731
|
const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
|
|
@@ -1464,7 +1774,7 @@ async function machineUsage(ref, since, p = paths()) {
|
|
|
1464
1774
|
let events = usageEventsFor(boxId, box.createdAt, p);
|
|
1465
1775
|
if (events.length === 0) {
|
|
1466
1776
|
insertUsageEvent(
|
|
1467
|
-
{ boxId, owner: box.owner, name: box.name, at: now, state: box.status, by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
|
|
1777
|
+
{ boxId, owner: box.owner, name: box.name, at: now, state: ledgerState(box.status), by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
|
|
1468
1778
|
p
|
|
1469
1779
|
);
|
|
1470
1780
|
events = usageEventsFor(boxId, box.createdAt, p);
|
|
@@ -1478,7 +1788,7 @@ async function seedUsageLedger(p = paths()) {
|
|
|
1478
1788
|
const boxId = box.id ?? `${box.owner}/${box.name}`;
|
|
1479
1789
|
if (usageEventsFor(boxId, box.createdAt, p).length > 0) continue;
|
|
1480
1790
|
insertUsageEvent(
|
|
1481
|
-
{ boxId, owner: box.owner, name: box.name, at: (/* @__PURE__ */ new Date()).toISOString(), state: box.status, by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
|
|
1791
|
+
{ boxId, owner: box.owner, name: box.name, at: (/* @__PURE__ */ new Date()).toISOString(), state: ledgerState(box.status), by: "upgrade", vcpu: box.vcpu, memMib: box.memMib, diskGib: Math.round(box.diskBytes / GIB) },
|
|
1482
1792
|
p
|
|
1483
1793
|
);
|
|
1484
1794
|
seeded += 1;
|
|
@@ -1502,16 +1812,46 @@ function versionOf(srcFileUrl, injected) {
|
|
|
1502
1812
|
}
|
|
1503
1813
|
|
|
1504
1814
|
// src/version.ts
|
|
1505
|
-
var VERSION = versionOf(import.meta.url, true ? "0.
|
|
1815
|
+
var VERSION = versionOf(import.meta.url, true ? "0.3.0" : void 0);
|
|
1506
1816
|
|
|
1507
1817
|
// src/caller.ts
|
|
1508
|
-
|
|
1818
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
1819
|
+
var OWNER_HEADER = "x-kvbox-owner";
|
|
1820
|
+
function resolveCaller(opts = {}) {
|
|
1821
|
+
const cp = opts.controlPlane;
|
|
1822
|
+
if (!cp) {
|
|
1823
|
+
return async (c, next) => {
|
|
1824
|
+
const cfg = await loadConfig();
|
|
1825
|
+
c.set("owner", cfg.owner);
|
|
1826
|
+
c.set("listScope", cfg.owner);
|
|
1827
|
+
await next();
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
const expected = digest(cp.secret);
|
|
1509
1831
|
return async (c, next) => {
|
|
1510
|
-
const
|
|
1511
|
-
c.
|
|
1832
|
+
const m = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
|
|
1833
|
+
if (!m || !timingSafeEqual(digest(m[1]), expected)) return c.json({ error: "not authorized" }, 401);
|
|
1834
|
+
const user = c.req.header(OWNER_HEADER);
|
|
1835
|
+
if (user === void 0) {
|
|
1836
|
+
const path = c.req.path.replace(/\/$/, "");
|
|
1837
|
+
const realmListing = c.req.method === "GET" && (path === "/machines" || path === "/snapshots");
|
|
1838
|
+
if (!realmListing && /^\/(machines|snapshots)(\/|$)/.test(c.req.path)) {
|
|
1839
|
+
return c.json({ error: "X-Kvbox-Owner header required" }, 400);
|
|
1840
|
+
}
|
|
1841
|
+
c.set("owner", "");
|
|
1842
|
+
c.set("listScope", { realm: cp.name });
|
|
1843
|
+
} else {
|
|
1844
|
+
if (!LOCAL_OWNER_RE.test(user)) return c.json({ error: "X-Kvbox-Owner: letters, digits, '.', '_', '-'" }, 400);
|
|
1845
|
+
const owner = `${user}@${cp.name}`;
|
|
1846
|
+
c.set("owner", owner);
|
|
1847
|
+
c.set("listScope", owner);
|
|
1848
|
+
}
|
|
1512
1849
|
await next();
|
|
1513
1850
|
};
|
|
1514
1851
|
}
|
|
1852
|
+
function digest(secret) {
|
|
1853
|
+
return createHash("sha256").update(secret).digest();
|
|
1854
|
+
}
|
|
1515
1855
|
|
|
1516
1856
|
// src/routes/capacity.ts
|
|
1517
1857
|
import { Hono } from "hono";
|
|
@@ -1523,108 +1863,9 @@ function capacityRoutes() {
|
|
|
1523
1863
|
|
|
1524
1864
|
// src/routes/machines.ts
|
|
1525
1865
|
import { Hono as Hono2 } from "hono";
|
|
1526
|
-
|
|
1527
|
-
// ../shared/src/api.ts
|
|
1528
|
-
import { z as z3 } from "zod";
|
|
1529
|
-
var SizeSchema = z3.enum(SIZE_NAMES);
|
|
1530
|
-
var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped"]);
|
|
1531
|
-
var MachineSchema = z3.object({
|
|
1532
|
-
/** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
|
|
1533
|
-
id: z3.string().optional(),
|
|
1534
|
-
name: z3.string(),
|
|
1535
|
-
/** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
|
|
1536
|
-
owner: z3.string(),
|
|
1537
|
-
ip: z3.string(),
|
|
1538
|
-
mac: z3.string(),
|
|
1539
|
-
pid: z3.number().int().nullable(),
|
|
1540
|
-
vcpu: z3.number().int(),
|
|
1541
|
-
memMib: z3.number().int(),
|
|
1542
|
-
diskBytes: z3.number().int(),
|
|
1543
|
-
// the box's own writable disk, as the guest sees it
|
|
1544
|
-
diskUsedBytes: z3.number().int(),
|
|
1545
|
-
// what that sparse file really occupies on the host
|
|
1546
|
-
base: z3.string().nullable(),
|
|
1547
|
-
// base image version the box runs on (null: pre-overlay box with its own copy)
|
|
1548
|
-
createdAt: z3.string(),
|
|
1549
|
-
source: z3.string(),
|
|
1550
|
-
// "base" or "snapshot:<name>"
|
|
1551
|
-
status: BoxStatusSchema
|
|
1552
|
-
});
|
|
1553
|
-
var CreateMachineRequest = z3.object({
|
|
1554
|
-
name: z3.string().optional(),
|
|
1555
|
-
/** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
|
|
1556
|
-
owner: z3.string().optional(),
|
|
1557
|
-
size: SizeSchema.optional(),
|
|
1558
|
-
from: z3.string().optional(),
|
|
1559
|
-
force: z3.boolean().optional()
|
|
1560
|
-
});
|
|
1561
|
-
var RescaleMachineRequest = z3.object({
|
|
1562
|
-
size: SizeSchema.optional(),
|
|
1563
|
-
vcpu: z3.number().int().min(1).max(64).optional(),
|
|
1564
|
-
memMib: z3.number().int().min(256).optional(),
|
|
1565
|
-
diskGib: z3.number().int().min(1).optional()
|
|
1566
|
-
});
|
|
1567
|
-
var SnapshotSchema = z3.object({
|
|
1568
|
-
name: z3.string(),
|
|
1569
|
-
owner: z3.string(),
|
|
1570
|
-
source: z3.string(),
|
|
1571
|
-
// the box it was taken from, as owner/name
|
|
1572
|
-
base: z3.string(),
|
|
1573
|
-
// base image version the overlay stacks on; a restore needs the same one
|
|
1574
|
-
vcpu: z3.number().int(),
|
|
1575
|
-
memMib: z3.number().int(),
|
|
1576
|
-
sizeBytes: z3.number().int(),
|
|
1577
|
-
// what vmstate + mem + overlay really occupy on the host
|
|
1578
|
-
createdAt: z3.string()
|
|
1579
|
-
});
|
|
1580
|
-
var CreateSnapshotRequest = z3.object({
|
|
1581
|
-
box: z3.string(),
|
|
1582
|
-
name: z3.string()
|
|
1583
|
-
});
|
|
1584
|
-
var CapacitySchema = z3.object({
|
|
1585
|
-
hostMemMib: z3.number().int(),
|
|
1586
|
-
hostCores: z3.number().int(),
|
|
1587
|
-
reservedMemMib: z3.number().int(),
|
|
1588
|
-
reservedVcpu: z3.number().int(),
|
|
1589
|
-
running: z3.number().int()
|
|
1590
|
-
});
|
|
1591
|
-
var HealthSchema = z3.object({
|
|
1592
|
-
ok: z3.literal(true),
|
|
1593
|
-
version: z3.string(),
|
|
1594
|
-
pid: z3.number().int(),
|
|
1595
|
-
/** Base image version new boxes get (null when the host has no usable base image yet); absent from older servers. */
|
|
1596
|
-
base: z3.string().nullable().optional(),
|
|
1597
|
-
/** Owner that boxes made over the local socket get (the Unix user running the server); absent from older servers. */
|
|
1598
|
-
owner: z3.string().optional()
|
|
1599
|
-
});
|
|
1600
|
-
var MachineUsageSchema = z3.object({
|
|
1601
|
-
name: z3.string(),
|
|
1602
|
-
owner: z3.string(),
|
|
1603
|
-
status: BoxStatusSchema,
|
|
1604
|
-
window: z3.object({ from: z3.string(), to: z3.string() }),
|
|
1605
|
-
/** standby and hibernated default to 0 so a newer CLI reads an older server's answer. */
|
|
1606
|
-
seconds: z3.object({
|
|
1607
|
-
running: z3.number().int(),
|
|
1608
|
-
standby: z3.number().int().default(0),
|
|
1609
|
-
hibernated: z3.number().int().default(0),
|
|
1610
|
-
stopped: z3.number().int()
|
|
1611
|
-
}),
|
|
1612
|
-
vcpuHours: z3.number(),
|
|
1613
|
-
ramGibHours: z3.number(),
|
|
1614
|
-
diskGibHours: z3.number(),
|
|
1615
|
-
cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional(),
|
|
1616
|
-
/** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
|
|
1617
|
-
estimatedStops: z3.number().int(),
|
|
1618
|
-
historyFrom: z3.string().nullable()
|
|
1619
|
-
});
|
|
1620
|
-
var ApiErrorSchema = z3.object({
|
|
1621
|
-
error: z3.string()
|
|
1622
|
-
});
|
|
1623
|
-
|
|
1624
|
-
// src/routes/machines.ts
|
|
1625
1866
|
function machineRoutes() {
|
|
1626
1867
|
const r = new Hono2();
|
|
1627
|
-
r.get("/", async (c) => c.json(await listMachines(c.get("
|
|
1868
|
+
r.get("/", async (c) => c.json(await listMachines(c.get("listScope"))));
|
|
1628
1869
|
r.post("/", async (c) => {
|
|
1629
1870
|
const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
|
|
1630
1871
|
return c.json(await createMachine(body, c.get("owner")), 201);
|
|
@@ -1662,7 +1903,7 @@ import { Hono as Hono3 } from "hono";
|
|
|
1662
1903
|
|
|
1663
1904
|
// src/core/snapshots.ts
|
|
1664
1905
|
import { existsSync as existsSync4 } from "node:fs";
|
|
1665
|
-
import { link as link2, mkdir as
|
|
1906
|
+
import { link as link2, mkdir as mkdir5, readdir as readdir3, rename as rename3, rm as rm2, stat } from "node:fs/promises";
|
|
1666
1907
|
var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
1667
1908
|
function toSnapshot(meta) {
|
|
1668
1909
|
return {
|
|
@@ -1676,10 +1917,10 @@ function toSnapshot(meta) {
|
|
|
1676
1917
|
createdAt: meta.createdAt
|
|
1677
1918
|
};
|
|
1678
1919
|
}
|
|
1679
|
-
async function listSnapshots(
|
|
1920
|
+
async function listSnapshots(scope, p = paths()) {
|
|
1680
1921
|
const out = [];
|
|
1681
1922
|
for (const ref of await listSnapshotRefs(p)) {
|
|
1682
|
-
if (
|
|
1923
|
+
if (!inScope(ref.owner, scope)) continue;
|
|
1683
1924
|
try {
|
|
1684
1925
|
out.push(toSnapshot(await readSnapshotMeta(ref, p)));
|
|
1685
1926
|
} catch (err) {
|
|
@@ -1721,7 +1962,7 @@ async function saveSnapshot(req, owner, p = paths()) {
|
|
|
1721
1962
|
if (existsSync4(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
|
|
1722
1963
|
const cfg = await loadConfig(p);
|
|
1723
1964
|
const m = p.machine(box.owner, box.name);
|
|
1724
|
-
await
|
|
1965
|
+
await mkdir5(s.dir, { recursive: true, mode: 448 });
|
|
1725
1966
|
const fc = new FirecrackerClient(m.sock);
|
|
1726
1967
|
let lineageTouched = false;
|
|
1727
1968
|
try {
|
|
@@ -1777,7 +2018,7 @@ async function mergeOntoPrevious(previous, diffFile, editor) {
|
|
|
1777
2018
|
const merged = `${diffFile}.merged`;
|
|
1778
2019
|
await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
|
|
1779
2020
|
await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
|
|
1780
|
-
await
|
|
2021
|
+
await rename3(merged, diffFile);
|
|
1781
2022
|
}
|
|
1782
2023
|
async function deleteSnapshot(ref, p = paths()) {
|
|
1783
2024
|
const dir = p.snapshot(ref.owner, ref.name).dir;
|
|
@@ -1788,7 +2029,7 @@ async function deleteSnapshot(ref, p = paths()) {
|
|
|
1788
2029
|
// src/routes/snapshots.ts
|
|
1789
2030
|
function snapshotRoutes() {
|
|
1790
2031
|
const r = new Hono3();
|
|
1791
|
-
r.get("/", async (c) => c.json(await listSnapshots(c.get("
|
|
2032
|
+
r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));
|
|
1792
2033
|
r.post("/", async (c) => {
|
|
1793
2034
|
const body = CreateSnapshotRequest.parse(await c.req.json());
|
|
1794
2035
|
return c.json(await saveSnapshot(body, c.get("owner")), 201);
|
|
@@ -1801,9 +2042,9 @@ function snapshotRoutes() {
|
|
|
1801
2042
|
}
|
|
1802
2043
|
|
|
1803
2044
|
// src/app.ts
|
|
1804
|
-
function createApp() {
|
|
2045
|
+
function createApp(opts = {}) {
|
|
1805
2046
|
const app = new Hono4();
|
|
1806
|
-
app.use(resolveCaller());
|
|
2047
|
+
app.use(resolveCaller(opts));
|
|
1807
2048
|
app.get(
|
|
1808
2049
|
"/health",
|
|
1809
2050
|
async (c) => c.json({
|
|
@@ -1854,22 +2095,60 @@ async function listen(opts = {}) {
|
|
|
1854
2095
|
});
|
|
1855
2096
|
await writeFile5(p.serverPid, `${process.pid}
|
|
1856
2097
|
`);
|
|
2098
|
+
let https;
|
|
2099
|
+
let httpsServer;
|
|
2100
|
+
const cp = cfg.controlPlane;
|
|
2101
|
+
if (cp) {
|
|
2102
|
+
const [key, cert] = await Promise.all([readFile6(p.tlsKey), readFile6(p.tlsCert)]).catch((err) => {
|
|
2103
|
+
throw new KvboxError(`joined ${cp.name} but the host's certificate is missing (${err.message}); run kvbox-server leave, then join again`);
|
|
2104
|
+
});
|
|
2105
|
+
httpsServer = createAdaptorServer({
|
|
2106
|
+
fetch: createApp({ controlPlane: { name: cp.name, secret: cp.secret } }).fetch,
|
|
2107
|
+
createServer: createHttpsServer,
|
|
2108
|
+
serverOptions: { key, cert }
|
|
2109
|
+
});
|
|
2110
|
+
const port = opts.httpsPort ?? cp.port;
|
|
2111
|
+
await new Promise((resolve, reject) => {
|
|
2112
|
+
httpsServer.once("error", reject);
|
|
2113
|
+
httpsServer.listen(port, "0.0.0.0", () => resolve());
|
|
2114
|
+
}).catch(async (err) => {
|
|
2115
|
+
server.close();
|
|
2116
|
+
await rm3(socketPath, { force: true });
|
|
2117
|
+
await rm3(p.serverPid, { force: true });
|
|
2118
|
+
throw new KvboxError(`cannot listen on port ${port} for ${cp.name} (${err.message})`);
|
|
2119
|
+
});
|
|
2120
|
+
const bound = httpsServer.address().port;
|
|
2121
|
+
https = { port: bound, url: `https://${cp.advertise}:${bound}` };
|
|
2122
|
+
}
|
|
2123
|
+
const close = async () => {
|
|
2124
|
+
await Promise.all([
|
|
2125
|
+
new Promise((resolve) => server.close(() => resolve())),
|
|
2126
|
+
httpsServer ? new Promise((resolve) => httpsServer.close(() => resolve())) : void 0
|
|
2127
|
+
]);
|
|
2128
|
+
await rm3(socketPath, { force: true });
|
|
2129
|
+
await rm3(p.serverPid, { force: true });
|
|
2130
|
+
};
|
|
1857
2131
|
const shutdown2 = async () => {
|
|
1858
2132
|
server.close();
|
|
2133
|
+
httpsServer?.close();
|
|
1859
2134
|
await rm3(socketPath, { force: true });
|
|
1860
2135
|
await rm3(p.serverPid, { force: true });
|
|
1861
2136
|
process.exit(0);
|
|
1862
2137
|
};
|
|
1863
2138
|
process.once("SIGINT", () => void shutdown2());
|
|
1864
2139
|
process.once("SIGTERM", () => void shutdown2());
|
|
1865
|
-
return { socketPath, server };
|
|
2140
|
+
return https ? { socketPath, server, https, close } : { socketPath, server, close };
|
|
1866
2141
|
}
|
|
1867
2142
|
|
|
1868
2143
|
// src/index.ts
|
|
1869
2144
|
var program = new Command("kvbox-server").description("kvbox host daemon: owns Firecracker and ~/.kvbox, serves the kvbox API").version(VERSION, "-v, --version").showHelpAfterError();
|
|
1870
2145
|
program.command("start", { isDefault: true }).description("serve the API on the local Unix socket (foreground)").option("--socket <path>", "socket to listen on (default: $KVBOX_HOME/server.sock)").action(async (opts) => {
|
|
1871
|
-
const { socketPath } = await listen(opts.socket ? { socketPath: opts.socket } : {});
|
|
2146
|
+
const { socketPath, https } = await listen(opts.socket ? { socketPath: opts.socket } : {});
|
|
1872
2147
|
console.log(`kvbox-server ${VERSION} listening on ${socketPath} (pid ${process.pid})`);
|
|
2148
|
+
if (https) {
|
|
2149
|
+
const cfg = await loadConfig(paths());
|
|
2150
|
+
console.log(`control plane ${cfg.controlPlane?.name}: answering on ${https.url}`);
|
|
2151
|
+
}
|
|
1873
2152
|
try {
|
|
1874
2153
|
const cfg = await loadConfig(paths());
|
|
1875
2154
|
const state = await ensureHostNetwork(cfg);
|
|
@@ -1878,6 +2157,17 @@ program.command("start", { isDefault: true }).description("serve the API on the
|
|
|
1878
2157
|
console.error(`warning: host network not ready (${err.message}); run: kvbox-server net up`);
|
|
1879
2158
|
}
|
|
1880
2159
|
});
|
|
2160
|
+
program.command("join <control-plane-url>").description("register this host with a control plane (needs a one-time token minted there); it answers the control plane from its next start").requiredOption("--token <token>", "one-time join token from the control plane").option("--advertise <host-or-ip>", "address the control plane reaches this host at (default: this host's default-route address)").option("--name <name>", "how the control plane lists this host (default: hostname)").action(async (url, opts) => {
|
|
2161
|
+
const joined = await joinControlPlane({ url, ...opts }, paths());
|
|
2162
|
+
console.log(`joined ${joined.controlPlane} as host ${joined.hostName}`);
|
|
2163
|
+
console.log(`it answers the control plane on ${joined.hostUrl} from its next start: run \`kvbox server stop\`; the next kvbox command starts it again`);
|
|
2164
|
+
console.log(`make sure port ${new URL(joined.hostUrl).port} is reachable from the control plane`);
|
|
2165
|
+
});
|
|
2166
|
+
program.command("leave").description("forget the control plane this host joined; the HTTPS port closes at the next start").action(async () => {
|
|
2167
|
+
const left = await leaveControlPlane(paths());
|
|
2168
|
+
console.log(`left ${left.controlPlane}; the HTTPS port closes at the next start`);
|
|
2169
|
+
if (!left.told) console.log(`(the control plane could not be told; remove this host there)`);
|
|
2170
|
+
});
|
|
1881
2171
|
program.command("net <action>").description("host network: up | down | status (bridge, forwarding, NAT; uses sudo)").action(async (action) => {
|
|
1882
2172
|
if (!["up", "down", "status"].includes(action)) throw new KvboxError("net: expected up, down or status");
|
|
1883
2173
|
const cfg = await loadConfig(paths());
|