@kvbox/server 0.0.0-stage → 0.5.5

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.
@@ -0,0 +1,2504 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { spawnSync } from "node:child_process";
5
+ import { Command } from "commander";
6
+
7
+ // ../shared/src/errors.ts
8
+ var KvboxError = class extends Error {
9
+ constructor(message, exitCode = 1) {
10
+ super(message);
11
+ this.exitCode = exitCode;
12
+ this.name = "KvboxError";
13
+ }
14
+ exitCode;
15
+ };
16
+ var NotFoundError = class extends KvboxError {
17
+ constructor(what) {
18
+ super(`${what}: not found`, 1);
19
+ this.name = "NotFoundError";
20
+ }
21
+ };
22
+
23
+ // ../shared/src/paths.ts
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ function kvboxHome() {
27
+ return process.env["KVBOX_HOME"] ?? join(homedir(), ".kvbox");
28
+ }
29
+ function paths(home = kvboxHome()) {
30
+ return {
31
+ home,
32
+ config: join(home, "config.json"),
33
+ /** The host's ledgers (SQLite via node:sqlite): usage events today. Boxes and snapshots stay files; see server core/db.ts. */
34
+ db: join(home, "kvbox.db"),
35
+ serverSock: join(home, "server.sock"),
36
+ serverPid: join(home, "server.pid"),
37
+ serverLog: join(home, "server.log"),
38
+ /** The CLI's login to the control plane (`kvbox login`): url, bearer token, user. Mode 600. */
39
+ cli: join(home, "cli.json"),
40
+ ssh: join(home, "ssh"),
41
+ sshKey: join(home, "ssh", "id_ed25519"),
42
+ /** The device key signed by the control plane's CA (#51); ssh loads it by itself next to `-i id_ed25519`. */
43
+ sshCert: join(home, "ssh", "id_ed25519-cert.pub"),
44
+ /** Host keys of the cloud boxes this laptop connected to, one entry per box (`connect`, #21). */
45
+ sshKnownHosts: join(home, "ssh", "known_hosts"),
46
+ /** The host's self-signed TLS identity for the control plane (`kvbox-server join`): pinned there by fingerprint. */
47
+ tls: join(home, "tls"),
48
+ tlsKey: join(home, "tls", "key.pem"),
49
+ tlsCert: join(home, "tls", "cert.pem"),
50
+ /** The host's WireGuard tunnel to its relay (`kvbox server relay`, #50): the conf net-setup.sh installs for wg-quick. Mode 600. */
51
+ relay: join(home, "relay"),
52
+ relayConf: join(home, "relay", "kvbox.conf"),
53
+ bin: join(home, "bin"),
54
+ firecracker: join(home, "bin", "firecracker"),
55
+ /** Firecracker's snapshot-editor, from the same release: merges a diff snapshot onto a full memory image. */
56
+ snapshotEditor: join(home, "bin", "snapshot-editor"),
57
+ assets: join(home, "assets"),
58
+ kernel: join(home, "assets", "vmlinux"),
59
+ /** Symlink to the current base image, assets/rootfs-<version>.ext4; boxes keep using the version they were made from. */
60
+ rootfs: join(home, "assets", "rootfs.ext4"),
61
+ baseImage: (version) => join(home, "assets", `rootfs-${version}.ext4`),
62
+ machines: join(home, "machines"),
63
+ snapshots: join(home, "snapshots"),
64
+ /** Boxes and snapshots are filed by owner: machines/<owner>/<name>. The owner is part of a box's identity. */
65
+ ownerMachines: (owner) => join(home, "machines", owner),
66
+ ownerSnapshots: (owner) => join(home, "snapshots", owner),
67
+ machine: (owner, name) => {
68
+ const dir = join(home, "machines", owner, name);
69
+ return {
70
+ dir,
71
+ meta: join(dir, "meta.json"),
72
+ /** The box's writable layer, stacked on the read-only base image inside the guest. */
73
+ overlay: join(dir, "overlay.ext4"),
74
+ /**
75
+ * The box's memory as of its last snapshot (a hard link to that snapshot's `mem`, never modified).
76
+ * The next snapshot is a diff of the pages dirtied since, merged onto this. Absent after a boot.
77
+ */
78
+ memBase: join(dir, "mem.base"),
79
+ /** `kvbox hibernate`: the box's memory image and VM state while it is hibernated; gone again after `wake` or `stop`. */
80
+ hibernateDir: join(dir, "hibernate"),
81
+ hibernateVmstate: join(dir, "hibernate", "vmstate"),
82
+ hibernateMem: join(dir, "hibernate", "mem"),
83
+ sock: join(dir, "fc.sock"),
84
+ /** Firecracker's vsock endpoint: the host connects here to reach kvbox-agent inside the box (core/agent.ts). */
85
+ vsock: join(dir, "vsock.sock"),
86
+ log: join(dir, "fc.log")
87
+ };
88
+ },
89
+ snapshot: (owner, name) => {
90
+ const dir = join(home, "snapshots", owner, name);
91
+ return {
92
+ dir,
93
+ meta: join(dir, "meta.json"),
94
+ vmstate: join(dir, "vmstate"),
95
+ mem: join(dir, "mem"),
96
+ /** Copy of the box's overlay at the moment of the snapshot; the base image is referenced by version in meta.json. */
97
+ overlay: join(dir, "overlay.ext4")
98
+ };
99
+ }
100
+ };
101
+ }
102
+
103
+ // src/core/config.ts
104
+ import { chmod, readFile, rename, writeFile } from "node:fs/promises";
105
+ import { z } from "zod";
106
+
107
+ // ../shared/src/sizes.ts
108
+ var SIZES = {
109
+ s: { vcpu: 2, memMib: 4096, diskGib: 8 },
110
+ m: { vcpu: 4, memMib: 8192, diskGib: 16 },
111
+ l: { vcpu: 8, memMib: 16384, diskGib: 32 }
112
+ };
113
+ var SIZE_NAMES = Object.keys(SIZES);
114
+ var DEFAULT_SIZE = "s";
115
+ function isSizeName(value) {
116
+ return value in SIZES;
117
+ }
118
+
119
+ // src/core/refs.ts
120
+ import { userInfo } from "node:os";
121
+ function shown(ref) {
122
+ return ref.display ?? ref.name;
123
+ }
124
+ var LOCAL_OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/;
125
+ var REALM_RE = /^[A-Za-z0-9.-]{1,128}$/;
126
+ var OWNER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}(@[A-Za-z0-9.-]{1,128})?$/;
127
+ function localOwner() {
128
+ try {
129
+ const name = userInfo().username;
130
+ if (LOCAL_OWNER_RE.test(name)) return name;
131
+ } catch {
132
+ }
133
+ const env = process.env["USER"];
134
+ return env !== void 0 && LOCAL_OWNER_RE.test(env) ? env : "kvbox";
135
+ }
136
+ function inScope(owner, scope) {
137
+ if (scope === null) return true;
138
+ if (typeof scope === "string") return owner === scope;
139
+ return realmOf(owner) === scope.realm;
140
+ }
141
+ function realmOf(owner) {
142
+ const at = owner.indexOf("@");
143
+ return at === -1 ? null : owner.slice(at + 1);
144
+ }
145
+ function parseRef(text, caller) {
146
+ const slash = text.indexOf("/");
147
+ if (slash === -1) return { owner: caller, name: text, display: text };
148
+ const owner = text.slice(0, slash);
149
+ const name = text.slice(slash + 1);
150
+ if (!OWNER_RE.test(owner) || name.length === 0 || name.includes("/")) {
151
+ throw new KvboxError(`invalid box reference "${text}": use <name> or <owner>/<name>`);
152
+ }
153
+ const realm = realmOf(caller);
154
+ if (realm !== null && realmOf(owner) !== realm) {
155
+ throw new KvboxError(`invalid box reference "${text}": ${owner} is not in your realm`);
156
+ }
157
+ return { owner, name, display: owner === caller ? name : text };
158
+ }
159
+ function refKey(ref) {
160
+ return `${ref.owner}/${ref.name}`;
161
+ }
162
+
163
+ // src/core/config.ts
164
+ var PricingSchema = z.object({
165
+ currency: z.string().min(1),
166
+ vcpuHour: z.number().nonnegative(),
167
+ ramGibHour: z.number().nonnegative(),
168
+ diskGibHour: z.number().nonnegative()
169
+ });
170
+ var ControlPlaneSchema = z.object({
171
+ url: z.string().url(),
172
+ /** The realm name: boxes the control plane creates are owned by `<user>@<name>`, a directory name on disk. */
173
+ name: z.string().regex(REALM_RE),
174
+ /** Minted at join; every HTTPS call from the control plane carries it as a bearer. */
175
+ secret: z.string().min(1),
176
+ /** The address the control plane reaches this host at. */
177
+ advertise: z.string().min(1),
178
+ port: z.number().int().positive().default(7777)
179
+ });
180
+ var RelaySchema = z.object({
181
+ name: z.string().min(1),
182
+ publicIp: z.string().min(1),
183
+ tunnelIp: z.string().min(1),
184
+ /** `<relay-ip>:<port>` the tunnel dials. */
185
+ endpoint: z.string().min(1)
186
+ });
187
+ var ConfigSchema = z.object({
188
+ kernel: z.string(),
189
+ rootfs: z.string(),
190
+ firecracker: z.string(),
191
+ snapshotEditor: z.string().optional(),
192
+ // default: bin/snapshot-editor next to firecracker
193
+ bridge: z.string().default("kvbox0"),
194
+ subnet: z.string().default("172.30.0.0/24"),
195
+ gateway: z.string().default("172.30.0.1"),
196
+ defaultSize: z.enum(SIZE_NAMES).default(DEFAULT_SIZE),
197
+ /** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
198
+ owner: z.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
199
+ pricing: PricingSchema.optional(),
200
+ /** Absent until the host joins a control plane; then the server also listens on HTTPS. */
201
+ controlPlane: ControlPlaneSchema.optional(),
202
+ /** Absent unless the host sits behind a relay. */
203
+ relay: RelaySchema.optional()
204
+ });
205
+ async function saveConfig(cfg, p = paths()) {
206
+ const parsed = ConfigSchema.parse(cfg);
207
+ const tmp = `${p.config}.tmp`;
208
+ await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", { mode: 384 });
209
+ await chmod(tmp, 384);
210
+ await rename(tmp, p.config);
211
+ }
212
+ function defaultConfig(p = paths()) {
213
+ return ConfigSchema.parse({
214
+ kernel: p.kernel,
215
+ rootfs: p.rootfs,
216
+ firecracker: p.firecracker,
217
+ snapshotEditor: p.snapshotEditor,
218
+ owner: localOwner()
219
+ });
220
+ }
221
+ async function loadConfig(p = paths()) {
222
+ try {
223
+ const raw = await readFile(p.config, "utf8");
224
+ return ConfigSchema.parse(JSON.parse(raw));
225
+ } catch (err) {
226
+ if (err.code === "ENOENT") {
227
+ const cfg = defaultConfig(p);
228
+ await writeFile(p.config, JSON.stringify(cfg, null, 2) + "\n");
229
+ return cfg;
230
+ }
231
+ throw err;
232
+ }
233
+ }
234
+
235
+ // src/core/controlPlane.ts
236
+ import { X509Certificate } from "node:crypto";
237
+ import { chmod as chmod2, mkdir, readFile as readFile2 } from "node:fs/promises";
238
+ import { hostname as osHostname } from "node:os";
239
+ import { request } from "undici";
240
+
241
+ // ../shared/src/api.ts
242
+ import { z as z2 } from "zod";
243
+ var SizeSchema = z2.enum(SIZE_NAMES);
244
+ var BoxStatusSchema = z2.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
245
+ var MachineSchema = z2.object({
246
+ /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
247
+ id: z2.string(),
248
+ name: z2.string(),
249
+ /** Who the box belongs to; names are unique per owner. A bare name is local to the host, `user@control-plane` later. */
250
+ owner: z2.string(),
251
+ ip: z2.string(),
252
+ mac: z2.string(),
253
+ pid: z2.number().int().nullable(),
254
+ vcpu: z2.number().int(),
255
+ memMib: z2.number().int(),
256
+ diskBytes: z2.number().int(),
257
+ // the box's own writable disk, as the guest sees it
258
+ diskUsedBytes: z2.number().int(),
259
+ // what that sparse file really occupies on the host
260
+ base: z2.string(),
261
+ // base image version the box's overlay stacks on
262
+ createdAt: z2.string(),
263
+ source: z2.string(),
264
+ // "base" or "snapshot:<name>"
265
+ status: BoxStatusSchema,
266
+ /**
267
+ * Public TCP port on the host that reaches the box's SSH while it runs (#49). Only boxes made
268
+ * through a control plane have one; the control plane adds the host's address for `connect` (#21).
269
+ */
270
+ sshPort: z2.number().int().optional(),
271
+ /**
272
+ * Where `ssh` reaches the box from outside: the host's public address and that port (#21). Only
273
+ * the control plane fills it in (a host does not know its own public address); absent for a box
274
+ * whose host reported no port or did not answer.
275
+ */
276
+ ssh: z2.object({ host: z2.string(), port: z2.number().int(), user: z2.string() }).optional(),
277
+ /** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
278
+ ownerKind: z2.enum(["user", "org"]).optional()
279
+ });
280
+ var CreateMachineRequest = z2.object({
281
+ name: z2.string().optional(),
282
+ /** Ignored over the local socket (the caller is the host owner); the control plane sets it over HTTPS. */
283
+ owner: z2.string().optional(),
284
+ size: SizeSchema.optional(),
285
+ from: z2.string().optional(),
286
+ force: z2.boolean().optional(),
287
+ /**
288
+ * Extra authorized_keys lines for the box, on top of the host's own key: the control plane sends
289
+ * its SSH CA as one `cert-authority …` line (#51). Written into the box's authorized_keys at every
290
+ * boot (#49); a host older than that ignores them.
291
+ */
292
+ sshKeys: z2.array(z2.string()).optional()
293
+ });
294
+ var SetSshKeysRequest = z2.object({
295
+ sshKeys: z2.array(z2.string())
296
+ });
297
+ var RescaleMachineRequest = z2.object({
298
+ size: SizeSchema.optional(),
299
+ vcpu: z2.number().int().min(1).max(64).optional(),
300
+ memMib: z2.number().int().min(256).optional(),
301
+ diskGib: z2.number().int().min(1).optional()
302
+ });
303
+ var SnapshotSchema = z2.object({
304
+ name: z2.string(),
305
+ owner: z2.string(),
306
+ source: z2.string(),
307
+ // the box it was taken from, as owner/name
308
+ base: z2.string(),
309
+ // base image version the overlay stacks on; a restore needs the same one
310
+ vcpu: z2.number().int(),
311
+ memMib: z2.number().int(),
312
+ sizeBytes: z2.number().int(),
313
+ // what vmstate + mem + overlay really occupy on the host
314
+ createdAt: z2.string()
315
+ });
316
+ var CreateSnapshotRequest = z2.object({
317
+ box: z2.string(),
318
+ name: z2.string()
319
+ });
320
+ var CapacitySchema = z2.object({
321
+ hostMemMib: z2.number().int(),
322
+ hostCores: z2.number().int(),
323
+ reservedMemMib: z2.number().int(),
324
+ reservedVcpu: z2.number().int(),
325
+ running: z2.number().int()
326
+ });
327
+ var HealthSchema = z2.object({
328
+ ok: z2.literal(true),
329
+ version: z2.string(),
330
+ pid: z2.number().int(),
331
+ /** Base image version new boxes get (null when the host has no usable base image yet). */
332
+ base: z2.string().nullable(),
333
+ /** Owner that boxes made over the local socket get (the Unix user running the server). */
334
+ owner: z2.string(),
335
+ /** The control plane this host joined (null when none). */
336
+ controlPlane: z2.object({
337
+ /** Realm name, e.g. kvbox.sh. */
338
+ name: z2.string(),
339
+ url: z2.string(),
340
+ /** Where this host answers the control plane. */
341
+ hostUrl: z2.string()
342
+ }).nullable(),
343
+ /** The relay this host reaches the internet through (#50); null when it has its own public address. Older servers omit it. */
344
+ relay: z2.object({
345
+ name: z2.string(),
346
+ /** The public IP the relay owns for this host. */
347
+ publicIp: z2.string(),
348
+ /** The relay's WireGuard endpoint the tunnel dials. */
349
+ endpoint: z2.string()
350
+ }).nullable().optional()
351
+ });
352
+ var MachineUsageSchema = z2.object({
353
+ name: z2.string(),
354
+ owner: z2.string(),
355
+ status: BoxStatusSchema,
356
+ window: z2.object({ from: z2.string(), to: z2.string() }),
357
+ seconds: z2.object({
358
+ running: z2.number().int(),
359
+ standby: z2.number().int(),
360
+ hibernated: z2.number().int(),
361
+ stopped: z2.number().int()
362
+ }),
363
+ vcpuHours: z2.number(),
364
+ ramGibHours: z2.number(),
365
+ diskGibHours: z2.number(),
366
+ cost: z2.object({ amount: z2.number(), currency: z2.string() }).optional(),
367
+ /** Stops the server did not witness (Firecracker died, host rebooted) and dated to when it noticed. */
368
+ estimatedStops: z2.number().int(),
369
+ historyFrom: z2.string().nullable()
370
+ });
371
+ var ApiErrorSchema = z2.object({
372
+ error: z2.string()
373
+ });
374
+ var CloudUserSchema = z2.object({
375
+ /** The handle: what hosts file boxes under (`<username>@<control plane>`). Lowercase, no `@` or `/`. */
376
+ username: z2.string(),
377
+ /** Display name, free text ("Alexandru Ghiura"). */
378
+ name: z2.string(),
379
+ /** Login identity, unique. */
380
+ email: z2.string(),
381
+ role: z2.enum(["admin", "user"]),
382
+ createdAt: z2.string()
383
+ });
384
+ var ORG_ROLES = ["owner", "admin", "member"];
385
+ var OrgRoleSchema = z2.enum(ORG_ROLES);
386
+ var CloudOrgSchema = z2.object({
387
+ slug: z2.string(),
388
+ /** Display name; the slug never changes, this may. */
389
+ name: z2.string(),
390
+ createdAt: z2.string()
391
+ });
392
+ var OrgMembershipSchema = CloudOrgSchema.extend({ role: OrgRoleSchema });
393
+ var OrgMemberSchema = z2.object({
394
+ username: z2.string(),
395
+ name: z2.string(),
396
+ role: OrgRoleSchema,
397
+ addedAt: z2.string()
398
+ });
399
+ var OrgDetailSchema = CloudOrgSchema.extend({ members: z2.array(OrgMemberSchema) });
400
+ var CreateOrgRequestSchema = z2.object({
401
+ slug: z2.string().min(1),
402
+ name: z2.string().min(1)
403
+ });
404
+ var RenameOrgRequestSchema = z2.object({ name: z2.string().min(1) });
405
+ var AddMemberRequestSchema = z2.object({
406
+ username: z2.string().min(1),
407
+ role: OrgRoleSchema.default("member")
408
+ });
409
+ var SetRoleRequestSchema = z2.object({ role: OrgRoleSchema });
410
+ var MeResponseSchema = CloudUserSchema.extend({ orgs: z2.array(OrgMembershipSchema) });
411
+ var LoginRequestSchema = z2.object({
412
+ email: z2.string().min(1),
413
+ password: z2.string().min(1),
414
+ publicKey: z2.string().optional()
415
+ });
416
+ var SignupRequestSchema = z2.object({
417
+ username: z2.string().min(1),
418
+ name: z2.string().min(1),
419
+ email: z2.string().min(1),
420
+ password: z2.string().min(1)
421
+ });
422
+ var LoginResponseSchema = z2.object({
423
+ token: z2.string(),
424
+ user: CloudUserSchema,
425
+ sshCert: z2.string().optional(),
426
+ sshCertExpires: z2.string().optional(),
427
+ /** Whose boxes the certificate opens: `<slug>@<realm>` for the user and each org they are in (orgs phase 4). */
428
+ sshCertPrincipals: z2.array(z2.string()).optional()
429
+ });
430
+ var SshCertRequestSchema = z2.object({
431
+ publicKey: z2.string().min(1)
432
+ });
433
+ var SshCertResponseSchema = z2.object({
434
+ cert: z2.string(),
435
+ expires: z2.string(),
436
+ principals: z2.array(z2.string())
437
+ });
438
+ var SshCaResponseSchema = z2.object({
439
+ publicKey: z2.string(),
440
+ /** CAs replaced by `ca rotate` (#55), newest first; a box still holding one of these has not had `kvbox server trust-ca` run on its host. */
441
+ retired: z2.array(z2.object({ publicKey: z2.string(), retiredAt: z2.string() })).optional()
442
+ });
443
+ var HostSshCaResponseSchema = z2.object({
444
+ publicKey: z2.string()
445
+ });
446
+ var CloudCaSchema = z2.object({
447
+ controlPlane: z2.string(),
448
+ publicKey: z2.string(),
449
+ fingerprint: z2.string()
450
+ });
451
+ var TrustCaRequestSchema = z2.object({
452
+ publicKey: z2.string().min(1)
453
+ });
454
+ var SshKeysAppliedSchema = z2.enum(["now", "start", "wake", "resume", "later"]);
455
+ var TrustCaReportSchema = z2.object({
456
+ publicKey: z2.string(),
457
+ fingerprint: z2.string(),
458
+ /** The guest trusts the CA now. */
459
+ updated: z2.array(z2.string()),
460
+ /** meta.json has the line; the guest gets it at `when`. */
461
+ pending: z2.array(z2.object({ box: z2.string(), when: SshKeysAppliedSchema, why: z2.string().optional() })),
462
+ failed: z2.array(z2.object({ box: z2.string(), error: z2.string() })),
463
+ /** Local boxes, untouched: they trust the host's key only. */
464
+ local: z2.number().int()
465
+ });
466
+ var HostJoinRequestSchema = z2.object({
467
+ token: z2.string().min(1),
468
+ name: z2.string().min(1),
469
+ url: z2.string().url(),
470
+ fingerprint: z2.string().regex(/^([0-9A-F]{2}:){31}[0-9A-F]{2}$/),
471
+ publicAddress: z2.string().min(1)
472
+ });
473
+ var HostJoinResponseSchema = z2.object({
474
+ hostSecret: z2.string(),
475
+ controlPlane: z2.object({ name: z2.string() })
476
+ });
477
+ var CloudHostSchema = z2.object({
478
+ name: z2.string(),
479
+ url: z2.string(),
480
+ publicAddress: z2.string(),
481
+ joinedAt: z2.string(),
482
+ lastSeenAt: z2.string().nullable(),
483
+ capacity: CapacitySchema.nullable()
484
+ });
485
+
486
+ // ../shared/src/ssh.ts
487
+ import { createHash } from "node:crypto";
488
+ var SSH_PUBLIC_KEY_RE = /^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com) ([A-Za-z0-9+/]+=*)(?: [^\r\n]*)?$/;
489
+ function parseSshPublicKey(text) {
490
+ const key = text.trim().replace(/^cert-authority\s+/, "");
491
+ if (!SSH_PUBLIC_KEY_RE.test(key)) {
492
+ throw new KvboxError("that is not an SSH public key; expected one line like: ssh-ed25519 AAAA\u2026 kvbox-ca@kvbox.sh");
493
+ }
494
+ return key;
495
+ }
496
+ function sshKeyFingerprint(publicKey) {
497
+ const m = SSH_PUBLIC_KEY_RE.exec(publicKey.trim());
498
+ if (!m) throw new KvboxError("cannot fingerprint: not an SSH public key");
499
+ return `SHA256:${createHash("sha256").update(Buffer.from(m[2], "base64")).digest("base64").replace(/=+$/, "")}`;
500
+ }
501
+ function caLineFor(principal, caPublicKey) {
502
+ return `cert-authority,principals="${principal}" ${caPublicKey}`;
503
+ }
504
+ var isCaLine = (line) => /^cert-authority(,|\s)/.test(line.trim());
505
+
506
+ // src/core/exec.ts
507
+ import { execFile } from "node:child_process";
508
+ import { promisify } from "node:util";
509
+ var execFileAsync = promisify(execFile);
510
+ async function run(argv, opts = {}) {
511
+ const [cmd, ...args] = opts.sudo ? ["sudo", "-n", ...argv] : argv;
512
+ if (!cmd) throw new KvboxError("run: empty argv");
513
+ if (process.env["KVBOX_DEBUG"]) console.error(`[exec] ${[cmd, ...args].join(" ")}`);
514
+ try {
515
+ const { stdout, stderr } = await execFileAsync(cmd, args, {
516
+ cwd: opts.cwd,
517
+ env: opts.env ?? process.env,
518
+ timeout: opts.timeoutMs ?? 3e4,
519
+ maxBuffer: 8 * 1024 * 1024
520
+ });
521
+ return { stdout, stderr };
522
+ } catch (err) {
523
+ const e = err;
524
+ if (typeof e.code === "number" && opts.okExitCodes?.includes(e.code)) return { stdout: e.stdout ?? "", stderr: e.stderr ?? "" };
525
+ const detail = (e.stderr ?? e.message ?? "").trim();
526
+ throw new KvboxError(`${[cmd, ...args].join(" ")} failed${detail ? `: ${detail}` : ""}`);
527
+ }
528
+ }
529
+
530
+ // src/core/controlPlane.ts
531
+ var DEFAULT_HTTPS_PORT = 7777;
532
+ async function ensureTlsCert(p = paths()) {
533
+ const have = await readFile2(p.tlsCert, "utf8").catch(() => null);
534
+ const haveKey = await readFile2(p.tlsKey).then(() => true, () => false);
535
+ if (have === null || !haveKey) {
536
+ await mkdir(p.tls, { recursive: true, mode: 448 });
537
+ try {
538
+ await run([
539
+ "openssl",
540
+ "req",
541
+ "-x509",
542
+ "-newkey",
543
+ "ec",
544
+ "-pkeyopt",
545
+ "ec_paramgen_curve:prime256v1",
546
+ "-nodes",
547
+ "-keyout",
548
+ p.tlsKey,
549
+ "-out",
550
+ p.tlsCert,
551
+ "-subj",
552
+ "/CN=kvbox-host",
553
+ "-days",
554
+ "3650"
555
+ ]);
556
+ } catch (err) {
557
+ throw new KvboxError(`could not make the host's certificate (is openssl installed?): ${err.message}`);
558
+ }
559
+ await chmod2(p.tlsKey, 384);
560
+ }
561
+ const pem = await readFile2(p.tlsCert, "utf8");
562
+ return { fingerprint: new X509Certificate(pem).fingerprint256 };
563
+ }
564
+ function isPrivateAddress(ip) {
565
+ const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ip);
566
+ if (!m) return false;
567
+ const [a, b] = [Number(m[1]), Number(m[2])];
568
+ 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;
569
+ }
570
+ async function defaultAdvertise() {
571
+ const { stdout } = await run(["ip", "-4", "route", "get", "1.1.1.1"]).catch((err) => {
572
+ throw new KvboxError(`cannot tell this host's address (${err.message}); pass --advertise <host-or-ip>`);
573
+ });
574
+ const m = /\bsrc\s+(\S+)/.exec(stdout);
575
+ if (!m) throw new KvboxError("cannot tell this host's address from its routes; pass --advertise <host-or-ip>");
576
+ return m[1];
577
+ }
578
+ async function joinControlPlane(opts, p = paths(), guessAddress = defaultAdvertise) {
579
+ const url = opts.url.replace(/\/+$/, "");
580
+ const port = opts.port ?? DEFAULT_HTTPS_PORT;
581
+ const cfg = await loadConfig(p);
582
+ if (cfg.controlPlane) throw new KvboxError(`this host has already joined ${cfg.controlPlane.name} (${cfg.controlPlane.url}); run kvbox server leave first`);
583
+ let advertise = opts.advertise;
584
+ if (advertise === void 0 && cfg.relay) {
585
+ advertise = cfg.relay.publicIp;
586
+ } else if (advertise === void 0) {
587
+ advertise = await guessAddress();
588
+ if (isPrivateAddress(advertise)) {
589
+ throw new KvboxError(
590
+ `${advertise} is a private address: a control plane on the internet cannot reach this host there.
591
+ Pass --advertise <public host or ip>, or --advertise ${advertise} if the control plane is on the same network.`
592
+ );
593
+ }
594
+ }
595
+ const name = opts.name ?? osHostname();
596
+ const { fingerprint } = await ensureTlsCert(p);
597
+ const hostUrl = `https://${advertise}:${port}`;
598
+ const body = { token: opts.token, name, url: hostUrl, fingerprint, publicAddress: advertise };
599
+ const res = await request(`${url}/hosts/join`, {
600
+ method: "POST",
601
+ headers: { "content-type": "application/json" },
602
+ body: JSON.stringify(body),
603
+ headersTimeout: 1e4,
604
+ bodyTimeout: 1e4
605
+ }).catch((err) => {
606
+ throw new KvboxError(`cannot reach ${url} (${err.message})`);
607
+ });
608
+ const text = await res.body.text();
609
+ if (res.statusCode < 200 || res.statusCode >= 300) {
610
+ throw new KvboxError(errorIn(text) ?? `control plane answered ${res.statusCode}`);
611
+ }
612
+ const parsed = HostJoinResponseSchema.safeParse(jsonOrNull(text));
613
+ if (!parsed.success) throw new KvboxError(`${url} did not answer like a kvbox control plane`);
614
+ if (!REALM_RE.test(parsed.data.controlPlane.name)) {
615
+ throw new KvboxError(`${url} calls itself "${parsed.data.controlPlane.name}", which cannot name a realm (letters, digits, '.', '-')`);
616
+ }
617
+ await saveConfig({ ...cfg, controlPlane: { url, name: parsed.data.controlPlane.name, secret: parsed.data.hostSecret, advertise, port } }, p);
618
+ return { hostName: name, controlPlane: parsed.data.controlPlane.name, hostUrl };
619
+ }
620
+ async function fetchCloudCa(p = paths()) {
621
+ const cfg = await loadConfig(p);
622
+ const cp = cfg.controlPlane;
623
+ if (!cp) throw new KvboxError("this host has not joined a control plane, so there is no CA to fetch; paste the key instead: kvbox server trust-ca <public-key>");
624
+ const res = await request(`${cp.url}/hosts/self/ssh-ca`, {
625
+ headers: { authorization: `Bearer ${cp.secret}` },
626
+ headersTimeout: 1e4,
627
+ bodyTimeout: 1e4
628
+ }).catch((err) => {
629
+ throw new KvboxError(`cannot reach ${cp.url} (${err.message})`);
630
+ });
631
+ const text = await res.body.text();
632
+ if (res.statusCode === 404) throw new KvboxError(`${cp.name} does not answer /hosts/self/ssh-ca: it is older than this host; paste the key instead`);
633
+ if (res.statusCode < 200 || res.statusCode >= 300) throw new KvboxError(errorIn(text) ?? `${cp.name} answered ${res.statusCode}`);
634
+ const parsed = HostSshCaResponseSchema.safeParse(jsonOrNull(text));
635
+ if (!parsed.success) throw new KvboxError(`${cp.name} did not answer with a CA key`);
636
+ const publicKey = parseSshPublicKey(parsed.data.publicKey);
637
+ return { controlPlane: cp.name, publicKey, fingerprint: sshKeyFingerprint(publicKey) };
638
+ }
639
+ async function leaveControlPlane(p = paths()) {
640
+ const cfg = await loadConfig(p);
641
+ const cp = cfg.controlPlane;
642
+ if (!cp) throw new KvboxError("this host has not joined a control plane");
643
+ const told = await request(`${cp.url}/hosts/self`, {
644
+ method: "DELETE",
645
+ headers: { authorization: `Bearer ${cp.secret}` },
646
+ headersTimeout: 3e3,
647
+ bodyTimeout: 3e3
648
+ }).then(async (res) => {
649
+ await res.body.dump();
650
+ return res.statusCode >= 200 && res.statusCode < 300;
651
+ }).catch(() => false);
652
+ const { controlPlane: _gone, ...rest } = cfg;
653
+ await saveConfig(rest, p);
654
+ return { controlPlane: cp.name, told };
655
+ }
656
+ function jsonOrNull(text) {
657
+ try {
658
+ return JSON.parse(text);
659
+ } catch {
660
+ return null;
661
+ }
662
+ }
663
+ function errorIn(text) {
664
+ const v = jsonOrNull(text);
665
+ if (v && typeof v === "object" && typeof v.error === "string") return v.error;
666
+ return void 0;
667
+ }
668
+
669
+ // src/core/kvm.ts
670
+ import { access, constants, readFile as readFile3, stat } from "node:fs/promises";
671
+ var KVM_DEVICE = "/dev/kvm";
672
+ async function groupName(gid, groupFile = "/etc/group") {
673
+ const text = await readFile3(groupFile, "utf8").catch(() => "");
674
+ for (const line of text.split("\n")) {
675
+ const [name, , id] = line.split(":");
676
+ if (id !== void 0 && Number.parseInt(id, 10) === gid && name) return name;
677
+ }
678
+ return "kvm";
679
+ }
680
+ async function requireKvm(device = KVM_DEVICE, groupFile = "/etc/group") {
681
+ try {
682
+ await access(device, constants.R_OK | constants.W_OK);
683
+ return;
684
+ } catch (err) {
685
+ const code = err.code;
686
+ if (code === "ENOENT") {
687
+ throw new KvboxError(`${device} is missing: this machine has no KVM, so boxes cannot run here (enable virtualization in the BIOS, then install-server.sh checks the rest)`);
688
+ }
689
+ if (code !== "EACCES" && code !== "EPERM") throw new KvboxError(`cannot open ${device}: ${err.message}`);
690
+ }
691
+ const owner = await stat(device).then((s) => groupName(s.gid, groupFile), () => "kvm");
692
+ throw new KvboxError(
693
+ `cannot start: this process is not allowed to open ${device}, so no box could run. It was started from a shell without the ${owner} group (a login from before you were added to it). Log out and back in, or start it from a shell that has the group: sg ${owner} -c "kvbox server start"`
694
+ );
695
+ }
696
+
697
+ // src/core/network.ts
698
+ import { randomInt } from "node:crypto";
699
+ import { existsSync } from "node:fs";
700
+ import { fileURLToPath } from "node:url";
701
+ var TAP_PREFIX = "kv-";
702
+ var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
703
+ function tapName(box) {
704
+ return `${TAP_PREFIX}${box}`.slice(0, 15);
705
+ }
706
+ function netSetupScript() {
707
+ const override = process.env["KVBOX_NET_SETUP"];
708
+ if (override) return override;
709
+ if (existsSync(PRIVILEGED_SCRIPT)) return PRIVILEGED_SCRIPT;
710
+ for (const rel of ["../scripts/net-setup.sh", "../../scripts/net-setup.sh"]) {
711
+ const candidate = fileURLToPath(new URL(rel, import.meta.url));
712
+ if (existsSync(candidate)) return candidate;
713
+ }
714
+ throw new KvboxError("net-setup.sh not found next to kvbox-server");
715
+ }
716
+ function bridgeExists(bridge) {
717
+ return existsSync(`/sys/class/net/${bridge}`);
718
+ }
719
+ function netRun(cfg, args) {
720
+ return run([netSetupScript(), ...args], {
721
+ env: { ...process.env, KVBOX_BRIDGE: cfg.bridge, KVBOX_SUBNET: cfg.subnet, KVBOX_GATEWAY: cfg.gateway }
722
+ }).catch((err) => {
723
+ throw new KvboxError(
724
+ `${err.message}
725
+ (root is needed for host networking; install-server.sh sets up a sudoers rule for ${PRIVILEGED_SCRIPT})`
726
+ );
727
+ });
728
+ }
729
+ async function netSetup(cfg, args) {
730
+ await netRun(cfg, args);
731
+ }
732
+ async function ensureHostNetwork(cfg) {
733
+ if (bridgeExists(cfg.bridge)) return "present";
734
+ await netRun(cfg, ["up"]);
735
+ return "created";
736
+ }
737
+ async function createTap(box, cfg, opts = {}) {
738
+ const tap = tapName(box);
739
+ await netRun(cfg, ["tap", "add", tap, ...opts.detached ? ["detached"] : []]);
740
+ return tap;
741
+ }
742
+ async function attachTap(box, cfg) {
743
+ await netRun(cfg, ["tap", "attach", tapName(box)]);
744
+ }
745
+ async function deleteTap(box, cfg) {
746
+ await netRun(cfg, ["tap", "del", tapName(box)]);
747
+ }
748
+ var SSH_PORT_MIN = 2e4;
749
+ var SSH_PORT_MAX = 6e4;
750
+ function pickSshPort(taken) {
751
+ const span = SSH_PORT_MAX - SSH_PORT_MIN + 1;
752
+ for (let i = 0; i < 32; i++) {
753
+ const port = SSH_PORT_MIN + randomInt(span);
754
+ if (!taken.has(port)) return port;
755
+ }
756
+ const start = randomInt(span);
757
+ for (let i = 0; i < span; i++) {
758
+ const port = SSH_PORT_MIN + (start + i) % span;
759
+ if (!taken.has(port)) return port;
760
+ }
761
+ throw new KvboxError(`no free port left between ${SSH_PORT_MIN} and ${SSH_PORT_MAX} on this host`);
762
+ }
763
+ function parseListeningPorts(ssOutput) {
764
+ const ports = /* @__PURE__ */ new Set();
765
+ for (const line of ssOutput.split("\n")) {
766
+ const cols = line.trim().split(/\s+/);
767
+ const local = cols[3];
768
+ if (!local) continue;
769
+ const port = Number(local.slice(local.lastIndexOf(":") + 1));
770
+ if (Number.isInteger(port) && port > 0) ports.add(port);
771
+ }
772
+ return ports;
773
+ }
774
+ async function listeningPorts() {
775
+ try {
776
+ return parseListeningPorts((await run(["ss", "-ltnH"])).stdout);
777
+ } catch {
778
+ return /* @__PURE__ */ new Set();
779
+ }
780
+ }
781
+ var proxyArgs = (p) => [p.box, p.boxIp, String(p.hostPort), String(p.guestPort), p.proto ?? "tcp"];
782
+ async function openProxy(proxy, cfg) {
783
+ await netRun(cfg, ["proxy", "add", ...proxyArgs(proxy)]);
784
+ }
785
+ async function closeProxy(proxy, cfg) {
786
+ await netRun(cfg, ["proxy", "rm", ...proxyArgs(proxy)]);
787
+ }
788
+ var ipToInt = (ip) => ip.split(".").reduce((acc, o) => (acc << 8) + Number(o), 0) >>> 0;
789
+ var intToIp = (n) => [24, 16, 8, 0].map((s) => n >>> s & 255).join(".");
790
+ function nextFreeIp(subnet, inUse) {
791
+ const [base, lenStr] = subnet.split("/");
792
+ const len = Number(lenStr);
793
+ if (!base || !Number.isInteger(len) || len < 8 || len > 30) throw new KvboxError(`bad subnet: ${subnet}`);
794
+ const mask = len === 0 ? 0 : ~0 << 32 - len >>> 0;
795
+ const network = (ipToInt(base) & mask) >>> 0;
796
+ const broadcast = (network | ~mask >>> 0) >>> 0;
797
+ const used = new Set(inUse.map(ipToInt));
798
+ for (let n = network + 2; n < broadcast; n++) {
799
+ if (!used.has(n)) return intToIp(n);
800
+ }
801
+ throw new KvboxError(`no free IP left in ${subnet}`);
802
+ }
803
+ function macFor(ip) {
804
+ const hex = ip.split(".").map((o) => Number(o).toString(16).padStart(2, "0").toUpperCase());
805
+ return ["06", "00", ...hex].join(":");
806
+ }
807
+
808
+ // src/core/relay.ts
809
+ import { chmod as chmod3, mkdir as mkdir2, rm, writeFile as writeFile2 } from "node:fs/promises";
810
+
811
+ // ../shared/src/relay.ts
812
+ import { z as z3 } from "zod";
813
+ var RELAY_TUNNEL_GATEWAY = "10.77.0.1";
814
+ var IPV4_RE = /^(\d{1,3})(\.\d{1,3}){3}$/;
815
+ var WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
816
+ var RelayJoinSchema = z3.object({
817
+ v: z3.literal(1),
818
+ /** How the relay lists this host. */
819
+ name: z3.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
820
+ /** `<relay-primary-ip>:<port>`, the WireGuard endpoint the host dials. */
821
+ relay: z3.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
822
+ /** The relay's WireGuard public key. */
823
+ relayKey: z3.string().regex(WG_KEY_RE),
824
+ /** This host's address inside the tunnel. */
825
+ tunnelIp: z3.string().regex(IPV4_RE),
826
+ /** This host's WireGuard private key, generated on the relay, kept nowhere else. */
827
+ privateKey: z3.string().regex(WG_KEY_RE),
828
+ /** The extra public IPv4 the relay owns for this host; what the host advertises to the control plane. */
829
+ publicIp: z3.string().regex(IPV4_RE)
830
+ });
831
+ function decodeRelayJoin(text) {
832
+ let parsed;
833
+ try {
834
+ parsed = JSON.parse(Buffer.from(text.trim(), "base64url").toString("utf8"));
835
+ } catch {
836
+ return null;
837
+ }
838
+ const res = RelayJoinSchema.safeParse(parsed);
839
+ return res.success ? res.data : null;
840
+ }
841
+
842
+ // src/core/relay.ts
843
+ function relayConf(join2) {
844
+ return [
845
+ "# kvbox relay tunnel: made by `kvbox server relay`, installed by net-setup.sh. Do not edit.",
846
+ "[Interface]",
847
+ `PrivateKey = ${join2.privateKey}`,
848
+ `Address = ${join2.tunnelIp}/32`,
849
+ "",
850
+ "[Peer]",
851
+ `PublicKey = ${join2.relayKey}`,
852
+ `Endpoint = ${join2.relay}`,
853
+ // Only the relay's own tunnel address goes through the tunnel: the host keeps its normal internet
854
+ // route, and the relay SNATs forwarded connections so their replies come back this way.
855
+ `AllowedIPs = ${RELAY_TUNNEL_GATEWAY}/32`,
856
+ // The router forgets an idle outbound mapping; a packet every 25 s keeps the relay able to reach us.
857
+ "PersistentKeepalive = 25",
858
+ ""
859
+ ].join("\n");
860
+ }
861
+ async function useRelay(joinString, p = paths()) {
862
+ const join2 = decodeRelayJoin(joinString);
863
+ if (!join2) throw new KvboxError("that is not a relay join string; copy the whole line `kvbox-relay add` printed on the relay");
864
+ const cfg = await loadConfig(p);
865
+ if (cfg.relay && cfg.relay.name !== join2.name) {
866
+ throw new KvboxError(`this host already goes through relay ${cfg.relay.name} (${cfg.relay.publicIp}); run kvbox server relay off first`);
867
+ }
868
+ if (cfg.relay && cfg.relay.publicIp !== join2.publicIp && cfg.controlPlane?.advertise === cfg.relay.publicIp) {
869
+ throw new KvboxError(
870
+ `${cfg.controlPlane.name} reaches this host at ${cfg.relay.publicIp}, not ${join2.publicIp}; run kvbox server leave first, then join again`
871
+ );
872
+ }
873
+ await mkdir2(p.relay, { recursive: true, mode: 448 });
874
+ await writeFile2(p.relayConf, relayConf(join2), { mode: 384 });
875
+ await chmod3(p.relayConf, 384);
876
+ await netSetup(cfg, ["relay", "up", p.relayConf, String(cfg.controlPlane?.port ?? DEFAULT_HTTPS_PORT)]);
877
+ const relay = { name: join2.name, publicIp: join2.publicIp, tunnelIp: join2.tunnelIp, endpoint: join2.relay };
878
+ await saveConfig({ ...cfg, relay }, p);
879
+ return relay;
880
+ }
881
+ async function dropRelay(p = paths()) {
882
+ const cfg = await loadConfig(p);
883
+ const relay = cfg.relay;
884
+ if (!relay) throw new KvboxError("this host does not go through a relay");
885
+ if (cfg.controlPlane?.advertise === relay.publicIp) {
886
+ throw new KvboxError(`${cfg.controlPlane.name} reaches this host through the relay (${relay.publicIp}); run kvbox server leave first`);
887
+ }
888
+ await netSetup(cfg, ["relay", "down"]);
889
+ await rm(p.relayConf, { force: true });
890
+ const { relay: _gone, ...rest } = cfg;
891
+ await saveConfig(rest, p);
892
+ return relay;
893
+ }
894
+
895
+ // src/listen.ts
896
+ import { readFile as readFile6, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
897
+ import { createServer as createHttpsServer } from "node:https";
898
+ import { createAdaptorServer } from "@hono/node-server";
899
+
900
+ // src/core/state.ts
901
+ import { randomBytes } from "node:crypto";
902
+ import { mkdir as mkdir3, readFile as readFile4, readdir, writeFile as writeFile3 } from "node:fs/promises";
903
+ import { z as z4 } from "zod";
904
+ var MachineMetaSchema = z4.object({
905
+ /** Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is `kv-<id>`. */
906
+ id: z4.string(),
907
+ name: z4.string(),
908
+ /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
909
+ owner: z4.string(),
910
+ ip: z4.string(),
911
+ mac: z4.string(),
912
+ pid: z4.number().int().nullable(),
913
+ // null: stopped on purpose, disk kept
914
+ vcpu: z4.number().int(),
915
+ memMib: z4.number().int(),
916
+ createdAt: z4.string(),
917
+ source: z4.string(),
918
+ /** Base image version (assets/rootfs-<version>.ext4) the box's overlay stacks on. */
919
+ base: z4.string(),
920
+ /**
921
+ * Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
922
+ * each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
923
+ * is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
924
+ */
925
+ memSnapshots: z4.number().int().optional(),
926
+ /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
927
+ paused: z4.boolean().optional(),
928
+ /**
929
+ * authorized_keys lines the box gets on top of the host's own key, as the control plane sent them
930
+ * at `new` (#49). kvbox-init rewrites authorized_keys from the kernel cmdline at every boot, so
931
+ * `start` has to send the same lines again. Absent: host key only.
932
+ */
933
+ sshKeys: z4.array(z4.string()).optional(),
934
+ /**
935
+ * The guest's authorized_keys may not match `sshKeys`: they changed while the box was not running,
936
+ * or its agent could not take them (#55, #63). Cleared by a push through kvbox-agent or a boot.
937
+ */
938
+ sshKeysStale: z4.boolean().optional(),
939
+ /** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
940
+ sshPort: z4.number().int().optional()
941
+ });
942
+ function newBoxId(taken) {
943
+ for (; ; ) {
944
+ const id = randomBytes(3).toString("hex");
945
+ if (!taken.has(id)) return id;
946
+ }
947
+ }
948
+ var SnapshotMetaSchema = z4.object({
949
+ name: z4.string(),
950
+ owner: z4.string(),
951
+ /** The box it was taken from, as owner/name. */
952
+ source: z4.string(),
953
+ base: z4.string(),
954
+ vcpu: z4.number().int(),
955
+ memMib: z4.number().int(),
956
+ sizeBytes: z4.number().int(),
957
+ createdAt: z4.string()
958
+ });
959
+ async function ensureLayout(p = paths()) {
960
+ for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
961
+ await mkdir3(dir, { recursive: true, mode: 448 });
962
+ }
963
+ return p;
964
+ }
965
+ async function readJson(file, schema) {
966
+ return schema.parse(JSON.parse(await readFile4(file, "utf8")));
967
+ }
968
+ async function listDirs(dir) {
969
+ try {
970
+ const entries = await readdir(dir, { withFileTypes: true });
971
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
972
+ } catch (err) {
973
+ if (err.code === "ENOENT") return [];
974
+ throw err;
975
+ }
976
+ }
977
+ async function readMachineMeta(ref, p = paths()) {
978
+ return readJson(p.machine(ref.owner, ref.name).meta, MachineMetaSchema);
979
+ }
980
+ async function writeMachineMeta(meta, p = paths()) {
981
+ const m = p.machine(meta.owner, meta.name);
982
+ await mkdir3(m.dir, { recursive: true });
983
+ await writeFile3(m.meta, JSON.stringify(meta, null, 2) + "\n");
984
+ }
985
+ async function listMachineRefs(p = paths()) {
986
+ return listRefs(p.machines);
987
+ }
988
+ async function readSnapshotMeta(ref, p = paths()) {
989
+ return readJson(p.snapshot(ref.owner, ref.name).meta, SnapshotMetaSchema);
990
+ }
991
+ async function writeSnapshotMeta(meta, p = paths()) {
992
+ const s = p.snapshot(meta.owner, meta.name);
993
+ await mkdir3(s.dir, { recursive: true });
994
+ await writeFile3(s.meta, JSON.stringify(meta, null, 2) + "\n");
995
+ }
996
+ async function listSnapshotRefs(p = paths()) {
997
+ return listRefs(p.snapshots);
998
+ }
999
+ async function listRefs(root) {
1000
+ const out = [];
1001
+ for (const owner of await listDirs(root)) {
1002
+ for (const name of await listDirs(`${root}/${owner}`)) {
1003
+ out.push({ owner, name });
1004
+ }
1005
+ }
1006
+ return out;
1007
+ }
1008
+ function pidAlive(pid) {
1009
+ if (pid === null || pid <= 0) return false;
1010
+ try {
1011
+ process.kill(pid, 0);
1012
+ return true;
1013
+ } catch (err) {
1014
+ return err.code === "EPERM";
1015
+ }
1016
+ }
1017
+
1018
+ // src/app.ts
1019
+ import { Hono as Hono5 } from "hono";
1020
+
1021
+ // ../shared/src/version.ts
1022
+ import { createRequire } from "node:module";
1023
+ function versionOf(srcFileUrl, injected) {
1024
+ if (typeof injected === "string") return injected;
1025
+ try {
1026
+ const require2 = createRequire(srcFileUrl);
1027
+ return require2("../package.json").version;
1028
+ } catch {
1029
+ return "0.0.0-dev";
1030
+ }
1031
+ }
1032
+
1033
+ // src/version.ts
1034
+ var VERSION = versionOf(import.meta.url, true ? "0.5.5" : void 0);
1035
+
1036
+ // src/caller.ts
1037
+ import { createHash as createHash2, timingSafeEqual } from "node:crypto";
1038
+ var OWNER_HEADER = "x-kvbox-owner";
1039
+ function resolveCaller(opts = {}) {
1040
+ const cp = opts.controlPlane;
1041
+ if (!cp) {
1042
+ return async (c, next) => {
1043
+ const cfg = await loadConfig();
1044
+ c.set("owner", cfg.owner);
1045
+ c.set("listScope", cfg.owner);
1046
+ await next();
1047
+ };
1048
+ }
1049
+ const expected = digest(cp.secret);
1050
+ return async (c, next) => {
1051
+ const m = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
1052
+ if (!m || !timingSafeEqual(digest(m[1]), expected)) return c.json({ error: "not authorized" }, 401);
1053
+ const user = c.req.header(OWNER_HEADER);
1054
+ if (user === void 0) {
1055
+ const path = c.req.path.replace(/\/$/, "");
1056
+ const realmListing = c.req.method === "GET" && (path === "/machines" || path === "/snapshots");
1057
+ if (!realmListing && /^\/(machines|snapshots)(\/|$)/.test(c.req.path)) {
1058
+ return c.json({ error: "X-Kvbox-Owner header required" }, 400);
1059
+ }
1060
+ c.set("owner", "");
1061
+ c.set("listScope", { realm: cp.name });
1062
+ } else {
1063
+ if (!LOCAL_OWNER_RE.test(user)) return c.json({ error: "X-Kvbox-Owner: letters, digits, '.', '_', '-'" }, 400);
1064
+ const owner = `${user}@${cp.name}`;
1065
+ c.set("owner", owner);
1066
+ c.set("listScope", owner);
1067
+ }
1068
+ await next();
1069
+ };
1070
+ }
1071
+ function digest(secret) {
1072
+ return createHash2("sha256").update(secret).digest();
1073
+ }
1074
+
1075
+ // src/core/machines.ts
1076
+ import { spawn } from "node:child_process";
1077
+ import { closeSync as closeSync2, existsSync as existsSync2, openSync as openSync2, statSync } from "node:fs";
1078
+ import { link, mkdir as mkdir4, readFile as readFile5, realpath, rm as rm2, truncate, writeFile as writeFile4 } from "node:fs/promises";
1079
+ import { basename } from "node:path";
1080
+ import { setTimeout as sleep } from "node:timers/promises";
1081
+
1082
+ // src/core/agent.ts
1083
+ import { connect } from "node:net";
1084
+ var AGENT_PORT = 52;
1085
+ async function agentCall(udsPath, req, timeoutMs = 5e3) {
1086
+ const reply = await new Promise((resolve, reject) => {
1087
+ const sock = connect(udsPath);
1088
+ let buf = "";
1089
+ let handshaken = false;
1090
+ const fail = (err) => {
1091
+ sock.destroy();
1092
+ reject(err);
1093
+ };
1094
+ sock.setTimeout(timeoutMs, () => fail(new KvboxError(`kvbox-agent did not answer within ${timeoutMs} ms`)));
1095
+ sock.once("error", (err) => fail(new KvboxError(`kvbox-agent unreachable: ${err.message}`)));
1096
+ sock.once("connect", () => sock.write(`CONNECT ${AGENT_PORT}
1097
+ `));
1098
+ sock.on("data", (chunk) => {
1099
+ buf += chunk.toString("utf8");
1100
+ let nl;
1101
+ while ((nl = buf.indexOf("\n")) !== -1) {
1102
+ const line = buf.slice(0, nl);
1103
+ buf = buf.slice(nl + 1);
1104
+ if (!handshaken) {
1105
+ if (!line.startsWith("OK ")) return fail(new KvboxError(`vsock connect refused: ${line}`));
1106
+ handshaken = true;
1107
+ sock.write(JSON.stringify(req) + "\n");
1108
+ } else {
1109
+ sock.end();
1110
+ return resolve(line);
1111
+ }
1112
+ }
1113
+ });
1114
+ sock.once("close", () => {
1115
+ if (!handshaken) fail(new KvboxError("vsock connection closed before the guest accepted"));
1116
+ else fail(new KvboxError("kvbox-agent closed the connection without a reply"));
1117
+ });
1118
+ });
1119
+ let parsed;
1120
+ try {
1121
+ parsed = JSON.parse(reply);
1122
+ } catch {
1123
+ throw new KvboxError(`kvbox-agent sent something that is not JSON: ${reply}`);
1124
+ }
1125
+ if (!parsed.ok) throw new KvboxError(`kvbox-agent: ${parsed.error}`);
1126
+ return parsed;
1127
+ }
1128
+ function setIdentity(udsPath, id, timeoutMs) {
1129
+ return agentCall(udsPath, { op: "identity", ...id }, timeoutMs);
1130
+ }
1131
+ function setAuthorizedKeys(udsPath, lines, timeoutMs) {
1132
+ return agentCall(udsPath, { op: "authorizedKeys", lines: [...lines] }, timeoutMs);
1133
+ }
1134
+ var isUnknownOp = (err) => /unknown op/.test(err.message ?? "");
1135
+
1136
+ // src/core/capacity.ts
1137
+ import { cpus, totalmem } from "node:os";
1138
+
1139
+ // ../shared/src/capacity.ts
1140
+ var MEM_HEADROOM_MIB = 2048;
1141
+ var VCPU_OVERSUBSCRIPTION = 2;
1142
+
1143
+ // src/core/capacity.ts
1144
+ function hostResources() {
1145
+ return { memMib: Math.floor(totalmem() / 1024 ** 2), cores: cpus().length };
1146
+ }
1147
+ async function runningReservations(p = paths()) {
1148
+ const out = [];
1149
+ for (const ref of await listMachineRefs(p)) {
1150
+ try {
1151
+ const meta = await readMachineMeta(ref, p);
1152
+ if (pidAlive(meta.pid)) out.push({ name: meta.name, vcpu: meta.vcpu, memMib: meta.memMib });
1153
+ } catch {
1154
+ }
1155
+ }
1156
+ return out;
1157
+ }
1158
+ async function capacity(p = paths()) {
1159
+ const host = hostResources();
1160
+ const running = await runningReservations(p);
1161
+ return {
1162
+ hostMemMib: host.memMib,
1163
+ hostCores: host.cores,
1164
+ reservedMemMib: running.reduce((n, r) => n + r.memMib, 0),
1165
+ reservedVcpu: running.reduce((n, r) => n + r.vcpu, 0),
1166
+ running: running.length
1167
+ };
1168
+ }
1169
+ function gb(mib) {
1170
+ const g = mib / 1024;
1171
+ return `${Number.isInteger(g) ? g : g.toFixed(1)} GB`;
1172
+ }
1173
+ function describeRunning(running) {
1174
+ if (running.length === 0) return "no box is running";
1175
+ const list = running.map((r) => `${r.name} ${r.vcpu} vCPU/${gb(r.memMib)}`).join(", ");
1176
+ const vcpu = running.reduce((n, r) => n + r.vcpu, 0);
1177
+ const mem = running.reduce((n, r) => n + r.memMib, 0);
1178
+ return `${running.length} running ${running.length === 1 ? "box reserves" : "boxes reserve"} ${vcpu} vCPU and ${gb(mem)} (${list})`;
1179
+ }
1180
+ function roomFor(host, running, want) {
1181
+ const reservedMem = running.reduce((n, r) => n + r.memMib, 0);
1182
+ const reservedVcpu = running.reduce((n, r) => n + r.vcpu, 0);
1183
+ const memLimit = host.memMib - MEM_HEADROOM_MIB;
1184
+ const vcpuLimit = host.cores * VCPU_OVERSUBSCRIPTION;
1185
+ const hint = "stop or remove a box (kvbox stop, kvbox rm), pick a smaller --size, or pass --force to start it anyway";
1186
+ if (reservedMem + want.memMib > memLimit) {
1187
+ return `not enough memory for a ${gb(want.memMib)} box: ${describeRunning(running)}; this host has ${gb(host.memMib)} and keeps ${gb(MEM_HEADROOM_MIB)} for itself, so boxes may reserve ${gb(memLimit)} in total. ` + hint;
1188
+ }
1189
+ if (reservedVcpu + want.vcpu > vcpuLimit) {
1190
+ return `too many vCPUs for a ${want.vcpu} vCPU box: ${describeRunning(running)}; this host has ${host.cores} cores and allows ${vcpuLimit} vCPUs in total (${VCPU_OVERSUBSCRIPTION}x). ` + hint;
1191
+ }
1192
+ return void 0;
1193
+ }
1194
+ async function assertRoomFor(vcpu, memMib, force, p = paths()) {
1195
+ if (force) return;
1196
+ const reason = roomFor(hostResources(), await runningReservations(p), { vcpu, memMib });
1197
+ if (reason !== void 0) throw new KvboxError(reason);
1198
+ }
1199
+
1200
+ // src/core/db.ts
1201
+ import { chmodSync, closeSync, mkdirSync, openSync } from "node:fs";
1202
+ import { dirname } from "node:path";
1203
+ import { DatabaseSync } from "node:sqlite";
1204
+ var MIGRATIONS = [
1205
+ `create table usage_events (
1206
+ id integer primary key,
1207
+ box_id text not null,
1208
+ owner text not null,
1209
+ name text not null,
1210
+ at text not null,
1211
+ state text not null,
1212
+ vcpu integer not null,
1213
+ mem_mib integer not null,
1214
+ disk_gib integer not null,
1215
+ by text not null
1216
+ );
1217
+ create index usage_events_box_at on usage_events (box_id, at);`
1218
+ ];
1219
+ var handles = /* @__PURE__ */ new Map();
1220
+ function openDb(p = paths()) {
1221
+ const have = handles.get(p.db);
1222
+ if (have !== void 0) return have;
1223
+ mkdirSync(dirname(p.db), { recursive: true, mode: 448 });
1224
+ closeSync(openSync(p.db, "a", 384));
1225
+ chmodSync(p.db, 384);
1226
+ const db = new DatabaseSync(p.db);
1227
+ db.exec("pragma journal_mode = wal");
1228
+ db.exec("pragma busy_timeout = 5000");
1229
+ migrate(db);
1230
+ handles.set(p.db, db);
1231
+ return db;
1232
+ }
1233
+ function migrate(db) {
1234
+ db.exec("create table if not exists schema_version (version integer not null)");
1235
+ const row = db.prepare("select version from schema_version").get();
1236
+ let version = row?.version ?? 0;
1237
+ if (row === void 0) db.prepare("insert into schema_version (version) values (0)").run();
1238
+ while (version < MIGRATIONS.length) {
1239
+ db.exec("begin");
1240
+ try {
1241
+ db.exec(MIGRATIONS[version]);
1242
+ version += 1;
1243
+ db.prepare("update schema_version set version = ?").run(version);
1244
+ db.exec("commit");
1245
+ } catch (err) {
1246
+ db.exec("rollback");
1247
+ throw err;
1248
+ }
1249
+ }
1250
+ }
1251
+ var LIVE_STATES = ["running", "standby"];
1252
+ function fromRaw(r) {
1253
+ return { boxId: r.box_id, owner: r.owner, name: r.name, at: r.at, state: r.state, vcpu: r.vcpu, memMib: r.mem_mib, diskGib: r.disk_gib, by: r.by };
1254
+ }
1255
+ function lastEvent(boxId, db) {
1256
+ const raw = db.prepare("select * from usage_events where box_id = ? order by at desc, id desc limit 1").get(boxId);
1257
+ return raw === void 0 ? void 0 : fromRaw(raw);
1258
+ }
1259
+ function insertRaw(e, db) {
1260
+ db.prepare(
1261
+ "insert into usage_events (box_id, owner, name, at, state, vcpu, mem_mib, disk_gib, by) values (?, ?, ?, ?, ?, ?, ?, ?, ?)"
1262
+ ).run(e.boxId, e.owner, e.name, e.at, e.state, e.vcpu, e.memMib, e.diskGib, e.by);
1263
+ }
1264
+ function insertUsageEvent(e, p = paths()) {
1265
+ const db = openDb(p);
1266
+ if (e.state === "running" && e.by !== "resume") closeOpenRun(e.boxId, e.at, p);
1267
+ insertRaw(e, db);
1268
+ }
1269
+ function closeOpenRun(boxId, at, p = paths()) {
1270
+ const db = openDb(p);
1271
+ const last = lastEvent(boxId, db);
1272
+ if (last === void 0 || !LIVE_STATES.includes(last.state)) return false;
1273
+ insertRaw({ ...last, at, state: "stopped", by: "server" }, db);
1274
+ return true;
1275
+ }
1276
+ function usageEventsFor(boxId, since, p = paths()) {
1277
+ const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
1278
+ return rows.map(fromRaw);
1279
+ }
1280
+
1281
+ // src/core/firecracker.ts
1282
+ import { Agent, request as request2 } from "undici";
1283
+ var FirecrackerClient = class {
1284
+ constructor(socketPath) {
1285
+ this.socketPath = socketPath;
1286
+ this.agent = new Agent({ connect: { socketPath } });
1287
+ }
1288
+ socketPath;
1289
+ agent;
1290
+ async call(method, path, body) {
1291
+ const res = await request2(`http://localhost${path}`, {
1292
+ dispatcher: this.agent,
1293
+ method,
1294
+ headers: body === void 0 ? {} : { "content-type": "application/json" },
1295
+ body: body === void 0 ? null : JSON.stringify(body)
1296
+ });
1297
+ const text = await res.body.text();
1298
+ if (res.statusCode >= 300) {
1299
+ throw new KvboxError(`firecracker ${method} ${path} -> ${res.statusCode}: ${text}`);
1300
+ }
1301
+ return text ? JSON.parse(text) : void 0;
1302
+ }
1303
+ bootSource(kernelImagePath, bootArgs2) {
1304
+ return this.call("PUT", "/boot-source", { kernel_image_path: kernelImagePath, boot_args: bootArgs2 });
1305
+ }
1306
+ /** /dev/vda: the shared base image, read-only. Firecracker adds `root=/dev/vda ro` to the boot args to match. */
1307
+ rootDrive(pathOnHost) {
1308
+ return this.drive("rootfs", pathOnHost, { root: true, readOnly: true });
1309
+ }
1310
+ /** The box's own writable disk, /dev/vdb: the guest stacks it on the base as an overlay. */
1311
+ overlayDrive(pathOnHost) {
1312
+ return this.drive("overlay", pathOnHost, { root: false, readOnly: false });
1313
+ }
1314
+ drive(id, pathOnHost, opts) {
1315
+ return this.call("PUT", `/drives/${id}`, {
1316
+ drive_id: id,
1317
+ path_on_host: pathOnHost,
1318
+ is_root_device: opts.root,
1319
+ is_read_only: opts.readOnly,
1320
+ // "Unsafe" (the default) acks guest flushes without doing anything, so the last journal
1321
+ // commit of a clean shutdown can be lost when the guest resets and Firecracker exits.
1322
+ // Writeback makes a guest flush a real fsync; a box's writes must survive `kvbox stop`.
1323
+ cache_type: "Writeback"
1324
+ });
1325
+ }
1326
+ /**
1327
+ * The host's control channel into the box: Firecracker listens on `udsPath`, a connection there
1328
+ * followed by `CONNECT <port>\n` reaches whatever listens on that vsock port in the guest (kvbox-agent).
1329
+ */
1330
+ vsock(guestCid, udsPath) {
1331
+ return this.call("PUT", "/vsock", { guest_cid: guestCid, uds_path: udsPath });
1332
+ }
1333
+ machineConfig(vcpuCount, memSizeMib) {
1334
+ return this.call("PUT", "/machine-config", { vcpu_count: vcpuCount, mem_size_mib: memSizeMib, track_dirty_pages: true });
1335
+ }
1336
+ networkInterface(ifaceId, hostDevName, guestMac) {
1337
+ return this.call("PUT", `/network-interfaces/${ifaceId}`, {
1338
+ iface_id: ifaceId,
1339
+ host_dev_name: hostDevName,
1340
+ guest_mac: guestMac
1341
+ });
1342
+ }
1343
+ start() {
1344
+ return this.call("PUT", "/actions", { action_type: "InstanceStart" });
1345
+ }
1346
+ /** Ask the guest to shut down cleanly (systemd handles Ctrl+Alt+Del as poweroff on Firecracker). */
1347
+ sendCtrlAltDel() {
1348
+ return this.call("PUT", "/actions", { action_type: "SendCtrlAltDel" });
1349
+ }
1350
+ pause() {
1351
+ return this.call("PATCH", "/vm", { state: "Paused" });
1352
+ }
1353
+ resume() {
1354
+ return this.call("PATCH", "/vm", { state: "Resumed" });
1355
+ }
1356
+ /**
1357
+ * Full: all of guest memory, dense (seconds for a 4 GB box). Diff: only pages dirtied since the last
1358
+ * snapshot (or since boot), written sparse; needs dirty page tracking and resets its bitmap.
1359
+ */
1360
+ snapshotCreate(snapshotPath, memFilePath, type = "Full") {
1361
+ return this.call("PUT", "/snapshot/create", {
1362
+ snapshot_type: type,
1363
+ snapshot_path: snapshotPath,
1364
+ mem_file_path: memFilePath
1365
+ });
1366
+ }
1367
+ /**
1368
+ * Restore a snapshot into this (fresh) process. The memory file is mapped privately: the guest's
1369
+ * writes never reach the snapshot. The vmstate carries the source box's tap and vsock paths; the
1370
+ * overrides point them at this box's. Drive paths are not overridable here: the vmstate names the overlay relative to the box dir (spawnFirecracker).
1371
+ */
1372
+ snapshotLoad(opts) {
1373
+ return this.call("PUT", "/snapshot/load", {
1374
+ snapshot_path: opts.snapshotPath,
1375
+ mem_backend: { backend_type: "File", backend_path: opts.memFilePath },
1376
+ network_overrides: [{ iface_id: opts.tap.ifaceId, host_dev_name: opts.tap.hostDevName }],
1377
+ vsock_override: { uds_path: opts.vsockPath },
1378
+ track_dirty_pages: opts.trackDirtyPages,
1379
+ resume_vm: opts.resume
1380
+ });
1381
+ }
1382
+ info() {
1383
+ return this.call("GET", "/");
1384
+ }
1385
+ async close() {
1386
+ await this.agent.close();
1387
+ }
1388
+ };
1389
+
1390
+ // src/core/machines.ts
1391
+ var NAME_RE = /^[a-z0-9][a-z0-9-]{0,11}$/;
1392
+ var AUTO_PREFIX = "box";
1393
+ var GUEST_CID = 3;
1394
+ var OVERLAY_REL = "overlay.ext4";
1395
+ var idsInUse = (metas) => new Set(metas.map((m) => m.id));
1396
+ var sshPortsInUse = (metas) => metas.flatMap((m) => m.sshPort === void 0 ? [] : [m.sshPort]);
1397
+ var isCloudBox = (owner) => realmOf(owner) !== null;
1398
+ function sshProxy(meta) {
1399
+ if (meta.sshPort === void 0 || meta.id === void 0) return void 0;
1400
+ return { box: meta.id, boxIp: meta.ip, hostPort: meta.sshPort, guestPort: 22 };
1401
+ }
1402
+ async function openSshPort(meta, cfg) {
1403
+ const proxy = sshProxy(meta);
1404
+ if (proxy) await openProxy(proxy, cfg);
1405
+ }
1406
+ async function closeSshPort(meta, cfg) {
1407
+ const proxy = sshProxy(meta);
1408
+ if (!proxy) return;
1409
+ await closeProxy(proxy, cfg).catch((err) => console.error(`warning: could not close port ${proxy.hostPort}: ${err.message}`));
1410
+ }
1411
+ async function newSshPort(existing) {
1412
+ return pickSshPort(/* @__PURE__ */ new Set([...sshPortsInUse(existing), ...await listeningPorts()]));
1413
+ }
1414
+ function diskStats(disk) {
1415
+ try {
1416
+ const st = statSync(disk);
1417
+ return { diskBytes: st.size, diskUsedBytes: st.blocks * 512 };
1418
+ } catch {
1419
+ return { diskBytes: 0, diskUsedBytes: 0 };
1420
+ }
1421
+ }
1422
+ function diskGibOf(meta, p) {
1423
+ try {
1424
+ return Math.round(statSync(p.machine(meta.owner, meta.name).overlay).size / 1024 ** 3);
1425
+ } catch {
1426
+ return 0;
1427
+ }
1428
+ }
1429
+ function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISOString()) {
1430
+ try {
1431
+ insertUsageEvent({ boxId: meta.id, owner: meta.owner, name: meta.name, at, state, by, vcpu: meta.vcpu, memMib: meta.memMib, diskGib: diskGibOf(meta, p) }, p);
1432
+ } catch (err) {
1433
+ console.error(`warning: usage ledger: ${err.message}`);
1434
+ }
1435
+ }
1436
+ function isHibernated(meta, p) {
1437
+ return !pidAlive(meta.pid) && existsSync2(p.machine(meta.owner, meta.name).hibernateVmstate);
1438
+ }
1439
+ function statusOf(meta, p = paths()) {
1440
+ if (pidAlive(meta.pid)) return meta.paused ? "standby" : "running";
1441
+ return isHibernated(meta, p) ? "hibernated" : "stopped";
1442
+ }
1443
+ function stateMessage(ref, status) {
1444
+ return `box ${shown(ref)} is ${status === "standby" ? "in standby" : status}`;
1445
+ }
1446
+ function toMachine(meta, p = paths()) {
1447
+ const alive = pidAlive(meta.pid);
1448
+ if (!alive && meta.pid !== null) {
1449
+ try {
1450
+ closeOpenRun(meta.id, (/* @__PURE__ */ new Date()).toISOString(), p);
1451
+ } catch (err) {
1452
+ console.error(`warning: usage ledger: ${err.message}`);
1453
+ }
1454
+ }
1455
+ return {
1456
+ id: meta.id,
1457
+ name: meta.name,
1458
+ owner: meta.owner,
1459
+ ip: meta.ip,
1460
+ mac: meta.mac,
1461
+ pid: alive ? meta.pid : null,
1462
+ vcpu: meta.vcpu,
1463
+ memMib: meta.memMib,
1464
+ ...diskStats(p.machine(meta.owner, meta.name).overlay),
1465
+ base: meta.base,
1466
+ createdAt: meta.createdAt,
1467
+ source: meta.source,
1468
+ status: statusOf(meta, p),
1469
+ ...meta.sshPort === void 0 ? {} : { sshPort: meta.sshPort }
1470
+ };
1471
+ }
1472
+ async function readAllMeta(p) {
1473
+ const metas = [];
1474
+ for (const ref of await listMachineRefs(p)) {
1475
+ try {
1476
+ metas.push(await readMachineMeta(ref, p));
1477
+ } catch (err) {
1478
+ console.error(`warning: skipping machines/${refKey(ref)}: ${err.message}`);
1479
+ }
1480
+ }
1481
+ return metas;
1482
+ }
1483
+ async function listMachines(scope, p = paths()) {
1484
+ return (await readAllMeta(p)).filter((m) => inScope(m.owner, scope)).map((m) => toMachine(m, p));
1485
+ }
1486
+ async function getMachine(ref, p = paths()) {
1487
+ return toMachine(await readMeta(ref, p), p);
1488
+ }
1489
+ function pickName(requested, taken) {
1490
+ if (requested !== void 0) {
1491
+ if (!NAME_RE.test(requested)) {
1492
+ throw new KvboxError(`invalid box name "${requested}": 1-12 chars, lowercase letters, digits and '-', starting with a letter or digit`);
1493
+ }
1494
+ if (taken.has(requested)) throw new KvboxError(`box ${requested} already exists`);
1495
+ return requested;
1496
+ }
1497
+ for (let i = 1; ; i++) {
1498
+ const candidate = `${AUTO_PREFIX}${i}`;
1499
+ if (!taken.has(candidate)) return candidate;
1500
+ }
1501
+ }
1502
+ var MAX_SSH_KEY_LINES = 8;
1503
+ var MAX_SSH_KEYS_B64 = 1500;
1504
+ function authorizedKeysLines(hostKey, extra) {
1505
+ const lines = [hostKey.trim(), ...extra.map((l) => l.trim())];
1506
+ if (lines.some((l) => l === "" || /[\r\n]/.test(l))) throw new KvboxError("sshKeys: every entry must be one non-empty authorized_keys line");
1507
+ if (lines.length > MAX_SSH_KEY_LINES) {
1508
+ throw new KvboxError(`a box takes at most ${MAX_SSH_KEY_LINES - 1} SSH keys on top of the host's own; ${extra.length} were given`);
1509
+ }
1510
+ return lines;
1511
+ }
1512
+ function authorizedKeysB64(hostKey, extra) {
1513
+ const lines = authorizedKeysLines(hostKey, extra);
1514
+ const b64 = Buffer.from(lines.join("\n") + "\n").toString("base64");
1515
+ if (b64.length > MAX_SSH_KEYS_B64) {
1516
+ throw new KvboxError(`the SSH keys for this box do not fit the kernel command line (${b64.length} bytes encoded, ${MAX_SSH_KEYS_B64} at most); use fewer or shorter keys`);
1517
+ }
1518
+ return b64;
1519
+ }
1520
+ function bootArgs(name, ip, cfg, pubkeyB64) {
1521
+ const args = `console=ttyS0 reboot=k panic=1 init=/sbin/overlay-init kvbox.overlay=/dev/vdb ip=${ip}::${cfg.gateway}:255.255.255.0:${name}:eth0:off kvbox.name=${name} kvbox.sshkey=${pubkeyB64}`;
1522
+ return process.arch === "arm64" ? `keep_bootcon ${args}` : args;
1523
+ }
1524
+ function baseVersionOf(file) {
1525
+ const m = /^rootfs-(.+)\.ext4$/.exec(basename(file));
1526
+ if (!m) throw new KvboxError(`base image ${file} is not versioned (expected assets/rootfs-<version>.ext4); run install-server.sh`);
1527
+ return m[1];
1528
+ }
1529
+ async function currentBase(cfg, p) {
1530
+ const file = await realpath(cfg.rootfs);
1531
+ const version = baseVersionOf(file);
1532
+ const expected = p.baseImage(version);
1533
+ if (file !== expected) throw new KvboxError(`base image ${cfg.rootfs} points outside ${p.assets} (${file})`);
1534
+ return { version, file };
1535
+ }
1536
+ function baseFileFor(meta, p) {
1537
+ const file = p.baseImage(meta.base);
1538
+ if (!existsSync2(file)) throw new KvboxError(`box ${meta.name} runs on base image ${meta.base}, which is gone from ${p.assets}`);
1539
+ return file;
1540
+ }
1541
+ var overlayCapable = /* @__PURE__ */ new Map();
1542
+ async function assertOverlayCapable(baseFile, version) {
1543
+ let ok = overlayCapable.get(baseFile);
1544
+ if (ok === void 0) {
1545
+ const { stdout } = await run(["debugfs", "-R", "stat /sbin/overlay-init", baseFile]);
1546
+ ok = /Type:\s+regular/.test(stdout);
1547
+ overlayCapable.set(baseFile, ok);
1548
+ }
1549
+ if (!ok) throw new KvboxError(`base image ${version} is not a kvbox base image (no /sbin/overlay-init); rerun install-server.sh`);
1550
+ }
1551
+ async function createOverlay(file, gib) {
1552
+ await writeFile4(file, "", { mode: 384 });
1553
+ await truncate(file, gib * 1024 ** 3);
1554
+ await run(["mkfs.ext4", "-q", "-F", "-m", "0", "-L", "kvbox-box", "-E", "lazy_itable_init=1,lazy_journal_init=1", file]);
1555
+ }
1556
+ async function waitFor(pred, timeoutMs, stepMs = 50) {
1557
+ const deadline = Date.now() + timeoutMs;
1558
+ while (Date.now() < deadline) {
1559
+ if (pred()) return true;
1560
+ await sleep(stepMs);
1561
+ }
1562
+ return pred();
1563
+ }
1564
+ async function tailOf(file, lines = 5) {
1565
+ try {
1566
+ return (await readFile5(file, "utf8")).trim().split("\n").slice(-lines).join("\n");
1567
+ } catch {
1568
+ return "";
1569
+ }
1570
+ }
1571
+ async function spawnFirecracker(ref, cfg, p) {
1572
+ const m = p.machine(ref.owner, ref.name);
1573
+ await rm2(m.sock, { force: true });
1574
+ await rm2(m.vsock, { force: true });
1575
+ const logFd = openSync2(m.log, "a");
1576
+ let child;
1577
+ try {
1578
+ child = spawn(cfg.firecracker, ["--api-sock", m.sock, "--id", ref.name, "--level", "Error"], {
1579
+ cwd: m.dir,
1580
+ detached: true,
1581
+ stdio: ["ignore", logFd, logFd]
1582
+ });
1583
+ } finally {
1584
+ closeSync2(logFd);
1585
+ }
1586
+ child.unref();
1587
+ const pid = child.pid;
1588
+ if (pid === void 0) throw new KvboxError("could not start firecracker");
1589
+ if (!await waitFor(() => existsSync2(m.sock), 3e3)) {
1590
+ killQuietly(pid);
1591
+ throw new KvboxError(`firecracker did not open its API socket; last log lines:
1592
+ ${await tailOf(m.log)}`);
1593
+ }
1594
+ return pid;
1595
+ }
1596
+ function killQuietly(pid) {
1597
+ try {
1598
+ process.kill(pid, "SIGKILL");
1599
+ } catch {
1600
+ }
1601
+ }
1602
+ async function launch(spec, tap, cfg, pubkeyB64, p) {
1603
+ const m = p.machine(spec.owner, spec.name);
1604
+ const baseFile = baseFileFor(spec, p);
1605
+ const pid = await spawnFirecracker(spec, cfg, p);
1606
+ try {
1607
+ const fc = new FirecrackerClient(m.sock);
1608
+ try {
1609
+ await fc.bootSource(cfg.kernel, bootArgs(spec.name, spec.ip, cfg, pubkeyB64));
1610
+ await fc.rootDrive(baseFile);
1611
+ await fc.overlayDrive(OVERLAY_REL);
1612
+ await fc.machineConfig(spec.vcpu, spec.memMib);
1613
+ await fc.networkInterface("eth0", tap, spec.mac);
1614
+ await fc.vsock(GUEST_CID, m.vsock);
1615
+ await fc.start();
1616
+ } finally {
1617
+ await fc.close();
1618
+ }
1619
+ return pid;
1620
+ } catch (err) {
1621
+ killQuietly(pid);
1622
+ throw err;
1623
+ }
1624
+ }
1625
+ async function loadSnapshot(spec, image, tap, cfg, p) {
1626
+ const m = p.machine(spec.owner, spec.name);
1627
+ const pid = await spawnFirecracker(spec, cfg, p);
1628
+ try {
1629
+ const fc = new FirecrackerClient(m.sock);
1630
+ try {
1631
+ await fc.snapshotLoad({
1632
+ snapshotPath: image.vmstate,
1633
+ memFilePath: image.mem,
1634
+ tap: { ifaceId: "eth0", hostDevName: tap },
1635
+ vsockPath: m.vsock,
1636
+ trackDirtyPages: true,
1637
+ resume: false
1638
+ });
1639
+ } finally {
1640
+ await fc.close();
1641
+ }
1642
+ return pid;
1643
+ } catch (err) {
1644
+ killQuietly(pid);
1645
+ throw err;
1646
+ }
1647
+ }
1648
+ async function currentBaseVersion(p = paths()) {
1649
+ return (await currentBase(await loadConfig(p), p)).version;
1650
+ }
1651
+ async function readHostPubkey(p) {
1652
+ try {
1653
+ return (await readFile5(`${p.sshKey}.pub`, "utf8")).trim();
1654
+ } catch {
1655
+ throw new KvboxError(`SSH key missing at ${p.sshKey}.pub; run install-server.sh`);
1656
+ }
1657
+ }
1658
+ function assertAssets(cfg) {
1659
+ for (const [what, file] of [["kernel", cfg.kernel], ["base image", cfg.rootfs], ["firecracker", cfg.firecracker]]) {
1660
+ if (!existsSync2(file)) throw new KvboxError(`${what} missing at ${file}; run install-server.sh`);
1661
+ }
1662
+ }
1663
+ async function createMachine(req, owner, p = paths()) {
1664
+ const cfg = await loadConfig(p);
1665
+ if (req.from !== void 0) return restoreMachine(req.from, owner, req, cfg, p);
1666
+ const sizeName = req.size ?? cfg.defaultSize;
1667
+ if (!isSizeName(sizeName)) throw new KvboxError(`unknown size "${sizeName}"; use one of ${Object.keys(SIZES).join(", ")}`);
1668
+ const size = SIZES[sizeName];
1669
+ assertAssets(cfg);
1670
+ await assertRoomFor(size.vcpu, size.memMib, req.force ?? false, p);
1671
+ const cloud = isCloudBox(owner);
1672
+ const sshKeys = cloud && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
1673
+ const keysB64 = authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
1674
+ const existing = await readAllMeta(p);
1675
+ const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
1676
+ const id = newBoxId(idsInUse(existing));
1677
+ const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1678
+ const mac = macFor(ip);
1679
+ const sshPort = cloud ? await newSshPort(existing) : void 0;
1680
+ const m = p.machine(owner, name);
1681
+ const base = await currentBase(cfg, p);
1682
+ await assertOverlayCapable(base.file, base.version);
1683
+ await ensureHostNetwork(cfg);
1684
+ await mkdir4(m.dir, { recursive: true, mode: 448 });
1685
+ let tapCreated = false;
1686
+ let pid;
1687
+ try {
1688
+ await createOverlay(m.overlay, size.diskGib);
1689
+ const tap = await createTap(id, cfg);
1690
+ tapCreated = true;
1691
+ const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys, sshPort };
1692
+ pid = await launch(spec, tap, cfg, keysB64, p);
1693
+ await openSshPort(spec, cfg);
1694
+ const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
1695
+ await writeMachineMeta(meta, p);
1696
+ recordState(meta, "running", "new", p, meta.createdAt);
1697
+ return toMachine(meta, p);
1698
+ } catch (err) {
1699
+ if (pid !== void 0) killQuietly(pid);
1700
+ await closeSshPort({ id, ip, sshPort }, cfg);
1701
+ if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1702
+ await rm2(m.dir, { recursive: true, force: true });
1703
+ throw err;
1704
+ }
1705
+ }
1706
+ async function readSnap(ref, p) {
1707
+ try {
1708
+ return await readSnapshotMeta(ref, p);
1709
+ } catch (err) {
1710
+ if (err.code === "ENOENT") throw new NotFoundError(`snapshot ${shown(ref)}`);
1711
+ throw err;
1712
+ }
1713
+ }
1714
+ function prefixLenOf(subnet) {
1715
+ return Number(subnet.split("/")[1]);
1716
+ }
1717
+ async function restoreMachine(fromText, owner, req, cfg, p) {
1718
+ const from = parseRef(fromText, owner);
1719
+ const snap = await readSnap(from, p);
1720
+ if (req.size !== void 0) {
1721
+ throw new KvboxError(`--size cannot be combined with --from: a restored box has the snapshot's size (${shown(from)}: ${snap.vcpu} vCPU, ${snap.memMib} MiB)`);
1722
+ }
1723
+ assertAssets(cfg);
1724
+ if (!existsSync2(p.baseImage(snap.base))) {
1725
+ throw new KvboxError(`snapshot ${shown(from)} stacks on base image ${snap.base}, which is gone from ${p.assets}`);
1726
+ }
1727
+ await assertRoomFor(snap.vcpu, snap.memMib, req.force ?? false, p);
1728
+ const cloud = isCloudBox(owner);
1729
+ const sshKeys = cloud && req.sshKeys !== void 0 && req.sshKeys.length > 0 ? req.sshKeys : void 0;
1730
+ authorizedKeysB64(await readHostPubkey(p), sshKeys ?? []);
1731
+ const existing = await readAllMeta(p);
1732
+ const name = pickName(req.name, new Set(existing.filter((m2) => m2.owner === owner).map((m2) => m2.name)));
1733
+ const id = newBoxId(idsInUse(existing));
1734
+ const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1735
+ const mac = macFor(ip);
1736
+ const sshPort = cloud ? await newSshPort(existing) : void 0;
1737
+ const m = p.machine(owner, name);
1738
+ const s = p.snapshot(from.owner, from.name);
1739
+ await ensureHostNetwork(cfg);
1740
+ await mkdir4(m.dir, { recursive: true, mode: 448 });
1741
+ let tapCreated = false;
1742
+ let pid;
1743
+ try {
1744
+ await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
1745
+ await link(s.mem, m.memBase);
1746
+ const tap = await createTap(id, cfg, { detached: true });
1747
+ tapCreated = true;
1748
+ const spec = { id, name, owner, ip, mac, vcpu: snap.vcpu, memMib: snap.memMib, base: snap.base, sshKeys, sshPort };
1749
+ pid = await loadSnapshot(spec, { vmstate: s.vmstate, mem: s.mem, label: shown(from) }, tap, cfg, p);
1750
+ await resumeVm(m.sock);
1751
+ await introduce(spec, m.vsock, cfg, {
1752
+ silent: `nothing answered on the restored box's control channel for 3 s; the restored box was discarded, try again`,
1753
+ noAnswer: "the restored box was discarded. kvbox-agent inside it took the request but gave no answer; try again, and if it keeps happening look at the box's journal (kvbox exec <box> -- sudo journalctl -u kvbox-agent) on a box started from the same snapshot."
1754
+ });
1755
+ const stale = await pushAuthorizedKeys({ owner, name, sshKeys }, m.vsock, p);
1756
+ await attachTap(id, cfg);
1757
+ await openSshPort(spec, cfg);
1758
+ const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0 };
1759
+ await writeMachineMeta(meta, p);
1760
+ recordState(meta, "running", "new", p, meta.createdAt);
1761
+ return toMachine(meta, p);
1762
+ } catch (err) {
1763
+ if (pid !== void 0) killQuietly(pid);
1764
+ await closeSshPort({ id, ip, sshPort }, cfg);
1765
+ if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1766
+ await rm2(m.dir, { recursive: true, force: true });
1767
+ throw err;
1768
+ }
1769
+ }
1770
+ var AGENT_REPLY_MS = 2e4;
1771
+ async function introduce(spec, vsock, cfg, failure) {
1772
+ const id = { name: spec.name, ip: spec.ip, prefixLen: prefixLenOf(cfg.subnet), gateway: cfg.gateway, mac: spec.mac, time: Math.floor(Date.now() / 1e3) };
1773
+ const deadline = Date.now() + 3e3;
1774
+ for (; ; ) {
1775
+ try {
1776
+ await setIdentity(vsock, id, AGENT_REPLY_MS);
1777
+ return;
1778
+ } catch (err) {
1779
+ const msg = err.message;
1780
+ const transient = /unreachable|refused|before the guest accepted/.test(msg);
1781
+ if (transient && Date.now() < deadline) {
1782
+ await sleep(100);
1783
+ continue;
1784
+ }
1785
+ if (transient) throw new KvboxError(`${msg}
1786
+ ${failure.silent}`);
1787
+ throw new KvboxError(`${msg}
1788
+ ${failure.noAnswer}`);
1789
+ }
1790
+ }
1791
+ }
1792
+ async function pushAuthorizedKeys(box, vsock, p) {
1793
+ try {
1794
+ await setAuthorizedKeys(vsock, authorizedKeysLines(await readHostPubkey(p), box.sshKeys ?? []), AGENT_REPLY_MS);
1795
+ return void 0;
1796
+ } catch (err) {
1797
+ const why = isUnknownOp(err) ? `its image predates base v0.6.0, whose kvbox-agent can replace authorized_keys (kvbox vm update ${shown(box)} moves it onto the current base)` : err.message;
1798
+ console.error(`warning: box ${shown(box)} keeps the authorized_keys it was frozen with: ${why}`);
1799
+ return why;
1800
+ }
1801
+ }
1802
+ async function resumeVm(sock) {
1803
+ const fc = new FirecrackerClient(sock);
1804
+ try {
1805
+ await fc.resume();
1806
+ } finally {
1807
+ await fc.close();
1808
+ }
1809
+ }
1810
+ async function shutdown(meta, cfg, p, graceMs) {
1811
+ const pid = meta.pid;
1812
+ if (pidAlive(pid)) {
1813
+ const fc = new FirecrackerClient(p.machine(meta.owner, meta.name).sock);
1814
+ try {
1815
+ if (meta.paused) await fc.resume();
1816
+ await fc.sendCtrlAltDel();
1817
+ } catch {
1818
+ process.kill(pid, "SIGTERM");
1819
+ } finally {
1820
+ await fc.close();
1821
+ }
1822
+ await waitDead(pid, graceMs);
1823
+ }
1824
+ await cleanupProcess(meta, cfg, p);
1825
+ }
1826
+ async function waitDead(pid, graceMs) {
1827
+ if (!await waitFor(() => !pidAlive(pid), graceMs, 100)) {
1828
+ killQuietly(pid);
1829
+ await waitFor(() => !pidAlive(pid), 1e3, 50);
1830
+ }
1831
+ }
1832
+ async function cleanupProcess(meta, cfg, p) {
1833
+ await closeSshPort(meta, cfg);
1834
+ await deleteTap(meta.id, cfg);
1835
+ const m = p.machine(meta.owner, meta.name);
1836
+ await rm2(m.sock, { force: true });
1837
+ await rm2(m.vsock, { force: true });
1838
+ }
1839
+ async function stopMachine(ref, p = paths()) {
1840
+ const meta = await readMeta(ref, p);
1841
+ const cfg = await loadConfig(p);
1842
+ const m = p.machine(ref.owner, ref.name);
1843
+ if (!pidAlive(meta.pid) && !isHibernated(meta, p)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
1844
+ await shutdown(meta, cfg, p, 1e4);
1845
+ await rm2(m.hibernateDir, { recursive: true, force: true });
1846
+ await rm2(m.memBase, { force: true });
1847
+ const stopped = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
1848
+ await writeMachineMeta(stopped, p);
1849
+ recordState(stopped, "stopped", "stop", p);
1850
+ return toMachine(stopped, p);
1851
+ }
1852
+ async function startMachine(ref, p = paths()) {
1853
+ const meta = await readMeta(ref, p);
1854
+ const cfg = await loadConfig(p);
1855
+ if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
1856
+ if (isHibernated(meta, p)) {
1857
+ throw new KvboxError(`${stateMessage(ref, "hibernated")}: kvbox wake ${shown(ref)} continues where it left off; to boot fresh instead, kvbox stop ${shown(ref)} first (discards its memory)`);
1858
+ }
1859
+ assertAssets(cfg);
1860
+ const keysB64 = authorizedKeysB64(await readHostPubkey(p), meta.sshKeys ?? []);
1861
+ await ensureHostNetwork(cfg);
1862
+ const tap = await createTap(meta.id, cfg);
1863
+ let pid;
1864
+ try {
1865
+ pid = await launch(meta, tap, cfg, keysB64, p);
1866
+ await openSshPort(meta, cfg);
1867
+ const started = { ...meta, pid, sshKeysStale: void 0 };
1868
+ await writeMachineMeta(started, p);
1869
+ recordState(started, "running", "start", p);
1870
+ return toMachine(started, p);
1871
+ } catch (err) {
1872
+ if (pid !== void 0) killQuietly(pid);
1873
+ await closeSshPort(meta, cfg);
1874
+ await deleteTap(meta.id, cfg).catch(() => void 0);
1875
+ throw err;
1876
+ }
1877
+ }
1878
+ async function setSshKeys(ref, sshKeys, p = paths()) {
1879
+ const meta = await readMeta(ref, p);
1880
+ if (!isCloudBox(meta.owner)) throw new KvboxError(`box ${shown(ref)} is local: it trusts this host's key, there are no authorized_keys lines to replace`);
1881
+ if (sshKeys.length === 0) throw new KvboxError("sshKeys: at least one authorized_keys line (the box would keep only the host's own key)");
1882
+ authorizedKeysB64(await readHostPubkey(p), sshKeys);
1883
+ const { meta: updated, applied } = await applySshKeys(meta, sshKeys, p);
1884
+ return { machine: toMachine(updated, p), applied };
1885
+ }
1886
+ async function applySshKeys(meta, sshKeys, p) {
1887
+ const status = statusOf(meta, p);
1888
+ let updated = { ...meta, sshKeys: [...sshKeys], sshKeysStale: status === "stopped" ? void 0 : true };
1889
+ let applied = status === "stopped" ? "start" : status === "standby" ? "resume" : status === "hibernated" ? "wake" : "later";
1890
+ let why;
1891
+ if (status === "running") {
1892
+ why = await pushAuthorizedKeys(updated, p.machine(meta.owner, meta.name).vsock, p);
1893
+ if (why === void 0) {
1894
+ updated = { ...updated, sshKeysStale: void 0 };
1895
+ applied = "now";
1896
+ }
1897
+ }
1898
+ await writeMachineMeta(updated, p);
1899
+ return why === void 0 ? { meta: updated, applied } : { meta: updated, applied, why };
1900
+ }
1901
+ async function trustCa(publicKeyText, p = paths()) {
1902
+ const publicKey = parseSshPublicKey(publicKeyText);
1903
+ const report = { publicKey, fingerprint: sshKeyFingerprint(publicKey), updated: [], pending: [], failed: [], local: 0 };
1904
+ const hostKey = await readHostPubkey(p);
1905
+ for (const meta of await readAllMeta(p)) {
1906
+ if (!isCloudBox(meta.owner)) {
1907
+ report.local++;
1908
+ continue;
1909
+ }
1910
+ const box = refKey(meta);
1911
+ try {
1912
+ const sshKeys = [...(meta.sshKeys ?? []).filter((l) => !isCaLine(l)), caLineFor(meta.owner, publicKey)];
1913
+ authorizedKeysLines(hostKey, sshKeys);
1914
+ const { applied, why } = await applySshKeys(meta, sshKeys, p);
1915
+ if (applied === "now") report.updated.push(box);
1916
+ else report.pending.push(why === void 0 ? { box, when: applied } : { box, when: applied, why });
1917
+ } catch (err) {
1918
+ report.failed.push({ box, error: err.message });
1919
+ }
1920
+ }
1921
+ return report;
1922
+ }
1923
+ async function pauseMachine(ref, p = paths()) {
1924
+ const meta = await readMeta(ref, p);
1925
+ const status = statusOf(meta, p);
1926
+ if (status === "standby") throw new KvboxError(`${stateMessage(ref, status)} already`);
1927
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)}; kvbox wake ${shown(ref)} brings it back`);
1928
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be paused`);
1929
+ const fc = new FirecrackerClient(p.machine(ref.owner, ref.name).sock);
1930
+ try {
1931
+ await fc.pause();
1932
+ } finally {
1933
+ await fc.close();
1934
+ }
1935
+ const paused = { ...meta, paused: true };
1936
+ await writeMachineMeta(paused, p);
1937
+ recordState(paused, "standby", "pause", p);
1938
+ return toMachine(paused, p);
1939
+ }
1940
+ async function resumeMachine(ref, p = paths()) {
1941
+ const meta = await readMeta(ref, p);
1942
+ const status = statusOf(meta, p);
1943
+ if (status === "running") throw new KvboxError(`${stateMessage(ref, status)} already`);
1944
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)}, not in standby; kvbox wake ${shown(ref)} brings it back`);
1945
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}, not in standby; kvbox start ${shown(ref)} boots it`);
1946
+ const m = p.machine(ref.owner, ref.name);
1947
+ await resumeVm(m.sock);
1948
+ const stale = meta.sshKeysStale ? await pushAuthorizedKeys(meta, m.vsock, p) : void 0;
1949
+ const running = { ...meta, paused: void 0, sshKeysStale: stale ? true : void 0 };
1950
+ await writeMachineMeta(running, p);
1951
+ recordState(running, "running", "resume", p);
1952
+ return toMachine(running, p);
1953
+ }
1954
+ async function hibernateMachine(ref, p = paths()) {
1955
+ const meta = await readMeta(ref, p);
1956
+ const cfg = await loadConfig(p);
1957
+ const status = statusOf(meta, p);
1958
+ if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)} already`);
1959
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be hibernated (there is no memory to keep)`);
1960
+ const m = p.machine(ref.owner, ref.name);
1961
+ await rm2(m.hibernateDir, { recursive: true, force: true });
1962
+ await mkdir4(m.hibernateDir, { recursive: true, mode: 448 });
1963
+ const fc = new FirecrackerClient(m.sock);
1964
+ try {
1965
+ try {
1966
+ await fc.pause();
1967
+ await fc.snapshotCreate(m.hibernateVmstate, m.hibernateMem, "Full");
1968
+ } catch (err) {
1969
+ await rm2(m.hibernateDir, { recursive: true, force: true });
1970
+ if (!meta.paused) await fc.resume().catch(() => void 0);
1971
+ throw err;
1972
+ }
1973
+ } finally {
1974
+ await fc.close();
1975
+ }
1976
+ killQuietly(meta.pid);
1977
+ await waitFor(() => !pidAlive(meta.pid), 5e3, 50);
1978
+ await cleanupProcess(meta, cfg, p);
1979
+ await run(["fallocate", "--dig-holes", m.hibernateMem]);
1980
+ await rm2(m.memBase, { force: true });
1981
+ const hibernated = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
1982
+ await writeMachineMeta(hibernated, p);
1983
+ recordState(hibernated, "hibernated", "hibernate", p);
1984
+ return toMachine(hibernated, p);
1985
+ }
1986
+ async function wakeMachine(ref, p = paths()) {
1987
+ const meta = await readMeta(ref, p);
1988
+ const cfg = await loadConfig(p);
1989
+ const status = statusOf(meta, p);
1990
+ if (status === "running" || status === "standby") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated`);
1991
+ if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}, not hibernated; kvbox start ${shown(ref)} boots it`);
1992
+ assertAssets(cfg);
1993
+ if (!existsSync2(p.baseImage(meta.base))) {
1994
+ throw new KvboxError(`box ${shown(ref)} stacks on base image ${meta.base}, which is gone from ${p.assets}`);
1995
+ }
1996
+ const m = p.machine(ref.owner, ref.name);
1997
+ await ensureHostNetwork(cfg);
1998
+ const tap = await createTap(meta.id, cfg, { detached: true });
1999
+ let pid;
2000
+ let stale;
2001
+ try {
2002
+ const spec = { id: meta.id, name: meta.name, owner: meta.owner, ip: meta.ip, mac: meta.mac, vcpu: meta.vcpu, memMib: meta.memMib, base: meta.base };
2003
+ pid = await loadSnapshot(spec, { vmstate: m.hibernateVmstate, mem: m.hibernateMem, label: `box ${shown(ref)}'s memory image` }, tap, cfg, p);
2004
+ await resumeVm(m.sock);
2005
+ await introduce(spec, m.vsock, cfg, {
2006
+ silent: `nothing answered on the box's control channel for 3 s; it stays hibernated, try kvbox wake ${shown(ref)} again`,
2007
+ noAnswer: `the box stays hibernated. kvbox-agent inside it took the request but gave no answer; try kvbox wake ${shown(ref)} again`
2008
+ });
2009
+ stale = await pushAuthorizedKeys(meta, m.vsock, p);
2010
+ await attachTap(meta.id, cfg);
2011
+ await openSshPort(meta, cfg);
2012
+ } catch (err) {
2013
+ if (pid !== void 0) killQuietly(pid);
2014
+ await closeSshPort(meta, cfg);
2015
+ await deleteTap(meta.id, cfg).catch(() => void 0);
2016
+ throw err;
2017
+ }
2018
+ await rm2(m.memBase, { force: true });
2019
+ await link(m.hibernateMem, m.memBase);
2020
+ await rm2(m.hibernateDir, { recursive: true, force: true });
2021
+ const running = { ...meta, pid, memSnapshots: 1, paused: void 0, sshKeysStale: stale ? true : void 0 };
2022
+ await writeMachineMeta(running, p);
2023
+ recordState(running, "running", "wake", p);
2024
+ return toMachine(running, p);
2025
+ }
2026
+ async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
2027
+ const wasRunning = pidAlive(meta.pid);
2028
+ if (wasRunning) {
2029
+ await shutdown(meta, cfg, p, 1e4);
2030
+ await rm2(p.machine(meta.owner, meta.name).memBase, { force: true });
2031
+ }
2032
+ const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0, paused: void 0 };
2033
+ await writeMachineMeta(updated, p);
2034
+ if (whileStopped) await whileStopped();
2035
+ recordState(updated, "stopped", by, p);
2036
+ return wasRunning ? startMachine(meta, p) : toMachine(updated, p);
2037
+ }
2038
+ function assertNotHibernated(meta, p) {
2039
+ if (isHibernated(meta, p)) {
2040
+ throw new KvboxError(`${stateMessage(meta, "hibernated")}; its memory image was made with its current shape and base: kvbox wake ${shown(meta)} first`);
2041
+ }
2042
+ }
2043
+ async function updateMachine(ref, p = paths()) {
2044
+ const meta = await readMeta(ref, p);
2045
+ const cfg = await loadConfig(p);
2046
+ assertNotHibernated(meta, p);
2047
+ assertAssets(cfg);
2048
+ const base = await currentBase(cfg, p);
2049
+ if (base.version === meta.base) return toMachine(meta, p);
2050
+ await assertOverlayCapable(base.file, base.version);
2051
+ return applyAtNextBoot(meta, { base: base.version }, "update", cfg, p);
2052
+ }
2053
+ async function rescaleMachine(ref, req, p = paths()) {
2054
+ const meta = await readMeta(ref, p);
2055
+ const cfg = await loadConfig(p);
2056
+ assertNotHibernated(meta, p);
2057
+ const disk = p.machine(ref.owner, ref.name).overlay;
2058
+ const currentDiskGib = Math.round(statSync(disk).size / 1024 ** 3);
2059
+ const preset = req.size !== void 0 && isSizeName(req.size) ? SIZES[req.size] : void 0;
2060
+ const target = {
2061
+ vcpu: req.vcpu ?? preset?.vcpu ?? meta.vcpu,
2062
+ memMib: req.memMib ?? preset?.memMib ?? meta.memMib,
2063
+ diskGib: req.diskGib ?? preset?.diskGib ?? currentDiskGib
2064
+ };
2065
+ if (target.diskGib < currentDiskGib) {
2066
+ throw new KvboxError(`box ${shown(ref)} has a ${currentDiskGib} GB disk and a disk cannot shrink; ${target.diskGib} GB is smaller`);
2067
+ }
2068
+ const changes = {};
2069
+ if (target.vcpu !== meta.vcpu) changes.vcpu = target.vcpu;
2070
+ if (target.memMib !== meta.memMib) changes.memMib = target.memMib;
2071
+ const growTo = target.diskGib > currentDiskGib ? target.diskGib : void 0;
2072
+ if (Object.keys(changes).length === 0 && growTo === void 0) return toMachine(meta, p);
2073
+ return applyAtNextBoot(meta, changes, "rescale", cfg, p, growTo === void 0 ? void 0 : () => growDisk(disk, growTo));
2074
+ }
2075
+ async function growDisk(file, gib) {
2076
+ await truncate(file, gib * 1024 ** 3);
2077
+ await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
2078
+ await run(["resize2fs", file]);
2079
+ }
2080
+ async function readMeta(ref, p) {
2081
+ try {
2082
+ return await readMachineMeta(ref, p);
2083
+ } catch (err) {
2084
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
2085
+ throw err;
2086
+ }
2087
+ }
2088
+ async function destroyMachine(ref, p = paths()) {
2089
+ const meta = await readMeta(ref, p);
2090
+ const cfg = await loadConfig(p);
2091
+ await shutdown(meta, cfg, p, 2e3);
2092
+ recordState(meta, "stopped", "rm", p);
2093
+ await rm2(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
2094
+ }
2095
+
2096
+ // src/routes/capacity.ts
2097
+ import { Hono } from "hono";
2098
+ function capacityRoutes() {
2099
+ const r = new Hono();
2100
+ r.get("/", async (c) => c.json(await capacity()));
2101
+ return r;
2102
+ }
2103
+
2104
+ // src/routes/machines.ts
2105
+ import { Hono as Hono2 } from "hono";
2106
+
2107
+ // src/core/usage.ts
2108
+ var HOUR_MS = 36e5;
2109
+ var round = (n, places) => Math.round(n * 10 ** places) / 10 ** places;
2110
+ function computeUsage(events, w) {
2111
+ const sorted = [...events].sort((a, b) => a.at < b.at ? -1 : a.at > b.at ? 1 : 0);
2112
+ const from = Date.parse(w.from);
2113
+ const to = Date.parse(w.to);
2114
+ const ms = { running: 0, standby: 0, hibernated: 0, stopped: 0 };
2115
+ let vcpuHours = 0;
2116
+ let ramGibHours = 0;
2117
+ let diskGibHours = 0;
2118
+ sorted.forEach((e, i) => {
2119
+ const start = Math.max(Date.parse(e.at), from);
2120
+ const next = sorted[i + 1];
2121
+ const end = Math.min(next === void 0 ? to : Date.parse(next.at), to);
2122
+ if (end <= start) return;
2123
+ const h = (end - start) / HOUR_MS;
2124
+ ms[e.state] += end - start;
2125
+ if (e.state === "running") vcpuHours += h * e.vcpu;
2126
+ if (e.state === "running" || e.state === "standby") ramGibHours += h * e.memMib / 1024;
2127
+ diskGibHours += h * e.diskGib;
2128
+ });
2129
+ const totals = {
2130
+ seconds: {
2131
+ running: Math.round(ms.running / 1e3),
2132
+ standby: Math.round(ms.standby / 1e3),
2133
+ hibernated: Math.round(ms.hibernated / 1e3),
2134
+ stopped: Math.round(ms.stopped / 1e3)
2135
+ },
2136
+ vcpuHours: round(vcpuHours, 3),
2137
+ ramGibHours: round(ramGibHours, 3),
2138
+ diskGibHours: round(diskGibHours, 3),
2139
+ estimatedStops: sorted.filter((e) => e.state === "stopped" && e.by === "server" && e.at >= w.from).length,
2140
+ historyFrom: sorted[0]?.at ?? null
2141
+ };
2142
+ if (w.pricing !== void 0) {
2143
+ const amount = vcpuHours * w.pricing.vcpuHour + ramGibHours * w.pricing.ramGibHour + diskGibHours * w.pricing.diskGibHour;
2144
+ totals.cost = { amount: round(amount, 4), currency: w.pricing.currency };
2145
+ }
2146
+ return totals;
2147
+ }
2148
+ async function machineUsage(ref, since, p = paths()) {
2149
+ const box = await getMachine(ref, p);
2150
+ const cfg = await loadConfig(p);
2151
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2152
+ const events = usageEventsFor(box.id, box.createdAt, p);
2153
+ const window = { from: since ?? box.createdAt, to: now };
2154
+ return { name: box.name, owner: box.owner, status: box.status, window, ...computeUsage(events, { ...window, pricing: cfg.pricing }) };
2155
+ }
2156
+
2157
+ // src/routes/machines.ts
2158
+ function machineRoutes() {
2159
+ const r = new Hono2();
2160
+ r.get("/", async (c) => c.json(await listMachines(c.get("listScope"))));
2161
+ r.post("/", async (c) => {
2162
+ const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
2163
+ return c.json(await createMachine(body, c.get("owner")), 201);
2164
+ });
2165
+ r.post("/:name/stop", async (c) => c.json(await stopMachine(parseRef(c.req.param("name"), c.get("owner")))));
2166
+ r.post("/:name/start", async (c) => c.json(await startMachine(parseRef(c.req.param("name"), c.get("owner")))));
2167
+ r.post("/:name/pause", async (c) => c.json(await pauseMachine(parseRef(c.req.param("name"), c.get("owner")))));
2168
+ r.post("/:name/resume", async (c) => c.json(await resumeMachine(parseRef(c.req.param("name"), c.get("owner")))));
2169
+ r.post("/:name/hibernate", async (c) => c.json(await hibernateMachine(parseRef(c.req.param("name"), c.get("owner")))));
2170
+ r.post("/:name/wake", async (c) => c.json(await wakeMachine(parseRef(c.req.param("name"), c.get("owner")))));
2171
+ r.post("/:name/update", async (c) => c.json(await updateMachine(parseRef(c.req.param("name"), c.get("owner")))));
2172
+ r.post("/:name/rescale", async (c) => {
2173
+ const body = RescaleMachineRequest.parse(await c.req.json().catch(() => ({})));
2174
+ return c.json(await rescaleMachine(parseRef(c.req.param("name"), c.get("owner")), body));
2175
+ });
2176
+ r.post("/:name/ssh-keys", async (c) => {
2177
+ const body = SetSshKeysRequest.safeParse(await c.req.json().catch(() => ({})));
2178
+ if (!body.success) throw new KvboxError("sshKeys: expected a list of authorized_keys lines");
2179
+ const { machine, applied } = await setSshKeys(parseRef(c.req.param("name"), c.get("owner")), body.data.sshKeys);
2180
+ return c.json({ ...machine, sshKeysApplied: applied });
2181
+ });
2182
+ r.get("/:name/usage", async (c) => {
2183
+ const since = c.req.query("since");
2184
+ let from;
2185
+ if (since !== void 0) {
2186
+ const t = Date.parse(since);
2187
+ if (Number.isNaN(t)) throw new KvboxError(`since: "${since}" is not a date; write it like 2026-10-19 or 2026-10-19T08:00:00Z`);
2188
+ from = new Date(t).toISOString();
2189
+ }
2190
+ return c.json(await machineUsage(parseRef(c.req.param("name"), c.get("owner")), from));
2191
+ });
2192
+ r.delete("/:name", async (c) => {
2193
+ await destroyMachine(parseRef(c.req.param("name"), c.get("owner")));
2194
+ return c.body(null, 204);
2195
+ });
2196
+ return r;
2197
+ }
2198
+
2199
+ // src/routes/snapshots.ts
2200
+ import { Hono as Hono3 } from "hono";
2201
+
2202
+ // src/core/snapshots.ts
2203
+ import { existsSync as existsSync3 } from "node:fs";
2204
+ import { link as link2, mkdir as mkdir5, readdir as readdir2, rename as rename2, rm as rm3, stat as stat2 } from "node:fs/promises";
2205
+ var NAME_RE2 = /^[a-z0-9][a-z0-9._-]{0,63}$/;
2206
+ function toSnapshot(meta) {
2207
+ return {
2208
+ name: meta.name,
2209
+ owner: meta.owner,
2210
+ source: meta.source,
2211
+ base: meta.base,
2212
+ vcpu: meta.vcpu,
2213
+ memMib: meta.memMib,
2214
+ sizeBytes: meta.sizeBytes,
2215
+ createdAt: meta.createdAt
2216
+ };
2217
+ }
2218
+ async function listSnapshots(scope, p = paths()) {
2219
+ const out = [];
2220
+ for (const ref of await listSnapshotRefs(p)) {
2221
+ if (!inScope(ref.owner, scope)) continue;
2222
+ try {
2223
+ out.push(toSnapshot(await readSnapshotMeta(ref, p)));
2224
+ } catch (err) {
2225
+ console.error(`warning: skipping snapshots/${refKey(ref)}: ${err.message}`);
2226
+ }
2227
+ }
2228
+ return out;
2229
+ }
2230
+ async function usedBytes(dir) {
2231
+ let total = 0;
2232
+ for (const entry of await readdir2(dir)) {
2233
+ total += (await stat2(`${dir}/${entry}`)).blocks * 512;
2234
+ }
2235
+ return total;
2236
+ }
2237
+ async function readBox(ref, p) {
2238
+ try {
2239
+ return await readMachineMeta(ref, p);
2240
+ } catch (err) {
2241
+ if (err.code === "ENOENT") throw new NotFoundError(`box ${shown(ref)}`);
2242
+ throw err;
2243
+ }
2244
+ }
2245
+ async function saveSnapshot(req, owner, p = paths()) {
2246
+ if (!NAME_RE2.test(req.name)) {
2247
+ throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
2248
+ }
2249
+ const box = await readBox(parseRef(req.box, owner), p);
2250
+ if (!pidAlive(box.pid)) {
2251
+ if (existsSync3(p.machine(box.owner, box.name).hibernateVmstate)) {
2252
+ throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
2253
+ }
2254
+ throw new KvboxError(`box ${req.box} is not running; a snapshot freezes a running box (kvbox start ${req.box} first)`);
2255
+ }
2256
+ const s = p.snapshot(owner, req.name);
2257
+ if (existsSync3(s.dir)) throw new KvboxError(`snapshot ${req.name} already exists`);
2258
+ const cfg = await loadConfig(p);
2259
+ const m = p.machine(box.owner, box.name);
2260
+ await mkdir5(s.dir, { recursive: true, mode: 448 });
2261
+ const fc = new FirecrackerClient(m.sock);
2262
+ let lineageTouched = false;
2263
+ try {
2264
+ const since = box.memSnapshots ?? 0;
2265
+ const mode = since === 0 || existsSync3(m.memBase) ? "Diff" : "Full";
2266
+ let failure;
2267
+ await fc.pause();
2268
+ try {
2269
+ await fc.snapshotCreate(s.vmstate, s.mem, mode);
2270
+ lineageTouched = true;
2271
+ await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, s.overlay]);
2272
+ } catch (err) {
2273
+ failure = err;
2274
+ } finally {
2275
+ if (!box.paused) await fc.resume();
2276
+ }
2277
+ if (lineageTouched) {
2278
+ await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
2279
+ }
2280
+ if (failure !== void 0) throw failure;
2281
+ if (mode === "Full") {
2282
+ await run(["fallocate", "--dig-holes", s.mem]);
2283
+ } else if (since > 0) {
2284
+ await mergeOntoPrevious(m.memBase, s.mem, cfg.snapshotEditor ?? p.snapshotEditor);
2285
+ }
2286
+ await rm3(m.memBase, { force: true });
2287
+ await link2(s.mem, m.memBase);
2288
+ const meta = {
2289
+ name: req.name,
2290
+ owner,
2291
+ source: refKey(box),
2292
+ base: box.base,
2293
+ vcpu: box.vcpu,
2294
+ memMib: box.memMib,
2295
+ sizeBytes: await usedBytes(s.dir),
2296
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2297
+ };
2298
+ await writeSnapshotMeta(meta, p);
2299
+ return toSnapshot(meta);
2300
+ } catch (err) {
2301
+ if (lineageTouched) await rm3(m.memBase, { force: true }).catch(() => void 0);
2302
+ await rm3(s.dir, { recursive: true, force: true });
2303
+ throw err;
2304
+ } finally {
2305
+ await fc.close();
2306
+ }
2307
+ }
2308
+ async function mergeOntoPrevious(previous, diffFile, editor) {
2309
+ if (!existsSync3(editor)) throw new KvboxError(`snapshot-editor missing at ${editor}; rerun install-server.sh`);
2310
+ const merged = `${diffFile}.merged`;
2311
+ await run(["cp", "--sparse=always", "--reflink=auto", previous, merged]);
2312
+ await run([editor, "edit-memory", "rebase", "--memory-path", merged, "--diff-path", diffFile]);
2313
+ await rename2(merged, diffFile);
2314
+ }
2315
+ async function deleteSnapshot(ref, p = paths()) {
2316
+ const dir = p.snapshot(ref.owner, ref.name).dir;
2317
+ if (!existsSync3(dir)) throw new NotFoundError(`snapshot ${shown(ref)}`);
2318
+ await rm3(dir, { recursive: true, force: true });
2319
+ }
2320
+
2321
+ // src/routes/snapshots.ts
2322
+ function snapshotRoutes() {
2323
+ const r = new Hono3();
2324
+ r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));
2325
+ r.post("/", async (c) => {
2326
+ const body = CreateSnapshotRequest.parse(await c.req.json());
2327
+ return c.json(await saveSnapshot(body, c.get("owner")), 201);
2328
+ });
2329
+ r.delete("/:name", async (c) => {
2330
+ await deleteSnapshot(parseRef(c.req.param("name"), c.get("owner")));
2331
+ return c.body(null, 204);
2332
+ });
2333
+ return r;
2334
+ }
2335
+
2336
+ // src/routes/ssh.ts
2337
+ import { Hono as Hono4 } from "hono";
2338
+ function sshRoutes(opts = {}) {
2339
+ const r = new Hono4();
2340
+ if (opts.controlPlane) {
2341
+ r.all("/*", (c) => c.json({ error: "trusting a CA is done on the host itself (kvbox server trust-ca there); the control plane cannot ask a host to" }, 403));
2342
+ return r;
2343
+ }
2344
+ r.get("/cloud-ca", async (c) => c.json(await fetchCloudCa()));
2345
+ r.post("/trust-ca", async (c) => {
2346
+ const body = TrustCaRequestSchema.safeParse(await c.req.json().catch(() => ({})));
2347
+ if (!body.success) throw new KvboxError("trust-ca: expected the CA public key as publicKey");
2348
+ return c.json(await trustCa(body.data.publicKey));
2349
+ });
2350
+ return r;
2351
+ }
2352
+
2353
+ // src/app.ts
2354
+ function createApp(opts = {}) {
2355
+ const app = new Hono5();
2356
+ app.use(resolveCaller(opts));
2357
+ app.get("/health", async (c) => {
2358
+ const cfg = await loadConfig();
2359
+ const cp = cfg.controlPlane;
2360
+ return c.json({
2361
+ ok: true,
2362
+ version: VERSION,
2363
+ pid: process.pid,
2364
+ base: await currentBaseVersion().catch(() => null),
2365
+ owner: cfg.owner,
2366
+ controlPlane: cp ? { name: cp.name, url: cp.url, hostUrl: `https://${cp.advertise}:${cp.port}` } : null,
2367
+ relay: cfg.relay ? { name: cfg.relay.name, publicIp: cfg.relay.publicIp, endpoint: cfg.relay.endpoint } : null
2368
+ });
2369
+ });
2370
+ app.route("/machines", machineRoutes());
2371
+ app.route("/snapshots", snapshotRoutes());
2372
+ app.route("/capacity", capacityRoutes());
2373
+ app.route("/ssh", sshRoutes(opts));
2374
+ app.notFound((c) => c.json({ error: `no route: ${c.req.method} ${c.req.path}` }, 404));
2375
+ app.onError((err, c) => {
2376
+ if (err instanceof NotFoundError) return c.json({ error: err.message }, 404);
2377
+ if (err instanceof KvboxError) {
2378
+ return c.json({ error: err.message }, err.exitCode === 2 ? 501 : 400);
2379
+ }
2380
+ console.error(err);
2381
+ return c.json({ error: err.message }, 500);
2382
+ });
2383
+ return app;
2384
+ }
2385
+
2386
+ // src/listen.ts
2387
+ async function listen(opts = {}) {
2388
+ const p = await ensureLayout(paths());
2389
+ const cfg = await loadConfig(p);
2390
+ const socketPath = opts.socketPath ?? p.serverSock;
2391
+ if (Buffer.byteLength(socketPath) > 100) {
2392
+ throw new KvboxError(`socket path too long for a Unix socket (${socketPath}); set KVBOX_HOME to a shorter path`);
2393
+ }
2394
+ await rm4(socketPath, { force: true });
2395
+ const server = createAdaptorServer({ fetch: createApp().fetch });
2396
+ await new Promise((resolve, reject) => {
2397
+ server.once("error", reject);
2398
+ server.listen(socketPath, () => resolve());
2399
+ });
2400
+ await writeFile5(p.serverPid, `${process.pid}
2401
+ `);
2402
+ let https;
2403
+ let httpsServer;
2404
+ const cp = cfg.controlPlane;
2405
+ if (cp) {
2406
+ const [key, cert] = await Promise.all([readFile6(p.tlsKey), readFile6(p.tlsCert)]).catch((err) => {
2407
+ throw new KvboxError(`joined ${cp.name} but the host's certificate is missing (${err.message}); run kvbox-server leave, then join again`);
2408
+ });
2409
+ httpsServer = createAdaptorServer({
2410
+ fetch: createApp({ controlPlane: { name: cp.name, secret: cp.secret } }).fetch,
2411
+ createServer: createHttpsServer,
2412
+ serverOptions: { key, cert }
2413
+ });
2414
+ const port = opts.httpsPort ?? cp.port;
2415
+ await new Promise((resolve, reject) => {
2416
+ httpsServer.once("error", reject);
2417
+ httpsServer.listen(port, "0.0.0.0", () => resolve());
2418
+ }).catch(async (err) => {
2419
+ server.close();
2420
+ await rm4(socketPath, { force: true });
2421
+ await rm4(p.serverPid, { force: true });
2422
+ throw new KvboxError(`cannot listen on port ${port} for ${cp.name} (${err.message})`);
2423
+ });
2424
+ const bound = httpsServer.address().port;
2425
+ https = { port: bound, url: `https://${cp.advertise}:${bound}` };
2426
+ }
2427
+ const close = async () => {
2428
+ await Promise.all([
2429
+ new Promise((resolve) => server.close(() => resolve())),
2430
+ httpsServer ? new Promise((resolve) => httpsServer.close(() => resolve())) : void 0
2431
+ ]);
2432
+ await rm4(socketPath, { force: true });
2433
+ await rm4(p.serverPid, { force: true });
2434
+ };
2435
+ const shutdown2 = async () => {
2436
+ server.close();
2437
+ httpsServer?.close();
2438
+ await rm4(socketPath, { force: true });
2439
+ await rm4(p.serverPid, { force: true });
2440
+ process.exit(0);
2441
+ };
2442
+ process.once("SIGINT", () => void shutdown2());
2443
+ process.once("SIGTERM", () => void shutdown2());
2444
+ return https ? { socketPath, server, https, close } : { socketPath, server, close };
2445
+ }
2446
+
2447
+ // src/index.ts
2448
+ var program = new Command("kvbox-server").description("kvbox host daemon: owns Firecracker and ~/.kvbox, serves the kvbox API").version(VERSION, "-v, --version").showHelpAfterError();
2449
+ 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) => {
2450
+ await requireKvm();
2451
+ const { socketPath, https } = await listen(opts.socket ? { socketPath: opts.socket } : {});
2452
+ console.log(`kvbox-server ${VERSION} listening on ${socketPath} (pid ${process.pid})`);
2453
+ if (https) {
2454
+ const cfg = await loadConfig(paths());
2455
+ console.log(`control plane ${cfg.controlPlane?.name}: answering on ${https.url}`);
2456
+ }
2457
+ try {
2458
+ const cfg = await loadConfig(paths());
2459
+ const state = await ensureHostNetwork(cfg);
2460
+ console.log(`host network: bridge ${cfg.bridge} ${state}`);
2461
+ } catch (err) {
2462
+ console.error(`warning: host network not ready (${err.message}); run: kvbox-server net up`);
2463
+ }
2464
+ });
2465
+ 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) => {
2466
+ const joined = await joinControlPlane({ url, ...opts }, paths());
2467
+ console.log(`joined ${joined.controlPlane} as host ${joined.hostName}`);
2468
+ console.log(`it answers the control plane on ${joined.hostUrl} once its server restarts`);
2469
+ console.log(`make sure port ${new URL(joined.hostUrl).port} is reachable from the control plane`);
2470
+ });
2471
+ program.command("leave").description("forget the control plane this host joined; the HTTPS port closes at the next start").action(async () => {
2472
+ const left = await leaveControlPlane(paths());
2473
+ console.log(`left ${left.controlPlane}; the HTTPS port closes once its server restarts`);
2474
+ if (!left.told) console.log(`(the control plane could not be told; remove this host there)`);
2475
+ });
2476
+ program.command("relay <join-string>").description("reach the internet through a relay (paste what `kvbox-relay add` printed there); `off` takes the tunnel down").action(async (joinString) => {
2477
+ if (joinString === "off") {
2478
+ const relay2 = await dropRelay(paths());
2479
+ console.log(`tunnel to relay ${relay2.name} is down; this host no longer answers at ${relay2.publicIp}`);
2480
+ return;
2481
+ }
2482
+ const relay = await useRelay(joinString, paths());
2483
+ console.log(`tunnel to relay ${relay.name} (${relay.endpoint}) is up`);
2484
+ console.log(`this host is reachable at ${relay.publicIp}; kvbox server join advertises it by default`);
2485
+ });
2486
+ program.command("net <action>").description("host network: up | down | status (bridge, forwarding, NAT; uses sudo)").action(async (action) => {
2487
+ if (!["up", "down", "status"].includes(action)) throw new KvboxError("net: expected up, down or status");
2488
+ const cfg = await loadConfig(paths());
2489
+ const res = spawnSync(netSetupScript(), [action], {
2490
+ stdio: "inherit",
2491
+ env: { ...process.env, KVBOX_BRIDGE: cfg.bridge, KVBOX_SUBNET: cfg.subnet, KVBOX_GATEWAY: cfg.gateway }
2492
+ });
2493
+ process.exit(res.status ?? 1);
2494
+ });
2495
+ try {
2496
+ await program.parseAsync(process.argv);
2497
+ } catch (err) {
2498
+ if (err instanceof KvboxError) {
2499
+ console.error(`error: ${err.message}`);
2500
+ process.exit(err.exitCode);
2501
+ }
2502
+ throw err;
2503
+ }
2504
+ //# sourceMappingURL=kvbox-server.js.map