@kvbox/server 0.5.8 → 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.
- package/dist/kvbox-server.js +518 -407
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
package/dist/kvbox-server.js
CHANGED
|
@@ -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/
|
|
867
|
-
import {
|
|
868
|
-
import { existsSync } from "node:fs";
|
|
869
|
-
import {
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
//
|
|
981
|
-
import {
|
|
982
|
-
var
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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(
|
|
920
|
+
parsed = JSON.parse(reply);
|
|
1004
921
|
} catch {
|
|
1005
|
-
|
|
922
|
+
throw new KvboxError(`kvbox-agent sent something that is not JSON: ${reply}`);
|
|
1006
923
|
}
|
|
1007
|
-
|
|
1008
|
-
return
|
|
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
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
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/
|
|
1065
|
-
import {
|
|
1066
|
-
|
|
1067
|
-
|
|
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 {
|
|
1072
|
-
import {
|
|
1073
|
-
|
|
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:
|
|
1076
|
-
name:
|
|
949
|
+
id: z4.string(),
|
|
950
|
+
name: z4.string(),
|
|
1077
951
|
/** Part of the box's identity: machines/<owner>/<name>. See refs.ts. */
|
|
1078
|
-
owner:
|
|
1079
|
-
ip:
|
|
1080
|
-
mac:
|
|
1081
|
-
pid:
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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:
|
|
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:
|
|
974
|
+
memSnapshots: z4.number().int().optional(),
|
|
1095
975
|
/** `kvbox pause`: Firecracker is alive but the VM is frozen (standby). Absent: not paused. */
|
|
1096
|
-
paused:
|
|
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:
|
|
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:
|
|
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:
|
|
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 =
|
|
1118
|
-
name:
|
|
1119
|
-
owner:
|
|
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:
|
|
1122
|
-
base:
|
|
1123
|
-
vcpu:
|
|
1124
|
-
memMib:
|
|
1125
|
-
sizeBytes:
|
|
1126
|
-
createdAt:
|
|
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
|
|
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
|
|
1152
|
-
await
|
|
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
|
|
1163
|
-
await
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
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.8" : 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
|
-
|
|
1238
|
-
|
|
1074
|
+
}
|
|
1075
|
+
return bootId.id;
|
|
1239
1076
|
}
|
|
1240
|
-
function
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
1576
|
-
|
|
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 !
|
|
1520
|
+
return !vmAlive(meta) && existsSync2(p.machine(meta.owner, meta.name).hibernateVmstate);
|
|
1628
1521
|
}
|
|
1629
1522
|
function statusOf(meta, p = paths()) {
|
|
1630
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
|
1764
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
2027
|
-
await
|
|
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 (!
|
|
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
|
|
2036
|
-
await
|
|
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 (
|
|
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
|
|
2152
|
-
await
|
|
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
|
|
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
|
|
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
|
|
2101
|
+
await rm(m.memBase, { force: true });
|
|
2209
2102
|
await link(m.hibernateMem, m.memBase);
|
|
2210
|
-
await
|
|
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 =
|
|
2110
|
+
const wasRunning = vmAlive(meta);
|
|
2218
2111
|
if (wasRunning) {
|
|
2219
2112
|
await shutdown(meta, cfg, p, 1e4);
|
|
2220
|
-
await
|
|
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
|
|
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 (!
|
|
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());
|