@ghalex/kvbox-server 0.2.0 → 0.3.0

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