@kvbox/server 0.5.7 → 0.5.9

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.
@@ -419,6 +419,12 @@ var CapacitySchema = z3.object({
419
419
  reservedVcpu: z3.number().int(),
420
420
  running: z3.number().int()
421
421
  });
422
+ var RecoveryReportSchema = z3.object({
423
+ at: z3.string(),
424
+ booted: z3.array(z3.string()),
425
+ leftStopped: z3.array(z3.string()),
426
+ failed: z3.array(z3.object({ box: z3.string(), error: z3.string() }))
427
+ });
422
428
  var HealthSchema = z3.object({
423
429
  ok: z3.literal(true),
424
430
  version: z3.string(),
@@ -442,7 +448,9 @@ var HealthSchema = z3.object({
442
448
  publicIp: z3.string(),
443
449
  /** The relay's WireGuard endpoint the tunnel dials. */
444
450
  endpoint: z3.string()
445
- }).nullable().optional()
451
+ }).nullable().optional(),
452
+ /** Boxes brought back at the server's last start; null when there were none or it is still at it. Older servers omit it. */
453
+ recovery: RecoveryReportSchema.nullable().optional()
446
454
  });
447
455
  var MachineUsageSchema = z3.object({
448
456
  name: z3.string(),
@@ -863,250 +871,122 @@ async function requireKvm(device = KVM_DEVICE, groupFile = "/etc/group") {
863
871
  );
864
872
  }
865
873
 
866
- // src/core/network.ts
867
- import { randomInt } from "node:crypto";
868
- import { existsSync } from "node:fs";
869
- import { fileURLToPath } from "node:url";
870
- var TAP_PREFIX = "kv-";
871
- var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
872
- function tapName(box) {
873
- return `${TAP_PREFIX}${box}`.slice(0, 15);
874
- }
875
- function netSetupScript() {
876
- const override = process.env["KVBOX_NET_SETUP"];
877
- if (override) return override;
878
- if (existsSync(PRIVILEGED_SCRIPT)) return PRIVILEGED_SCRIPT;
879
- for (const rel of ["../scripts/net-setup.sh", "../../scripts/net-setup.sh"]) {
880
- const candidate = fileURLToPath(new URL(rel, import.meta.url));
881
- if (existsSync(candidate)) return candidate;
882
- }
883
- throw new KvboxError("net-setup.sh not found next to kvbox-server");
884
- }
885
- function bridgeExists(bridge) {
886
- return existsSync(`/sys/class/net/${bridge}`);
887
- }
888
- function netRun(cfg, args) {
889
- return run([netSetupScript(), ...args], {
890
- env: { ...process.env, KVBOX_BRIDGE: cfg.bridge, KVBOX_SUBNET: cfg.subnet, KVBOX_GATEWAY: cfg.gateway }
891
- }).catch((err) => {
892
- throw new KvboxError(
893
- `${err.message}
894
- (root is needed for host networking; install-server.sh sets up a sudoers rule for ${PRIVILEGED_SCRIPT})`
895
- );
896
- });
897
- }
898
- async function netSetup(cfg, args) {
899
- await netRun(cfg, args);
900
- }
901
- async function ensureHostNetwork(cfg) {
902
- if (bridgeExists(cfg.bridge)) return "present";
903
- await netRun(cfg, ["up"]);
904
- return "created";
905
- }
906
- async function createTap(box, cfg, opts = {}) {
907
- const tap = tapName(box);
908
- await netRun(cfg, ["tap", "add", tap, ...opts.detached ? ["detached"] : []]);
909
- return tap;
910
- }
911
- async function attachTap(box, cfg) {
912
- await netRun(cfg, ["tap", "attach", tapName(box)]);
913
- }
914
- async function deleteTap(box, cfg) {
915
- await netRun(cfg, ["tap", "del", tapName(box)]);
916
- }
917
- var SSH_PORT_MIN = 2e4;
918
- var SSH_PORT_MAX = 6e4;
919
- function pickSshPort(taken) {
920
- const span = SSH_PORT_MAX - SSH_PORT_MIN + 1;
921
- for (let i = 0; i < 32; i++) {
922
- const port = SSH_PORT_MIN + randomInt(span);
923
- if (!taken.has(port)) return port;
924
- }
925
- const start = randomInt(span);
926
- for (let i = 0; i < span; i++) {
927
- const port = SSH_PORT_MIN + (start + i) % span;
928
- if (!taken.has(port)) return port;
929
- }
930
- throw new KvboxError(`no free port left between ${SSH_PORT_MIN} and ${SSH_PORT_MAX} on this host`);
931
- }
932
- function parseListeningPorts(ssOutput) {
933
- const ports = /* @__PURE__ */ new Set();
934
- for (const line of ssOutput.split("\n")) {
935
- const cols = line.trim().split(/\s+/);
936
- const local = cols[3];
937
- if (!local) continue;
938
- const port = Number(local.slice(local.lastIndexOf(":") + 1));
939
- if (Number.isInteger(port) && port > 0) ports.add(port);
940
- }
941
- return ports;
942
- }
943
- async function listeningPorts() {
944
- try {
945
- return parseListeningPorts((await run(["ss", "-ltnH"])).stdout);
946
- } catch {
947
- return /* @__PURE__ */ new Set();
948
- }
949
- }
950
- var proxyArgs = (p) => [p.box, p.boxIp, String(p.hostPort), String(p.guestPort), p.proto ?? "tcp"];
951
- async function openProxy(proxy, cfg) {
952
- await netRun(cfg, ["proxy", "add", ...proxyArgs(proxy)]);
953
- }
954
- async function closeProxy(proxy, cfg) {
955
- await netRun(cfg, ["proxy", "rm", ...proxyArgs(proxy)]);
956
- }
957
- var ipToInt = (ip) => ip.split(".").reduce((acc, o) => (acc << 8) + Number(o), 0) >>> 0;
958
- var intToIp = (n) => [24, 16, 8, 0].map((s) => n >>> s & 255).join(".");
959
- function nextFreeIp(subnet, inUse) {
960
- const [base, lenStr] = subnet.split("/");
961
- const len = Number(lenStr);
962
- if (!base || !Number.isInteger(len) || len < 8 || len > 30) throw new KvboxError(`bad subnet: ${subnet}`);
963
- const mask = len === 0 ? 0 : ~0 << 32 - len >>> 0;
964
- const network = (ipToInt(base) & mask) >>> 0;
965
- const broadcast = (network | ~mask >>> 0) >>> 0;
966
- const used = new Set(inUse.map(ipToInt));
967
- for (let n = network + 2; n < broadcast; n++) {
968
- if (!used.has(n)) return intToIp(n);
969
- }
970
- throw new KvboxError(`no free IP left in ${subnet}`);
971
- }
972
- function macFor(ip) {
973
- const hex = ip.split(".").map((o) => Number(o).toString(16).padStart(2, "0").toUpperCase());
974
- return ["06", "00", ...hex].join(":");
975
- }
976
-
977
- // src/core/relay.ts
978
- import { chmod as chmod3, mkdir as mkdir2, rm, writeFile as writeFile2 } from "node:fs/promises";
874
+ // src/core/machines.ts
875
+ import { spawn } from "node:child_process";
876
+ import { closeSync as closeSync2, existsSync as existsSync2, openSync as openSync2, statSync } from "node:fs";
877
+ import { link, mkdir as mkdir3, readFile as readFile5, realpath, rm, truncate, writeFile as writeFile3 } from "node:fs/promises";
878
+ import { basename } from "node:path";
879
+ import { setTimeout as sleep } from "node:timers/promises";
979
880
 
980
- // ../shared/src/relay.ts
981
- import { z as z4 } from "zod";
982
- var RELAY_TUNNEL_GATEWAY = "10.77.0.1";
983
- var IPV4_RE = /^(\d{1,3})(\.\d{1,3}){3}$/;
984
- var WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
985
- var RelayJoinSchema = z4.object({
986
- v: z4.literal(1),
987
- /** How the relay lists this host. */
988
- name: z4.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
989
- /** `<relay-primary-ip>:<port>`, the WireGuard endpoint the host dials. */
990
- relay: z4.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
991
- /** The relay's WireGuard public key. */
992
- relayKey: z4.string().regex(WG_KEY_RE),
993
- /** This host's address inside the tunnel. */
994
- tunnelIp: z4.string().regex(IPV4_RE),
995
- /** This host's WireGuard private key, generated on the relay, kept nowhere else. */
996
- privateKey: z4.string().regex(WG_KEY_RE),
997
- /** The extra public IPv4 the relay owns for this host; what the host advertises to the control plane. */
998
- publicIp: z4.string().regex(IPV4_RE)
999
- });
1000
- function decodeRelayJoin(text) {
881
+ // src/core/agent.ts
882
+ import { connect } from "node:net";
883
+ var AGENT_PORT = 52;
884
+ async function agentCall(udsPath, req, timeoutMs = 5e3) {
885
+ const reply = await new Promise((resolve, reject) => {
886
+ const sock = connect(udsPath);
887
+ let buf = "";
888
+ let handshaken = false;
889
+ const fail = (err) => {
890
+ sock.destroy();
891
+ reject(err);
892
+ };
893
+ sock.setTimeout(timeoutMs, () => fail(new KvboxError(`kvbox-agent did not answer within ${timeoutMs} ms`)));
894
+ sock.once("error", (err) => fail(new KvboxError(`kvbox-agent unreachable: ${err.message}`)));
895
+ sock.once("connect", () => sock.write(`CONNECT ${AGENT_PORT}
896
+ `));
897
+ sock.on("data", (chunk) => {
898
+ buf += chunk.toString("utf8");
899
+ let nl;
900
+ while ((nl = buf.indexOf("\n")) !== -1) {
901
+ const line = buf.slice(0, nl);
902
+ buf = buf.slice(nl + 1);
903
+ if (!handshaken) {
904
+ if (!line.startsWith("OK ")) return fail(new KvboxError(`vsock connect refused: ${line}`));
905
+ handshaken = true;
906
+ sock.write(JSON.stringify(req) + "\n");
907
+ } else {
908
+ sock.end();
909
+ return resolve(line);
910
+ }
911
+ }
912
+ });
913
+ sock.once("close", () => {
914
+ if (!handshaken) fail(new KvboxError("vsock connection closed before the guest accepted"));
915
+ else fail(new KvboxError("kvbox-agent closed the connection without a reply"));
916
+ });
917
+ });
1001
918
  let parsed;
1002
919
  try {
1003
- parsed = JSON.parse(Buffer.from(text.trim(), "base64url").toString("utf8"));
920
+ parsed = JSON.parse(reply);
1004
921
  } catch {
1005
- return null;
922
+ throw new KvboxError(`kvbox-agent sent something that is not JSON: ${reply}`);
1006
923
  }
1007
- const res = RelayJoinSchema.safeParse(parsed);
1008
- return res.success ? res.data : null;
1009
- }
1010
-
1011
- // src/core/relay.ts
1012
- function relayConf(join2) {
1013
- return [
1014
- "# kvbox relay tunnel: made by `kvbox server relay`, installed by net-setup.sh. Do not edit.",
1015
- "[Interface]",
1016
- `PrivateKey = ${join2.privateKey}`,
1017
- `Address = ${join2.tunnelIp}/32`,
1018
- "",
1019
- "[Peer]",
1020
- `PublicKey = ${join2.relayKey}`,
1021
- `Endpoint = ${join2.relay}`,
1022
- // Only the relay's own tunnel address goes through the tunnel: the host keeps its normal internet
1023
- // route, and the relay SNATs forwarded connections so their replies come back this way.
1024
- `AllowedIPs = ${RELAY_TUNNEL_GATEWAY}/32`,
1025
- // The router forgets an idle outbound mapping; a packet every 25 s keeps the relay able to reach us.
1026
- "PersistentKeepalive = 25",
1027
- ""
1028
- ].join("\n");
924
+ if (!parsed.ok) throw new KvboxError(`kvbox-agent: ${parsed.error}`);
925
+ return parsed;
1029
926
  }
1030
- async function useRelay(joinString, p = paths()) {
1031
- const join2 = decodeRelayJoin(joinString);
1032
- if (!join2) throw new KvboxError("that is not a relay join string; copy the whole line `kvbox-relay add` printed on the relay");
1033
- const cfg = await loadConfig(p);
1034
- if (cfg.relay && cfg.relay.name !== join2.name) {
1035
- throw new KvboxError(`this host already goes through relay ${cfg.relay.name} (${cfg.relay.publicIp}); run kvbox server relay off first`);
1036
- }
1037
- if (cfg.relay && cfg.relay.publicIp !== join2.publicIp && cfg.controlPlane?.advertise === cfg.relay.publicIp) {
1038
- throw new KvboxError(
1039
- `${cfg.controlPlane.name} reaches this host at ${cfg.relay.publicIp}, not ${join2.publicIp}; run kvbox server leave first, then join again`
1040
- );
1041
- }
1042
- await mkdir2(p.relay, { recursive: true, mode: 448 });
1043
- await writeFile2(p.relayConf, relayConf(join2), { mode: 384 });
1044
- await chmod3(p.relayConf, 384);
1045
- await netSetup(cfg, ["relay", "up", p.relayConf, String(cfg.controlPlane?.port ?? DEFAULT_HTTPS_PORT)]);
1046
- const relay = { name: join2.name, publicIp: join2.publicIp, tunnelIp: join2.tunnelIp, endpoint: join2.relay };
1047
- await saveConfig({ ...cfg, relay }, p);
1048
- return relay;
927
+ function setIdentity(udsPath, id, timeoutMs) {
928
+ return agentCall(udsPath, { op: "identity", ...id }, timeoutMs);
1049
929
  }
1050
- async function dropRelay(p = paths()) {
1051
- const cfg = await loadConfig(p);
1052
- const relay = cfg.relay;
1053
- if (!relay) throw new KvboxError("this host does not go through a relay");
1054
- if (cfg.controlPlane?.advertise === relay.publicIp) {
1055
- throw new KvboxError(`${cfg.controlPlane.name} reaches this host through the relay (${relay.publicIp}); run kvbox server leave first`);
1056
- }
1057
- await netSetup(cfg, ["relay", "down"]);
1058
- await rm(p.relayConf, { force: true });
1059
- const { relay: _gone, ...rest } = cfg;
1060
- await saveConfig(rest, p);
1061
- return relay;
930
+ function setAuthorizedKeys(udsPath, lines, timeoutMs) {
931
+ return agentCall(udsPath, { op: "authorizedKeys", lines: [...lines] }, timeoutMs);
1062
932
  }
933
+ var isUnknownOp = (err) => /unknown op/.test(err.message ?? "");
1063
934
 
1064
- // src/listen.ts
1065
- import { readFile as readFile6, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
1066
- import { createServer as createHttpsServer } from "node:https";
1067
- import { createAdaptorServer } from "@hono/node-server";
935
+ // src/core/capacity.ts
936
+ import { cpus, totalmem } from "node:os";
937
+
938
+ // ../shared/src/capacity.ts
939
+ var MEM_HEADROOM_MIB = 2048;
940
+ var VCPU_OVERSUBSCRIPTION = 2;
1068
941
 
1069
942
  // src/core/state.ts
1070
943
  import { randomBytes } from "node:crypto";
1071
- import { mkdir as mkdir3, readFile as readFile4, readdir, writeFile as writeFile3 } from "node:fs/promises";
1072
- import { z as z5 } from "zod";
1073
- var MachineMetaSchema = z5.object({
944
+ import { readFileSync } from "node:fs";
945
+ import { mkdir as mkdir2, readFile as readFile4, readdir, writeFile as writeFile2 } from "node:fs/promises";
946
+ import { z as z4 } from "zod";
947
+ var MachineMetaSchema = z4.object({
1074
948
  /** Host-unique, minted at `kvbox new` (fresh or restored) and kept for the box's life; the tap is `kv-<id>`. */
1075
- id: z5.string(),
1076
- name: z5.string(),
949
+ id: z4.string(),
950
+ name: z4.string(),
1077
951
  /** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
1078
- owner: z5.string(),
1079
- ip: z5.string(),
1080
- mac: z5.string(),
1081
- pid: z5.number().int().nullable(),
952
+ owner: z4.string(),
953
+ ip: z4.string(),
954
+ mac: z4.string(),
955
+ pid: z4.number().int().nullable(),
1082
956
  // null: stopped on purpose, disk kept
1083
- vcpu: z5.number().int(),
1084
- memMib: z5.number().int(),
1085
- createdAt: z5.string(),
1086
- source: z5.string(),
957
+ /**
958
+ * The host boot `pid` belongs to (/proc/sys/kernel/random/boot_id at launch, #29). After a reboot
959
+ * the number can name some other process; a pid from another boot is dead whatever `kill -0` says.
960
+ * Absent (boxes launched before #29): the pid alone decides.
961
+ */
962
+ bootId: z4.string().optional(),
963
+ vcpu: z4.number().int(),
964
+ memMib: z4.number().int(),
965
+ createdAt: z4.string(),
966
+ source: z4.string(),
1087
967
  /** Base image version (assets/rootfs-<version>.ext4) the box's overlay stacks on. */
1088
- base: z5.string(),
968
+ base: z4.string(),
1089
969
  /**
1090
970
  * Snapshots taken since this VM started (boot or restore). Firecracker resets its dirty-page bitmap at
1091
971
  * each one, so when this is > 0 the next diff is only standalone merged onto `mem.base`; if that file
1092
972
  * is missing the lineage is broken and the next snapshot has to be a full dump. Absent: 0.
1093
973
  */
1094
- memSnapshots: z5.number().int().optional(),
974
+ memSnapshots: z4.number().int().optional(),
1095
975
  /** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
1096
- paused: z5.boolean().optional(),
976
+ paused: z4.boolean().optional(),
1097
977
  /**
1098
978
  * authorized_keys lines the box gets on top of the host's own key, as the control plane sent them
1099
979
  * at `new` (#49). kvbox-init rewrites authorized_keys from the kernel cmdline at every boot, so
1100
980
  * `start` has to send the same lines again. Absent: host key only.
1101
981
  */
1102
- sshKeys: z5.array(z5.string()).optional(),
982
+ sshKeys: z4.array(z4.string()).optional(),
1103
983
  /**
1104
984
  * The guest's authorized_keys may not match `sshKeys`: they changed while the box was not running,
1105
985
  * or its agent could not take them (#55, #63). Cleared by a push through kvbox-agent or a boot.
1106
986
  */
1107
- sshKeysStale: z5.boolean().optional(),
987
+ sshKeysStale: z4.boolean().optional(),
1108
988
  /** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
1109
- sshPort: z5.number().int().optional()
989
+ sshPort: z4.number().int().optional()
1110
990
  });
1111
991
  function newBoxId(taken) {
1112
992
  for (; ; ) {
@@ -1114,20 +994,20 @@ function newBoxId(taken) {
1114
994
  if (!taken.has(id)) return id;
1115
995
  }
1116
996
  }
1117
- var SnapshotMetaSchema = z5.object({
1118
- name: z5.string(),
1119
- owner: z5.string(),
997
+ var SnapshotMetaSchema = z4.object({
998
+ name: z4.string(),
999
+ owner: z4.string(),
1120
1000
  /** The box it was taken from, as owner/name. */
1121
- source: z5.string(),
1122
- base: z5.string(),
1123
- vcpu: z5.number().int(),
1124
- memMib: z5.number().int(),
1125
- sizeBytes: z5.number().int(),
1126
- createdAt: z5.string()
1001
+ source: z4.string(),
1002
+ base: z4.string(),
1003
+ vcpu: z4.number().int(),
1004
+ memMib: z4.number().int(),
1005
+ sizeBytes: z4.number().int(),
1006
+ createdAt: z4.string()
1127
1007
  });
1128
1008
  async function ensureLayout(p = paths()) {
1129
1009
  for (const dir of [p.home, p.ssh, p.bin, p.assets, p.machines, p.snapshots]) {
1130
- await mkdir3(dir, { recursive: true, mode: 448 });
1010
+ await mkdir2(dir, { recursive: true, mode: 448 });
1131
1011
  }
1132
1012
  return p;
1133
1013
  }
@@ -1148,8 +1028,8 @@ async function readMachineMeta(ref, p = paths()) {
1148
1028
  }
1149
1029
  async function writeMachineMeta(meta, p = paths()) {
1150
1030
  const m = p.machine(meta.owner, meta.name);
1151
- await mkdir3(m.dir, { recursive: true });
1152
- await writeFile3(m.meta, JSON.stringify(meta, null, 2) + "\n");
1031
+ await mkdir2(m.dir, { recursive: true });
1032
+ await writeFile2(m.meta, JSON.stringify(meta, null, 2) + "\n");
1153
1033
  }
1154
1034
  async function listMachineRefs(p = paths()) {
1155
1035
  return listRefs(p.machines);
@@ -1159,8 +1039,8 @@ async function readSnapshotMeta(ref, p = paths()) {
1159
1039
  }
1160
1040
  async function writeSnapshotMeta(meta, p = paths()) {
1161
1041
  const s = p.snapshot(meta.owner, meta.name);
1162
- await mkdir3(s.dir, { recursive: true });
1163
- await writeFile3(s.meta, JSON.stringify(meta, null, 2) + "\n");
1042
+ await mkdir2(s.dir, { recursive: true });
1043
+ await writeFile2(s.meta, JSON.stringify(meta, null, 2) + "\n");
1164
1044
  }
1165
1045
  async function listSnapshotRefs(p = paths()) {
1166
1046
  return listRefs(p.snapshots);
@@ -1183,131 +1063,29 @@ function pidAlive(pid) {
1183
1063
  return err.code === "EPERM";
1184
1064
  }
1185
1065
  }
1186
-
1187
- // src/app.ts
1188
- import { Hono as Hono6 } from "hono";
1189
-
1190
- // ../shared/src/version.ts
1191
- import { createRequire } from "node:module";
1192
- function versionOf(srcFileUrl, injected) {
1193
- if (typeof injected === "string") return injected;
1194
- try {
1195
- const require2 = createRequire(srcFileUrl);
1196
- return require2("../package.json").version;
1197
- } catch {
1198
- return "0.0.0-dev";
1199
- }
1200
- }
1201
-
1202
- // src/version.ts
1203
- var VERSION = versionOf(import.meta.url, true ? "0.5.7" : void 0);
1204
-
1205
- // src/caller.ts
1206
- import { createHash as createHash2, timingSafeEqual } from "node:crypto";
1207
- var OWNER_HEADER = "x-kvbox-owner";
1208
- function resolveCaller(opts = {}) {
1209
- const cp = opts.controlPlane;
1210
- if (!cp) {
1211
- return async (c, next) => {
1212
- const cfg = await loadConfig();
1213
- c.set("owner", cfg.owner);
1214
- c.set("listScope", cfg.owner);
1215
- await next();
1216
- };
1217
- }
1218
- const expected = digest(cp.secret);
1219
- return async (c, next) => {
1220
- const m = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
1221
- if (!m || !timingSafeEqual(digest(m[1]), expected)) return c.json({ error: "not authorized" }, 401);
1222
- const user = c.req.header(OWNER_HEADER);
1223
- if (user === void 0) {
1224
- const path = c.req.path.replace(/\/$/, "");
1225
- const realmListing = c.req.method === "GET" && (path === "/machines" || path === "/snapshots");
1226
- if (!realmListing && /^\/(machines|snapshots)(\/|$)/.test(c.req.path)) {
1227
- return c.json({ error: "X-Kvbox-Owner header required" }, 400);
1228
- }
1229
- c.set("owner", "");
1230
- c.set("listScope", { realm: cp.name });
1231
- } else {
1232
- if (!LOCAL_OWNER_RE.test(user)) return c.json({ error: "X-Kvbox-Owner: letters, digits, '.', '_', '-'" }, 400);
1233
- const owner = `${user}@${cp.name}`;
1234
- c.set("owner", owner);
1235
- c.set("listScope", owner);
1066
+ var bootId;
1067
+ function currentBootId() {
1068
+ if (bootId === void 0) {
1069
+ try {
1070
+ bootId = { id: readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() };
1071
+ } catch {
1072
+ bootId = { id: void 0 };
1236
1073
  }
1237
- await next();
1238
- };
1074
+ }
1075
+ return bootId.id;
1239
1076
  }
1240
- function digest(secret) {
1241
- return createHash2("sha256").update(secret).digest();
1077
+ function vmAlive(meta) {
1078
+ if (meta.bootId !== void 0 && meta.bootId !== currentBootId()) return false;
1079
+ return pidAlive(meta.pid);
1242
1080
  }
1243
-
1244
- // src/core/machines.ts
1245
- import { spawn } from "node:child_process";
1246
- import { closeSync as closeSync2, existsSync as existsSync2, openSync as openSync2, statSync } from "node:fs";
1247
- import { link, mkdir as mkdir4, readFile as readFile5, realpath, rm as rm2, truncate, writeFile as writeFile4 } from "node:fs/promises";
1248
- import { basename } from "node:path";
1249
- import { setTimeout as sleep } from "node:timers/promises";
1250
-
1251
- // src/core/agent.ts
1252
- import { connect } from "node:net";
1253
- var AGENT_PORT = 52;
1254
- async function agentCall(udsPath, req, timeoutMs = 5e3) {
1255
- const reply = await new Promise((resolve, reject) => {
1256
- const sock = connect(udsPath);
1257
- let buf = "";
1258
- let handshaken = false;
1259
- const fail = (err) => {
1260
- sock.destroy();
1261
- reject(err);
1262
- };
1263
- sock.setTimeout(timeoutMs, () => fail(new KvboxError(`kvbox-agent did not answer within ${timeoutMs} ms`)));
1264
- sock.once("error", (err) => fail(new KvboxError(`kvbox-agent unreachable: ${err.message}`)));
1265
- sock.once("connect", () => sock.write(`CONNECT ${AGENT_PORT}
1266
- `));
1267
- sock.on("data", (chunk) => {
1268
- buf += chunk.toString("utf8");
1269
- let nl;
1270
- while ((nl = buf.indexOf("\n")) !== -1) {
1271
- const line = buf.slice(0, nl);
1272
- buf = buf.slice(nl + 1);
1273
- if (!handshaken) {
1274
- if (!line.startsWith("OK ")) return fail(new KvboxError(`vsock connect refused: ${line}`));
1275
- handshaken = true;
1276
- sock.write(JSON.stringify(req) + "\n");
1277
- } else {
1278
- sock.end();
1279
- return resolve(line);
1280
- }
1281
- }
1282
- });
1283
- sock.once("close", () => {
1284
- if (!handshaken) fail(new KvboxError("vsock connection closed before the guest accepted"));
1285
- else fail(new KvboxError("kvbox-agent closed the connection without a reply"));
1286
- });
1287
- });
1288
- let parsed;
1081
+ function hostBootTime() {
1289
1082
  try {
1290
- parsed = JSON.parse(reply);
1083
+ const m = /^btime (\d+)$/m.exec(readFileSync("/proc/stat", "utf8"));
1084
+ return m ? new Date(Number(m[1]) * 1e3) : null;
1291
1085
  } catch {
1292
- throw new KvboxError(`kvbox-agent sent something that is not JSON: ${reply}`);
1086
+ return null;
1293
1087
  }
1294
- if (!parsed.ok) throw new KvboxError(`kvbox-agent: ${parsed.error}`);
1295
- return parsed;
1296
- }
1297
- function setIdentity(udsPath, id, timeoutMs) {
1298
- return agentCall(udsPath, { op: "identity", ...id }, timeoutMs);
1299
- }
1300
- function setAuthorizedKeys(udsPath, lines, timeoutMs) {
1301
- return agentCall(udsPath, { op: "authorizedKeys", lines: [...lines] }, timeoutMs);
1302
1088
  }
1303
- var isUnknownOp = (err) => /unknown op/.test(err.message ?? "");
1304
-
1305
- // src/core/capacity.ts
1306
- import { cpus, totalmem } from "node:os";
1307
-
1308
- // ../shared/src/capacity.ts
1309
- var MEM_HEADROOM_MIB = 2048;
1310
- var VCPU_OVERSUBSCRIPTION = 2;
1311
1089
 
1312
1090
  // src/core/capacity.ts
1313
1091
  function hostResources() {
@@ -1318,7 +1096,7 @@ async function runningReservations(p = paths()) {
1318
1096
  for (const ref of await listMachineRefs(p)) {
1319
1097
  try {
1320
1098
  const meta = await readMachineMeta(ref, p);
1321
- if (pidAlive(meta.pid)) out.push({ name: meta.name, vcpu: meta.vcpu, memMib: meta.memMib });
1099
+ if (vmAlive(meta)) out.push({ name: meta.name, vcpu: meta.vcpu, memMib: meta.memMib });
1322
1100
  } catch {
1323
1101
  }
1324
1102
  }
@@ -1447,6 +1225,10 @@ function closeOpenRun(boxId, at, p = paths()) {
1447
1225
  insertRaw({ ...last, at, state: "stopped", by: "server" }, db);
1448
1226
  return true;
1449
1227
  }
1228
+ function closeRunCutByBoot(boxId, bootAt, now, p = paths()) {
1229
+ const last = lastEvent(boxId, openDb(p));
1230
+ return closeOpenRun(boxId, last !== void 0 && last.at < bootAt ? bootAt : now, p);
1231
+ }
1450
1232
  function usageEventsFor(boxId, since, p = paths()) {
1451
1233
  const rows = openDb(p).prepare("select * from usage_events where box_id = ? and at >= ? order by at, id").all(boxId, since);
1452
1234
  return rows.map(fromRaw);
@@ -1543,39 +1325,150 @@ var FirecrackerClient = class {
1543
1325
  resume() {
1544
1326
  return this.call("PATCH", "/vm", { state: "Resumed" });
1545
1327
  }
1546
- /**
1547
- * Full: all of guest memory, dense (seconds for a 4 GB box). Diff: only pages dirtied since the last
1548
- * snapshot (or since boot), written sparse; needs dirty page tracking and resets its bitmap.
1549
- */
1550
- snapshotCreate(snapshotPath, memFilePath, type = "Full") {
1551
- return this.call("PUT", "/snapshot/create", {
1552
- snapshot_type: type,
1553
- snapshot_path: snapshotPath,
1554
- mem_file_path: memFilePath
1555
- });
1328
+ /**
1329
+ * Full: all of guest memory, dense (seconds for a 4 GB box). Diff: only pages dirtied since the last
1330
+ * snapshot (or since boot), written sparse; needs dirty page tracking and resets its bitmap.
1331
+ */
1332
+ snapshotCreate(snapshotPath, memFilePath, type = "Full") {
1333
+ return this.call("PUT", "/snapshot/create", {
1334
+ snapshot_type: type,
1335
+ snapshot_path: snapshotPath,
1336
+ mem_file_path: memFilePath
1337
+ });
1338
+ }
1339
+ /**
1340
+ * Restore a snapshot into this (fresh) process. The memory file is mapped privately: the guest's
1341
+ * writes never reach the snapshot. The vmstate carries the source box's tap and vsock paths; the
1342
+ * overrides point them at this box's. Drive paths are not overridable here: the vmstate names the overlay relative to the box dir (spawnFirecracker).
1343
+ */
1344
+ snapshotLoad(opts) {
1345
+ return this.call("PUT", "/snapshot/load", {
1346
+ snapshot_path: opts.snapshotPath,
1347
+ mem_backend: { backend_type: "File", backend_path: opts.memFilePath },
1348
+ network_overrides: [{ iface_id: opts.tap.ifaceId, host_dev_name: opts.tap.hostDevName }],
1349
+ vsock_override: { uds_path: opts.vsockPath },
1350
+ track_dirty_pages: opts.trackDirtyPages,
1351
+ resume_vm: opts.resume
1352
+ });
1353
+ }
1354
+ info() {
1355
+ return this.call("GET", "/");
1356
+ }
1357
+ async close() {
1358
+ await this.agent.close();
1359
+ }
1360
+ };
1361
+
1362
+ // src/core/network.ts
1363
+ import { randomInt } from "node:crypto";
1364
+ import { existsSync } from "node:fs";
1365
+ import { fileURLToPath } from "node:url";
1366
+ var TAP_PREFIX = "kv-";
1367
+ var PRIVILEGED_SCRIPT = "/usr/local/lib/kvbox/net-setup.sh";
1368
+ function tapName(box) {
1369
+ return `${TAP_PREFIX}${box}`.slice(0, 15);
1370
+ }
1371
+ function netSetupScript() {
1372
+ const override = process.env["KVBOX_NET_SETUP"];
1373
+ if (override) return override;
1374
+ if (existsSync(PRIVILEGED_SCRIPT)) return PRIVILEGED_SCRIPT;
1375
+ for (const rel of ["../scripts/net-setup.sh", "../../scripts/net-setup.sh"]) {
1376
+ const candidate = fileURLToPath(new URL(rel, import.meta.url));
1377
+ if (existsSync(candidate)) return candidate;
1378
+ }
1379
+ throw new KvboxError("net-setup.sh not found next to kvbox-server");
1380
+ }
1381
+ function bridgeExists(bridge) {
1382
+ return existsSync(`/sys/class/net/${bridge}`);
1383
+ }
1384
+ function netRun(cfg, args) {
1385
+ return run([netSetupScript(), ...args], {
1386
+ env: { ...process.env, KVBOX_BRIDGE: cfg.bridge, KVBOX_SUBNET: cfg.subnet, KVBOX_GATEWAY: cfg.gateway }
1387
+ }).catch((err) => {
1388
+ throw new KvboxError(
1389
+ `${err.message}
1390
+ (root is needed for host networking; install-server.sh sets up a sudoers rule for ${PRIVILEGED_SCRIPT})`
1391
+ );
1392
+ });
1393
+ }
1394
+ async function netSetup(cfg, args) {
1395
+ await netRun(cfg, args);
1396
+ }
1397
+ async function ensureHostNetwork(cfg) {
1398
+ if (bridgeExists(cfg.bridge)) return "present";
1399
+ await netRun(cfg, ["up"]);
1400
+ return "created";
1401
+ }
1402
+ async function createTap(box, cfg, opts = {}) {
1403
+ const tap = tapName(box);
1404
+ await netRun(cfg, ["tap", "add", tap, ...opts.detached ? ["detached"] : []]);
1405
+ return tap;
1406
+ }
1407
+ async function attachTap(box, cfg) {
1408
+ await netRun(cfg, ["tap", "attach", tapName(box)]);
1409
+ }
1410
+ async function deleteTap(box, cfg) {
1411
+ await netRun(cfg, ["tap", "del", tapName(box)]);
1412
+ }
1413
+ var SSH_PORT_MIN = 2e4;
1414
+ var SSH_PORT_MAX = 6e4;
1415
+ function pickSshPort(taken) {
1416
+ const span = SSH_PORT_MAX - SSH_PORT_MIN + 1;
1417
+ for (let i = 0; i < 32; i++) {
1418
+ const port = SSH_PORT_MIN + randomInt(span);
1419
+ if (!taken.has(port)) return port;
1420
+ }
1421
+ const start = randomInt(span);
1422
+ for (let i = 0; i < span; i++) {
1423
+ const port = SSH_PORT_MIN + (start + i) % span;
1424
+ if (!taken.has(port)) return port;
1556
1425
  }
1557
- /**
1558
- * Restore a snapshot into this (fresh) process. The memory file is mapped privately: the guest's
1559
- * writes never reach the snapshot. The vmstate carries the source box's tap and vsock paths; the
1560
- * overrides point them at this box's. Drive paths are not overridable here: the vmstate names the overlay relative to the box dir (spawnFirecracker).
1561
- */
1562
- snapshotLoad(opts) {
1563
- return this.call("PUT", "/snapshot/load", {
1564
- snapshot_path: opts.snapshotPath,
1565
- mem_backend: { backend_type: "File", backend_path: opts.memFilePath },
1566
- network_overrides: [{ iface_id: opts.tap.ifaceId, host_dev_name: opts.tap.hostDevName }],
1567
- vsock_override: { uds_path: opts.vsockPath },
1568
- track_dirty_pages: opts.trackDirtyPages,
1569
- resume_vm: opts.resume
1570
- });
1426
+ throw new KvboxError(`no free port left between ${SSH_PORT_MIN} and ${SSH_PORT_MAX} on this host`);
1427
+ }
1428
+ function parseListeningPorts(ssOutput) {
1429
+ const ports = /* @__PURE__ */ new Set();
1430
+ for (const line of ssOutput.split("\n")) {
1431
+ const cols = line.trim().split(/\s+/);
1432
+ const local = cols[3];
1433
+ if (!local) continue;
1434
+ const port = Number(local.slice(local.lastIndexOf(":") + 1));
1435
+ if (Number.isInteger(port) && port > 0) ports.add(port);
1571
1436
  }
1572
- info() {
1573
- return this.call("GET", "/");
1437
+ return ports;
1438
+ }
1439
+ async function listeningPorts() {
1440
+ try {
1441
+ return parseListeningPorts((await run(["ss", "-ltnH"])).stdout);
1442
+ } catch {
1443
+ return /* @__PURE__ */ new Set();
1574
1444
  }
1575
- async close() {
1576
- await this.agent.close();
1445
+ }
1446
+ var proxyArgs = (p) => [p.box, p.boxIp, String(p.hostPort), String(p.guestPort), p.proto ?? "tcp"];
1447
+ async function openProxy(proxy, cfg) {
1448
+ await netRun(cfg, ["proxy", "add", ...proxyArgs(proxy)]);
1449
+ }
1450
+ async function closeProxy(proxy, cfg) {
1451
+ await netRun(cfg, ["proxy", "rm", ...proxyArgs(proxy)]);
1452
+ }
1453
+ var ipToInt = (ip) => ip.split(".").reduce((acc, o) => (acc << 8) + Number(o), 0) >>> 0;
1454
+ var intToIp = (n) => [24, 16, 8, 0].map((s) => n >>> s & 255).join(".");
1455
+ function nextFreeIp(subnet, inUse) {
1456
+ const [base, lenStr] = subnet.split("/");
1457
+ const len = Number(lenStr);
1458
+ if (!base || !Number.isInteger(len) || len < 8 || len > 30) throw new KvboxError(`bad subnet: ${subnet}`);
1459
+ const mask = len === 0 ? 0 : ~0 << 32 - len >>> 0;
1460
+ const network = (ipToInt(base) & mask) >>> 0;
1461
+ const broadcast = (network | ~mask >>> 0) >>> 0;
1462
+ const used = new Set(inUse.map(ipToInt));
1463
+ for (let n = network + 2; n < broadcast; n++) {
1464
+ if (!used.has(n)) return intToIp(n);
1577
1465
  }
1578
- };
1466
+ throw new KvboxError(`no free IP left in ${subnet}`);
1467
+ }
1468
+ function macFor(ip) {
1469
+ const hex = ip.split(".").map((o) => Number(o).toString(16).padStart(2, "0").toUpperCase());
1470
+ return ["06", "00", ...hex].join(":");
1471
+ }
1579
1472
 
1580
1473
  // src/core/machines.ts
1581
1474
  var NAME_RE = /^[a-z0-9][a-z0-9-]{0,11}$/;
@@ -1624,17 +1517,17 @@ function recordState(meta, state, by, p, at = (/* @__PURE__ */ new Date()).toISO
1624
1517
  }
1625
1518
  }
1626
1519
  function isHibernated(meta, p) {
1627
- return !pidAlive(meta.pid) && existsSync2(p.machine(meta.owner, meta.name).hibernateVmstate);
1520
+ return !vmAlive(meta) && existsSync2(p.machine(meta.owner, meta.name).hibernateVmstate);
1628
1521
  }
1629
1522
  function statusOf(meta, p = paths()) {
1630
- if (pidAlive(meta.pid)) return meta.paused ? "standby" : "running";
1523
+ if (vmAlive(meta)) return meta.paused ? "standby" : "running";
1631
1524
  return isHibernated(meta, p) ? "hibernated" : "stopped";
1632
1525
  }
1633
1526
  function stateMessage(ref, status) {
1634
1527
  return `box ${shown(ref)} is ${status === "standby" ? "in standby" : status}`;
1635
1528
  }
1636
1529
  function toMachine(meta, p = paths()) {
1637
- const alive = pidAlive(meta.pid);
1530
+ const alive = vmAlive(meta);
1638
1531
  if (!alive && meta.pid !== null) {
1639
1532
  try {
1640
1533
  closeOpenRun(meta.id, (/* @__PURE__ */ new Date()).toISOString(), p);
@@ -1739,7 +1632,7 @@ async function assertOverlayCapable(baseFile, version) {
1739
1632
  if (!ok) throw new KvboxError(`base image ${version} is not a kvbox base image (no /sbin/overlay-init); rerun install-server.sh`);
1740
1633
  }
1741
1634
  async function createOverlay(file, gib) {
1742
- await writeFile4(file, "", { mode: 384 });
1635
+ await writeFile3(file, "", { mode: 384 });
1743
1636
  await truncate(file, gib * 1024 ** 3);
1744
1637
  await run(["mkfs.ext4", "-q", "-F", "-m", "0", "-L", "kvbox-box", "-E", "lazy_itable_init=1,lazy_journal_init=1", file]);
1745
1638
  }
@@ -1760,8 +1653,8 @@ async function tailOf(file, lines = 5) {
1760
1653
  }
1761
1654
  async function spawnFirecracker(ref, cfg, p) {
1762
1655
  const m = p.machine(ref.owner, ref.name);
1763
- await rm2(m.sock, { force: true });
1764
- await rm2(m.vsock, { force: true });
1656
+ await rm(m.sock, { force: true });
1657
+ await rm(m.vsock, { force: true });
1765
1658
  const logFd = openSync2(m.log, "a");
1766
1659
  let child;
1767
1660
  try {
@@ -1871,7 +1764,7 @@ async function createMachine(req, owner, p = paths()) {
1871
1764
  const base = await currentBase(cfg, p);
1872
1765
  await assertOverlayCapable(base.file, base.version);
1873
1766
  await ensureHostNetwork(cfg);
1874
- await mkdir4(m.dir, { recursive: true, mode: 448 });
1767
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
1875
1768
  let tapCreated = false;
1876
1769
  let pid;
1877
1770
  try {
@@ -1881,7 +1774,7 @@ async function createMachine(req, owner, p = paths()) {
1881
1774
  const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys, sshPort };
1882
1775
  pid = await launch(spec, tap, cfg, keysB64, p);
1883
1776
  await openSshPort(spec, cfg);
1884
- const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
1777
+ const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
1885
1778
  await writeMachineMeta(meta, p);
1886
1779
  recordState(meta, "running", "new", p, meta.createdAt);
1887
1780
  return toMachine(meta, p);
@@ -1889,7 +1782,7 @@ async function createMachine(req, owner, p = paths()) {
1889
1782
  if (pid !== void 0) killQuietly(pid);
1890
1783
  await closeSshPort({ id, ip, sshPort }, cfg);
1891
1784
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1892
- await rm2(m.dir, { recursive: true, force: true });
1785
+ await rm(m.dir, { recursive: true, force: true });
1893
1786
  throw err;
1894
1787
  }
1895
1788
  }
@@ -1927,7 +1820,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1927
1820
  const m = p.machine(owner, name);
1928
1821
  const s = p.snapshot(from.owner, from.name);
1929
1822
  await ensureHostNetwork(cfg);
1930
- await mkdir4(m.dir, { recursive: true, mode: 448 });
1823
+ await mkdir3(m.dir, { recursive: true, mode: 448 });
1931
1824
  let tapCreated = false;
1932
1825
  let pid;
1933
1826
  try {
@@ -1945,7 +1838,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1945
1838
  const stale = await pushAuthorizedKeys({ owner, name, sshKeys }, m.vsock, p);
1946
1839
  await attachTap(id, cfg);
1947
1840
  await openSshPort(spec, cfg);
1948
- const meta = { ...spec, pid, createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0 };
1841
+ const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0 };
1949
1842
  await writeMachineMeta(meta, p);
1950
1843
  recordState(meta, "running", "new", p, meta.createdAt);
1951
1844
  return toMachine(meta, p);
@@ -1953,7 +1846,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1953
1846
  if (pid !== void 0) killQuietly(pid);
1954
1847
  await closeSshPort({ id, ip, sshPort }, cfg);
1955
1848
  if (tapCreated) await deleteTap(id, cfg).catch(() => void 0);
1956
- await rm2(m.dir, { recursive: true, force: true });
1849
+ await rm(m.dir, { recursive: true, force: true });
1957
1850
  throw err;
1958
1851
  }
1959
1852
  }
@@ -1999,7 +1892,7 @@ async function resumeVm(sock) {
1999
1892
  }
2000
1893
  async function shutdown(meta, cfg, p, graceMs) {
2001
1894
  const pid = meta.pid;
2002
- if (pidAlive(pid)) {
1895
+ if (vmAlive(meta)) {
2003
1896
  const fc = new FirecrackerClient(p.machine(meta.owner, meta.name).sock);
2004
1897
  try {
2005
1898
  if (meta.paused) await fc.resume();
@@ -2023,17 +1916,17 @@ async function cleanupProcess(meta, cfg, p) {
2023
1916
  await closeSshPort(meta, cfg);
2024
1917
  await deleteTap(meta.id, cfg);
2025
1918
  const m = p.machine(meta.owner, meta.name);
2026
- await rm2(m.sock, { force: true });
2027
- await rm2(m.vsock, { force: true });
1919
+ await rm(m.sock, { force: true });
1920
+ await rm(m.vsock, { force: true });
2028
1921
  }
2029
1922
  async function stopMachine(ref, p = paths()) {
2030
1923
  const meta = await readMeta(ref, p);
2031
1924
  const cfg = await loadConfig(p);
2032
1925
  const m = p.machine(ref.owner, ref.name);
2033
- if (!pidAlive(meta.pid) && !isHibernated(meta, p)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
1926
+ if (!vmAlive(meta) && !isHibernated(meta, p)) throw new KvboxError(`box ${shown(ref)} is already stopped`);
2034
1927
  await shutdown(meta, cfg, p, 1e4);
2035
- await rm2(m.hibernateDir, { recursive: true, force: true });
2036
- await rm2(m.memBase, { force: true });
1928
+ await rm(m.hibernateDir, { recursive: true, force: true });
1929
+ await rm(m.memBase, { force: true });
2037
1930
  const stopped = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
2038
1931
  await writeMachineMeta(stopped, p);
2039
1932
  recordState(stopped, "stopped", "stop", p);
@@ -2042,7 +1935,7 @@ async function stopMachine(ref, p = paths()) {
2042
1935
  async function startMachine(ref, p = paths()) {
2043
1936
  const meta = await readMeta(ref, p);
2044
1937
  const cfg = await loadConfig(p);
2045
- if (pidAlive(meta.pid)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
1938
+ if (vmAlive(meta)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
2046
1939
  if (isHibernated(meta, p)) {
2047
1940
  throw new KvboxError(`${stateMessage(ref, "hibernated")}: kvbox wake ${shown(ref)} continues where it left off; to boot fresh instead, kvbox stop ${shown(ref)} first (discards its memory)`);
2048
1941
  }
@@ -2054,7 +1947,7 @@ async function startMachine(ref, p = paths()) {
2054
1947
  try {
2055
1948
  pid = await launch(meta, tap, cfg, keysB64, p);
2056
1949
  await openSshPort(meta, cfg);
2057
- const started = { ...meta, pid, sshKeysStale: void 0 };
1950
+ const started = { ...meta, pid, bootId: currentBootId(), sshKeysStale: void 0 };
2058
1951
  await writeMachineMeta(started, p);
2059
1952
  recordState(started, "running", "start", p);
2060
1953
  return toMachine(started, p);
@@ -2148,15 +2041,15 @@ async function hibernateMachine(ref, p = paths()) {
2148
2041
  if (status === "hibernated") throw new KvboxError(`${stateMessage(ref, status)} already`);
2149
2042
  if (status === "stopped") throw new KvboxError(`${stateMessage(ref, status)}; only a running box can be hibernated (there is no memory to keep)`);
2150
2043
  const m = p.machine(ref.owner, ref.name);
2151
- await rm2(m.hibernateDir, { recursive: true, force: true });
2152
- await mkdir4(m.hibernateDir, { recursive: true, mode: 448 });
2044
+ await rm(m.hibernateDir, { recursive: true, force: true });
2045
+ await mkdir3(m.hibernateDir, { recursive: true, mode: 448 });
2153
2046
  const fc = new FirecrackerClient(m.sock);
2154
2047
  try {
2155
2048
  try {
2156
2049
  await fc.pause();
2157
2050
  await fc.snapshotCreate(m.hibernateVmstate, m.hibernateMem, "Full");
2158
2051
  } catch (err) {
2159
- await rm2(m.hibernateDir, { recursive: true, force: true });
2052
+ await rm(m.hibernateDir, { recursive: true, force: true });
2160
2053
  if (!meta.paused) await fc.resume().catch(() => void 0);
2161
2054
  throw err;
2162
2055
  }
@@ -2167,7 +2060,7 @@ async function hibernateMachine(ref, p = paths()) {
2167
2060
  await waitFor(() => !pidAlive(meta.pid), 5e3, 50);
2168
2061
  await cleanupProcess(meta, cfg, p);
2169
2062
  await run(["fallocate", "--dig-holes", m.hibernateMem]);
2170
- await rm2(m.memBase, { force: true });
2063
+ await rm(m.memBase, { force: true });
2171
2064
  const hibernated = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
2172
2065
  await writeMachineMeta(hibernated, p);
2173
2066
  recordState(hibernated, "hibernated", "hibernate", p);
@@ -2205,19 +2098,19 @@ async function wakeMachine(ref, p = paths()) {
2205
2098
  await deleteTap(meta.id, cfg).catch(() => void 0);
2206
2099
  throw err;
2207
2100
  }
2208
- await rm2(m.memBase, { force: true });
2101
+ await rm(m.memBase, { force: true });
2209
2102
  await link(m.hibernateMem, m.memBase);
2210
- await rm2(m.hibernateDir, { recursive: true, force: true });
2211
- const running = { ...meta, pid, memSnapshots: 1, paused: void 0, sshKeysStale: stale ? true : void 0 };
2103
+ await rm(m.hibernateDir, { recursive: true, force: true });
2104
+ const running = { ...meta, pid, bootId: currentBootId(), memSnapshots: 1, paused: void 0, sshKeysStale: stale ? true : void 0 };
2212
2105
  await writeMachineMeta(running, p);
2213
2106
  recordState(running, "running", "wake", p);
2214
2107
  return toMachine(running, p);
2215
2108
  }
2216
2109
  async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
2217
- const wasRunning = pidAlive(meta.pid);
2110
+ const wasRunning = vmAlive(meta);
2218
2111
  if (wasRunning) {
2219
2112
  await shutdown(meta, cfg, p, 1e4);
2220
- await rm2(p.machine(meta.owner, meta.name).memBase, { force: true });
2113
+ await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
2221
2114
  }
2222
2115
  const updated = { ...meta, ...changes, pid: null, memSnapshots: void 0, paused: void 0 };
2223
2116
  await writeMachineMeta(updated, p);
@@ -2280,7 +2173,216 @@ async function destroyMachine(ref, p = paths()) {
2280
2173
  const cfg = await loadConfig(p);
2281
2174
  await shutdown(meta, cfg, p, 2e3);
2282
2175
  recordState(meta, "stopped", "rm", p);
2283
- await rm2(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
2176
+ await rm(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
2177
+ }
2178
+ var RECOVERY_CONCURRENCY = 2;
2179
+ async function settleDeadBoxes(p = paths()) {
2180
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2181
+ const bootAt = hostBootTime()?.toISOString() ?? now;
2182
+ const boot = [];
2183
+ const leftStopped = [];
2184
+ for (const meta of await readAllMeta(p)) {
2185
+ if (!cutByReboot(meta) || vmAlive(meta)) continue;
2186
+ try {
2187
+ closeRunCutByBoot(meta.id, bootAt, now, p);
2188
+ } catch (err) {
2189
+ console.error(`warning: usage ledger: ${err.message}`);
2190
+ }
2191
+ await rm(p.machine(meta.owner, meta.name).memBase, { force: true });
2192
+ if (meta.paused) {
2193
+ await writeMachineMeta({ ...meta, pid: null, bootId: void 0, memSnapshots: void 0, paused: void 0 }, p);
2194
+ leftStopped.push(refKey(meta));
2195
+ } else {
2196
+ await writeMachineMeta({ ...meta, memSnapshots: void 0 }, p);
2197
+ boot.push({ owner: meta.owner, name: meta.name });
2198
+ }
2199
+ }
2200
+ return { boot, leftStopped };
2201
+ }
2202
+ function cutByReboot(meta) {
2203
+ return meta.pid !== null && meta.bootId !== currentBootId();
2204
+ }
2205
+ var recovery = null;
2206
+ var recoveryStopping = false;
2207
+ var recoveryRun = Promise.resolve();
2208
+ function lastRecovery() {
2209
+ return recovery;
2210
+ }
2211
+ async function stopRecovery() {
2212
+ recoveryStopping = true;
2213
+ await recoveryRun;
2214
+ }
2215
+ async function bootRecovered(settled, p = paths()) {
2216
+ const refs = settled.boot;
2217
+ if (refs.length === 0 && settled.leftStopped.length === 0) return null;
2218
+ const booted = [];
2219
+ const failed = [];
2220
+ const queue = [...refs];
2221
+ const worker = async () => {
2222
+ for (let ref = queue.shift(); ref !== void 0 && !recoveryStopping; ref = queue.shift()) {
2223
+ try {
2224
+ if (!cutByReboot(await readMeta(ref, p))) continue;
2225
+ await startMachine(ref, p);
2226
+ booted.push(refKey(ref));
2227
+ } catch (err) {
2228
+ failed.push({ box: refKey(ref), error: err.message });
2229
+ }
2230
+ }
2231
+ };
2232
+ const run2 = Promise.all(Array.from({ length: Math.min(RECOVERY_CONCURRENCY, refs.length) }, worker));
2233
+ recoveryRun = run2;
2234
+ await run2;
2235
+ recovery = { at: (/* @__PURE__ */ new Date()).toISOString(), booted, leftStopped: [...settled.leftStopped], failed };
2236
+ return recovery;
2237
+ }
2238
+
2239
+ // src/core/relay.ts
2240
+ import { chmod as chmod3, mkdir as mkdir4, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
2241
+
2242
+ // ../shared/src/relay.ts
2243
+ import { z as z5 } from "zod";
2244
+ var RELAY_TUNNEL_GATEWAY = "10.77.0.1";
2245
+ var IPV4_RE = /^(\d{1,3})(\.\d{1,3}){3}$/;
2246
+ var WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/;
2247
+ var RelayJoinSchema = z5.object({
2248
+ v: z5.literal(1),
2249
+ /** How the relay lists this host. */
2250
+ name: z5.string().regex(/^[a-z0-9][a-z0-9-]{0,30}$/),
2251
+ /** `<relay-primary-ip>:<port>`, the WireGuard endpoint the host dials. */
2252
+ relay: z5.string().regex(/^(\d{1,3})(\.\d{1,3}){3}:\d{1,5}$/),
2253
+ /** The relay's WireGuard public key. */
2254
+ relayKey: z5.string().regex(WG_KEY_RE),
2255
+ /** This host's address inside the tunnel. */
2256
+ tunnelIp: z5.string().regex(IPV4_RE),
2257
+ /** This host's WireGuard private key, generated on the relay, kept nowhere else. */
2258
+ privateKey: z5.string().regex(WG_KEY_RE),
2259
+ /** The extra public IPv4 the relay owns for this host; what the host advertises to the control plane. */
2260
+ publicIp: z5.string().regex(IPV4_RE)
2261
+ });
2262
+ function decodeRelayJoin(text) {
2263
+ let parsed;
2264
+ try {
2265
+ parsed = JSON.parse(Buffer.from(text.trim(), "base64url").toString("utf8"));
2266
+ } catch {
2267
+ return null;
2268
+ }
2269
+ const res = RelayJoinSchema.safeParse(parsed);
2270
+ return res.success ? res.data : null;
2271
+ }
2272
+
2273
+ // src/core/relay.ts
2274
+ function relayConf(join2) {
2275
+ return [
2276
+ "# kvbox relay tunnel: made by `kvbox server relay`, installed by net-setup.sh. Do not edit.",
2277
+ "[Interface]",
2278
+ `PrivateKey = ${join2.privateKey}`,
2279
+ `Address = ${join2.tunnelIp}/32`,
2280
+ "",
2281
+ "[Peer]",
2282
+ `PublicKey = ${join2.relayKey}`,
2283
+ `Endpoint = ${join2.relay}`,
2284
+ // Only the relay's own tunnel address goes through the tunnel: the host keeps its normal internet
2285
+ // route, and the relay SNATs forwarded connections so their replies come back this way.
2286
+ `AllowedIPs = ${RELAY_TUNNEL_GATEWAY}/32`,
2287
+ // The router forgets an idle outbound mapping; a packet every 25 s keeps the relay able to reach us.
2288
+ "PersistentKeepalive = 25",
2289
+ ""
2290
+ ].join("\n");
2291
+ }
2292
+ async function useRelay(joinString, p = paths()) {
2293
+ const join2 = decodeRelayJoin(joinString);
2294
+ if (!join2) throw new KvboxError("that is not a relay join string; copy the whole line `kvbox-relay add` printed on the relay");
2295
+ const cfg = await loadConfig(p);
2296
+ if (cfg.relay && cfg.relay.name !== join2.name) {
2297
+ throw new KvboxError(`this host already goes through relay ${cfg.relay.name} (${cfg.relay.publicIp}); run kvbox server relay off first`);
2298
+ }
2299
+ if (cfg.relay && cfg.relay.publicIp !== join2.publicIp && cfg.controlPlane?.advertise === cfg.relay.publicIp) {
2300
+ throw new KvboxError(
2301
+ `${cfg.controlPlane.name} reaches this host at ${cfg.relay.publicIp}, not ${join2.publicIp}; run kvbox server leave first, then join again`
2302
+ );
2303
+ }
2304
+ await mkdir4(p.relay, { recursive: true, mode: 448 });
2305
+ await writeFile4(p.relayConf, relayConf(join2), { mode: 384 });
2306
+ await chmod3(p.relayConf, 384);
2307
+ await netSetup(cfg, ["relay", "up", p.relayConf, String(cfg.controlPlane?.port ?? DEFAULT_HTTPS_PORT)]);
2308
+ const relay = { name: join2.name, publicIp: join2.publicIp, tunnelIp: join2.tunnelIp, endpoint: join2.relay };
2309
+ await saveConfig({ ...cfg, relay }, p);
2310
+ return relay;
2311
+ }
2312
+ async function dropRelay(p = paths()) {
2313
+ const cfg = await loadConfig(p);
2314
+ const relay = cfg.relay;
2315
+ if (!relay) throw new KvboxError("this host does not go through a relay");
2316
+ if (cfg.controlPlane?.advertise === relay.publicIp) {
2317
+ throw new KvboxError(`${cfg.controlPlane.name} reaches this host through the relay (${relay.publicIp}); run kvbox server leave first`);
2318
+ }
2319
+ await netSetup(cfg, ["relay", "down"]);
2320
+ await rm2(p.relayConf, { force: true });
2321
+ const { relay: _gone, ...rest } = cfg;
2322
+ await saveConfig(rest, p);
2323
+ return relay;
2324
+ }
2325
+
2326
+ // src/listen.ts
2327
+ import { readFile as readFile6, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
2328
+ import { createServer as createHttpsServer } from "node:https";
2329
+ import { createAdaptorServer } from "@hono/node-server";
2330
+
2331
+ // src/app.ts
2332
+ import { Hono as Hono6 } from "hono";
2333
+
2334
+ // ../shared/src/version.ts
2335
+ import { createRequire } from "node:module";
2336
+ function versionOf(srcFileUrl, injected) {
2337
+ if (typeof injected === "string") return injected;
2338
+ try {
2339
+ const require2 = createRequire(srcFileUrl);
2340
+ return require2("../package.json").version;
2341
+ } catch {
2342
+ return "0.0.0-dev";
2343
+ }
2344
+ }
2345
+
2346
+ // src/version.ts
2347
+ var VERSION = versionOf(import.meta.url, true ? "0.5.9" : void 0);
2348
+
2349
+ // src/caller.ts
2350
+ import { createHash as createHash2, timingSafeEqual } from "node:crypto";
2351
+ var OWNER_HEADER = "x-kvbox-owner";
2352
+ function resolveCaller(opts = {}) {
2353
+ const cp = opts.controlPlane;
2354
+ if (!cp) {
2355
+ return async (c, next) => {
2356
+ const cfg = await loadConfig();
2357
+ c.set("owner", cfg.owner);
2358
+ c.set("listScope", cfg.owner);
2359
+ await next();
2360
+ };
2361
+ }
2362
+ const expected = digest(cp.secret);
2363
+ return async (c, next) => {
2364
+ const m = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
2365
+ if (!m || !timingSafeEqual(digest(m[1]), expected)) return c.json({ error: "not authorized" }, 401);
2366
+ const user = c.req.header(OWNER_HEADER);
2367
+ if (user === void 0) {
2368
+ const path = c.req.path.replace(/\/$/, "");
2369
+ const realmListing = c.req.method === "GET" && (path === "/machines" || path === "/snapshots");
2370
+ if (!realmListing && /^\/(machines|snapshots)(\/|$)/.test(c.req.path)) {
2371
+ return c.json({ error: "X-Kvbox-Owner header required" }, 400);
2372
+ }
2373
+ c.set("owner", "");
2374
+ c.set("listScope", { realm: cp.name });
2375
+ } else {
2376
+ if (!LOCAL_OWNER_RE.test(user)) return c.json({ error: "X-Kvbox-Owner: letters, digits, '.', '_', '-'" }, 400);
2377
+ const owner = `${user}@${cp.name}`;
2378
+ c.set("owner", owner);
2379
+ c.set("listScope", owner);
2380
+ }
2381
+ await next();
2382
+ };
2383
+ }
2384
+ function digest(secret) {
2385
+ return createHash2("sha256").update(secret).digest();
2284
2386
  }
2285
2387
 
2286
2388
  // src/routes/capacity.ts
@@ -2403,7 +2505,7 @@ async function saveSnapshot(req, owner, p = paths()) {
2403
2505
  throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
2404
2506
  }
2405
2507
  const box = await readBox(parseRef(req.box, owner), p);
2406
- if (!pidAlive(box.pid)) {
2508
+ if (!vmAlive(box)) {
2407
2509
  if (existsSync3(p.machine(box.owner, box.name).hibernateVmstate)) {
2408
2510
  throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
2409
2511
  }
@@ -2549,7 +2651,8 @@ function createApp(opts = {}) {
2549
2651
  base: await currentBaseVersion().catch(() => null),
2550
2652
  owner: cfg.owner,
2551
2653
  controlPlane: cp ? { name: cp.name, url: cp.url, hostUrl: `https://${cp.advertise}:${cp.port}` } : null,
2552
- relay: cfg.relay ? { name: cfg.relay.name, publicIp: cfg.relay.publicIp, endpoint: cfg.relay.endpoint } : null
2654
+ relay: cfg.relay ? { name: cfg.relay.name, publicIp: cfg.relay.publicIp, endpoint: cfg.relay.endpoint } : null,
2655
+ recovery: lastRecovery()
2553
2656
  });
2554
2657
  });
2555
2658
  app.route("/machines", machineRoutes());
@@ -2621,6 +2724,7 @@ async function listen(opts = {}) {
2621
2724
  const shutdown2 = async () => {
2622
2725
  server.close();
2623
2726
  httpsServer?.close();
2727
+ await stopRecovery();
2624
2728
  await rm4(socketPath, { force: true });
2625
2729
  await rm4(p.serverPid, { force: true });
2626
2730
  process.exit(0);
@@ -2634,6 +2738,7 @@ async function listen(opts = {}) {
2634
2738
  var program = new Command("kvbox-server").description("kvbox host daemon: owns Firecracker and ~/.kvbox, serves the kvbox API").version(VERSION, "-v, --version").showHelpAfterError();
2635
2739
  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) => {
2636
2740
  await requireKvm();
2741
+ const settled = await settleDeadBoxes(paths());
2637
2742
  const { socketPath, https } = await listen(opts.socket ? { socketPath: opts.socket } : {});
2638
2743
  console.log(`kvbox-server ${VERSION} listening on ${socketPath} (pid ${process.pid})`);
2639
2744
  if (https) {
@@ -2647,6 +2752,12 @@ program.command("start", { isDefault: true }).description("serve the API on the
2647
2752
  } catch (err) {
2648
2753
  console.error(`warning: host network not ready (${err.message}); run: kvbox-server net up`);
2649
2754
  }
2755
+ const recovery2 = await bootRecovered(settled, paths());
2756
+ if (recovery2) {
2757
+ for (const box of recovery2.booted) console.log(`box ${box} was running when it went down; booted it again`);
2758
+ for (const box of recovery2.leftStopped) console.log(`box ${box} was in standby when it went down; its memory is gone, left stopped`);
2759
+ for (const f of recovery2.failed) console.error(`warning: box ${f.box} was running when it went down and did not come back: ${f.error}`);
2760
+ }
2650
2761
  });
2651
2762
  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) => {
2652
2763
  const joined = await joinControlPlane({ url, ...opts }, paths());