@kvbox/server 0.5.9 → 0.5.11
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 +296 -45
- package/dist/kvbox-server.js.map +1 -1
- package/package.json +1 -1
- package/scripts/net-setup.sh +57 -6
package/dist/kvbox-server.js
CHANGED
|
@@ -292,6 +292,8 @@ var ConfigSchema = z2.object({
|
|
|
292
292
|
/** Who boxes made over the local socket belong to: the Unix user running the server unless set here. No `@`, no `/`. */
|
|
293
293
|
owner: z2.string().regex(LOCAL_OWNER_RE, "owner: letters, digits, '.', '_', '-'; no '@' or '/'").default(localOwner),
|
|
294
294
|
pricing: PricingSchema.optional(),
|
|
295
|
+
/** What a new box gets when `new` does not say (#47): seconds without network traffic before it is hibernated; 0: never. */
|
|
296
|
+
autoHibernateSec: z2.number().int().min(0).default(0),
|
|
295
297
|
/** Absent until the host joins a control plane; then the server also listens on HTTPS. */
|
|
296
298
|
controlPlane: ControlPlaneSchema.optional(),
|
|
297
299
|
/** Absent unless the host sits behind a relay. */
|
|
@@ -370,7 +372,9 @@ var MachineSchema = z3.object({
|
|
|
370
372
|
*/
|
|
371
373
|
ssh: z3.object({ host: z3.string(), port: z3.number().int(), user: z3.string() }).optional(),
|
|
372
374
|
/** Whether the owner slug is a user or an org. Only the control plane knows; it fills it in on `GET /machines`. */
|
|
373
|
-
ownerKind: z3.enum(["user", "org"]).optional()
|
|
375
|
+
ownerKind: z3.enum(["user", "org"]).optional(),
|
|
376
|
+
/** Seconds without network traffic before the host hibernates the box (#47). Absent: never. */
|
|
377
|
+
autoHibernateSec: z3.number().int().positive().optional()
|
|
374
378
|
});
|
|
375
379
|
var CreateMachineRequest = z3.object({
|
|
376
380
|
name: z3.string().optional(),
|
|
@@ -384,7 +388,9 @@ var CreateMachineRequest = z3.object({
|
|
|
384
388
|
* its SSH CA as one `cert-authority …` line (#51). Written into the box's authorized_keys at every
|
|
385
389
|
* boot (#49); a host older than that ignores them.
|
|
386
390
|
*/
|
|
387
|
-
sshKeys: z3.array(z3.string()).optional()
|
|
391
|
+
sshKeys: z3.array(z3.string()).optional(),
|
|
392
|
+
/** Seconds without network traffic before the box is hibernated (#47); 0: never. Absent: the host's default. */
|
|
393
|
+
autoHibernateSec: z3.number().int().min(0).optional()
|
|
388
394
|
});
|
|
389
395
|
var SetSshKeysRequest = z3.object({
|
|
390
396
|
sshKeys: z3.array(z3.string())
|
|
@@ -395,6 +401,10 @@ var RescaleMachineRequest = z3.object({
|
|
|
395
401
|
memMib: z3.number().int().min(256).optional(),
|
|
396
402
|
diskGib: z3.number().int().min(1).optional()
|
|
397
403
|
});
|
|
404
|
+
var SetMachineRequest = z3.object({
|
|
405
|
+
/** Seconds without network traffic before the box is hibernated (#47); 0: never. */
|
|
406
|
+
autoHibernateSec: z3.number().int().min(0).optional()
|
|
407
|
+
});
|
|
398
408
|
var SnapshotSchema = z3.object({
|
|
399
409
|
name: z3.string(),
|
|
400
410
|
owner: z3.string(),
|
|
@@ -447,7 +457,10 @@ var HealthSchema = z3.object({
|
|
|
447
457
|
/** The public IP the relay owns for this host. */
|
|
448
458
|
publicIp: z3.string(),
|
|
449
459
|
/** The relay's WireGuard endpoint the tunnel dials. */
|
|
450
|
-
endpoint: z3.string()
|
|
460
|
+
endpoint: z3.string(),
|
|
461
|
+
/** Times the server redialed the tunnel since it started, because the relay stopped answering. Older servers omit them. */
|
|
462
|
+
restarts: z3.number().int().optional(),
|
|
463
|
+
lastRestartAt: z3.string().nullable().optional()
|
|
451
464
|
}).nullable().optional(),
|
|
452
465
|
/** 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
466
|
recovery: RecoveryReportSchema.nullable().optional()
|
|
@@ -471,6 +484,23 @@ var MachineUsageSchema = z3.object({
|
|
|
471
484
|
estimatedStops: z3.number().int(),
|
|
472
485
|
historyFrom: z3.string().nullable()
|
|
473
486
|
});
|
|
487
|
+
var GuestStatsSchema = z3.object({
|
|
488
|
+
/** Load average over 1, 5 and 15 minutes. */
|
|
489
|
+
load: z3.tuple([z3.number(), z3.number(), z3.number()]),
|
|
490
|
+
cpus: z3.number().int(),
|
|
491
|
+
memTotalMib: z3.number().int(),
|
|
492
|
+
/** Total minus available: page cache counts as free. */
|
|
493
|
+
memUsedMib: z3.number().int(),
|
|
494
|
+
/** The box's root, which is its overlay disk: the box's own writes, not the shared base. */
|
|
495
|
+
diskTotalBytes: z3.number().int(),
|
|
496
|
+
diskUsedBytes: z3.number().int(),
|
|
497
|
+
uptimeSec: z3.number().int()
|
|
498
|
+
});
|
|
499
|
+
var MachineStatsSchema = z3.object({
|
|
500
|
+
name: z3.string(),
|
|
501
|
+
owner: z3.string(),
|
|
502
|
+
stats: GuestStatsSchema.nullable()
|
|
503
|
+
});
|
|
474
504
|
var UsageStatusSchema = z3.enum([...BoxStatusSchema.options, "removed"]);
|
|
475
505
|
var BoxUsageSchema = MachineUsageSchema.omit({ window: true, status: true }).extend({
|
|
476
506
|
/** The host's box id; a name removed and recreated appears twice, with two ids. */
|
|
@@ -930,7 +960,13 @@ function setIdentity(udsPath, id, timeoutMs) {
|
|
|
930
960
|
function setAuthorizedKeys(udsPath, lines, timeoutMs) {
|
|
931
961
|
return agentCall(udsPath, { op: "authorizedKeys", lines: [...lines] }, timeoutMs);
|
|
932
962
|
}
|
|
963
|
+
async function stats(udsPath, timeoutMs) {
|
|
964
|
+
const reply = GuestStatsSchema.safeParse(await agentCall(udsPath, { op: "stats" }, timeoutMs));
|
|
965
|
+
if (!reply.success) throw new KvboxError(`kvbox-agent sent stats kvbox does not understand: ${reply.error.issues[0]?.message ?? "invalid"}`);
|
|
966
|
+
return reply.data;
|
|
967
|
+
}
|
|
933
968
|
var isUnknownOp = (err) => /unknown op/.test(err.message ?? "");
|
|
969
|
+
var isNoAgent = (err) => /vsock connect refused|vsock connection closed before the guest accepted|kvbox-agent unreachable: connect (ENOENT|ECONNREFUSED)/.test(err.message ?? "");
|
|
934
970
|
|
|
935
971
|
// src/core/capacity.ts
|
|
936
972
|
import { cpus, totalmem } from "node:os";
|
|
@@ -986,7 +1022,9 @@ var MachineMetaSchema = z4.object({
|
|
|
986
1022
|
*/
|
|
987
1023
|
sshKeysStale: z4.boolean().optional(),
|
|
988
1024
|
/** Public port on the host DNATed to the box's SSH while it runs (#49); fixed for the box's life. Absent: none. */
|
|
989
|
-
sshPort: z4.number().int().optional()
|
|
1025
|
+
sshPort: z4.number().int().optional(),
|
|
1026
|
+
/** Seconds without traffic on the box's tap before the server hibernates it (#47, core/idle.ts). Absent: never. */
|
|
1027
|
+
autoHibernateSec: z4.number().int().positive().optional()
|
|
990
1028
|
});
|
|
991
1029
|
function newBoxId(taken) {
|
|
992
1030
|
for (; ; ) {
|
|
@@ -1250,6 +1288,33 @@ function usageEventsInScope(scope, window, p = paths()) {
|
|
|
1250
1288
|
return rows.filter((r) => inScope(r.owner, scope)).map(withId);
|
|
1251
1289
|
}
|
|
1252
1290
|
|
|
1291
|
+
// src/core/locks.ts
|
|
1292
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1293
|
+
var tails = /* @__PURE__ */ new Map();
|
|
1294
|
+
var held = new AsyncLocalStorage();
|
|
1295
|
+
async function withBoxLock(ref, fn) {
|
|
1296
|
+
const key = refKey(ref);
|
|
1297
|
+
const mine = held.getStore();
|
|
1298
|
+
if (mine?.has(key)) return fn();
|
|
1299
|
+
const previous = tails.get(key);
|
|
1300
|
+
let release;
|
|
1301
|
+
const tail = new Promise((resolve) => release = resolve);
|
|
1302
|
+
tails.set(key, tail);
|
|
1303
|
+
try {
|
|
1304
|
+
await previous;
|
|
1305
|
+
return await held.run(/* @__PURE__ */ new Set([...mine ?? [], key]), fn);
|
|
1306
|
+
} finally {
|
|
1307
|
+
release();
|
|
1308
|
+
if (tails.get(key) === tail) tails.delete(key);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
function boxBusy(ref) {
|
|
1312
|
+
return tails.has(refKey(ref));
|
|
1313
|
+
}
|
|
1314
|
+
function locked(fn) {
|
|
1315
|
+
return (...args) => withBoxLock(args[0], () => fn(...args));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1253
1318
|
// src/core/firecracker.ts
|
|
1254
1319
|
import { Agent, request as request2 } from "undici";
|
|
1255
1320
|
var FirecrackerClient = class {
|
|
@@ -1549,9 +1614,13 @@ function toMachine(meta, p = paths()) {
|
|
|
1549
1614
|
createdAt: meta.createdAt,
|
|
1550
1615
|
source: meta.source,
|
|
1551
1616
|
status: statusOf(meta, p),
|
|
1552
|
-
...meta.sshPort === void 0 ? {} : { sshPort: meta.sshPort }
|
|
1617
|
+
...meta.sshPort === void 0 ? {} : { sshPort: meta.sshPort },
|
|
1618
|
+
...meta.autoHibernateSec ? { autoHibernateSec: meta.autoHibernateSec } : {}
|
|
1553
1619
|
};
|
|
1554
1620
|
}
|
|
1621
|
+
function autoHibernateFor(req, cfg) {
|
|
1622
|
+
return (req.autoHibernateSec ?? cfg.autoHibernateSec) || void 0;
|
|
1623
|
+
}
|
|
1555
1624
|
async function readAllMeta(p) {
|
|
1556
1625
|
const metas = [];
|
|
1557
1626
|
for (const ref of await listMachineRefs(p)) {
|
|
@@ -1774,7 +1843,7 @@ async function createMachine(req, owner, p = paths()) {
|
|
|
1774
1843
|
const spec = { id, name, owner, ip, mac, vcpu: size.vcpu, memMib: size.memMib, base: base.version, sshKeys, sshPort };
|
|
1775
1844
|
pid = await launch(spec, tap, cfg, keysB64, p);
|
|
1776
1845
|
await openSshPort(spec, cfg);
|
|
1777
|
-
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base" };
|
|
1846
|
+
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: "base", autoHibernateSec: autoHibernateFor(req, cfg) };
|
|
1778
1847
|
await writeMachineMeta(meta, p);
|
|
1779
1848
|
recordState(meta, "running", "new", p, meta.createdAt);
|
|
1780
1849
|
return toMachine(meta, p);
|
|
@@ -1838,7 +1907,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
|
|
|
1838
1907
|
const stale = await pushAuthorizedKeys({ owner, name, sshKeys }, m.vsock, p);
|
|
1839
1908
|
await attachTap(id, cfg);
|
|
1840
1909
|
await openSshPort(spec, cfg);
|
|
1841
|
-
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0 };
|
|
1910
|
+
const meta = { ...spec, pid, bootId: currentBootId(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), source: `snapshot:${refKey(from)}`, memSnapshots: 1, sshKeysStale: stale ? true : void 0, autoHibernateSec: autoHibernateFor(req, cfg) };
|
|
1842
1911
|
await writeMachineMeta(meta, p);
|
|
1843
1912
|
recordState(meta, "running", "new", p, meta.createdAt);
|
|
1844
1913
|
return toMachine(meta, p);
|
|
@@ -1919,7 +1988,7 @@ async function cleanupProcess(meta, cfg, p) {
|
|
|
1919
1988
|
await rm(m.sock, { force: true });
|
|
1920
1989
|
await rm(m.vsock, { force: true });
|
|
1921
1990
|
}
|
|
1922
|
-
async function
|
|
1991
|
+
var stopMachine = locked(async function stopMachine2(ref, p = paths()) {
|
|
1923
1992
|
const meta = await readMeta(ref, p);
|
|
1924
1993
|
const cfg = await loadConfig(p);
|
|
1925
1994
|
const m = p.machine(ref.owner, ref.name);
|
|
@@ -1931,8 +2000,8 @@ async function stopMachine(ref, p = paths()) {
|
|
|
1931
2000
|
await writeMachineMeta(stopped, p);
|
|
1932
2001
|
recordState(stopped, "stopped", "stop", p);
|
|
1933
2002
|
return toMachine(stopped, p);
|
|
1934
|
-
}
|
|
1935
|
-
async function
|
|
2003
|
+
});
|
|
2004
|
+
var startMachine = locked(async function startMachine2(ref, p = paths()) {
|
|
1936
2005
|
const meta = await readMeta(ref, p);
|
|
1937
2006
|
const cfg = await loadConfig(p);
|
|
1938
2007
|
if (vmAlive(meta)) throw new KvboxError(`box ${shown(ref)} is already ${meta.paused ? "up, in standby (kvbox resume " + shown(ref) + ")" : "running"}`);
|
|
@@ -1957,15 +2026,15 @@ async function startMachine(ref, p = paths()) {
|
|
|
1957
2026
|
await deleteTap(meta.id, cfg).catch(() => void 0);
|
|
1958
2027
|
throw err;
|
|
1959
2028
|
}
|
|
1960
|
-
}
|
|
1961
|
-
async function
|
|
2029
|
+
});
|
|
2030
|
+
var setSshKeys = locked(async function setSshKeys2(ref, sshKeys, p = paths()) {
|
|
1962
2031
|
const meta = await readMeta(ref, p);
|
|
1963
2032
|
if (!isCloudBox(meta.owner)) throw new KvboxError(`box ${shown(ref)} is local: it trusts this host's key, there are no authorized_keys lines to replace`);
|
|
1964
2033
|
if (sshKeys.length === 0) throw new KvboxError("sshKeys: at least one authorized_keys line (the box would keep only the host's own key)");
|
|
1965
2034
|
authorizedKeysB64(await readHostPubkey(p), sshKeys);
|
|
1966
2035
|
const { meta: updated, applied } = await applySshKeys(meta, sshKeys, p);
|
|
1967
2036
|
return { machine: toMachine(updated, p), applied };
|
|
1968
|
-
}
|
|
2037
|
+
});
|
|
1969
2038
|
async function applySshKeys(meta, sshKeys, p) {
|
|
1970
2039
|
const status = statusOf(meta, p);
|
|
1971
2040
|
let updated = { ...meta, sshKeys: [...sshKeys], sshKeysStale: status === "stopped" ? void 0 : true };
|
|
@@ -1983,27 +2052,30 @@ async function applySshKeys(meta, sshKeys, p) {
|
|
|
1983
2052
|
}
|
|
1984
2053
|
async function trustCa(publicKeyText, p = paths()) {
|
|
1985
2054
|
const publicKey = parseSshPublicKey(publicKeyText);
|
|
1986
|
-
const
|
|
2055
|
+
const report2 = { publicKey, fingerprint: sshKeyFingerprint(publicKey), updated: [], pending: [], failed: [], local: 0 };
|
|
1987
2056
|
const hostKey = await readHostPubkey(p);
|
|
1988
|
-
for (const
|
|
1989
|
-
if (!isCloudBox(
|
|
1990
|
-
|
|
2057
|
+
for (const listed of await readAllMeta(p)) {
|
|
2058
|
+
if (!isCloudBox(listed.owner)) {
|
|
2059
|
+
report2.local++;
|
|
1991
2060
|
continue;
|
|
1992
2061
|
}
|
|
1993
|
-
const box = refKey(
|
|
2062
|
+
const box = refKey(listed);
|
|
1994
2063
|
try {
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2064
|
+
await withBoxLock(listed, async () => {
|
|
2065
|
+
const meta = await readMeta(listed, p);
|
|
2066
|
+
const sshKeys = [...(meta.sshKeys ?? []).filter((l) => !isCaLine(l)), caLineFor(meta.owner, publicKey)];
|
|
2067
|
+
authorizedKeysLines(hostKey, sshKeys);
|
|
2068
|
+
const { applied, why } = await applySshKeys(meta, sshKeys, p);
|
|
2069
|
+
if (applied === "now") report2.updated.push(box);
|
|
2070
|
+
else report2.pending.push(why === void 0 ? { box, when: applied } : { box, when: applied, why });
|
|
2071
|
+
});
|
|
2000
2072
|
} catch (err) {
|
|
2001
|
-
|
|
2073
|
+
report2.failed.push({ box, error: err.message });
|
|
2002
2074
|
}
|
|
2003
2075
|
}
|
|
2004
|
-
return
|
|
2076
|
+
return report2;
|
|
2005
2077
|
}
|
|
2006
|
-
async function
|
|
2078
|
+
var pauseMachine = locked(async function pauseMachine2(ref, p = paths()) {
|
|
2007
2079
|
const meta = await readMeta(ref, p);
|
|
2008
2080
|
const status = statusOf(meta, p);
|
|
2009
2081
|
if (status === "standby") throw new KvboxError(`${stateMessage(ref, status)} already`);
|
|
@@ -2019,8 +2091,8 @@ async function pauseMachine(ref, p = paths()) {
|
|
|
2019
2091
|
await writeMachineMeta(paused, p);
|
|
2020
2092
|
recordState(paused, "standby", "pause", p);
|
|
2021
2093
|
return toMachine(paused, p);
|
|
2022
|
-
}
|
|
2023
|
-
async function
|
|
2094
|
+
});
|
|
2095
|
+
var resumeMachine = locked(async function resumeMachine2(ref, p = paths()) {
|
|
2024
2096
|
const meta = await readMeta(ref, p);
|
|
2025
2097
|
const status = statusOf(meta, p);
|
|
2026
2098
|
if (status === "running") throw new KvboxError(`${stateMessage(ref, status)} already`);
|
|
@@ -2033,8 +2105,8 @@ async function resumeMachine(ref, p = paths()) {
|
|
|
2033
2105
|
await writeMachineMeta(running, p);
|
|
2034
2106
|
recordState(running, "running", "resume", p);
|
|
2035
2107
|
return toMachine(running, p);
|
|
2036
|
-
}
|
|
2037
|
-
async function
|
|
2108
|
+
});
|
|
2109
|
+
var hibernateMachine = locked(async function hibernateMachine2(ref, p = paths(), by = "hibernate") {
|
|
2038
2110
|
const meta = await readMeta(ref, p);
|
|
2039
2111
|
const cfg = await loadConfig(p);
|
|
2040
2112
|
const status = statusOf(meta, p);
|
|
@@ -2063,10 +2135,10 @@ async function hibernateMachine(ref, p = paths()) {
|
|
|
2063
2135
|
await rm(m.memBase, { force: true });
|
|
2064
2136
|
const hibernated = { ...meta, pid: null, memSnapshots: void 0, paused: void 0 };
|
|
2065
2137
|
await writeMachineMeta(hibernated, p);
|
|
2066
|
-
recordState(hibernated, "hibernated",
|
|
2138
|
+
recordState(hibernated, "hibernated", by, p);
|
|
2067
2139
|
return toMachine(hibernated, p);
|
|
2068
|
-
}
|
|
2069
|
-
async function
|
|
2140
|
+
});
|
|
2141
|
+
var wakeMachine = locked(async function wakeMachine2(ref, p = paths()) {
|
|
2070
2142
|
const meta = await readMeta(ref, p);
|
|
2071
2143
|
const cfg = await loadConfig(p);
|
|
2072
2144
|
const status = statusOf(meta, p);
|
|
@@ -2105,7 +2177,7 @@ async function wakeMachine(ref, p = paths()) {
|
|
|
2105
2177
|
await writeMachineMeta(running, p);
|
|
2106
2178
|
recordState(running, "running", "wake", p);
|
|
2107
2179
|
return toMachine(running, p);
|
|
2108
|
-
}
|
|
2180
|
+
});
|
|
2109
2181
|
async function applyAtNextBoot(meta, changes, by, cfg, p, whileStopped) {
|
|
2110
2182
|
const wasRunning = vmAlive(meta);
|
|
2111
2183
|
if (wasRunning) {
|
|
@@ -2123,7 +2195,7 @@ function assertNotHibernated(meta, p) {
|
|
|
2123
2195
|
throw new KvboxError(`${stateMessage(meta, "hibernated")}; its memory image was made with its current shape and base: kvbox wake ${shown(meta)} first`);
|
|
2124
2196
|
}
|
|
2125
2197
|
}
|
|
2126
|
-
async function
|
|
2198
|
+
var updateMachine = locked(async function updateMachine2(ref, p = paths()) {
|
|
2127
2199
|
const meta = await readMeta(ref, p);
|
|
2128
2200
|
const cfg = await loadConfig(p);
|
|
2129
2201
|
assertNotHibernated(meta, p);
|
|
@@ -2132,8 +2204,8 @@ async function updateMachine(ref, p = paths()) {
|
|
|
2132
2204
|
if (base.version === meta.base) return toMachine(meta, p);
|
|
2133
2205
|
await assertOverlayCapable(base.file, base.version);
|
|
2134
2206
|
return applyAtNextBoot(meta, { base: base.version }, "update", cfg, p);
|
|
2135
|
-
}
|
|
2136
|
-
async function
|
|
2207
|
+
});
|
|
2208
|
+
var rescaleMachine = locked(async function rescaleMachine2(ref, req, p = paths()) {
|
|
2137
2209
|
const meta = await readMeta(ref, p);
|
|
2138
2210
|
const cfg = await loadConfig(p);
|
|
2139
2211
|
assertNotHibernated(meta, p);
|
|
@@ -2154,7 +2226,14 @@ async function rescaleMachine(ref, req, p = paths()) {
|
|
|
2154
2226
|
const growTo = target.diskGib > currentDiskGib ? target.diskGib : void 0;
|
|
2155
2227
|
if (Object.keys(changes).length === 0 && growTo === void 0) return toMachine(meta, p);
|
|
2156
2228
|
return applyAtNextBoot(meta, changes, "rescale", cfg, p, growTo === void 0 ? void 0 : () => growDisk(disk, growTo));
|
|
2157
|
-
}
|
|
2229
|
+
});
|
|
2230
|
+
var setMachine = locked(async function setMachine2(ref, req, p = paths()) {
|
|
2231
|
+
const meta = await readMeta(ref, p);
|
|
2232
|
+
const next = { ...meta };
|
|
2233
|
+
if (req.autoHibernateSec !== void 0) next.autoHibernateSec = req.autoHibernateSec || void 0;
|
|
2234
|
+
await writeMachineMeta(next, p);
|
|
2235
|
+
return toMachine(next, p);
|
|
2236
|
+
});
|
|
2158
2237
|
async function growDisk(file, gib) {
|
|
2159
2238
|
await truncate(file, gib * 1024 ** 3);
|
|
2160
2239
|
await run(["e2fsck", "-f", "-p", file], { okExitCodes: [1] });
|
|
@@ -2168,13 +2247,13 @@ async function readMeta(ref, p) {
|
|
|
2168
2247
|
throw err;
|
|
2169
2248
|
}
|
|
2170
2249
|
}
|
|
2171
|
-
async function
|
|
2250
|
+
var destroyMachine = locked(async function destroyMachine2(ref, p = paths()) {
|
|
2172
2251
|
const meta = await readMeta(ref, p);
|
|
2173
2252
|
const cfg = await loadConfig(p);
|
|
2174
2253
|
await shutdown(meta, cfg, p, 2e3);
|
|
2175
2254
|
recordState(meta, "stopped", "rm", p);
|
|
2176
2255
|
await rm(p.machine(ref.owner, ref.name).dir, { recursive: true, force: true });
|
|
2177
|
-
}
|
|
2256
|
+
});
|
|
2178
2257
|
var RECOVERY_CONCURRENCY = 2;
|
|
2179
2258
|
async function settleDeadBoxes(p = paths()) {
|
|
2180
2259
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2323,8 +2402,156 @@ async function dropRelay(p = paths()) {
|
|
|
2323
2402
|
return relay;
|
|
2324
2403
|
}
|
|
2325
2404
|
|
|
2405
|
+
// src/core/idle.ts
|
|
2406
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
2407
|
+
var HIBERNATE_CONCURRENCY = 2;
|
|
2408
|
+
async function tapTraffic(id) {
|
|
2409
|
+
const dir = `/sys/class/net/${tapName(id)}/statistics`;
|
|
2410
|
+
try {
|
|
2411
|
+
const [rx, tx] = await Promise.all([readFile6(`${dir}/rx_bytes`, "utf8"), readFile6(`${dir}/tx_bytes`, "utf8")]);
|
|
2412
|
+
return Number(rx) + Number(tx);
|
|
2413
|
+
} catch {
|
|
2414
|
+
return null;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
async function hibernateIfRunning(box, p) {
|
|
2418
|
+
const ref = { owner: box.owner, name: box.name };
|
|
2419
|
+
return withBoxLock(ref, async () => {
|
|
2420
|
+
if ((await getMachine(ref, p)).status !== "running") return false;
|
|
2421
|
+
await hibernateMachine(ref, p, "auto-hibernate");
|
|
2422
|
+
return true;
|
|
2423
|
+
});
|
|
2424
|
+
}
|
|
2425
|
+
function startIdleWatch(opts = {}) {
|
|
2426
|
+
const intervalMs = opts.intervalMs ?? 6e4;
|
|
2427
|
+
const p = opts.p ?? paths();
|
|
2428
|
+
const list = opts.list ?? (() => listMachines(null, p));
|
|
2429
|
+
const traffic = opts.traffic ?? tapTraffic;
|
|
2430
|
+
const hibernate = opts.hibernate ?? ((box) => hibernateIfRunning(box, p));
|
|
2431
|
+
const busy = opts.busy ?? ((box) => boxBusy(box));
|
|
2432
|
+
const now = opts.now ?? Date.now;
|
|
2433
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
2434
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2435
|
+
let timer;
|
|
2436
|
+
let stopped = false;
|
|
2437
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
2438
|
+
const hibernateOne = async (box, quiet, bytes, t) => {
|
|
2439
|
+
inflight.add(box.id);
|
|
2440
|
+
try {
|
|
2441
|
+
if (await hibernate(box)) log(`box ${box.owner}/${box.name}: no network traffic for ${quiet} min; hibernated it (kvbox connect wakes it)`);
|
|
2442
|
+
seen.delete(box.id);
|
|
2443
|
+
} catch (err) {
|
|
2444
|
+
log(`warning: box ${box.owner}/${box.name}: could not hibernate it after ${quiet} min without traffic: ${err.message}`);
|
|
2445
|
+
seen.set(box.id, { bytes, since: t });
|
|
2446
|
+
} finally {
|
|
2447
|
+
inflight.delete(box.id);
|
|
2448
|
+
}
|
|
2449
|
+
};
|
|
2450
|
+
const check = async () => {
|
|
2451
|
+
const started = [];
|
|
2452
|
+
const watched = /* @__PURE__ */ new Set();
|
|
2453
|
+
for (const box of await list()) {
|
|
2454
|
+
if (!box.autoHibernateSec || box.status !== "running") continue;
|
|
2455
|
+
if (inflight.has(box.id)) {
|
|
2456
|
+
watched.add(box.id);
|
|
2457
|
+
continue;
|
|
2458
|
+
}
|
|
2459
|
+
const bytes = await traffic(box.id);
|
|
2460
|
+
if (bytes === null) continue;
|
|
2461
|
+
watched.add(box.id);
|
|
2462
|
+
const t = now();
|
|
2463
|
+
const last = seen.get(box.id);
|
|
2464
|
+
if (!last || last.bytes !== bytes || busy(box)) {
|
|
2465
|
+
seen.set(box.id, { bytes, since: t });
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
if (t - last.since < box.autoHibernateSec * 1e3) continue;
|
|
2469
|
+
if (inflight.size >= HIBERNATE_CONCURRENCY) continue;
|
|
2470
|
+
started.push(hibernateOne(box, Math.round((t - last.since) / 6e4), bytes, t));
|
|
2471
|
+
}
|
|
2472
|
+
for (const id of seen.keys()) if (!watched.has(id)) seen.delete(id);
|
|
2473
|
+
return started;
|
|
2474
|
+
};
|
|
2475
|
+
const round2 = async () => {
|
|
2476
|
+
await Promise.all(await check());
|
|
2477
|
+
};
|
|
2478
|
+
const schedule = () => {
|
|
2479
|
+
if (stopped) return;
|
|
2480
|
+
timer = setTimeout(() => void check().catch((err) => log(`warning: idle watch: ${err.message}`)).finally(schedule), intervalMs);
|
|
2481
|
+
timer.unref();
|
|
2482
|
+
};
|
|
2483
|
+
schedule();
|
|
2484
|
+
return {
|
|
2485
|
+
stop: () => {
|
|
2486
|
+
stopped = true;
|
|
2487
|
+
clearTimeout(timer);
|
|
2488
|
+
},
|
|
2489
|
+
round: round2
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// src/core/relayWatch.ts
|
|
2494
|
+
var report = { restarts: 0, lastRestartAt: null };
|
|
2495
|
+
function relayWatchReport() {
|
|
2496
|
+
return report;
|
|
2497
|
+
}
|
|
2498
|
+
async function pingRelay() {
|
|
2499
|
+
try {
|
|
2500
|
+
await run(["ping", "-c", "1", "-W", "3", RELAY_TUNNEL_GATEWAY], { timeoutMs: 5e3 });
|
|
2501
|
+
return true;
|
|
2502
|
+
} catch {
|
|
2503
|
+
return false;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
function startRelayWatch(opts = {}) {
|
|
2507
|
+
const intervalMs = opts.intervalMs ?? 3e4;
|
|
2508
|
+
const misses = opts.misses ?? 3;
|
|
2509
|
+
const probe = opts.probe ?? pingRelay;
|
|
2510
|
+
const p = opts.p ?? paths();
|
|
2511
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
2512
|
+
const restart = opts.restart ?? (async () => netSetup(await loadConfig(p), ["relay", "restart"]));
|
|
2513
|
+
report = { restarts: 0, lastRestartAt: null };
|
|
2514
|
+
let missed = 0;
|
|
2515
|
+
let stuck = false;
|
|
2516
|
+
let timer;
|
|
2517
|
+
let stopped = false;
|
|
2518
|
+
const round2 = async () => {
|
|
2519
|
+
const cfg = await loadConfig(p).catch(() => null);
|
|
2520
|
+
if (!cfg?.relay) {
|
|
2521
|
+
missed = 0;
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (await probe()) {
|
|
2525
|
+
if (stuck) log(`relay ${cfg.relay.name}: the tunnel carries traffic again`);
|
|
2526
|
+
missed = 0;
|
|
2527
|
+
stuck = false;
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (++missed < misses) return;
|
|
2531
|
+
missed = 0;
|
|
2532
|
+
if (!stuck) log(`relay ${cfg.relay.name}: no answer through the tunnel for ${misses * intervalMs / 1e3} s; restarting it`);
|
|
2533
|
+
try {
|
|
2534
|
+
await restart();
|
|
2535
|
+
report = { restarts: report.restarts + 1, lastRestartAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2536
|
+
} catch (err) {
|
|
2537
|
+
if (!stuck) console.error(`warning: relay ${cfg.relay.name}: could not restart the tunnel: ${err.message}`);
|
|
2538
|
+
}
|
|
2539
|
+
stuck = true;
|
|
2540
|
+
};
|
|
2541
|
+
const schedule = () => {
|
|
2542
|
+
if (stopped) return;
|
|
2543
|
+
timer = setTimeout(() => void round2().catch((err) => console.error(`warning: relay watch: ${err.message}`)).finally(schedule), intervalMs);
|
|
2544
|
+
timer.unref();
|
|
2545
|
+
};
|
|
2546
|
+
schedule();
|
|
2547
|
+
return () => {
|
|
2548
|
+
stopped = true;
|
|
2549
|
+
clearTimeout(timer);
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2326
2553
|
// src/listen.ts
|
|
2327
|
-
import { readFile as
|
|
2554
|
+
import { readFile as readFile7, rm as rm4, writeFile as writeFile5 } from "node:fs/promises";
|
|
2328
2555
|
import { createServer as createHttpsServer } from "node:https";
|
|
2329
2556
|
import { createAdaptorServer } from "@hono/node-server";
|
|
2330
2557
|
|
|
@@ -2344,7 +2571,7 @@ function versionOf(srcFileUrl, injected) {
|
|
|
2344
2571
|
}
|
|
2345
2572
|
|
|
2346
2573
|
// src/version.ts
|
|
2347
|
-
var VERSION = versionOf(import.meta.url, true ? "0.5.
|
|
2574
|
+
var VERSION = versionOf(import.meta.url, true ? "0.5.11" : void 0);
|
|
2348
2575
|
|
|
2349
2576
|
// src/caller.ts
|
|
2350
2577
|
import { createHash as createHash2, timingSafeEqual } from "node:crypto";
|
|
@@ -2396,6 +2623,19 @@ function capacityRoutes() {
|
|
|
2396
2623
|
// src/routes/machines.ts
|
|
2397
2624
|
import { Hono as Hono2 } from "hono";
|
|
2398
2625
|
|
|
2626
|
+
// src/core/stats.ts
|
|
2627
|
+
var STATS_REPLY_MS = 3e3;
|
|
2628
|
+
async function machineStats(ref, p = paths()) {
|
|
2629
|
+
const m = await getMachine(ref, p);
|
|
2630
|
+
if (m.status !== "running") throw new KvboxError(`${stateMessage(ref, m.status)}; only a running box can say how it is doing`);
|
|
2631
|
+
try {
|
|
2632
|
+
return { name: m.name, owner: m.owner, stats: await stats(p.machine(m.owner, m.name).vsock, STATS_REPLY_MS) };
|
|
2633
|
+
} catch (err) {
|
|
2634
|
+
if (isUnknownOp(err) || isNoAgent(err)) return { name: m.name, owner: m.owner, stats: null };
|
|
2635
|
+
throw err;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2399
2639
|
// src/core/usage.ts
|
|
2400
2640
|
async function machineUsage(ref, since, p = paths()) {
|
|
2401
2641
|
const box = await getMachine(ref, p);
|
|
@@ -2431,6 +2671,10 @@ function machineRoutes() {
|
|
|
2431
2671
|
const body = RescaleMachineRequest.parse(await c.req.json().catch(() => ({})));
|
|
2432
2672
|
return c.json(await rescaleMachine(parseRef(c.req.param("name"), c.get("owner")), body));
|
|
2433
2673
|
});
|
|
2674
|
+
r.post("/:name/set", async (c) => {
|
|
2675
|
+
const body = SetMachineRequest.parse(await c.req.json().catch(() => ({})));
|
|
2676
|
+
return c.json(await setMachine(parseRef(c.req.param("name"), c.get("owner")), body));
|
|
2677
|
+
});
|
|
2434
2678
|
r.post("/:name/ssh-keys", async (c) => {
|
|
2435
2679
|
const body = SetSshKeysRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
2436
2680
|
if (!body.success) throw new KvboxError("sshKeys: expected a list of authorized_keys lines");
|
|
@@ -2447,6 +2691,7 @@ function machineRoutes() {
|
|
|
2447
2691
|
}
|
|
2448
2692
|
return c.json(await machineUsage(parseRef(c.req.param("name"), c.get("owner")), from));
|
|
2449
2693
|
});
|
|
2694
|
+
r.get("/:name/stats", async (c) => c.json(await machineStats(parseRef(c.req.param("name"), c.get("owner")))));
|
|
2450
2695
|
r.delete("/:name", async (c) => {
|
|
2451
2696
|
await destroyMachine(parseRef(c.req.param("name"), c.get("owner")));
|
|
2452
2697
|
return c.body(null, 204);
|
|
@@ -2504,7 +2749,11 @@ async function saveSnapshot(req, owner, p = paths()) {
|
|
|
2504
2749
|
if (!NAME_RE2.test(req.name)) {
|
|
2505
2750
|
throw new KvboxError(`invalid snapshot name "${req.name}": 1-64 chars, lowercase letters, digits, '.', '_' and '-', starting with a letter or digit`);
|
|
2506
2751
|
}
|
|
2507
|
-
const
|
|
2752
|
+
const ref = parseRef(req.box, owner);
|
|
2753
|
+
return withBoxLock(ref, () => snapshotBox(ref, req, owner, p));
|
|
2754
|
+
}
|
|
2755
|
+
async function snapshotBox(ref, req, owner, p) {
|
|
2756
|
+
const box = await readBox(ref, p);
|
|
2508
2757
|
if (!vmAlive(box)) {
|
|
2509
2758
|
if (existsSync3(p.machine(box.owner, box.name).hibernateVmstate)) {
|
|
2510
2759
|
throw new KvboxError(`box ${req.box} is hibernated; a snapshot freezes a running box (kvbox wake ${req.box} first)`);
|
|
@@ -2651,7 +2900,7 @@ function createApp(opts = {}) {
|
|
|
2651
2900
|
base: await currentBaseVersion().catch(() => null),
|
|
2652
2901
|
owner: cfg.owner,
|
|
2653
2902
|
controlPlane: cp ? { name: cp.name, url: cp.url, hostUrl: `https://${cp.advertise}:${cp.port}` } : null,
|
|
2654
|
-
relay: cfg.relay ? { name: cfg.relay.name, publicIp: cfg.relay.publicIp, endpoint: cfg.relay.endpoint } : null,
|
|
2903
|
+
relay: cfg.relay ? { name: cfg.relay.name, publicIp: cfg.relay.publicIp, endpoint: cfg.relay.endpoint, ...relayWatchReport() } : null,
|
|
2655
2904
|
recovery: lastRecovery()
|
|
2656
2905
|
});
|
|
2657
2906
|
});
|
|
@@ -2692,7 +2941,7 @@ async function listen(opts = {}) {
|
|
|
2692
2941
|
let httpsServer;
|
|
2693
2942
|
const cp = cfg.controlPlane;
|
|
2694
2943
|
if (cp) {
|
|
2695
|
-
const [key, cert] = await Promise.all([
|
|
2944
|
+
const [key, cert] = await Promise.all([readFile7(p.tlsKey), readFile7(p.tlsCert)]).catch((err) => {
|
|
2696
2945
|
throw new KvboxError(`joined ${cp.name} but the host's certificate is missing (${err.message}); run kvbox-server leave, then join again`);
|
|
2697
2946
|
});
|
|
2698
2947
|
httpsServer = createAdaptorServer({
|
|
@@ -2752,6 +3001,8 @@ program.command("start", { isDefault: true }).description("serve the API on the
|
|
|
2752
3001
|
} catch (err) {
|
|
2753
3002
|
console.error(`warning: host network not ready (${err.message}); run: kvbox-server net up`);
|
|
2754
3003
|
}
|
|
3004
|
+
startRelayWatch();
|
|
3005
|
+
startIdleWatch();
|
|
2755
3006
|
const recovery2 = await bootRecovered(settled, paths());
|
|
2756
3007
|
if (recovery2) {
|
|
2757
3008
|
for (const box of recovery2.booted) console.log(`box ${box} was running when it went down; booted it again`);
|