@kvbox/server 0.5.13 → 0.5.15

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.
@@ -351,6 +351,18 @@ var PortForwardSchema = z3.object({
351
351
  var PortsRequest = z3.object({
352
352
  ports: z3.array(z3.object({ guestPort: z3.number().int().min(1).max(65535), proto: PortProtoSchema.optional() })).min(1).max(MAX_BOX_PORTS)
353
353
  });
354
+ var BoxPortSchema = z3.number().int().min(1).max(65535);
355
+ var ClaimSubdomainRequest = z3.object({ name: z3.string(), port: BoxPortSchema });
356
+ var SubdomainPortRequest = z3.object({ port: BoxPortSchema });
357
+ var SubdomainRouteRequest = z3.object({ name: z3.string(), port: BoxPortSchema });
358
+ var SubdomainSchema = z3.object({
359
+ owner: z3.string(),
360
+ box: z3.string(),
361
+ subdomain: z3.string(),
362
+ port: BoxPortSchema,
363
+ url: z3.string(),
364
+ routes: z3.array(z3.object({ name: z3.string(), port: BoxPortSchema, url: z3.string() }))
365
+ });
354
366
  var MachineSchema = z3.object({
355
367
  /** Host-unique short id (the tap is `kv-<id>`). Missing only on a box made before ids that has not restarted since. */
356
368
  id: z3.string(),
@@ -1970,7 +1982,7 @@ async function restoreMachine(fromText, owner, req, cfg, p) {
1970
1982
  }
1971
1983
  }
1972
1984
  var AGENT_REPLY_MS = 2e4;
1973
- async function introduce(spec, vsock, cfg, failure) {
1985
+ async function introduce(spec, vsock, cfg, failure2) {
1974
1986
  const id = { name: spec.name, ip: spec.ip, prefixLen: prefixLenOf(cfg.subnet), gateway: cfg.gateway, mac: spec.mac, time: Math.floor(Date.now() / 1e3) };
1975
1987
  const deadline = Date.now() + 3e3;
1976
1988
  for (; ; ) {
@@ -1985,9 +1997,9 @@ async function introduce(spec, vsock, cfg, failure) {
1985
1997
  continue;
1986
1998
  }
1987
1999
  if (transient) throw new KvboxError(`${msg}
1988
- ${failure.silent}`);
2000
+ ${failure2.silent}`);
1989
2001
  throw new KvboxError(`${msg}
1990
- ${failure.noAnswer}`);
2002
+ ${failure2.noAnswer}`);
1991
2003
  }
1992
2004
  }
1993
2005
  }
@@ -2658,7 +2670,7 @@ import { createServer as createHttpsServer } from "node:https";
2658
2670
  import { createAdaptorServer } from "@hono/node-server";
2659
2671
 
2660
2672
  // src/app.ts
2661
- import { Hono as Hono6 } from "hono";
2673
+ import { Hono as Hono7 } from "hono";
2662
2674
 
2663
2675
  // ../shared/src/version.ts
2664
2676
  import { createRequire } from "node:module";
@@ -2673,10 +2685,25 @@ function versionOf(srcFileUrl, injected) {
2673
2685
  }
2674
2686
 
2675
2687
  // src/version.ts
2676
- var VERSION = versionOf(import.meta.url, true ? "0.5.13" : void 0);
2688
+ var VERSION = versionOf(import.meta.url, true ? "0.5.15" : void 0);
2677
2689
 
2678
2690
  // src/caller.ts
2679
2691
  import { createHash as createHash2, timingSafeEqual } from "node:crypto";
2692
+
2693
+ // ../shared/src/names.ts
2694
+ import { createHmac } from "node:crypto";
2695
+ var PROXY_ERROR_HEADER = "x-kvbox-proxy-error";
2696
+ var PROXIED_HEADER = "x-kvbox-proxied";
2697
+ var VISITOR_PATH_HEADER = "x-kvbox-path";
2698
+ var VISITOR_AUTH_HEADER = "x-kvbox-authorization";
2699
+ var NOT_FORWARDED = /* @__PURE__ */ new Set(["expect"]);
2700
+ var HOP_BY_HOP = /* @__PURE__ */ new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade"]);
2701
+ var BOX_HTTP_PATH_RE = /^\/machines\/([^/]+)\/http\/(\d{1,5})\/?$/;
2702
+ function edgeCredential(hostSecret) {
2703
+ return createHmac("sha256", hostSecret).update("kvbox-edge-v1").digest("base64url");
2704
+ }
2705
+
2706
+ // src/caller.ts
2680
2707
  var OWNER_HEADER = "x-kvbox-owner";
2681
2708
  function resolveCaller(opts = {}) {
2682
2709
  const cp = opts.controlPlane;
@@ -2688,10 +2715,11 @@ function resolveCaller(opts = {}) {
2688
2715
  await next();
2689
2716
  };
2690
2717
  }
2691
- const expected = digest(cp.secret);
2718
+ const authorized = secretCheck(cp);
2719
+ const edge = edgeCheck(cp);
2692
2720
  return async (c, next) => {
2693
- const m = /^Bearer\s+(\S+)$/i.exec(c.req.header("authorization") ?? "");
2694
- if (!m || !timingSafeEqual(digest(m[1]), expected)) return c.json({ error: "not authorized" }, 401);
2721
+ const auth = c.req.header("authorization");
2722
+ if (!authorized(auth) && !(edge(auth) && BOX_HTTP_PATH_RE.test(c.req.path))) return c.json({ error: "not authorized" }, 401);
2695
2723
  const user = c.req.header(OWNER_HEADER);
2696
2724
  if (user === void 0) {
2697
2725
  const path = c.req.path.replace(/\/$/, "");
@@ -2710,20 +2738,237 @@ function resolveCaller(opts = {}) {
2710
2738
  await next();
2711
2739
  };
2712
2740
  }
2741
+ function secretCheck(cp) {
2742
+ return bearerCheck(cp.secret);
2743
+ }
2744
+ function edgeCheck(cp) {
2745
+ return bearerCheck(edgeCredential(cp.secret));
2746
+ }
2747
+ function bearerCheck(token) {
2748
+ const expected = digest(token);
2749
+ return (authorization) => {
2750
+ const m = /^Bearer\s+(\S+)$/i.exec(authorization ?? "");
2751
+ return m !== null && timingSafeEqual(digest(m[1]), expected);
2752
+ };
2753
+ }
2713
2754
  function digest(secret) {
2714
2755
  return createHash2("sha256").update(secret).digest();
2715
2756
  }
2716
2757
 
2717
- // src/routes/capacity.ts
2758
+ // src/routes/boxHttp.ts
2759
+ import { connect as connect2 } from "node:net";
2760
+ import { Readable } from "node:stream";
2718
2761
  import { Hono } from "hono";
2719
- function capacityRoutes() {
2762
+ import { request as request3 } from "undici";
2763
+
2764
+ // ../shared/src/httpHead.ts
2765
+ var END = "\r\n\r\n";
2766
+ var HeadTimeoutError = class extends Error {
2767
+ };
2768
+ function readHead(socket, opts) {
2769
+ return new Promise((resolve, reject) => {
2770
+ let bytes = Buffer.alloc(0);
2771
+ const done = (err) => {
2772
+ clearTimeout(timer);
2773
+ socket.off("data", onData);
2774
+ socket.off("close", onClose);
2775
+ socket.pause();
2776
+ if (err) return reject(err);
2777
+ const end = bytes.indexOf(END);
2778
+ resolve({ head: bytes.subarray(0, end).toString("latin1"), rest: bytes.subarray(end + END.length) });
2779
+ };
2780
+ const onData = (chunk) => {
2781
+ bytes = Buffer.concat([bytes, chunk]);
2782
+ if (bytes.includes(END)) done();
2783
+ else if (bytes.length > opts.maxBytes) done(new Error("answer head too large"));
2784
+ };
2785
+ const onClose = () => done(new Error("closed before answering"));
2786
+ const timer = setTimeout(() => done(new HeadTimeoutError(`no answer within ${opts.timeoutMs / 1e3} s`)), opts.timeoutMs);
2787
+ socket.on("data", onData);
2788
+ socket.once("close", onClose);
2789
+ });
2790
+ }
2791
+ function rewriteHead(head, drop, add = {}) {
2792
+ const [status, ...lines] = head.split("\r\n");
2793
+ const kept = lines.filter((l) => !drop(l.slice(0, l.indexOf(":")).trim().toLowerCase()));
2794
+ const extra = Object.entries(add).map(([k, v]) => `${k}: ${v}`);
2795
+ return Buffer.from(`${[status, ...kept, ...extra].join("\r\n")}${END}`, "latin1");
2796
+ }
2797
+
2798
+ // src/routes/boxHttp.ts
2799
+ var HEADERS_TIMEOUT_MS = 55e3;
2800
+ var MAX_HANDSHAKE_BYTES = 16 * 1024;
2801
+ var CONNECT_TIMEOUT_MS = 5e3;
2802
+ var DROPPED = /* @__PURE__ */ new Set([...HOP_BY_HOP, ...NOT_FORWARDED, "host", "authorization", OWNER_HEADER, VISITOR_AUTH_HEADER, VISITOR_PATH_HEADER]);
2803
+ var ProxyFailure = class extends Error {
2804
+ constructor(status, code, message) {
2805
+ super(message);
2806
+ this.status = status;
2807
+ this.code = code;
2808
+ }
2809
+ status;
2810
+ code;
2811
+ };
2812
+ function visitorPath(value) {
2813
+ const path = Array.isArray(value) ? value[0] : value;
2814
+ return path !== void 0 && path !== null && path.startsWith("/") && !/[\s]/.test(path) ? path : "/";
2815
+ }
2816
+ async function targetOf(url, owner, path) {
2817
+ const m = BOX_HTTP_PATH_RE.exec(url.pathname);
2818
+ const port = Number(m?.[2]);
2819
+ if (!m || port < 1 || port > 65535) throw new ProxyFailure(404, "not-found", `no route: ${url.pathname}`);
2820
+ const name = decodeURIComponent(m[1]);
2821
+ let box;
2822
+ try {
2823
+ box = await getMachine(parseRef(name, owner));
2824
+ } catch (err) {
2825
+ if (err instanceof NotFoundError) throw new ProxyFailure(404, "not-found", err.message);
2826
+ throw err;
2827
+ }
2828
+ if (box.status !== "running") throw new ProxyFailure(409, "not-running", `box ${name} is ${box.status}`);
2829
+ return { ip: box.ip, port, path };
2830
+ }
2831
+ function boxHeaders(incoming, target, upgrade = false) {
2832
+ const out = [];
2833
+ let host = `${target.ip}:${target.port}`;
2834
+ for (const [name, value] of incoming) {
2835
+ const key = name.toLowerCase();
2836
+ if (key === "x-forwarded-host") host = value;
2837
+ if (key === VISITOR_AUTH_HEADER) out.push(["authorization", value]);
2838
+ if (upgrade && (key === "upgrade" || key === "connection")) out.push([name, value]);
2839
+ else if (!DROPPED.has(key)) out.push([name, value]);
2840
+ }
2841
+ out.push(["host", host]);
2842
+ return out;
2843
+ }
2844
+ var failure = (err) => new Response(JSON.stringify({ error: err.message }), { status: err.status, headers: { "content-type": "application/json", [PROXY_ERROR_HEADER]: err.code } });
2845
+ function boxHttpRoutes() {
2720
2846
  const r = new Hono();
2847
+ r.all("/:name/http/:port", forward);
2848
+ return r;
2849
+ }
2850
+ async function forward(c) {
2851
+ let target;
2852
+ try {
2853
+ target = await targetOf(new URL(c.req.url), c.get("owner"), visitorPath(c.req.header(VISITOR_PATH_HEADER)));
2854
+ } catch (err) {
2855
+ if (err instanceof ProxyFailure) return failure(err);
2856
+ throw err;
2857
+ }
2858
+ const raw = c.req.raw;
2859
+ const pairs = [];
2860
+ raw.headers.forEach((value, name) => pairs.push([name, value]));
2861
+ let res;
2862
+ try {
2863
+ res = await request3(`http://${target.ip}:${target.port}${target.path}`, {
2864
+ method: raw.method,
2865
+ headers: boxHeaders(pairs, target).flat(),
2866
+ body: raw.body === null ? null : Readable.fromWeb(raw.body),
2867
+ headersTimeout: HEADERS_TIMEOUT_MS,
2868
+ bodyTimeout: 0,
2869
+ signal: raw.signal
2870
+ });
2871
+ } catch (err) {
2872
+ const e = err;
2873
+ if (e.code === "UND_ERR_HEADERS_TIMEOUT") return failure(new ProxyFailure(504, "timeout", `box took over ${HEADERS_TIMEOUT_MS / 1e3} s to answer on port ${target.port}`));
2874
+ return failure(new ProxyFailure(502, "unreachable", `box answered nothing on port ${target.port} (${e.code ?? e.message})`));
2875
+ }
2876
+ const headers = new Headers();
2877
+ for (const [name, value] of Object.entries(res.headers)) {
2878
+ if (value === void 0 || HOP_BY_HOP.has(name) || name.startsWith("x-kvbox-")) continue;
2879
+ for (const v of Array.isArray(value) ? value : [value]) headers.append(name, v);
2880
+ }
2881
+ headers.set(PROXIED_HEADER, "1");
2882
+ const empty = res.statusCode === 204 || res.statusCode === 304 || raw.method === "HEAD";
2883
+ if (empty) res.body.resume();
2884
+ return new Response(empty ? null : Readable.toWeb(res.body), { status: res.statusCode, headers });
2885
+ }
2886
+ function refuse(socket, status, text, code = "refused") {
2887
+ const body = JSON.stringify({ error: text });
2888
+ socket.end(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Error"}\r
2889
+ content-type: application/json\r
2890
+ content-length: ${Buffer.byteLength(body)}\r
2891
+ ${PROXY_ERROR_HEADER}: ${code}\r
2892
+ connection: close\r
2893
+ \r
2894
+ ${body}`);
2895
+ }
2896
+ function headerPairs(raw) {
2897
+ const out = [];
2898
+ for (let i = 0; i + 1 < raw.length; i += 2) out.push([raw[i], raw[i + 1]]);
2899
+ return out;
2900
+ }
2901
+ function boxUpgradeHandler(cp, headersTimeoutMs = HEADERS_TIMEOUT_MS) {
2902
+ const full = secretCheck(cp);
2903
+ const edge = edgeCheck(cp);
2904
+ const authorized = (auth) => full(auth) || edge(auth);
2905
+ return (req, socket, head) => {
2906
+ socket.on("error", () => socket.destroy());
2907
+ void (async () => {
2908
+ const headers = req.headers;
2909
+ if (!authorized(headers.authorization)) return refuse(socket, 401, "not authorized");
2910
+ const user = headers[OWNER_HEADER];
2911
+ if (typeof user !== "string" || !LOCAL_OWNER_RE.test(user)) return refuse(socket, 400, "X-Kvbox-Owner header required");
2912
+ let target;
2913
+ try {
2914
+ target = await targetOf(new URL(req.url ?? "/", "https://host"), `${user}@${cp.name}`, visitorPath(headers[VISITOR_PATH_HEADER]));
2915
+ } catch (err) {
2916
+ if (err instanceof ProxyFailure) return refuse(socket, err.status, err.message, err.code);
2917
+ return refuse(socket, 500, err.message);
2918
+ }
2919
+ if (socket.destroyed) return;
2920
+ const box = connect2({ host: target.ip, port: target.port, timeout: CONNECT_TIMEOUT_MS });
2921
+ let connected = false;
2922
+ let piped = false;
2923
+ box.once("timeout", () => box.destroy(new Error("timed out")));
2924
+ box.once("error", (err) => {
2925
+ if (!connected && !socket.destroyed) refuse(socket, 502, `box answered nothing on port ${target.port} (${err.code ?? err.message})`, "unreachable");
2926
+ else if (piped) socket.destroy();
2927
+ });
2928
+ socket.once("close", () => box.destroy());
2929
+ box.once("close", () => {
2930
+ if (piped) socket.destroy();
2931
+ });
2932
+ box.once("connect", () => {
2933
+ connected = true;
2934
+ box.setTimeout(0);
2935
+ const lines = boxHeaders(headerPairs(req.rawHeaders), target, true).map(([k, v]) => `${k}: ${v}\r
2936
+ `);
2937
+ box.write(`${req.method ?? "GET"} ${target.path} HTTP/1.1\r
2938
+ ${lines.join("")}\r
2939
+ `);
2940
+ if (head.length > 0) box.write(head);
2941
+ readHead(box, { maxBytes: MAX_HANDSHAKE_BYTES, timeoutMs: headersTimeoutMs }).then(
2942
+ (answer) => {
2943
+ if (socket.destroyed) return void box.destroy();
2944
+ socket.write(rewriteHead(answer.head, (name) => name.startsWith("x-kvbox-"), { [PROXIED_HEADER]: "1" }));
2945
+ if (answer.rest.length > 0) socket.write(answer.rest);
2946
+ piped = true;
2947
+ box.pipe(socket);
2948
+ socket.pipe(box);
2949
+ },
2950
+ (err) => {
2951
+ box.destroy();
2952
+ if (socket.destroyed) return;
2953
+ if (err instanceof HeadTimeoutError) refuse(socket, 504, `box took over ${headersTimeoutMs / 1e3} s to answer on port ${target.port}`, "timeout");
2954
+ else refuse(socket, 502, `box gave no answer on port ${target.port} (${err.message})`, "unreachable");
2955
+ }
2956
+ );
2957
+ });
2958
+ })();
2959
+ };
2960
+ }
2961
+
2962
+ // src/routes/capacity.ts
2963
+ import { Hono as Hono2 } from "hono";
2964
+ function capacityRoutes() {
2965
+ const r = new Hono2();
2721
2966
  r.get("/", async (c) => c.json(await capacity()));
2722
2967
  return r;
2723
2968
  }
2724
2969
 
2725
2970
  // src/routes/machines.ts
2726
- import { Hono as Hono2 } from "hono";
2971
+ import { Hono as Hono3 } from "hono";
2727
2972
 
2728
2973
  // src/core/stats.ts
2729
2974
  var STATS_REPLY_MS = 3e3;
@@ -2756,7 +3001,7 @@ async function usageReport(scope, window, p = paths()) {
2756
3001
 
2757
3002
  // src/routes/machines.ts
2758
3003
  function machineRoutes() {
2759
- const r = new Hono2();
3004
+ const r = new Hono3();
2760
3005
  r.get("/", async (c) => c.json(await listMachines(c.get("listScope"))));
2761
3006
  r.post("/", async (c) => {
2762
3007
  const body = CreateMachineRequest.parse(await c.req.json().catch(() => ({})));
@@ -2812,7 +3057,7 @@ function machineRoutes() {
2812
3057
  }
2813
3058
 
2814
3059
  // src/routes/snapshots.ts
2815
- import { Hono as Hono3 } from "hono";
3060
+ import { Hono as Hono4 } from "hono";
2816
3061
 
2817
3062
  // src/core/snapshots.ts
2818
3063
  import { existsSync as existsSync3 } from "node:fs";
@@ -2882,21 +3127,21 @@ async function snapshotBox(ref, req, owner, p) {
2882
3127
  try {
2883
3128
  const since = box.memSnapshots ?? 0;
2884
3129
  const mode = since === 0 || existsSync3(m.memBase) ? "Diff" : "Full";
2885
- let failure;
3130
+ let failure2;
2886
3131
  await fc.pause();
2887
3132
  try {
2888
3133
  await fc.snapshotCreate(s.vmstate, s.mem, mode);
2889
3134
  lineageTouched = true;
2890
3135
  await run(["cp", "--sparse=always", "--reflink=auto", m.overlay, s.overlay]);
2891
3136
  } catch (err) {
2892
- failure = err;
3137
+ failure2 = err;
2893
3138
  } finally {
2894
3139
  if (!box.paused) await fc.resume();
2895
3140
  }
2896
3141
  if (lineageTouched) {
2897
3142
  await writeMachineMeta({ ...box, memSnapshots: since + 1 }, p);
2898
3143
  }
2899
- if (failure !== void 0) throw failure;
3144
+ if (failure2 !== void 0) throw failure2;
2900
3145
  if (mode === "Full") {
2901
3146
  await run(["fallocate", "--dig-holes", s.mem]);
2902
3147
  } else if (since > 0) {
@@ -2939,7 +3184,7 @@ async function deleteSnapshot(ref, p = paths()) {
2939
3184
 
2940
3185
  // src/routes/snapshots.ts
2941
3186
  function snapshotRoutes() {
2942
- const r = new Hono3();
3187
+ const r = new Hono4();
2943
3188
  r.get("/", async (c) => c.json(await listSnapshots(c.get("listScope"))));
2944
3189
  r.post("/", async (c) => {
2945
3190
  const body = CreateSnapshotRequest.parse(await c.req.json());
@@ -2953,9 +3198,9 @@ function snapshotRoutes() {
2953
3198
  }
2954
3199
 
2955
3200
  // src/routes/ssh.ts
2956
- import { Hono as Hono4 } from "hono";
3201
+ import { Hono as Hono5 } from "hono";
2957
3202
  function sshRoutes(opts = {}) {
2958
- const r = new Hono4();
3203
+ const r = new Hono5();
2959
3204
  if (opts.controlPlane) {
2960
3205
  r.all("/*", (c) => c.json({ error: "trusting a CA is done on the host itself (kvbox server trust-ca there); the control plane cannot ask a host to" }, 403));
2961
3206
  return r;
@@ -2970,10 +3215,10 @@ function sshRoutes(opts = {}) {
2970
3215
  }
2971
3216
 
2972
3217
  // src/routes/usage.ts
2973
- import { Hono as Hono5 } from "hono";
3218
+ import { Hono as Hono6 } from "hono";
2974
3219
  var PULL_LIMIT = 1e3;
2975
3220
  function usageRoutes(opts = {}) {
2976
- const r = new Hono5();
3221
+ const r = new Hono6();
2977
3222
  r.get("/", async (c) => {
2978
3223
  let window;
2979
3224
  try {
@@ -3000,7 +3245,7 @@ function whole(value, name) {
3000
3245
 
3001
3246
  // src/app.ts
3002
3247
  function createApp(opts = {}) {
3003
- const app = new Hono6();
3248
+ const app = new Hono7();
3004
3249
  app.use(resolveCaller(opts));
3005
3250
  app.get("/health", async (c) => {
3006
3251
  const cfg = await loadConfig();
@@ -3016,6 +3261,7 @@ function createApp(opts = {}) {
3016
3261
  recovery: lastRecovery()
3017
3262
  });
3018
3263
  });
3264
+ if (opts.controlPlane) app.route("/machines", boxHttpRoutes());
3019
3265
  app.route("/machines", machineRoutes());
3020
3266
  app.route("/snapshots", snapshotRoutes());
3021
3267
  app.route("/capacity", capacityRoutes());
@@ -3061,6 +3307,7 @@ async function listen(opts = {}) {
3061
3307
  createServer: createHttpsServer,
3062
3308
  serverOptions: { key, cert }
3063
3309
  });
3310
+ httpsServer.on("upgrade", boxUpgradeHandler({ name: cp.name, secret: cp.secret }));
3064
3311
  const port = opts.httpsPort ?? cp.port;
3065
3312
  await new Promise((resolve, reject) => {
3066
3313
  httpsServer.once("error", reject);