@kvbox/server 0.5.11 → 0.5.13

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.
@@ -339,6 +339,18 @@ import { request } from "undici";
339
339
  import { z as z3 } from "zod";
340
340
  var SizeSchema = z3.enum(SIZE_NAMES);
341
341
  var BoxStatusSchema = z3.enum(["running", "standby", "hibernated", "stopped", "unknown"]);
342
+ var PortProtoSchema = z3.enum(["tcp", "udp"]);
343
+ var MAX_BOX_PORTS = 16;
344
+ var PortForwardSchema = z3.object({
345
+ guestPort: z3.number().int().min(1).max(65535),
346
+ hostPort: z3.number().int(),
347
+ proto: PortProtoSchema,
348
+ /** The host's public address; only the control plane fills it in, as for `ssh`. */
349
+ address: z3.string().optional()
350
+ });
351
+ var PortsRequest = z3.object({
352
+ ports: z3.array(z3.object({ guestPort: z3.number().int().min(1).max(65535), proto: PortProtoSchema.optional() })).min(1).max(MAX_BOX_PORTS)
353
+ });
342
354
  var MachineSchema = z3.object({
343
355
  /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
344
356
  id: z3.string(),
@@ -371,6 +383,8 @@ var MachineSchema = z3.object({
371
383
  * whose host reported no port or did not answer.
372
384
  */
373
385
  ssh: z3.object({ host: z3.string(), port: z3.number().int(), user: z3.string() }).optional(),
386
+ /** Box ports published on the host (#30). Absent: none. */
387
+ ports: z3.array(PortForwardSchema).optional(),
374
388
  /** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
375
389
  ownerKind: z3.enum(["user", "org"]).optional(),
376
390
  /** Seconds without network traffic before the host hibernates the box (#47). Absent: never. */
@@ -1023,6 +1037,12 @@ var MachineMetaSchema = z4.object({
1023
1037
  sshKeysStale: z4.boolean().optional(),
1024
1038
  /** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
1025
1039
  sshPort: z4.number().int().optional(),
1040
+ /**
1041
+ * Box ports published on the host (#30, `kvbox vm proxy`): unique per guest port and protocol,
1042
+ * each host port the box's for its life. DNATed while the box runs, rebuilt from here with its
1043
+ * current IP every time it comes up. Absent: none.
1044
+ */
1045
+ ports: z4.array(z4.object({ guestPort: z4.number().int(), hostPort: z4.number().int(), proto: z4.enum(["tcp", "udp"]) })).optional(),
1026
1046
  /** Seconds without traffic on the box's tap before the server hibernates it (#47, core/idle.ts). Absent: never. */
1027
1047
  autoHibernateSec: z4.number().int().positive().optional()
1028
1048
  });
@@ -1292,8 +1312,13 @@ function usageEventsInScope(scope, window, p = paths()) {
1292
1312
  import { AsyncLocalStorage } from "node:async_hooks";
1293
1313
  var tails = /* @__PURE__ */ new Map();
1294
1314
  var held = new AsyncLocalStorage();
1295
- async function withBoxLock(ref, fn) {
1296
- const key = refKey(ref);
1315
+ function withBoxLock(ref, fn) {
1316
+ return withLock(refKey(ref), fn);
1317
+ }
1318
+ function withHostLock(name, fn) {
1319
+ return withLock(`host:${name}`, fn);
1320
+ }
1321
+ async function withLock(key, fn) {
1297
1322
  const mine = held.getStore();
1298
1323
  if (mine?.has(key)) return fn();
1299
1324
  const previous = tails.get(key);
@@ -1502,11 +1527,14 @@ function parseListeningPorts(ssOutput) {
1502
1527
  return ports;
1503
1528
  }
1504
1529
  async function listeningPorts() {
1505
- try {
1506
- return parseListeningPorts((await run(["ss", "-ltnH"])).stdout);
1507
- } catch {
1508
- return /* @__PURE__ */ new Set();
1530
+ const ports = /* @__PURE__ */ new Set();
1531
+ for (const flags of ["-ltnH", "-lunH"]) {
1532
+ try {
1533
+ for (const port of parseListeningPorts((await run(["ss", flags])).stdout)) ports.add(port);
1534
+ } catch {
1535
+ }
1509
1536
  }
1537
+ return ports;
1510
1538
  }
1511
1539
  var proxyArgs = (p) => [p.box, p.boxIp, String(p.hostPort), String(p.guestPort), p.proto ?? "tcp"];
1512
1540
  async function openProxy(proxy, cfg) {
@@ -1541,23 +1569,34 @@ var AUTO_PREFIX = "box";
1541
1569
  var GUEST_CID = 3;
1542
1570
  var OVERLAY_REL = "overlay.ext4";
1543
1571
  var idsInUse = (metas) => new Set(metas.map((m) => m.id));
1544
- var sshPortsInUse = (metas) => metas.flatMap((m) => m.sshPort === void 0 ? [] : [m.sshPort]);
1572
+ var publicPortsInUse = (metas) => metas.flatMap((m) => [...m.sshPort === void 0 ? [] : [m.sshPort], ...(m.ports ?? []).map((f) => f.hostPort)]);
1545
1573
  var isCloudBox = (owner) => realmOf(owner) !== null;
1546
- function sshProxy(meta) {
1547
- if (meta.sshPort === void 0 || meta.id === void 0) return void 0;
1548
- return { box: meta.id, boxIp: meta.ip, hostPort: meta.sshPort, guestPort: 22 };
1549
- }
1550
- async function openSshPort(meta, cfg) {
1551
- const proxy = sshProxy(meta);
1552
- if (proxy) await openProxy(proxy, cfg);
1553
- }
1554
- async function closeSshPort(meta, cfg) {
1555
- const proxy = sshProxy(meta);
1556
- if (!proxy) return;
1557
- await closeProxy(proxy, cfg).catch((err) => console.error(`warning: could not close port ${proxy.hostPort}: ${err.message}`));
1558
- }
1559
- async function newSshPort(existing) {
1560
- return pickSshPort(/* @__PURE__ */ new Set([...sshPortsInUse(existing), ...await listeningPorts()]));
1574
+ function boxProxies(meta) {
1575
+ const ssh = meta.sshPort === void 0 ? [] : [{ box: meta.id, boxIp: meta.ip, hostPort: meta.sshPort, guestPort: 22 }];
1576
+ return [...ssh, ...(meta.ports ?? []).map((f) => ({ box: meta.id, boxIp: meta.ip, hostPort: f.hostPort, guestPort: f.guestPort, proto: f.proto }))];
1577
+ }
1578
+ async function openPorts(meta, cfg) {
1579
+ for (const proxy of boxProxies(meta)) await openProxy(proxy, cfg);
1580
+ }
1581
+ async function closePorts(meta, cfg) {
1582
+ for (const proxy of boxProxies(meta)) {
1583
+ await closeProxy(proxy, cfg).catch((err) => console.error(`warning: could not close port ${proxy.hostPort}: ${err.message}`));
1584
+ }
1585
+ }
1586
+ var reservedPorts = /* @__PURE__ */ new Set();
1587
+ async function drawPorts(count, p) {
1588
+ return withHostLock("ports", async () => {
1589
+ const reserved = [...reservedPorts];
1590
+ const taken = /* @__PURE__ */ new Set([...reserved, ...publicPortsInUse(await readAllMeta(p)), ...await listeningPorts()]);
1591
+ const ports = [];
1592
+ for (let i = 0; i < count; i++) {
1593
+ const port = pickSshPort(taken);
1594
+ taken.add(port);
1595
+ ports.push(port);
1596
+ }
1597
+ for (const port of ports) reservedPorts.add(port);
1598
+ return { ports, release: () => ports.forEach((port) => reservedPorts.delete(port)) };
1599
+ });
1561
1600
  }
1562
1601
  function diskStats(disk) {
1563
1602
  try {
@@ -1615,6 +1654,7 @@ function toMachine(meta, p = paths()) {
1615
1654
  source: meta.source,
1616
1655
  status: statusOf(meta, p),
1617
1656
  ...meta.sshPort === void 0 ? {} : { sshPort: meta.sshPort },
1657
+ ...meta.ports?.length ? { ports: meta.ports.map((f) => ({ ...f })) } : {},
1618
1658
  ...meta.autoHibernateSec ? { autoHibernateSec: meta.autoHibernateSec } : {}
1619
1659
  };
1620
1660
  }
@@ -1828,7 +1868,6 @@ async function createMachine(req, owner, p = paths()) {
1828
1868
  const id = newBoxId(idsInUse(existing));
1829
1869
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1830
1870
  const mac = macFor(ip);
1831
- const sshPort = cloud ? await newSshPort(existing) : void 0;
1832
1871
  const m = p.machine(owner, name);
1833
1872
  const base = await currentBase(cfg, p);
1834
1873
  await assertOverlayCapable(base.file, base.version);
@@ -1836,23 +1875,29 @@ async function createMachine(req, owner, p = paths()) {
1836
1875
  await mkdir3(m.dir, { recursive: true, mode: 448 });
1837
1876
  let tapCreated = false;
1838
1877
  let pid;
1878
+ let sshPort;
1879
+ let drawn;
1839
1880
  try {
1881
+ drawn = cloud ? await drawPorts(1, p) : void 0;
1882
+ sshPort = drawn?.ports[0];
1840
1883
  await createOverlay(m.overlay, size.diskGib);
1841
1884
  const tap = await createTap(id, cfg);
1842
1885
  tapCreated = true;
1843
1886
  const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys, sshPort };
1844
1887
  pid = await launch(spec, tap, cfg, keysB64, p);
1845
- await openSshPort(spec, cfg);
1888
+ await openPorts(spec, cfg);
1846
1889
  const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base", autoHibernateSec: autoHibernateFor(req, cfg) };
1847
1890
  await writeMachineMeta(meta, p);
1848
1891
  recordState(meta, "running", "new", p, meta.createdAt);
1849
1892
  return toMachine(meta, p);
1850
1893
  } catch (err) {
1851
1894
  if (pid !== void 0) killQuietly(pid);
1852
- await closeSshPort({ id, ip, sshPort }, cfg);
1895
+ await closePorts({ id, ip, sshPort }, cfg);
1853
1896
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1854
1897
  await rm(m.dir, { recursive: true, force: true });
1855
1898
  throw err;
1899
+ } finally {
1900
+ drawn?.release();
1856
1901
  }
1857
1902
  }
1858
1903
  async function readSnap(ref, p) {
@@ -1885,14 +1930,17 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1885
1930
  const id = newBoxId(idsInUse(existing));
1886
1931
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1887
1932
  const mac = macFor(ip);
1888
- const sshPort = cloud ? await newSshPort(existing) : void 0;
1889
1933
  const m = p.machine(owner, name);
1890
1934
  const s = p.snapshot(from.owner, from.name);
1891
1935
  await ensureHostNetwork(cfg);
1892
1936
  await mkdir3(m.dir, { recursive: true, mode: 448 });
1893
1937
  let tapCreated = false;
1894
1938
  let pid;
1939
+ let sshPort;
1940
+ let drawn;
1895
1941
  try {
1942
+ drawn = cloud ? await drawPorts(1, p) : void 0;
1943
+ sshPort = drawn?.ports[0];
1896
1944
  await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
1897
1945
  await link(s.mem, m.memBase);
1898
1946
  const tap = await createTap(id, cfg, { detached: true });
@@ -1906,17 +1954,19 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1906
1954
  });
1907
1955
  const stale = await pushAuthorizedKeys({ owner, name, sshKeys }, m.vsock, p);
1908
1956
  await attachTap(id, cfg);
1909
- await openSshPort(spec, cfg);
1957
+ await openPorts(spec, cfg);
1910
1958
  const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0, autoHibernateSec: autoHibernateFor(req, cfg) };
1911
1959
  await writeMachineMeta(meta, p);
1912
1960
  recordState(meta, "running", "new", p, meta.createdAt);
1913
1961
  return toMachine(meta, p);
1914
1962
  } catch (err) {
1915
1963
  if (pid !== void 0) killQuietly(pid);
1916
- await closeSshPort({ id, ip, sshPort }, cfg);
1964
+ await closePorts({ id, ip, sshPort }, cfg);
1917
1965
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1918
1966
  await rm(m.dir, { recursive: true, force: true });
1919
1967
  throw err;
1968
+ } finally {
1969
+ drawn?.release();
1920
1970
  }
1921
1971
  }
1922
1972
  var AGENT_REPLY_MS = 2e4;
@@ -1982,7 +2032,7 @@ async function waitDead(pid, graceMs) {
1982
2032
  }
1983
2033
  }
1984
2034
  async function cleanupProcess(meta, cfg, p) {
1985
- await closeSshPort(meta, cfg);
2035
+ await closePorts(meta, cfg);
1986
2036
  await deleteTap(meta.id, cfg);
1987
2037
  const m = p.machine(meta.owner, meta.name);
1988
2038
  await rm(m.sock, { force: true });
@@ -2015,14 +2065,14 @@ var startMachine = locked(async function startMachine2(ref, p = paths()) {
2015
2065
  let pid;
2016
2066
  try {
2017
2067
  pid = await launch(meta, tap, cfg, keysB64, p);
2018
- await openSshPort(meta, cfg);
2068
+ await openPorts(meta, cfg);
2019
2069
  const started = { ...meta, pid, bootId: currentBootId(), sshKeysStale: void 0 };
2020
2070
  await writeMachineMeta(started, p);
2021
2071
  recordState(started, "running", "start", p);
2022
2072
  return toMachine(started, p);
2023
2073
  } catch (err) {
2024
2074
  if (pid !== void 0) killQuietly(pid);
2025
- await closeSshPort(meta, cfg);
2075
+ await closePorts(meta, cfg);
2026
2076
  await deleteTap(meta.id, cfg).catch(() => void 0);
2027
2077
  throw err;
2028
2078
  }
@@ -2163,10 +2213,10 @@ var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
2163
2213
  });
2164
2214
  stale = await pushAuthorizedKeys(meta, m.vsock, p);
2165
2215
  await attachTap(meta.id, cfg);
2166
- await openSshPort(meta, cfg);
2216
+ await openPorts(meta, cfg);
2167
2217
  } catch (err) {
2168
2218
  if (pid !== void 0) killQuietly(pid);
2169
- await closeSshPort(meta, cfg);
2219
+ await closePorts(meta, cfg);
2170
2220
  await deleteTap(meta.id, cfg).catch(() => void 0);
2171
2221
  throw err;
2172
2222
  }
@@ -2234,6 +2284,58 @@ var setMachine = locked(async function setMachine2(ref, req, p = paths()) {
2234
2284
  await writeMachineMeta(next, p);
2235
2285
  return toMachine(next, p);
2236
2286
  });
2287
+ var portKeys = (req) => {
2288
+ const keys = req.ports.map((x) => ({ guestPort: x.guestPort, proto: x.proto ?? "tcp" }));
2289
+ return keys.filter((k, i) => keys.findIndex((o) => o.guestPort === k.guestPort && o.proto === k.proto) === i);
2290
+ };
2291
+ var samePort = (a) => (b) => a.guestPort === b.guestPort && a.proto === b.proto;
2292
+ var addPorts = locked(async function addPorts2(ref, req, p = paths()) {
2293
+ const meta = await readMeta(ref, p);
2294
+ const cfg = await loadConfig(p);
2295
+ const current = meta.ports ?? [];
2296
+ const wanted = portKeys(req);
2297
+ const already = wanted.find((w) => current.some(samePort(w)));
2298
+ if (already) throw new KvboxError(`box ${shown(ref)} already publishes port ${already.guestPort} (${already.proto})`);
2299
+ if (meta.sshPort !== void 0 && wanted.some(samePort({ guestPort: 22, proto: "tcp" }))) {
2300
+ throw new KvboxError(`box ${shown(ref)} already reaches port 22 from outside on port ${meta.sshPort} (kvbox connect)`);
2301
+ }
2302
+ if (current.length + wanted.length > MAX_BOX_PORTS) {
2303
+ throw new KvboxError(`a box publishes at most ${MAX_BOX_PORTS} ports; box ${shown(ref)} has ${current.length}`);
2304
+ }
2305
+ const drawn = await drawPorts(wanted.length, p);
2306
+ const opened = [];
2307
+ try {
2308
+ const added = wanted.map((w, i) => ({ ...w, hostPort: drawn.ports[i] }));
2309
+ if (vmAlive(meta)) {
2310
+ for (const proxy of boxProxies({ id: meta.id, ip: meta.ip, ports: added })) {
2311
+ await openProxy(proxy, cfg);
2312
+ opened.push(proxy);
2313
+ }
2314
+ }
2315
+ const next = { ...meta, ports: [...current, ...added] };
2316
+ await writeMachineMeta(next, p);
2317
+ return toMachine(next, p);
2318
+ } catch (err) {
2319
+ for (const proxy of opened) await closeProxy(proxy, cfg).catch(() => void 0);
2320
+ throw err;
2321
+ } finally {
2322
+ drawn.release();
2323
+ }
2324
+ });
2325
+ var removePorts = locked(async function removePorts2(ref, req, p = paths()) {
2326
+ const meta = await readMeta(ref, p);
2327
+ const cfg = await loadConfig(p);
2328
+ const current = meta.ports ?? [];
2329
+ const gone = portKeys(req);
2330
+ const missing = gone.find((g) => !current.some(samePort(g)));
2331
+ if (missing) throw new KvboxError(`box ${shown(ref)} does not publish port ${missing.guestPort} (${missing.proto})`);
2332
+ const removed = current.filter((f) => gone.some(samePort(f)));
2333
+ for (const proxy of boxProxies({ id: meta.id, ip: meta.ip, ports: removed })) await closeProxy(proxy, cfg);
2334
+ const kept = current.filter((f) => !gone.some(samePort(f)));
2335
+ const next = { ...meta, ports: kept.length > 0 ? kept : void 0 };
2336
+ await writeMachineMeta(next, p);
2337
+ return toMachine(next, p);
2338
+ });
2237
2339
  async function growDisk(file, gib) {
2238
2340
  await truncate(file, gib * 1024 ** 3);
2239
2341
  await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
@@ -2571,7 +2673,7 @@ function versionOf(srcFileUrl, injected) {
2571
2673
  }
2572
2674
 
2573
2675
  // src/version.ts
2574
- var VERSION = versionOf(import.meta.url, true ? "0.5.11" : void 0);
2676
+ var VERSION = versionOf(import.meta.url, true ? "0.5.13" : void 0);
2575
2677
 
2576
2678
  // src/caller.ts
2577
2679
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
@@ -2681,6 +2783,16 @@ function machineRoutes() {
2681
2783
  const { machine, applied } = await setSshKeys(parseRef(c.req.param("name"), c.get("owner")), body.data.sshKeys);
2682
2784
  return c.json({ ...machine, sshKeysApplied: applied });
2683
2785
  });
2786
+ r.post("/:name/ports", async (c) => {
2787
+ const body = PortsRequest.safeParse(await c.req.json().catch(() => ({})));
2788
+ if (!body.success) throw new KvboxError("ports: expected a list of box ports");
2789
+ return c.json(await addPorts(parseRef(c.req.param("name"), c.get("owner")), body.data), 201);
2790
+ });
2791
+ r.delete("/:name/ports", async (c) => {
2792
+ const body = PortsRequest.safeParse(await c.req.json().catch(() => ({})));
2793
+ if (!body.success) throw new KvboxError("ports: expected a list of box ports");
2794
+ return c.json(await removePorts(parseRef(c.req.param("name"), c.get("owner")), body.data));
2795
+ });
2684
2796
  r.get("/:name/usage", async (c) => {
2685
2797
  const since = c.req.query("since");
2686
2798
  let from;