@ghalex/kvbox-server 0.2.0 → 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 +457 -201
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
package/dist/kvbox-server.js
CHANGED
|
@@ -39,6 +39,10 @@ function paths(home = kvboxHome()) {
|
|
|
39
39
|
cli: join(home, "cli.json"),
|
|
40
40
|
ssh: join(home, "ssh"),
|
|
41
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"),
|
|
42
46
|
bin: join(home, "bin"),
|
|
43
47
|
firecracker: join(home, "bin", "firecracker"),
|
|
44
48
|
/** Firecracker's snapshot-editor, from the same release: merges a diff snapshot onto a full memory image. */
|
|
@@ -92,7 +96,7 @@ function paths(home = kvboxHome()) {
|
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
// src/core/config.ts
|
|
95
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
99
|
+
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
|
96
100
|
import { z } from "zod";
|
|
97
101
|
|
|
98
102
|
// ../shared/src/sizes.ts
|
|
@@ -113,6 +117,7 @@ function shown(ref) {
|
|
|
113
117
|
return ref.display ?? ref.name;
|
|
114
118
|
}
|
|
115
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}$/;
|
|
116
121
|
var OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}(@[A-Za-z0-9.-]{1,128})?$/;
|
|
117
122
|
function localOwner() {
|
|
118
123
|
try {
|
|
@@ -123,6 +128,15 @@ function localOwner() {
|
|
|
123
128
|
const env = process.env["USER"];
|
|
124
129
|
return env !== void 0 && LOCAL_OWNER_RE.test(env) ? env : "kvbox";
|
|
125
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
|
+
}
|
|
126
140
|
function parseRef(text, caller) {
|
|
127
141
|
const slash = text.indexOf("/");
|
|
128
142
|
if (slash === -1) return { owner: caller, name: text, display: text };
|
|
@@ -131,6 +145,10 @@ function parseRef(text, caller) {
|
|
|
131
145
|
if (!OWNER_RE.test(owner) || name.length === 0 || name.includes("/")) {
|
|
132
146
|
throw new KvboxError(`invalid box reference "${text}": use <name> or <owner>/<name>`);
|
|
133
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
|
+
}
|
|
134
152
|
return { owner, name, display: owner === caller ? name : text };
|
|
135
153
|
}
|
|
136
154
|
function refKey(ref) {
|
|
@@ -144,6 +162,16 @@ var PricingSchema = z.object({
|
|
|
144
162
|
ramGibHour: z.number().nonnegative(),
|
|
145
163
|
diskGibHour: z.number().nonnegative()
|
|
146
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
|
+
});
|
|
147
175
|
var ConfigSchema = z.object({
|
|
148
176
|
kernel: z.string(),
|
|
149
177
|
rootfs: z.string(),
|
|
@@ -156,8 +184,17 @@ var ConfigSchema = z.object({
|
|
|
156
184
|
defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE),
|
|
157
185
|
/** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
|
|
158
186
|
owner: z.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
|
|
159
|
-
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()
|
|
160
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
|
+
}
|
|
161
198
|
function defaultConfig(p = paths()) {
|
|
162
199
|
return ConfigSchema.parse({
|
|
163
200
|
kernel: p.kernel,
|
|
@@ -181,9 +218,152 @@ async function loadConfig(p = paths()) {
|
|
|
181
218
|
}
|
|
182
219
|
}
|
|
183
220
|
|
|
184
|
-
// src/core/
|
|
185
|
-
import {
|
|
186
|
-
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
|
+
});
|
|
187
367
|
|
|
188
368
|
// src/core/exec.ts
|
|
189
369
|
import { execFile } from "node:child_process";
|
|
@@ -209,7 +389,127 @@ async function run(argv, opts = {}) {
|
|
|
209
389
|
}
|
|
210
390
|
}
|
|
211
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
|
+
|
|
212
510
|
// src/core/network.ts
|
|
511
|
+
import { existsSync } from "node:fs";
|
|
512
|
+
import { fileURLToPath } from "node:url";
|
|
213
513
|
var TAP_PREFIX = "kv-";
|
|
214
514
|
var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
|
|
215
515
|
function tapName(box) {
|
|
@@ -275,41 +575,42 @@ function macFor(ip) {
|
|
|
275
575
|
}
|
|
276
576
|
|
|
277
577
|
// src/listen.ts
|
|
278
|
-
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";
|
|
279
580
|
import { createAdaptorServer } from "@hono/node-server";
|
|
280
581
|
|
|
281
582
|
// src/core/state.ts
|
|
282
583
|
import { randomBytes } from "node:crypto";
|
|
283
|
-
import { mkdir, readFile as
|
|
284
|
-
import { z as
|
|
285
|
-
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({
|
|
286
587
|
/**
|
|
287
588
|
* Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is
|
|
288
589
|
* `kv-<id>`. Absent only on a box that predates ids and is still running on its `kv-<name>` tap:
|
|
289
590
|
* `migrateBoxIds` gives stopped ones theirs at server start, a running one gets it when it next starts.
|
|
290
591
|
*/
|
|
291
|
-
id:
|
|
292
|
-
name:
|
|
592
|
+
id: z3.string().optional(),
|
|
593
|
+
name: z3.string(),
|
|
293
594
|
/** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
|
|
294
|
-
owner:
|
|
295
|
-
ip:
|
|
296
|
-
mac:
|
|
297
|
-
pid:
|
|
595
|
+
owner: z3.string(),
|
|
596
|
+
ip: z3.string(),
|
|
597
|
+
mac: z3.string(),
|
|
598
|
+
pid: z3.number().int().nullable(),
|
|
298
599
|
// null: stopped on purpose, disk kept
|
|
299
|
-
vcpu:
|
|
300
|
-
memMib:
|
|
301
|
-
createdAt:
|
|
302
|
-
source:
|
|
600
|
+
vcpu: z3.number().int(),
|
|
601
|
+
memMib: z3.number().int(),
|
|
602
|
+
createdAt: z3.string(),
|
|
603
|
+
source: z3.string(),
|
|
303
604
|
/** Base image version (assets/rootfs-<version>.ext4) the box overlays. Absent: a pre-overlay box with its own disk.ext4. */
|
|
304
|
-
base:
|
|
605
|
+
base: z3.string().optional(),
|
|
305
606
|
/**
|
|
306
607
|
* Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
|
|
307
608
|
* each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
|
|
308
609
|
* is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
|
|
309
610
|
*/
|
|
310
|
-
memSnapshots:
|
|
611
|
+
memSnapshots: z3.number().int().optional(),
|
|
311
612
|
/** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
|
|
312
|
-
paused:
|
|
613
|
+
paused: z3.boolean().optional()
|
|
313
614
|
});
|
|
314
615
|
function newBoxId(taken) {
|
|
315
616
|
for (; ; ) {
|
|
@@ -317,25 +618,25 @@ function newBoxId(taken) {
|
|
|
317
618
|
if (!taken.has(id)) return id;
|
|
318
619
|
}
|
|
319
620
|
}
|
|
320
|
-
var SnapshotMetaSchema =
|
|
321
|
-
name:
|
|
322
|
-
owner:
|
|
621
|
+
var SnapshotMetaSchema = z3.object({
|
|
622
|
+
name: z3.string(),
|
|
623
|
+
owner: z3.string(),
|
|
323
624
|
/** The box it was taken from, as owner/name. */
|
|
324
|
-
source:
|
|
325
|
-
base:
|
|
326
|
-
vcpu:
|
|
327
|
-
memMib:
|
|
328
|
-
sizeBytes:
|
|
329
|
-
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()
|
|
330
631
|
});
|
|
331
632
|
async function ensureLayout(p = paths()) {
|
|
332
633
|
for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
|
|
333
|
-
await
|
|
634
|
+
await mkdir2(dir, { recursive: true, mode: 448 });
|
|
334
635
|
}
|
|
335
636
|
return p;
|
|
336
637
|
}
|
|
337
638
|
async function readJson(file, schema) {
|
|
338
|
-
return schema.parse(JSON.parse(await
|
|
639
|
+
return schema.parse(JSON.parse(await readFile3(file, "utf8")));
|
|
339
640
|
}
|
|
340
641
|
async function listDirs(dir) {
|
|
341
642
|
try {
|
|
@@ -351,7 +652,7 @@ async function readMachineMeta(ref, p = paths()) {
|
|
|
351
652
|
}
|
|
352
653
|
async function writeMachineMeta(meta, p = paths()) {
|
|
353
654
|
const m = p.machine(meta.owner, meta.name);
|
|
354
|
-
await
|
|
655
|
+
await mkdir2(m.dir, { recursive: true });
|
|
355
656
|
await writeFile2(m.meta, JSON.stringify(meta, null, 2) + "\n");
|
|
356
657
|
}
|
|
357
658
|
async function listMachineRefs(p = paths()) {
|
|
@@ -362,7 +663,7 @@ async function readSnapshotMeta(ref, p = paths()) {
|
|
|
362
663
|
}
|
|
363
664
|
async function writeSnapshotMeta(meta, p = paths()) {
|
|
364
665
|
const s = p.snapshot(meta.owner, meta.name);
|
|
365
|
-
await
|
|
666
|
+
await mkdir2(s.dir, { recursive: true });
|
|
366
667
|
await writeFile2(s.meta, JSON.stringify(meta, null, 2) + "\n");
|
|
367
668
|
}
|
|
368
669
|
async function listSnapshotRefs(p = paths()) {
|
|
@@ -389,7 +690,7 @@ function pidAlive(pid) {
|
|
|
389
690
|
|
|
390
691
|
// src/core/migrate.ts
|
|
391
692
|
import { existsSync as existsSync2 } from "node:fs";
|
|
392
|
-
import { mkdir as
|
|
693
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile4, rename as rename2, writeFile as writeFile3 } from "node:fs/promises";
|
|
393
694
|
import { join as join2 } from "node:path";
|
|
394
695
|
|
|
395
696
|
// src/core/db.ts
|
|
@@ -492,8 +793,8 @@ async function migrateOwnerLayout(owner, p = paths()) {
|
|
|
492
793
|
// a restored box names its snapshot: that moved under the same owner
|
|
493
794
|
source: typeof meta["source"] === "string" && meta["source"].startsWith("snapshot:") && !meta["source"].includes("/") ? `snapshot:${owner}/${meta["source"].slice("snapshot:".length)}` : meta["source"]
|
|
494
795
|
}));
|
|
495
|
-
await
|
|
496
|
-
await
|
|
796
|
+
await mkdir3(p.ownerMachines(owner), { recursive: true, mode: 448 });
|
|
797
|
+
await rename2(from, to);
|
|
497
798
|
done.boxes.push(name);
|
|
498
799
|
}
|
|
499
800
|
for (const name of await oldDepthEntries(p.snapshots)) {
|
|
@@ -508,8 +809,8 @@ async function migrateOwnerLayout(owner, p = paths()) {
|
|
|
508
809
|
owner,
|
|
509
810
|
source: typeof meta["source"] === "string" && !meta["source"].includes("/") ? `${owner}/${meta["source"]}` : meta["source"]
|
|
510
811
|
}));
|
|
511
|
-
await
|
|
512
|
-
await
|
|
812
|
+
await mkdir3(p.ownerSnapshots(owner), { recursive: true, mode: 448 });
|
|
813
|
+
await rename2(from, to);
|
|
513
814
|
done.snapshots.push(name);
|
|
514
815
|
}
|
|
515
816
|
return done;
|
|
@@ -525,7 +826,7 @@ async function oldDepthEntries(root) {
|
|
|
525
826
|
return entries.filter((e) => e.isDirectory() && existsSync2(join2(root, e.name, "meta.json"))).map((e) => e.name).sort();
|
|
526
827
|
}
|
|
527
828
|
async function patchJson(file, patch) {
|
|
528
|
-
const raw = JSON.parse(await
|
|
829
|
+
const raw = JSON.parse(await readFile4(file, "utf8"));
|
|
529
830
|
await writeFile3(file, JSON.stringify(patch(raw), null, 2) + "\n");
|
|
530
831
|
}
|
|
531
832
|
async function migrateBoxIds(p = paths()) {
|
|
@@ -561,7 +862,7 @@ async function adoptBoxId(meta, taken, p = paths()) {
|
|
|
561
862
|
// src/core/machines.ts
|
|
562
863
|
import { spawn } from "node:child_process";
|
|
563
864
|
import { closeSync as closeSync2, existsSync as existsSync3, openSync as openSync2, statSync } from "node:fs";
|
|
564
|
-
import { link, mkdir as
|
|
865
|
+
import { link, mkdir as mkdir4, readFile as readFile5, realpath, rm, truncate, writeFile as writeFile4 } from "node:fs/promises";
|
|
565
866
|
import { basename } from "node:path";
|
|
566
867
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
567
868
|
|
|
@@ -617,8 +918,12 @@ function setIdentity(udsPath, id, timeoutMs) {
|
|
|
617
918
|
|
|
618
919
|
// src/core/capacity.ts
|
|
619
920
|
import { cpus, totalmem } from "node:os";
|
|
921
|
+
|
|
922
|
+
// ../shared/src/capacity.ts
|
|
620
923
|
var MEM_HEADROOM_MIB = 2048;
|
|
621
924
|
var VCPU_OVERSUBSCRIPTION = 2;
|
|
925
|
+
|
|
926
|
+
// src/core/capacity.ts
|
|
622
927
|
function hostResources() {
|
|
623
928
|
return { memMib: Math.floor(totalmem() / 1024 ** 2), cores: cpus().length };
|
|
624
929
|
}
|
|
@@ -676,7 +981,7 @@ async function assertRoomFor(vcpu, memMib, force, p = paths()) {
|
|
|
676
981
|
}
|
|
677
982
|
|
|
678
983
|
// src/core/firecracker.ts
|
|
679
|
-
import { Agent, request } from "undici";
|
|
984
|
+
import { Agent, request as request2 } from "undici";
|
|
680
985
|
var FirecrackerClient = class {
|
|
681
986
|
constructor(socketPath) {
|
|
682
987
|
this.socketPath = socketPath;
|
|
@@ -685,7 +990,7 @@ var FirecrackerClient = class {
|
|
|
685
990
|
socketPath;
|
|
686
991
|
agent;
|
|
687
992
|
async call(method, path, body) {
|
|
688
|
-
const res = await
|
|
993
|
+
const res = await request2(`http://localhost${path}`, {
|
|
689
994
|
dispatcher: this.agent,
|
|
690
995
|
method,
|
|
691
996
|
headers: body === void 0 ? {} : { "content-type": "application/json" },
|
|
@@ -878,8 +1183,8 @@ async function readAllMeta(p) {
|
|
|
878
1183
|
}
|
|
879
1184
|
return metas;
|
|
880
1185
|
}
|
|
881
|
-
async function listMachines(
|
|
882
|
-
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));
|
|
883
1188
|
}
|
|
884
1189
|
async function getMachine(ref, p = paths()) {
|
|
885
1190
|
return toMachine(await readMeta(ref, p), p);
|
|
@@ -945,7 +1250,7 @@ async function waitFor(pred, timeoutMs, stepMs = 50) {
|
|
|
945
1250
|
}
|
|
946
1251
|
async function tailOf(file, lines = 5) {
|
|
947
1252
|
try {
|
|
948
|
-
return (await
|
|
1253
|
+
return (await readFile5(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
|
|
949
1254
|
} catch {
|
|
950
1255
|
return "";
|
|
951
1256
|
}
|
|
@@ -1044,11 +1349,11 @@ async function currentBaseVersion(p = paths()) {
|
|
|
1044
1349
|
return (await currentBase(await loadConfig(p), p)).version;
|
|
1045
1350
|
}
|
|
1046
1351
|
async function namesOverlayAbsolutely(vmstate) {
|
|
1047
|
-
return (await
|
|
1352
|
+
return (await readFile5(vmstate)).includes(`/${OVERLAY_REL}`);
|
|
1048
1353
|
}
|
|
1049
1354
|
async function readPubkeyB64(p) {
|
|
1050
1355
|
try {
|
|
1051
|
-
return Buffer.from((await
|
|
1356
|
+
return Buffer.from((await readFile5(`${p.sshKey}.pub`, "utf8")).trim()).toString("base64");
|
|
1052
1357
|
} catch {
|
|
1053
1358
|
throw new KvboxError(`SSH key missing at ${p.sshKey}.pub; run install-server.sh`);
|
|
1054
1359
|
}
|
|
@@ -1076,7 +1381,7 @@ async function createMachine(req, owner, p = paths()) {
|
|
|
1076
1381
|
const base = await currentBase(cfg, p);
|
|
1077
1382
|
await assertOverlayCapable(base.file, base.version);
|
|
1078
1383
|
await ensureHostNetwork(cfg);
|
|
1079
|
-
await
|
|
1384
|
+
await mkdir4(m.dir, { recursive: true, mode: 448 });
|
|
1080
1385
|
let tapCreated = false;
|
|
1081
1386
|
try {
|
|
1082
1387
|
await createOverlay(m.overlay, size.diskGib);
|
|
@@ -1124,7 +1429,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
|
|
|
1124
1429
|
const m = p.machine(owner, name);
|
|
1125
1430
|
const s = p.snapshot(from.owner, from.name);
|
|
1126
1431
|
await ensureHostNetwork(cfg);
|
|
1127
|
-
await
|
|
1432
|
+
await mkdir4(m.dir, { recursive: true, mode: 448 });
|
|
1128
1433
|
let tapCreated = false;
|
|
1129
1434
|
let pid;
|
|
1130
1435
|
try {
|
|
@@ -1282,7 +1587,7 @@ async function hibernateMachine(ref, p = paths()) {
|
|
|
1282
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`);
|
|
1283
1588
|
const m = p.machine(ref.owner, ref.name);
|
|
1284
1589
|
await rm(m.hibernateDir, { recursive: true, force: true });
|
|
1285
|
-
await
|
|
1590
|
+
await mkdir4(m.hibernateDir, { recursive: true, mode: 448 });
|
|
1286
1591
|
const fc = new FirecrackerClient(m.sock);
|
|
1287
1592
|
try {
|
|
1288
1593
|
try {
|
|
@@ -1417,6 +1722,10 @@ async function destroyMachine(ref, p = paths()) {
|
|
|
1417
1722
|
|
|
1418
1723
|
// src/core/usage.ts
|
|
1419
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
|
+
}
|
|
1420
1729
|
var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
|
|
1421
1730
|
function computeUsage(events, w) {
|
|
1422
1731
|
const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
|
|
@@ -1465,7 +1774,7 @@ async function machineUsage(ref, since, p = paths()) {
|
|
|
1465
1774
|
let events = usageEventsFor(boxId, box.createdAt, p);
|
|
1466
1775
|
if (events.length === 0) {
|
|
1467
1776
|
insertUsageEvent(
|
|
1468
|
-
{ 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) },
|
|
1469
1778
|
p
|
|
1470
1779
|
);
|
|
1471
1780
|
events = usageEventsFor(boxId, box.createdAt, p);
|
|
@@ -1479,7 +1788,7 @@ async function seedUsageLedger(p = paths()) {
|
|
|
1479
1788
|
const boxId = box.id ?? `${box.owner}/${box.name}`;
|
|
1480
1789
|
if (usageEventsFor(boxId, box.createdAt, p).length > 0) continue;
|
|
1481
1790
|
insertUsageEvent(
|
|
1482
|
-
{ 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) },
|
|
1483
1792
|
p
|
|
1484
1793
|
);
|
|
1485
1794
|
seeded += 1;
|
|
@@ -1503,16 +1812,46 @@ function versionOf(srcFileUrl, injected) {
|
|
|
1503
1812
|
}
|
|
1504
1813
|
|
|
1505
1814
|
// src/version.ts
|
|
1506
|
-
var VERSION = versionOf(import.meta.url, true ? "0.
|
|
1815
|
+
var VERSION = versionOf(import.meta.url, true ? "0.3.0" : void 0);
|
|
1507
1816
|
|
|
1508
1817
|
// src/caller.ts
|
|
1509
|
-
|
|
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);
|
|
1510
1831
|
return async (c, next) => {
|
|
1511
|
-
const
|
|
1512
|
-
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
|
+
}
|
|
1513
1849
|
await next();
|
|
1514
1850
|
};
|
|
1515
1851
|
}
|
|
1852
|
+
function digest(secret) {
|
|
1853
|
+
return createHash("sha256").update(secret).digest();
|
|
1854
|
+
}
|
|
1516
1855
|
|
|
1517
1856
|
// src/routes/capacity.ts
|
|
1518
1857
|
import { Hono } from "hono";
|
|
@@ -1524,141 +1863,9 @@ function capacityRoutes() {
|
|
|
1524
1863
|
|
|
1525
1864
|
// src/routes/machines.ts
|
|
1526
1865
|
import { Hono as Hono2 } from "hono";
|
|
1527
|
-
|
|
1528
|
-
// ../shared/src/api.ts
|
|
1529
|
-
import { z as z3 } from "zod";
|
|
1530
|
-
var SizeSchema = z3.enum(SIZE_NAMES);
|
|
1531
|
-
var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped"]);
|
|
1532
|
-
var MachineSchema = z3.object({
|
|
1533
|
-
/** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
|
|
1534
|
-
id: z3.string().optional(),
|
|
1535
|
-
name: z3.string(),
|
|
1536
|
-
/** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
|
|
1537
|
-
owner: z3.string(),
|
|
1538
|
-
ip: z3.string(),
|
|
1539
|
-
mac: z3.string(),
|
|
1540
|
-
pid: z3.number().int().nullable(),
|
|
1541
|
-
vcpu: z3.number().int(),
|
|
1542
|
-
memMib: z3.number().int(),
|
|
1543
|
-
diskBytes: z3.number().int(),
|
|
1544
|
-
// the box's own writable disk, as the guest sees it
|
|
1545
|
-
diskUsedBytes: z3.number().int(),
|
|
1546
|
-
// what that sparse file really occupies on the host
|
|
1547
|
-
base: z3.string().nullable(),
|
|
1548
|
-
// base image version the box runs on (null: pre-overlay box with its own copy)
|
|
1549
|
-
createdAt: z3.string(),
|
|
1550
|
-
source: z3.string(),
|
|
1551
|
-
// "base" or "snapshot:<name>"
|
|
1552
|
-
status: BoxStatusSchema
|
|
1553
|
-
});
|
|
1554
|
-
var CreateMachineRequest = z3.object({
|
|
1555
|
-
name: z3.string().optional(),
|
|
1556
|
-
/** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
|
|
1557
|
-
owner: z3.string().optional(),
|
|
1558
|
-
size: SizeSchema.optional(),
|
|
1559
|
-
from: z3.string().optional(),
|
|
1560
|
-
force: z3.boolean().optional()
|
|
1561
|
-
});
|
|
1562
|
-
var RescaleMachineRequest = z3.object({
|
|
1563
|
-
size: SizeSchema.optional(),
|
|
1564
|
-
vcpu: z3.number().int().min(1).max(64).optional(),
|
|
1565
|
-
memMib: z3.number().int().min(256).optional(),
|
|
1566
|
-
diskGib: z3.number().int().min(1).optional()
|
|
1567
|
-
});
|
|
1568
|
-
var SnapshotSchema = z3.object({
|
|
1569
|
-
name: z3.string(),
|
|
1570
|
-
owner: z3.string(),
|
|
1571
|
-
source: z3.string(),
|
|
1572
|
-
// the box it was taken from, as owner/name
|
|
1573
|
-
base: z3.string(),
|
|
1574
|
-
// base image version the overlay stacks on; a restore needs the same one
|
|
1575
|
-
vcpu: z3.number().int(),
|
|
1576
|
-
memMib: z3.number().int(),
|
|
1577
|
-
sizeBytes: z3.number().int(),
|
|
1578
|
-
// what vmstate + mem + overlay really occupy on the host
|
|
1579
|
-
createdAt: z3.string()
|
|
1580
|
-
});
|
|
1581
|
-
var CreateSnapshotRequest = z3.object({
|
|
1582
|
-
box: z3.string(),
|
|
1583
|
-
name: z3.string()
|
|
1584
|
-
});
|
|
1585
|
-
var CapacitySchema = z3.object({
|
|
1586
|
-
hostMemMib: z3.number().int(),
|
|
1587
|
-
hostCores: z3.number().int(),
|
|
1588
|
-
reservedMemMib: z3.number().int(),
|
|
1589
|
-
reservedVcpu: z3.number().int(),
|
|
1590
|
-
running: z3.number().int()
|
|
1591
|
-
});
|
|
1592
|
-
var HealthSchema = z3.object({
|
|
1593
|
-
ok: z3.literal(true),
|
|
1594
|
-
version: z3.string(),
|
|
1595
|
-
pid: z3.number().int(),
|
|
1596
|
-
/** Base image version new boxes get (null when the host has no usable base image yet); absent from older servers. */
|
|
1597
|
-
base: z3.string().nullable().optional(),
|
|
1598
|
-
/** Owner that boxes made over the local socket get (the Unix user running the server); absent from older servers. */
|
|
1599
|
-
owner: z3.string().optional()
|
|
1600
|
-
});
|
|
1601
|
-
var MachineUsageSchema = z3.object({
|
|
1602
|
-
name: z3.string(),
|
|
1603
|
-
owner: z3.string(),
|
|
1604
|
-
status: BoxStatusSchema,
|
|
1605
|
-
window: z3.object({ from: z3.string(), to: z3.string() }),
|
|
1606
|
-
/** standby and hibernated default to 0 so a newer CLI reads an older server's answer. */
|
|
1607
|
-
seconds: z3.object({
|
|
1608
|
-
running: z3.number().int(),
|
|
1609
|
-
standby: z3.number().int().default(0),
|
|
1610
|
-
hibernated: z3.number().int().default(0),
|
|
1611
|
-
stopped: z3.number().int()
|
|
1612
|
-
}),
|
|
1613
|
-
vcpuHours: z3.number(),
|
|
1614
|
-
ramGibHours: z3.number(),
|
|
1615
|
-
diskGibHours: z3.number(),
|
|
1616
|
-
cost: z3.object({ amount: z3.number(), currency: z3.string() }).optional(),
|
|
1617
|
-
/** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
|
|
1618
|
-
estimatedStops: z3.number().int(),
|
|
1619
|
-
historyFrom: z3.string().nullable()
|
|
1620
|
-
});
|
|
1621
|
-
var ApiErrorSchema = z3.object({
|
|
1622
|
-
error: z3.string()
|
|
1623
|
-
});
|
|
1624
|
-
var CloudUserSchema = z3.object({
|
|
1625
|
-
name: z3.string(),
|
|
1626
|
-
role: z3.enum(["admin", "user"]),
|
|
1627
|
-
createdAt: z3.string()
|
|
1628
|
-
});
|
|
1629
|
-
var LoginRequestSchema = z3.object({
|
|
1630
|
-
name: z3.string().min(1),
|
|
1631
|
-
password: z3.string().min(1),
|
|
1632
|
-
publicKey: z3.string().optional()
|
|
1633
|
-
});
|
|
1634
|
-
var LoginResponseSchema = z3.object({
|
|
1635
|
-
token: z3.string(),
|
|
1636
|
-
user: CloudUserSchema
|
|
1637
|
-
});
|
|
1638
|
-
var HostJoinRequestSchema = z3.object({
|
|
1639
|
-
token: z3.string().min(1),
|
|
1640
|
-
name: z3.string().min(1),
|
|
1641
|
-
url: z3.string().url(),
|
|
1642
|
-
fingerprint: z3.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
|
|
1643
|
-
publicAddress: z3.string().min(1)
|
|
1644
|
-
});
|
|
1645
|
-
var HostJoinResponseSchema = z3.object({
|
|
1646
|
-
hostSecret: z3.string(),
|
|
1647
|
-
controlPlane: z3.object({ name: z3.string() })
|
|
1648
|
-
});
|
|
1649
|
-
var CloudHostSchema = z3.object({
|
|
1650
|
-
name: z3.string(),
|
|
1651
|
-
url: z3.string(),
|
|
1652
|
-
publicAddress: z3.string(),
|
|
1653
|
-
joinedAt: z3.string(),
|
|
1654
|
-
lastSeenAt: z3.string().nullable(),
|
|
1655
|
-
capacity: CapacitySchema.nullable()
|
|
1656
|
-
});
|
|
1657
|
-
|
|
1658
|
-
// src/routes/machines.ts
|
|
1659
1866
|
function machineRoutes() {
|
|
1660
1867
|
const r = new Hono2();
|
|
1661
|
-
r.get("/", async (c) => c.json(await listMachines(c.get("
|
|
1868
|
+
r.get("/", async (c) => c.json(await listMachines(c.get("listScope"))));
|
|
1662
1869
|
r.post("/", async (c) => {
|
|
1663
1870
|
const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
|
|
1664
1871
|
return c.json(await createMachine(body, c.get("owner")), 201);
|
|
@@ -1696,7 +1903,7 @@ import { Hono as Hono3 } from "hono";
|
|
|
1696
1903
|
|
|
1697
1904
|
// src/core/snapshots.ts
|
|
1698
1905
|
import { existsSync as existsSync4 } from "node:fs";
|
|
1699
|
-
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";
|
|
1700
1907
|
var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
1701
1908
|
function toSnapshot(meta) {
|
|
1702
1909
|
return {
|
|
@@ -1710,10 +1917,10 @@ function toSnapshot(meta) {
|
|
|
1710
1917
|
createdAt: meta.createdAt
|
|
1711
1918
|
};
|
|
1712
1919
|
}
|
|
1713
|
-
async function listSnapshots(
|
|
1920
|
+
async function listSnapshots(scope, p = paths()) {
|
|
1714
1921
|
const out = [];
|
|
1715
1922
|
for (const ref of await listSnapshotRefs(p)) {
|
|
1716
|
-
if (
|
|
1923
|
+
if (!inScope(ref.owner, scope)) continue;
|
|
1717
1924
|
try {
|
|
1718
1925
|
out.push(toSnapshot(await readSnapshotMeta(ref, p)));
|
|
1719
1926
|
} catch (err) {
|
|
@@ -1755,7 +1962,7 @@ async function saveSnapshot(req, owner, p = paths()) {
|
|
|
1755
1962
|
if (existsSync4(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
|
|
1756
1963
|
const cfg = await loadConfig(p);
|
|
1757
1964
|
const m = p.machine(box.owner, box.name);
|
|
1758
|
-
await
|
|
1965
|
+
await mkdir5(s.dir, { recursive: true, mode: 448 });
|
|
1759
1966
|
const fc = new FirecrackerClient(m.sock);
|
|
1760
1967
|
let lineageTouched = false;
|
|
1761
1968
|
try {
|
|
@@ -1811,7 +2018,7 @@ async function mergeOntoPrevious(previous, diffFile, editor) {
|
|
|
1811
2018
|
const merged = `${diffFile}.merged`;
|
|
1812
2019
|
await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
|
|
1813
2020
|
await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
|
|
1814
|
-
await
|
|
2021
|
+
await rename3(merged, diffFile);
|
|
1815
2022
|
}
|
|
1816
2023
|
async function deleteSnapshot(ref, p = paths()) {
|
|
1817
2024
|
const dir = p.snapshot(ref.owner, ref.name).dir;
|
|
@@ -1822,7 +2029,7 @@ async function deleteSnapshot(ref, p = paths()) {
|
|
|
1822
2029
|
// src/routes/snapshots.ts
|
|
1823
2030
|
function snapshotRoutes() {
|
|
1824
2031
|
const r = new Hono3();
|
|
1825
|
-
r.get("/", async (c) => c.json(await listSnapshots(c.get("
|
|
2032
|
+
r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));
|
|
1826
2033
|
r.post("/", async (c) => {
|
|
1827
2034
|
const body = CreateSnapshotRequest.parse(await c.req.json());
|
|
1828
2035
|
return c.json(await saveSnapshot(body, c.get("owner")), 201);
|
|
@@ -1835,9 +2042,9 @@ function snapshotRoutes() {
|
|
|
1835
2042
|
}
|
|
1836
2043
|
|
|
1837
2044
|
// src/app.ts
|
|
1838
|
-
function createApp() {
|
|
2045
|
+
function createApp(opts = {}) {
|
|
1839
2046
|
const app = new Hono4();
|
|
1840
|
-
app.use(resolveCaller());
|
|
2047
|
+
app.use(resolveCaller(opts));
|
|
1841
2048
|
app.get(
|
|
1842
2049
|
"/health",
|
|
1843
2050
|
async (c) => c.json({
|
|
@@ -1888,22 +2095,60 @@ async function listen(opts = {}) {
|
|
|
1888
2095
|
});
|
|
1889
2096
|
await writeFile5(p.serverPid, `${process.pid}
|
|
1890
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
|
+
};
|
|
1891
2131
|
const shutdown2 = async () => {
|
|
1892
2132
|
server.close();
|
|
2133
|
+
httpsServer?.close();
|
|
1893
2134
|
await rm3(socketPath, { force: true });
|
|
1894
2135
|
await rm3(p.serverPid, { force: true });
|
|
1895
2136
|
process.exit(0);
|
|
1896
2137
|
};
|
|
1897
2138
|
process.once("SIGINT", () => void shutdown2());
|
|
1898
2139
|
process.once("SIGTERM", () => void shutdown2());
|
|
1899
|
-
return { socketPath, server };
|
|
2140
|
+
return https ? { socketPath, server, https, close } : { socketPath, server, close };
|
|
1900
2141
|
}
|
|
1901
2142
|
|
|
1902
2143
|
// src/index.ts
|
|
1903
2144
|
var program = new Command("kvbox-server").description("kvbox host daemon: owns Firecracker and ~/.kvbox, serves the kvbox API").version(VERSION, "-v, --version").showHelpAfterError();
|
|
1904
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) => {
|
|
1905
|
-
const { socketPath } = await listen(opts.socket ? { socketPath: opts.socket } : {});
|
|
2146
|
+
const { socketPath, https } = await listen(opts.socket ? { socketPath: opts.socket } : {});
|
|
1906
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
|
+
}
|
|
1907
2152
|
try {
|
|
1908
2153
|
const cfg = await loadConfig(paths());
|
|
1909
2154
|
const state = await ensureHostNetwork(cfg);
|
|
@@ -1912,6 +2157,17 @@ program.command("start", { isDefault: true }).description("serve the API on the
|
|
|
1912
2157
|
console.error(`warning: host network not ready (${err.message}); run: kvbox-server net up`);
|
|
1913
2158
|
}
|
|
1914
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
|
+
});
|
|
1915
2171
|
program.command("net <action>").description("host network: up | down | status (bridge, forwarding, NAT; uses sudo)").action(async (action) => {
|
|
1916
2172
|
if (!["up", "down", "status"].includes(action)) throw new KvboxError("net: expected up, down or status");
|
|
1917
2173
|
const cfg = await loadConfig(paths());
|