@ghalex/kvbox-server 0.1.4 → 0.3.0

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