@kvbox/server 0.5.12 → 0.5.14

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,30 @@ 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
+ });
354
+ var BoxPortSchema = z3.number().int().min(1).max(65535);
355
+ var ClaimSubdomainRequest = z3.object({ name: z3.string(), port: BoxPortSchema });
356
+ var SubdomainPortRequest = z3.object({ port: BoxPortSchema });
357
+ var SubdomainRouteRequest = z3.object({ name: z3.string(), port: BoxPortSchema });
358
+ var SubdomainSchema = z3.object({
359
+ owner: z3.string(),
360
+ box: z3.string(),
361
+ subdomain: z3.string(),
362
+ port: BoxPortSchema,
363
+ url: z3.string(),
364
+ routes: z3.array(z3.object({ name: z3.string(), port: BoxPortSchema, url: z3.string() }))
365
+ });
342
366
  var MachineSchema = z3.object({
343
367
  /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
344
368
  id: z3.string(),
@@ -371,6 +395,8 @@ var MachineSchema = z3.object({
371
395
  * whose host reported no port or did not answer.
372
396
  */
373
397
  ssh: z3.object({ host: z3.string(), port: z3.number().int(), user: z3.string() }).optional(),
398
+ /** Box ports published on the host (#30). Absent: none. */
399
+ ports: z3.array(PortForwardSchema).optional(),
374
400
  /** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
375
401
  ownerKind: z3.enum(["user", "org"]).optional(),
376
402
  /** Seconds without network traffic before the host hibernates the box (#47). Absent: never. */
@@ -1023,6 +1049,12 @@ var MachineMetaSchema = z4.object({
1023
1049
  sshKeysStale: z4.boolean().optional(),
1024
1050
  /** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
1025
1051
  sshPort: z4.number().int().optional(),
1052
+ /**
1053
+ * Box ports published on the host (#30, `kvbox vm proxy`): unique per guest port and protocol,
1054
+ * each host port the box's for its life. DNATed while the box runs, rebuilt from here with its
1055
+ * current IP every time it comes up. Absent: none.
1056
+ */
1057
+ ports: z4.array(z4.object({ guestPort: z4.number().int(), hostPort: z4.number().int(), proto: z4.enum(["tcp", "udp"]) })).optional(),
1026
1058
  /** Seconds without traffic on the box's tap before the server hibernates it (#47, core/idle.ts). Absent: never. */
1027
1059
  autoHibernateSec: z4.number().int().positive().optional()
1028
1060
  });
@@ -1292,8 +1324,13 @@ function usageEventsInScope(scope, window, p = paths()) {
1292
1324
  import { AsyncLocalStorage } from "node:async_hooks";
1293
1325
  var tails = /* @__PURE__ */ new Map();
1294
1326
  var held = new AsyncLocalStorage();
1295
- async function withBoxLock(ref, fn) {
1296
- const key = refKey(ref);
1327
+ function withBoxLock(ref, fn) {
1328
+ return withLock(refKey(ref), fn);
1329
+ }
1330
+ function withHostLock(name, fn) {
1331
+ return withLock(`host:${name}`, fn);
1332
+ }
1333
+ async function withLock(key, fn) {
1297
1334
  const mine = held.getStore();
1298
1335
  if (mine?.has(key)) return fn();
1299
1336
  const previous = tails.get(key);
@@ -1502,11 +1539,14 @@ function parseListeningPorts(ssOutput) {
1502
1539
  return ports;
1503
1540
  }
1504
1541
  async function listeningPorts() {
1505
- try {
1506
- return parseListeningPorts((await run(["ss", "-ltnH"])).stdout);
1507
- } catch {
1508
- return /* @__PURE__ */ new Set();
1542
+ const ports = /* @__PURE__ */ new Set();
1543
+ for (const flags of ["-ltnH", "-lunH"]) {
1544
+ try {
1545
+ for (const port of parseListeningPorts((await run(["ss", flags])).stdout)) ports.add(port);
1546
+ } catch {
1547
+ }
1509
1548
  }
1549
+ return ports;
1510
1550
  }
1511
1551
  var proxyArgs = (p) => [p.box, p.boxIp, String(p.hostPort), String(p.guestPort), p.proto ?? "tcp"];
1512
1552
  async function openProxy(proxy, cfg) {
@@ -1541,23 +1581,34 @@ var AUTO_PREFIX = "box";
1541
1581
  var GUEST_CID = 3;
1542
1582
  var OVERLAY_REL = "overlay.ext4";
1543
1583
  var idsInUse = (metas) => new Set(metas.map((m) => m.id));
1544
- var sshPortsInUse = (metas) => metas.flatMap((m) => m.sshPort === void 0 ? [] : [m.sshPort]);
1584
+ var publicPortsInUse = (metas) => metas.flatMap((m) => [...m.sshPort === void 0 ? [] : [m.sshPort], ...(m.ports ?? []).map((f) => f.hostPort)]);
1545
1585
  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()]));
1586
+ function boxProxies(meta) {
1587
+ const ssh = meta.sshPort === void 0 ? [] : [{ box: meta.id, boxIp: meta.ip, hostPort: meta.sshPort, guestPort: 22 }];
1588
+ return [...ssh, ...(meta.ports ?? []).map((f) => ({ box: meta.id, boxIp: meta.ip, hostPort: f.hostPort, guestPort: f.guestPort, proto: f.proto }))];
1589
+ }
1590
+ async function openPorts(meta, cfg) {
1591
+ for (const proxy of boxProxies(meta)) await openProxy(proxy, cfg);
1592
+ }
1593
+ async function closePorts(meta, cfg) {
1594
+ for (const proxy of boxProxies(meta)) {
1595
+ await closeProxy(proxy, cfg).catch((err) => console.error(`warning: could not close port ${proxy.hostPort}: ${err.message}`));
1596
+ }
1597
+ }
1598
+ var reservedPorts = /* @__PURE__ */ new Set();
1599
+ async function drawPorts(count, p) {
1600
+ return withHostLock("ports", async () => {
1601
+ const reserved = [...reservedPorts];
1602
+ const taken = /* @__PURE__ */ new Set([...reserved, ...publicPortsInUse(await readAllMeta(p)), ...await listeningPorts()]);
1603
+ const ports = [];
1604
+ for (let i = 0; i < count; i++) {
1605
+ const port = pickSshPort(taken);
1606
+ taken.add(port);
1607
+ ports.push(port);
1608
+ }
1609
+ for (const port of ports) reservedPorts.add(port);
1610
+ return { ports, release: () => ports.forEach((port) => reservedPorts.delete(port)) };
1611
+ });
1561
1612
  }
1562
1613
  function diskStats(disk) {
1563
1614
  try {
@@ -1615,6 +1666,7 @@ function toMachine(meta, p = paths()) {
1615
1666
  source: meta.source,
1616
1667
  status: statusOf(meta, p),
1617
1668
  ...meta.sshPort === void 0 ? {} : { sshPort: meta.sshPort },
1669
+ ...meta.ports?.length ? { ports: meta.ports.map((f) => ({ ...f })) } : {},
1618
1670
  ...meta.autoHibernateSec ? { autoHibernateSec: meta.autoHibernateSec } : {}
1619
1671
  };
1620
1672
  }
@@ -1828,7 +1880,6 @@ async function createMachine(req, owner, p = paths()) {
1828
1880
  const id = newBoxId(idsInUse(existing));
1829
1881
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1830
1882
  const mac = macFor(ip);
1831
- const sshPort = cloud ? await newSshPort(existing) : void 0;
1832
1883
  const m = p.machine(owner, name);
1833
1884
  const base = await currentBase(cfg, p);
1834
1885
  await assertOverlayCapable(base.file, base.version);
@@ -1836,23 +1887,29 @@ async function createMachine(req, owner, p = paths()) {
1836
1887
  await mkdir3(m.dir, { recursive: true, mode: 448 });
1837
1888
  let tapCreated = false;
1838
1889
  let pid;
1890
+ let sshPort;
1891
+ let drawn;
1839
1892
  try {
1893
+ drawn = cloud ? await drawPorts(1, p) : void 0;
1894
+ sshPort = drawn?.ports[0];
1840
1895
  await createOverlay(m.overlay, size.diskGib);
1841
1896
  const tap = await createTap(id, cfg);
1842
1897
  tapCreated = true;
1843
1898
  const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys, sshPort };
1844
1899
  pid = await launch(spec, tap, cfg, keysB64, p);
1845
- await openSshPort(spec, cfg);
1900
+ await openPorts(spec, cfg);
1846
1901
  const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base", autoHibernateSec: autoHibernateFor(req, cfg) };
1847
1902
  await writeMachineMeta(meta, p);
1848
1903
  recordState(meta, "running", "new", p, meta.createdAt);
1849
1904
  return toMachine(meta, p);
1850
1905
  } catch (err) {
1851
1906
  if (pid !== void 0) killQuietly(pid);
1852
- await closeSshPort({ id, ip, sshPort }, cfg);
1907
+ await closePorts({ id, ip, sshPort }, cfg);
1853
1908
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1854
1909
  await rm(m.dir, { recursive: true, force: true });
1855
1910
  throw err;
1911
+ } finally {
1912
+ drawn?.release();
1856
1913
  }
1857
1914
  }
1858
1915
  async function readSnap(ref, p) {
@@ -1885,14 +1942,17 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1885
1942
  const id = newBoxId(idsInUse(existing));
1886
1943
  const ip = nextFreeIp(cfg.subnet, [...existing.map((m2) => m2.ip), cfg.gateway]);
1887
1944
  const mac = macFor(ip);
1888
- const sshPort = cloud ? await newSshPort(existing) : void 0;
1889
1945
  const m = p.machine(owner, name);
1890
1946
  const s = p.snapshot(from.owner, from.name);
1891
1947
  await ensureHostNetwork(cfg);
1892
1948
  await mkdir3(m.dir, { recursive: true, mode: 448 });
1893
1949
  let tapCreated = false;
1894
1950
  let pid;
1951
+ let sshPort;
1952
+ let drawn;
1895
1953
  try {
1954
+ drawn = cloud ? await drawPorts(1, p) : void 0;
1955
+ sshPort = drawn?.ports[0];
1896
1956
  await run(["cp", "--sparse=always", "--reflink=auto", s.overlay, m.overlay]);
1897
1957
  await link(s.mem, m.memBase);
1898
1958
  const tap = await createTap(id, cfg, { detached: true });
@@ -1906,21 +1966,23 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1906
1966
  });
1907
1967
  const stale = await pushAuthorizedKeys({ owner, name, sshKeys }, m.vsock, p);
1908
1968
  await attachTap(id, cfg);
1909
- await openSshPort(spec, cfg);
1969
+ await openPorts(spec, cfg);
1910
1970
  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
1971
  await writeMachineMeta(meta, p);
1912
1972
  recordState(meta, "running", "new", p, meta.createdAt);
1913
1973
  return toMachine(meta, p);
1914
1974
  } catch (err) {
1915
1975
  if (pid !== void 0) killQuietly(pid);
1916
- await closeSshPort({ id, ip, sshPort }, cfg);
1976
+ await closePorts({ id, ip, sshPort }, cfg);
1917
1977
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1918
1978
  await rm(m.dir, { recursive: true, force: true });
1919
1979
  throw err;
1980
+ } finally {
1981
+ drawn?.release();
1920
1982
  }
1921
1983
  }
1922
1984
  var AGENT_REPLY_MS = 2e4;
1923
- async function introduce(spec, vsock, cfg, failure) {
1985
+ async function introduce(spec, vsock, cfg, failure2) {
1924
1986
  const id = { name: spec.name, ip: spec.ip, prefixLen: prefixLenOf(cfg.subnet), gateway: cfg.gateway, mac: spec.mac, time: Math.floor(Date.now() / 1e3) };
1925
1987
  const deadline = Date.now() + 3e3;
1926
1988
  for (; ; ) {
@@ -1935,9 +1997,9 @@ async function introduce(spec, vsock, cfg, failure) {
1935
1997
  continue;
1936
1998
  }
1937
1999
  if (transient) throw new KvboxError(`${msg}
1938
- ${failure.silent}`);
2000
+ ${failure2.silent}`);
1939
2001
  throw new KvboxError(`${msg}
1940
- ${failure.noAnswer}`);
2002
+ ${failure2.noAnswer}`);
1941
2003
  }
1942
2004
  }
1943
2005
  }
@@ -1982,7 +2044,7 @@ async function waitDead(pid, graceMs) {
1982
2044
  }
1983
2045
  }
1984
2046
  async function cleanupProcess(meta, cfg, p) {
1985
- await closeSshPort(meta, cfg);
2047
+ await closePorts(meta, cfg);
1986
2048
  await deleteTap(meta.id, cfg);
1987
2049
  const m = p.machine(meta.owner, meta.name);
1988
2050
  await rm(m.sock, { force: true });
@@ -2015,14 +2077,14 @@ var startMachine = locked(async function startMachine2(ref, p = paths()) {
2015
2077
  let pid;
2016
2078
  try {
2017
2079
  pid = await launch(meta, tap, cfg, keysB64, p);
2018
- await openSshPort(meta, cfg);
2080
+ await openPorts(meta, cfg);
2019
2081
  const started = { ...meta, pid, bootId: currentBootId(), sshKeysStale: void 0 };
2020
2082
  await writeMachineMeta(started, p);
2021
2083
  recordState(started, "running", "start", p);
2022
2084
  return toMachine(started, p);
2023
2085
  } catch (err) {
2024
2086
  if (pid !== void 0) killQuietly(pid);
2025
- await closeSshPort(meta, cfg);
2087
+ await closePorts(meta, cfg);
2026
2088
  await deleteTap(meta.id, cfg).catch(() => void 0);
2027
2089
  throw err;
2028
2090
  }
@@ -2163,10 +2225,10 @@ var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
2163
2225
  });
2164
2226
  stale = await pushAuthorizedKeys(meta, m.vsock, p);
2165
2227
  await attachTap(meta.id, cfg);
2166
- await openSshPort(meta, cfg);
2228
+ await openPorts(meta, cfg);
2167
2229
  } catch (err) {
2168
2230
  if (pid !== void 0) killQuietly(pid);
2169
- await closeSshPort(meta, cfg);
2231
+ await closePorts(meta, cfg);
2170
2232
  await deleteTap(meta.id, cfg).catch(() => void 0);
2171
2233
  throw err;
2172
2234
  }
@@ -2234,6 +2296,58 @@ var setMachine = locked(async function setMachine2(ref, req, p = paths()) {
2234
2296
  await writeMachineMeta(next, p);
2235
2297
  return toMachine(next, p);
2236
2298
  });
2299
+ var portKeys = (req) => {
2300
+ const keys = req.ports.map((x) => ({ guestPort: x.guestPort, proto: x.proto ?? "tcp" }));
2301
+ return keys.filter((k, i) => keys.findIndex((o) => o.guestPort === k.guestPort && o.proto === k.proto) === i);
2302
+ };
2303
+ var samePort = (a) => (b) => a.guestPort === b.guestPort && a.proto === b.proto;
2304
+ var addPorts = locked(async function addPorts2(ref, req, p = paths()) {
2305
+ const meta = await readMeta(ref, p);
2306
+ const cfg = await loadConfig(p);
2307
+ const current = meta.ports ?? [];
2308
+ const wanted = portKeys(req);
2309
+ const already = wanted.find((w) => current.some(samePort(w)));
2310
+ if (already) throw new KvboxError(`box ${shown(ref)} already publishes port ${already.guestPort} (${already.proto})`);
2311
+ if (meta.sshPort !== void 0 && wanted.some(samePort({ guestPort: 22, proto: "tcp" }))) {
2312
+ throw new KvboxError(`box ${shown(ref)} already reaches port 22 from outside on port ${meta.sshPort} (kvbox connect)`);
2313
+ }
2314
+ if (current.length + wanted.length > MAX_BOX_PORTS) {
2315
+ throw new KvboxError(`a box publishes at most ${MAX_BOX_PORTS} ports; box ${shown(ref)} has ${current.length}`);
2316
+ }
2317
+ const drawn = await drawPorts(wanted.length, p);
2318
+ const opened = [];
2319
+ try {
2320
+ const added = wanted.map((w, i) => ({ ...w, hostPort: drawn.ports[i] }));
2321
+ if (vmAlive(meta)) {
2322
+ for (const proxy of boxProxies({ id: meta.id, ip: meta.ip, ports: added })) {
2323
+ await openProxy(proxy, cfg);
2324
+ opened.push(proxy);
2325
+ }
2326
+ }
2327
+ const next = { ...meta, ports: [...current, ...added] };
2328
+ await writeMachineMeta(next, p);
2329
+ return toMachine(next, p);
2330
+ } catch (err) {
2331
+ for (const proxy of opened) await closeProxy(proxy, cfg).catch(() => void 0);
2332
+ throw err;
2333
+ } finally {
2334
+ drawn.release();
2335
+ }
2336
+ });
2337
+ var removePorts = locked(async function removePorts2(ref, req, p = paths()) {
2338
+ const meta = await readMeta(ref, p);
2339
+ const cfg = await loadConfig(p);
2340
+ const current = meta.ports ?? [];
2341
+ const gone = portKeys(req);
2342
+ const missing = gone.find((g) => !current.some(samePort(g)));
2343
+ if (missing) throw new KvboxError(`box ${shown(ref)} does not publish port ${missing.guestPort} (${missing.proto})`);
2344
+ const removed = current.filter((f) => gone.some(samePort(f)));
2345
+ for (const proxy of boxProxies({ id: meta.id, ip: meta.ip, ports: removed })) await closeProxy(proxy, cfg);
2346
+ const kept = current.filter((f) => !gone.some(samePort(f)));
2347
+ const next = { ...meta, ports: kept.length > 0 ? kept : void 0 };
2348
+ await writeMachineMeta(next, p);
2349
+ return toMachine(next, p);
2350
+ });
2237
2351
  async function growDisk(file, gib) {
2238
2352
  await truncate(file, gib * 1024 ** 3);
2239
2353
  await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
@@ -2556,7 +2670,7 @@ import { createServer as createHttpsServer } from "node:https";
2556
2670
  import { createAdaptorServer } from "@hono/node-server";
2557
2671
 
2558
2672
  // src/app.ts
2559
- import { Hono as Hono6 } from "hono";
2673
+ import { Hono as Hono7 } from "hono";
2560
2674
 
2561
2675
  // ../shared/src/version.ts
2562
2676
  import { createRequire } from "node:module";
@@ -2571,10 +2685,25 @@ function versionOf(srcFileUrl, injected) {
2571
2685
  }
2572
2686
 
2573
2687
  // src/version.ts
2574
- var VERSION = versionOf(import.meta.url, true ? "0.5.12" : void 0);
2688
+ var VERSION = versionOf(import.meta.url, true ? "0.5.14" : void 0);
2575
2689
 
2576
2690
  // src/caller.ts
2577
2691
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
2692
+
2693
+ // ../shared/src/names.ts
2694
+ import { createHmac } from "node:crypto";
2695
+ var PROXY_ERROR_HEADER = "x-kvbox-proxy-error";
2696
+ var PROXIED_HEADER = "x-kvbox-proxied";
2697
+ var VISITOR_PATH_HEADER = "x-kvbox-path";
2698
+ var VISITOR_AUTH_HEADER = "x-kvbox-authorization";
2699
+ var NOT_FORWARDED = /* @__PURE__ */ new Set(["expect"]);
2700
+ var HOP_BY_HOP = /* @__PURE__ */ new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade"]);
2701
+ var BOX_HTTP_PATH_RE = /^\/machines\/([^/]+)\/http\/(\d{1,5})\/?$/;
2702
+ function edgeCredential(hostSecret) {
2703
+ return createHmac("sha256", hostSecret).update("kvbox-edge-v1").digest("base64url");
2704
+ }
2705
+
2706
+ // src/caller.ts
2578
2707
  var OWNER_HEADER = "x-kvbox-owner";
2579
2708
  function resolveCaller(opts = {}) {
2580
2709
  const cp = opts.controlPlane;
@@ -2586,10 +2715,11 @@ function resolveCaller(opts = {}) {
2586
2715
  await next();
2587
2716
  };
2588
2717
  }
2589
- const expected = digest(cp.secret);
2718
+ const authorized = secretCheck(cp);
2719
+ const edge = edgeCheck(cp);
2590
2720
  return async (c, next) => {
2591
- const m = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
2592
- if (!m || !timingSafeEqual(digest(m[1]), expected)) return c.json({ error: "not authorized" }, 401);
2721
+ const auth = c.req.header("authorization");
2722
+ if (!authorized(auth) && !(edge(auth) && BOX_HTTP_PATH_RE.test(c.req.path))) return c.json({ error: "not authorized" }, 401);
2593
2723
  const user = c.req.header(OWNER_HEADER);
2594
2724
  if (user === void 0) {
2595
2725
  const path = c.req.path.replace(/\/$/, "");
@@ -2608,20 +2738,237 @@ function resolveCaller(opts = {}) {
2608
2738
  await next();
2609
2739
  };
2610
2740
  }
2741
+ function secretCheck(cp) {
2742
+ return bearerCheck(cp.secret);
2743
+ }
2744
+ function edgeCheck(cp) {
2745
+ return bearerCheck(edgeCredential(cp.secret));
2746
+ }
2747
+ function bearerCheck(token) {
2748
+ const expected = digest(token);
2749
+ return (authorization) => {
2750
+ const m = /^Bearer\s+(\S+)$/i.exec(authorization ?? "");
2751
+ return m !== null && timingSafeEqual(digest(m[1]), expected);
2752
+ };
2753
+ }
2611
2754
  function digest(secret) {
2612
2755
  return createHash2("sha256").update(secret).digest();
2613
2756
  }
2614
2757
 
2615
- // src/routes/capacity.ts
2758
+ // src/routes/boxHttp.ts
2759
+ import { connect as connect2 } from "node:net";
2760
+ import { Readable } from "node:stream";
2616
2761
  import { Hono } from "hono";
2617
- function capacityRoutes() {
2762
+ import { request as request3 } from "undici";
2763
+
2764
+ // ../shared/src/httpHead.ts
2765
+ var END = "\r\n\r\n";
2766
+ var HeadTimeoutError = class extends Error {
2767
+ };
2768
+ function readHead(socket, opts) {
2769
+ return new Promise((resolve, reject) => {
2770
+ let bytes = Buffer.alloc(0);
2771
+ const done = (err) => {
2772
+ clearTimeout(timer);
2773
+ socket.off("data", onData);
2774
+ socket.off("close", onClose);
2775
+ socket.pause();
2776
+ if (err) return reject(err);
2777
+ const end = bytes.indexOf(END);
2778
+ resolve({ head: bytes.subarray(0, end).toString("latin1"), rest: bytes.subarray(end + END.length) });
2779
+ };
2780
+ const onData = (chunk) => {
2781
+ bytes = Buffer.concat([bytes, chunk]);
2782
+ if (bytes.includes(END)) done();
2783
+ else if (bytes.length > opts.maxBytes) done(new Error("answer head too large"));
2784
+ };
2785
+ const onClose = () => done(new Error("closed before answering"));
2786
+ const timer = setTimeout(() => done(new HeadTimeoutError(`no answer within ${opts.timeoutMs / 1e3} s`)), opts.timeoutMs);
2787
+ socket.on("data", onData);
2788
+ socket.once("close", onClose);
2789
+ });
2790
+ }
2791
+ function rewriteHead(head, drop, add = {}) {
2792
+ const [status, ...lines] = head.split("\r\n");
2793
+ const kept = lines.filter((l) => !drop(l.slice(0, l.indexOf(":")).trim().toLowerCase()));
2794
+ const extra = Object.entries(add).map(([k, v]) => `${k}: ${v}`);
2795
+ return Buffer.from(`${[status, ...kept, ...extra].join("\r\n")}${END}`, "latin1");
2796
+ }
2797
+
2798
+ // src/routes/boxHttp.ts
2799
+ var HEADERS_TIMEOUT_MS = 55e3;
2800
+ var MAX_HANDSHAKE_BYTES = 16 * 1024;
2801
+ var CONNECT_TIMEOUT_MS = 5e3;
2802
+ var DROPPED = /* @__PURE__ */ new Set([...HOP_BY_HOP, ...NOT_FORWARDED, "host", "authorization", OWNER_HEADER, VISITOR_AUTH_HEADER, VISITOR_PATH_HEADER]);
2803
+ var ProxyFailure = class extends Error {
2804
+ constructor(status, code, message) {
2805
+ super(message);
2806
+ this.status = status;
2807
+ this.code = code;
2808
+ }
2809
+ status;
2810
+ code;
2811
+ };
2812
+ function visitorPath(value) {
2813
+ const path = Array.isArray(value) ? value[0] : value;
2814
+ return path !== void 0 && path !== null && path.startsWith("/") && !/[\s]/.test(path) ? path : "/";
2815
+ }
2816
+ async function targetOf(url, owner, path) {
2817
+ const m = BOX_HTTP_PATH_RE.exec(url.pathname);
2818
+ const port = Number(m?.[2]);
2819
+ if (!m || port < 1 || port > 65535) throw new ProxyFailure(404, "not-found", `no route: ${url.pathname}`);
2820
+ const name = decodeURIComponent(m[1]);
2821
+ let box;
2822
+ try {
2823
+ box = await getMachine(parseRef(name, owner));
2824
+ } catch (err) {
2825
+ if (err instanceof NotFoundError) throw new ProxyFailure(404, "not-found", err.message);
2826
+ throw err;
2827
+ }
2828
+ if (box.status !== "running") throw new ProxyFailure(409, "not-running", `box ${name} is ${box.status}`);
2829
+ return { ip: box.ip, port, path };
2830
+ }
2831
+ function boxHeaders(incoming, target, upgrade = false) {
2832
+ const out = [];
2833
+ let host = `${target.ip}:${target.port}`;
2834
+ for (const [name, value] of incoming) {
2835
+ const key = name.toLowerCase();
2836
+ if (key === "x-forwarded-host") host = value;
2837
+ if (key === VISITOR_AUTH_HEADER) out.push(["authorization", value]);
2838
+ if (upgrade && (key === "upgrade" || key === "connection")) out.push([name, value]);
2839
+ else if (!DROPPED.has(key)) out.push([name, value]);
2840
+ }
2841
+ out.push(["host", host]);
2842
+ return out;
2843
+ }
2844
+ var failure = (err) => new Response(JSON.stringify({ error: err.message }), { status: err.status, headers: { "content-type": "application/json", [PROXY_ERROR_HEADER]: err.code } });
2845
+ function boxHttpRoutes() {
2618
2846
  const r = new Hono();
2847
+ r.all("/:name/http/:port", forward);
2848
+ return r;
2849
+ }
2850
+ async function forward(c) {
2851
+ let target;
2852
+ try {
2853
+ target = await targetOf(new URL(c.req.url), c.get("owner"), visitorPath(c.req.header(VISITOR_PATH_HEADER)));
2854
+ } catch (err) {
2855
+ if (err instanceof ProxyFailure) return failure(err);
2856
+ throw err;
2857
+ }
2858
+ const raw = c.req.raw;
2859
+ const pairs = [];
2860
+ raw.headers.forEach((value, name) => pairs.push([name, value]));
2861
+ let res;
2862
+ try {
2863
+ res = await request3(`http://${target.ip}:${target.port}${target.path}`, {
2864
+ method: raw.method,
2865
+ headers: boxHeaders(pairs, target).flat(),
2866
+ body: raw.body === null ? null : Readable.fromWeb(raw.body),
2867
+ headersTimeout: HEADERS_TIMEOUT_MS,
2868
+ bodyTimeout: 0,
2869
+ signal: raw.signal
2870
+ });
2871
+ } catch (err) {
2872
+ const e = err;
2873
+ if (e.code === "UND_ERR_HEADERS_TIMEOUT") return failure(new ProxyFailure(504, "timeout", `box took over ${HEADERS_TIMEOUT_MS / 1e3} s to answer on port ${target.port}`));
2874
+ return failure(new ProxyFailure(502, "unreachable", `box answered nothing on port ${target.port} (${e.code ?? e.message})`));
2875
+ }
2876
+ const headers = new Headers();
2877
+ for (const [name, value] of Object.entries(res.headers)) {
2878
+ if (value === void 0 || HOP_BY_HOP.has(name) || name.startsWith("x-kvbox-")) continue;
2879
+ for (const v of Array.isArray(value) ? value : [value]) headers.append(name, v);
2880
+ }
2881
+ headers.set(PROXIED_HEADER, "1");
2882
+ const empty = res.statusCode === 204 || res.statusCode === 304 || raw.method === "HEAD";
2883
+ if (empty) res.body.resume();
2884
+ return new Response(empty ? null : Readable.toWeb(res.body), { status: res.statusCode, headers });
2885
+ }
2886
+ function refuse(socket, status, text, code = "refused") {
2887
+ const body = JSON.stringify({ error: text });
2888
+ socket.end(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Error"}\r
2889
+ content-type: application/json\r
2890
+ content-length: ${Buffer.byteLength(body)}\r
2891
+ ${PROXY_ERROR_HEADER}: ${code}\r
2892
+ connection: close\r
2893
+ \r
2894
+ ${body}`);
2895
+ }
2896
+ function headerPairs(raw) {
2897
+ const out = [];
2898
+ for (let i = 0; i + 1 < raw.length; i += 2) out.push([raw[i], raw[i + 1]]);
2899
+ return out;
2900
+ }
2901
+ function boxUpgradeHandler(cp, headersTimeoutMs = HEADERS_TIMEOUT_MS) {
2902
+ const full = secretCheck(cp);
2903
+ const edge = edgeCheck(cp);
2904
+ const authorized = (auth) => full(auth) || edge(auth);
2905
+ return (req, socket, head) => {
2906
+ socket.on("error", () => socket.destroy());
2907
+ void (async () => {
2908
+ const headers = req.headers;
2909
+ if (!authorized(headers.authorization)) return refuse(socket, 401, "not authorized");
2910
+ const user = headers[OWNER_HEADER];
2911
+ if (typeof user !== "string" || !LOCAL_OWNER_RE.test(user)) return refuse(socket, 400, "X-Kvbox-Owner header required");
2912
+ let target;
2913
+ try {
2914
+ target = await targetOf(new URL(req.url ?? "/", "https://host"), `${user}@${cp.name}`, visitorPath(headers[VISITOR_PATH_HEADER]));
2915
+ } catch (err) {
2916
+ if (err instanceof ProxyFailure) return refuse(socket, err.status, err.message, err.code);
2917
+ return refuse(socket, 500, err.message);
2918
+ }
2919
+ if (socket.destroyed) return;
2920
+ const box = connect2({ host: target.ip, port: target.port, timeout: CONNECT_TIMEOUT_MS });
2921
+ let connected = false;
2922
+ let piped = false;
2923
+ box.once("timeout", () => box.destroy(new Error("timed out")));
2924
+ box.once("error", (err) => {
2925
+ if (!connected && !socket.destroyed) refuse(socket, 502, `box answered nothing on port ${target.port} (${err.code ?? err.message})`, "unreachable");
2926
+ else if (piped) socket.destroy();
2927
+ });
2928
+ socket.once("close", () => box.destroy());
2929
+ box.once("close", () => {
2930
+ if (piped) socket.destroy();
2931
+ });
2932
+ box.once("connect", () => {
2933
+ connected = true;
2934
+ box.setTimeout(0);
2935
+ const lines = boxHeaders(headerPairs(req.rawHeaders), target, true).map(([k, v]) => `${k}: ${v}\r
2936
+ `);
2937
+ box.write(`${req.method ?? "GET"} ${target.path} HTTP/1.1\r
2938
+ ${lines.join("")}\r
2939
+ `);
2940
+ if (head.length > 0) box.write(head);
2941
+ readHead(box, { maxBytes: MAX_HANDSHAKE_BYTES, timeoutMs: headersTimeoutMs }).then(
2942
+ (answer) => {
2943
+ if (socket.destroyed) return void box.destroy();
2944
+ socket.write(rewriteHead(answer.head, (name) => name.startsWith("x-kvbox-"), { [PROXIED_HEADER]: "1" }));
2945
+ if (answer.rest.length > 0) socket.write(answer.rest);
2946
+ piped = true;
2947
+ box.pipe(socket);
2948
+ socket.pipe(box);
2949
+ },
2950
+ (err) => {
2951
+ box.destroy();
2952
+ if (socket.destroyed) return;
2953
+ if (err instanceof HeadTimeoutError) refuse(socket, 504, `box took over ${headersTimeoutMs / 1e3} s to answer on port ${target.port}`, "timeout");
2954
+ else refuse(socket, 502, `box gave no answer on port ${target.port} (${err.message})`, "unreachable");
2955
+ }
2956
+ );
2957
+ });
2958
+ })();
2959
+ };
2960
+ }
2961
+
2962
+ // src/routes/capacity.ts
2963
+ import { Hono as Hono2 } from "hono";
2964
+ function capacityRoutes() {
2965
+ const r = new Hono2();
2619
2966
  r.get("/", async (c) => c.json(await capacity()));
2620
2967
  return r;
2621
2968
  }
2622
2969
 
2623
2970
  // src/routes/machines.ts
2624
- import { Hono as Hono2 } from "hono";
2971
+ import { Hono as Hono3 } from "hono";
2625
2972
 
2626
2973
  // src/core/stats.ts
2627
2974
  var STATS_REPLY_MS = 3e3;
@@ -2654,7 +3001,7 @@ async function usageReport(scope, window, p = paths()) {
2654
3001
 
2655
3002
  // src/routes/machines.ts
2656
3003
  function machineRoutes() {
2657
- const r = new Hono2();
3004
+ const r = new Hono3();
2658
3005
  r.get("/", async (c) => c.json(await listMachines(c.get("listScope"))));
2659
3006
  r.post("/", async (c) => {
2660
3007
  const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
@@ -2681,6 +3028,16 @@ function machineRoutes() {
2681
3028
  const { machine, applied } = await setSshKeys(parseRef(c.req.param("name"), c.get("owner")), body.data.sshKeys);
2682
3029
  return c.json({ ...machine, sshKeysApplied: applied });
2683
3030
  });
3031
+ r.post("/:name/ports", async (c) => {
3032
+ const body = PortsRequest.safeParse(await c.req.json().catch(() => ({})));
3033
+ if (!body.success) throw new KvboxError("ports: expected a list of box ports");
3034
+ return c.json(await addPorts(parseRef(c.req.param("name"), c.get("owner")), body.data), 201);
3035
+ });
3036
+ r.delete("/:name/ports", async (c) => {
3037
+ const body = PortsRequest.safeParse(await c.req.json().catch(() => ({})));
3038
+ if (!body.success) throw new KvboxError("ports: expected a list of box ports");
3039
+ return c.json(await removePorts(parseRef(c.req.param("name"), c.get("owner")), body.data));
3040
+ });
2684
3041
  r.get("/:name/usage", async (c) => {
2685
3042
  const since = c.req.query("since");
2686
3043
  let from;
@@ -2700,7 +3057,7 @@ function machineRoutes() {
2700
3057
  }
2701
3058
 
2702
3059
  // src/routes/snapshots.ts
2703
- import { Hono as Hono3 } from "hono";
3060
+ import { Hono as Hono4 } from "hono";
2704
3061
 
2705
3062
  // src/core/snapshots.ts
2706
3063
  import { existsSync as existsSync3 } from "node:fs";
@@ -2770,21 +3127,21 @@ async function snapshotBox(ref, req, owner, p) {
2770
3127
  try {
2771
3128
  const since = box.memSnapshots ?? 0;
2772
3129
  const mode = since === 0 || existsSync3(m.memBase) ? "Diff" : "Full";
2773
- let failure;
3130
+ let failure2;
2774
3131
  await fc.pause();
2775
3132
  try {
2776
3133
  await fc.snapshotCreate(s.vmstate, s.mem, mode);
2777
3134
  lineageTouched = true;
2778
3135
  await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, s.overlay]);
2779
3136
  } catch (err) {
2780
- failure = err;
3137
+ failure2 = err;
2781
3138
  } finally {
2782
3139
  if (!box.paused) await fc.resume();
2783
3140
  }
2784
3141
  if (lineageTouched) {
2785
3142
  await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
2786
3143
  }
2787
- if (failure !== void 0) throw failure;
3144
+ if (failure2 !== void 0) throw failure2;
2788
3145
  if (mode === "Full") {
2789
3146
  await run(["fallocate", "--dig-holes", s.mem]);
2790
3147
  } else if (since > 0) {
@@ -2827,7 +3184,7 @@ async function deleteSnapshot(ref, p = paths()) {
2827
3184
 
2828
3185
  // src/routes/snapshots.ts
2829
3186
  function snapshotRoutes() {
2830
- const r = new Hono3();
3187
+ const r = new Hono4();
2831
3188
  r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));
2832
3189
  r.post("/", async (c) => {
2833
3190
  const body = CreateSnapshotRequest.parse(await c.req.json());
@@ -2841,9 +3198,9 @@ function snapshotRoutes() {
2841
3198
  }
2842
3199
 
2843
3200
  // src/routes/ssh.ts
2844
- import { Hono as Hono4 } from "hono";
3201
+ import { Hono as Hono5 } from "hono";
2845
3202
  function sshRoutes(opts = {}) {
2846
- const r = new Hono4();
3203
+ const r = new Hono5();
2847
3204
  if (opts.controlPlane) {
2848
3205
  r.all("/*", (c) => c.json({ error: "trusting a CA is done on the host itself (kvbox server trust-ca there); the control plane cannot ask a host to" }, 403));
2849
3206
  return r;
@@ -2858,10 +3215,10 @@ function sshRoutes(opts = {}) {
2858
3215
  }
2859
3216
 
2860
3217
  // src/routes/usage.ts
2861
- import { Hono as Hono5 } from "hono";
3218
+ import { Hono as Hono6 } from "hono";
2862
3219
  var PULL_LIMIT = 1e3;
2863
3220
  function usageRoutes(opts = {}) {
2864
- const r = new Hono5();
3221
+ const r = new Hono6();
2865
3222
  r.get("/", async (c) => {
2866
3223
  let window;
2867
3224
  try {
@@ -2888,7 +3245,7 @@ function whole(value, name) {
2888
3245
 
2889
3246
  // src/app.ts
2890
3247
  function createApp(opts = {}) {
2891
- const app = new Hono6();
3248
+ const app = new Hono7();
2892
3249
  app.use(resolveCaller(opts));
2893
3250
  app.get("/health", async (c) => {
2894
3251
  const cfg = await loadConfig();
@@ -2904,6 +3261,7 @@ function createApp(opts = {}) {
2904
3261
  recovery: lastRecovery()
2905
3262
  });
2906
3263
  });
3264
+ if (opts.controlPlane) app.route("/machines", boxHttpRoutes());
2907
3265
  app.route("/machines", machineRoutes());
2908
3266
  app.route("/snapshots", snapshotRoutes());
2909
3267
  app.route("/capacity", capacityRoutes());
@@ -2949,6 +3307,7 @@ async function listen(opts = {}) {
2949
3307
  createServer: createHttpsServer,
2950
3308
  serverOptions: { key, cert }
2951
3309
  });
3310
+ httpsServer.on("upgrade", boxUpgradeHandler({ name: cp.name, secret: cp.secret }));
2952
3311
  const port = opts.httpsPort ?? cp.port;
2953
3312
  await new Promise((resolve, reject) => {
2954
3313
  httpsServer.once("error", reject);