@rallycry/conveyor-k3 0.1.10 → 0.1.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/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PersonalClusterEnrollmentBundle, PersonalClusterEnrollmentCompletion, PersonalClusterDesiredState, PersonalClusterHeartbeat, PersonalClusterKubeletState } from '@project/shared';
1
+ import { PersonalClusterEnrollmentBundle, PersonalClusterEnrollmentCompletion, PersonalClusterDesiredState, PersonalClusterHeartbeat, PersonalClusterWorkloads, PersonalClusterKubeletState } from '@project/shared';
2
2
 
3
3
  interface ClusterConnectionConfig {
4
4
  apiUrl: string;
@@ -19,8 +19,8 @@ declare class ClusterConnection {
19
19
  enroll(): Promise<PersonalClusterEnrollmentBundle>;
20
20
  completeEnrollment(payload: PersonalClusterEnrollmentCompletion): Promise<void>;
21
21
  requestDesiredState(): Promise<PersonalClusterDesiredState>;
22
- /** Fire-and-forget: a lost heartbeat is covered by the next one. */
23
- sendHeartbeat(payload: PersonalClusterHeartbeat): void;
22
+ /** Old servers still receive liveness even when they do not acknowledge inspection data. */
23
+ sendHeartbeat(payload: PersonalClusterHeartbeat): Promise<PersonalClusterWorkloads | undefined>;
24
24
  /**
25
25
  * Tell Conveyor this machine is stopping, so placement skips it at once
26
26
  * instead of waiting out the stale window.
@@ -83,6 +83,7 @@ interface DaemonPorts {
83
83
  fs: FileSystemPort$1;
84
84
  connection: ClusterConnection;
85
85
  cliVersion: string;
86
+ writeWorkloads?: (data: PersonalClusterWorkloads | undefined) => Promise<void>;
86
87
  log: (line: string) => void;
87
88
  }
88
89
  declare class ClusterDaemon {
@@ -150,7 +151,11 @@ declare class ClusterDaemon {
150
151
  private strandedBytesExceedLimit;
151
152
  prune(): Promise<void>;
152
153
  private buildHeartbeat;
154
+ private persistWorkloads;
155
+ private beating?;
156
+ private stopped;
153
157
  beat(): Promise<void>;
158
+ private runBeat;
154
159
  }
155
160
 
156
161
  /**
@@ -710,6 +715,7 @@ interface GuestSrvRecord {
710
715
  weight: number;
711
716
  }
712
717
  interface ResolvedNetwork {
718
+ publicWeb?: boolean;
713
719
  hosts: ResolvedNetworkHost[];
714
720
  /** Static SRV answers needed by cloudflared's documented edge discovery. */
715
721
  srv: GuestSrvRecord[];
package/dist/index.js CHANGED
@@ -4109,12 +4109,12 @@ var coerce = {
4109
4109
  };
4110
4110
  var NEVER = INVALID;
4111
4111
 
4112
- // ../shared/dist/chunk-3D2OVVIS.js
4113
- var DEFAULT_SONNET_MODEL = "claude-sonnet-5";
4112
+ // ../shared/dist/chunk-PVMC53P5.js
4113
+ var DEFAULT_SONNET_MODEL = "claude-sonnet-5-5";
4114
4114
  var DEFAULT_OPUS_MODEL = "claude-opus-5-5";
4115
4115
  var DEFAULT_HAIKU_MODEL = "claude-haiku-4-5-20251001";
4116
4116
  var FABLE_MODEL = "claude-fable-5-1";
4117
- var PREVIOUS_SONNET_MODEL = "claude-sonnet-4-6";
4117
+ var PREVIOUS_SONNET_MODEL = "claude-sonnet-5";
4118
4118
  var PREVIOUS_OPUS_MODEL = "claude-opus-5";
4119
4119
  function formatModelId(provider, model) {
4120
4120
  return `${provider}/${model}`;
@@ -6175,8 +6175,8 @@ function anthropicEntry(model, label, inputPerMillion, outputPerMillion, opts =
6175
6175
  var ANTHROPIC_CATALOG = [
6176
6176
  anthropicEntry(DEFAULT_OPUS_MODEL, "Opus 5.5 Latest", 4, 20),
6177
6177
  anthropicEntry(PREVIOUS_OPUS_MODEL, "Opus 5", 5, 25),
6178
- anthropicEntry(DEFAULT_SONNET_MODEL, "Sonnet 5 Latest", 3, 15),
6179
- anthropicEntry(PREVIOUS_SONNET_MODEL, "Sonnet 4.6", 3, 15),
6178
+ anthropicEntry(DEFAULT_SONNET_MODEL, "Sonnet 5.5 Latest", 2, 10),
6179
+ anthropicEntry(PREVIOUS_SONNET_MODEL, "Sonnet 5", 2, 10),
6180
6180
  // The Haiku line (4.5 and older) predates the tuning surface and 400s on it.
6181
6181
  anthropicEntry(DEFAULT_HAIKU_MODEL, "Haiku 4.5", 1, 5, { supportsEffort: false }),
6182
6182
  anthropicEntry(FABLE_MODEL, "Fable 5.1", 10, 50)
@@ -6611,9 +6611,16 @@ var ClusterConnection = class {
6611
6611
  const ack = await this.require().timeout(3e4).emitWithAck(PERSONAL_CLUSTER_EVENTS.requestDesiredState, {});
6612
6612
  return unwrap(ack, "Fetching settings");
6613
6613
  }
6614
- /** Fire-and-forget: a lost heartbeat is covered by the next one. */
6615
- sendHeartbeat(payload) {
6616
- this.socket?.emit(PERSONAL_CLUSTER_EVENTS.heartbeat, payload);
6614
+ /** Old servers still receive liveness even when they do not acknowledge inspection data. */
6615
+ async sendHeartbeat(payload) {
6616
+ if (!this.socket?.connected) return void 0;
6617
+ try {
6618
+ const ack = await this.socket.timeout(8e3).emitWithAck(PERSONAL_CLUSTER_EVENTS.heartbeat, payload);
6619
+ if (!this.socket?.connected) return void 0;
6620
+ return unwrap(ack, "Reading pod assignments");
6621
+ } catch {
6622
+ return void 0;
6623
+ }
6617
6624
  }
6618
6625
  /**
6619
6626
  * Tell Conveyor this machine is stopping, so placement skips it at once
@@ -7398,6 +7405,7 @@ async function collectHeartbeat(ports, state, inventory) {
7398
7405
  const runnerUsers = await detectRunnerUsers(ports.exec);
7399
7406
  const ungranted = ungrantedRunnerUsers(runnerUsers, await ports.readFile(RUNNERS_SUDOERS_FILE));
7400
7407
  return {
7408
+ publicWebProxy: (await ports.readFile("/etc/conveyor-vm/public-web"))?.trim() === "enabled",
7401
7409
  cliVersion: state.cliVersion,
7402
7410
  k3sVersion: await versionOf(ports.exec, "k3s", ["--version"]),
7403
7411
  cloudflaredVersion: await versionOf(ports.exec, "cloudflared", ["--version"]),
@@ -7420,6 +7428,31 @@ async function collectHeartbeat(ports, state, inventory) {
7420
7428
  ]
7421
7429
  };
7422
7430
  }
7431
+ async function readPodIdentities(exec) {
7432
+ const result = await exec(
7433
+ "k3s",
7434
+ [
7435
+ "kubectl",
7436
+ "get",
7437
+ "pods",
7438
+ "-A",
7439
+ "-l",
7440
+ CONVEYOR_POD_SELECTOR,
7441
+ "--request-timeout=5s",
7442
+ "--no-headers",
7443
+ "-o",
7444
+ "custom-columns=NS:.metadata.namespace,NAME:.metadata.name,UID:.metadata.uid"
7445
+ ],
7446
+ { timeoutMs: 7e3, maxOutputBytes: 65536 }
7447
+ );
7448
+ if (result.exitCode !== 0) return [];
7449
+ const rows = result.stdout.trim().split("\n").filter(Boolean);
7450
+ if (rows.length > 100) return [];
7451
+ return rows.flatMap((row) => {
7452
+ const [namespace, name, uid, extra] = row.trim().split(/\s+/);
7453
+ return !extra && [namespace, name, uid].every((s) => s && /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(s)) ? [{ namespace, name, uid }] : [];
7454
+ });
7455
+ }
7423
7456
 
7424
7457
  // src/daemon/runner.ts
7425
7458
  var HOUR_MS = 60 * 6e4;
@@ -7489,10 +7522,13 @@ var ClusterDaemon = class {
7489
7522
  await this.beat();
7490
7523
  }
7491
7524
  async stop() {
7525
+ this.stopped = true;
7492
7526
  for (const timer of this.timers) clearInterval(timer);
7493
7527
  this.timers = [];
7494
7528
  await this.ports.connection.sayGoodbye();
7495
7529
  this.ports.connection.disconnect();
7530
+ await this.beating;
7531
+ await this.persistWorkloads(void 0);
7496
7532
  }
7497
7533
  /** Fetch the desired state and converge to it. Queued, never concurrent. */
7498
7534
  syncAndReconcile() {
@@ -7654,10 +7690,33 @@ var ClusterDaemon = class {
7654
7690
  inventory
7655
7691
  );
7656
7692
  }
7657
- async beat() {
7693
+ async persistWorkloads(data) {
7658
7694
  try {
7659
- this.ports.connection.sendHeartbeat(await this.buildHeartbeat());
7695
+ await this.ports.writeWorkloads?.(data);
7660
7696
  } catch (error) {
7697
+ this.ports.log(
7698
+ `Pod context cache unavailable: ${error instanceof Error ? error.message : String(error)}`
7699
+ );
7700
+ }
7701
+ }
7702
+ beating;
7703
+ stopped = false;
7704
+ beat() {
7705
+ if (this.stopped) return Promise.resolve();
7706
+ this.beating ??= this.runBeat().finally(() => {
7707
+ this.beating = void 0;
7708
+ });
7709
+ return this.beating;
7710
+ }
7711
+ async runBeat() {
7712
+ try {
7713
+ const workloads = await this.ports.connection.sendHeartbeat({
7714
+ ...await this.buildHeartbeat(),
7715
+ observedPods: await readPodIdentities(this.ports.exec).catch(() => [])
7716
+ });
7717
+ if (!this.stopped) await this.persistWorkloads(workloads);
7718
+ } catch (error) {
7719
+ await this.persistWorkloads(void 0);
7661
7720
  this.ports.log(`Heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
7662
7721
  }
7663
7722
  }
@@ -8449,6 +8508,13 @@ while True:
8449
8508
  udp.sendto(response, peer)
8450
8509
  `;
8451
8510
 
8511
+ // src/vm/public-web.ts
8512
+ import { resolve4 } from "dns/promises";
8513
+ import { networkInterfaces as networkInterfaces2 } from "os";
8514
+ var PUBLIC_WEB_IP = "192.168.5.250";
8515
+ var PUBLIC_WEB_PORT = 3128;
8516
+ var PUBLIC_WEB_URL = `http://${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}`;
8517
+
8452
8518
  // src/vm/qemu-policy.ts
8453
8519
  var ALLOWED_PROBES = new Set(
8454
8520
  [
@@ -8685,7 +8751,10 @@ function restrictedUsernet(network, forwards, ncPath) {
8685
8751
  "restrict=on",
8686
8752
  "ipv6=off",
8687
8753
  `hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
8688
- ...guestForwards
8754
+ ...guestForwards,
8755
+ ...network.publicWeb === true ? [
8756
+ `guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
8757
+ ] : []
8689
8758
  ].join(",");
8690
8759
  }
8691
8760
  function validateForwards(forwards) {
package/dist/vm-qemu.js CHANGED
@@ -7,6 +7,10 @@ import { dirname, join as join5 } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { createHash } from "crypto";
9
9
 
10
+ // src/vm/public-web.ts
11
+ import { resolve4 } from "dns/promises";
12
+ import { networkInterfaces as networkInterfaces2 } from "os";
13
+
10
14
  // src/vm/network.ts
11
15
  import { networkInterfaces } from "os";
12
16
  import { resolve4 as systemResolve4, resolveSrv as systemResolveSrv } from "dns/promises";
@@ -50,6 +54,98 @@ function isGloballyRoutableIPv4(address) {
50
54
  ].some(([base, mask]) => inCidr(value, parseIPv4(base), mask));
51
55
  }
52
56
 
57
+ // src/vm/public-web.ts
58
+ var PUBLIC_WEB_IP = "192.168.5.250";
59
+ var PUBLIC_WEB_PORT = 3128;
60
+ var PUBLIC_WEB_URL = `http://${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}`;
61
+ async function publicWebAddress(host, lookup = resolve4, interfaces = networkInterfaces2) {
62
+ if (!/^[a-z0-9.-]+$/i.test(host) || host.length > 253)
63
+ throw new Error("Invalid public destination");
64
+ const own = new Set(
65
+ Object.values(interfaces()).flatMap((items) => items?.map((i) => i.address) ?? [])
66
+ );
67
+ const answers = /^\d+\.\d+\.\d+\.\d+$/.test(host) ? [host] : await lookup(host);
68
+ if (!answers.length || answers.some((ip) => !isGloballyRoutableIPv4(ip) || own.has(ip)))
69
+ throw new Error("Host, LAN and reserved destinations are blocked");
70
+ const address = answers[0];
71
+ if (!address) throw new Error("No public DNS answer");
72
+ return address;
73
+ }
74
+ function publicWebRequest(header) {
75
+ const lines = header.split("\r\n");
76
+ const match = /^(CONNECT|GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) ([^\s]+) HTTP\/1\.[01]$/.exec(
77
+ lines.shift() ?? ""
78
+ );
79
+ if (!match) throw new Error("Unsupported proxy request");
80
+ const connect = match[1] === "CONNECT";
81
+ const target = match[2] ?? "";
82
+ if (connect && !/^[a-z0-9.-]+:(80|443)$/i.test(target))
83
+ throw new Error("Invalid CONNECT destination");
84
+ const { url, port } = proxyUrl(target, connect);
85
+ for (const line of lines.filter(Boolean)) {
86
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e]*$/.test(line))
87
+ throw new Error("Invalid proxy header");
88
+ }
89
+ const headers = lines.filter(
90
+ (line) => line && !/^(host|connection|proxy-connection|proxy-authorization):/i.test(line)
91
+ );
92
+ return {
93
+ host: url.hostname,
94
+ port,
95
+ connect,
96
+ forward: connect ? "" : `${match[1]} ${url.pathname}${url.search} HTTP/1.1\r
97
+ Host: ${url.host}\r
98
+ Connection: close\r
99
+ ${headers.length ? headers.join("\r\n") + "\r\n" : ""}\r
100
+ `
101
+ };
102
+ }
103
+ function readPublicWebRequest(input) {
104
+ return new Promise((resolve, reject) => {
105
+ let data = Buffer.alloc(0);
106
+ const cleanup = () => {
107
+ clearTimeout(timer);
108
+ input.pause();
109
+ input.removeListener("data", receive);
110
+ input.removeListener("end", closed);
111
+ input.removeListener("error", failed);
112
+ };
113
+ const failed = (error) => {
114
+ cleanup();
115
+ reject(error);
116
+ };
117
+ const closed = () => failed(new Error("Incomplete proxy request"));
118
+ const timer = setTimeout(() => failed(new Error("Proxy handshake timed out")), 1e4);
119
+ const receive = (chunk) => {
120
+ data = Buffer.concat([data, chunk]);
121
+ const end = data.indexOf("\r\n\r\n");
122
+ if (end < 0 && data.length > 16384 || end > 16384)
123
+ return failed(new Error("Proxy header too large"));
124
+ if (end < 0) return;
125
+ cleanup();
126
+ try {
127
+ const request = publicWebRequest(data.subarray(0, end).toString("latin1"));
128
+ const remaining = Buffer.concat([Buffer.from(request.forward), data.subarray(end + 4)]);
129
+ if (remaining.length) input.unshift(remaining);
130
+ resolve(request);
131
+ } catch (error) {
132
+ reject(error);
133
+ }
134
+ };
135
+ input.on("data", receive);
136
+ input.once("end", closed);
137
+ input.once("error", failed);
138
+ });
139
+ }
140
+ function proxyUrl(target, connect) {
141
+ const url = new URL(connect ? `https://${target}` : target);
142
+ if (url.username || url.password || url.hash || !connect && url.protocol !== "http:")
143
+ throw new Error("Invalid proxy URL");
144
+ const port = Number(url.port || (connect ? 443 : 80));
145
+ if (port !== 80 && port !== 443) throw new Error("Only public web ports 80 and 443 are allowed");
146
+ return { url, port };
147
+ }
148
+
53
149
  // src/vm/qemu-policy.ts
54
150
  var ALLOWED_PROBES = new Set(
55
151
  [
@@ -286,7 +382,10 @@ function restrictedUsernet(network, forwards, ncPath) {
286
382
  "restrict=on",
287
383
  "ipv6=off",
288
384
  `hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
289
- ...guestForwards
385
+ ...guestForwards,
386
+ ...network.publicWeb === true ? [
387
+ `guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
388
+ ] : []
290
389
  ].join(",");
291
390
  }
292
391
  function validateForwards(forwards) {
@@ -473,7 +572,7 @@ import { Socket } from "net";
473
572
  var MAX_RELAYS = 16;
474
573
  var MAX_RELAY_BYTES = 1024 * 1024 * 1024;
475
574
  var RELAY_CONNECT_MS = 6e4;
476
- var RELAY_IDLE_MS = 3e4;
575
+ var RELAY_IDLE_MS = 15 * 6e4;
477
576
  var GUARD_FRESH_MS = 6e3;
478
577
  var RELAY_PREFIX = "--conveyor-vm-relay";
479
578
  function validPid(value) {
@@ -589,7 +688,7 @@ function parseRelayInvocation(args) {
589
688
  }
590
689
  const address = args[5] ?? "";
591
690
  const port = Number(args[6]);
592
- if (!isGloballyRoutableIPv4(address) || !Number.isInteger(port) || port < 1 || port > 65535) {
691
+ if (!(isGloballyRoutableIPv4(address) || address === "public-web" && port === PUBLIC_WEB_PORT) || !Number.isInteger(port) || port < 1 || port > 65535) {
593
692
  throw new Error("Invalid VM relay destination");
594
693
  }
595
694
  return { address, port };
@@ -668,21 +767,53 @@ function currentGuardStillValid(root, runId) {
668
767
  function runRelay(root, invocation, io) {
669
768
  return Promise.resolve().then(() => runRelayNow(root, invocation, io));
670
769
  }
671
- function runRelayNow(root, invocation, io) {
770
+ async function runRelayNow(root, invocation, io) {
672
771
  const guard = readLiveGuard(root);
673
- assertApprovedDestination(root, invocation);
772
+ const publicWeb = invocation.address === "public-web" && invocation.port === PUBLIC_WEB_PORT;
773
+ if (publicWeb) {
774
+ if (readNetwork(root).publicWeb !== true) throw new Error("Public web access is not enabled");
775
+ } else assertApprovedDestination(root, invocation);
674
776
  const record = registerRelay(root, guard.runId);
777
+ const streams = io ?? { socket: new Socket(), input: process.stdin, output: process.stdout };
778
+ let connectedResponse = false;
779
+ if (publicWeb) {
780
+ const releaseHandshake = handshakeExitCleanup(root, record);
781
+ let timeout;
782
+ try {
783
+ const request = await readPublicWebRequest(streams.input);
784
+ const address = await Promise.race([
785
+ publicWebAddress(request.host),
786
+ new Promise((_, reject) => {
787
+ timeout = setTimeout(() => reject(new Error("DNS lookup timed out")), 5e3);
788
+ })
789
+ ]);
790
+ if (!currentGuardStillValid(root, record.runId))
791
+ throw new Error("Resource guard is no longer healthy");
792
+ invocation = { address, port: request.port };
793
+ connectedResponse = request.connect;
794
+ } catch {
795
+ unregisterRelay(root, record);
796
+ streams.output.write(
797
+ "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
798
+ );
799
+ streams.input.destroy();
800
+ streams.socket.destroy();
801
+ return;
802
+ } finally {
803
+ if (timeout) clearTimeout(timeout);
804
+ releaseHandshake();
805
+ }
806
+ }
807
+ return transportRelay(root, invocation, streams, record, connectedResponse);
808
+ }
809
+ function transportRelay(root, invocation, streams, record, connectedResponse) {
675
810
  return new Promise((resolve) => {
676
811
  let finished = false;
677
812
  let sent = 0;
678
813
  let received = 0;
679
814
  let guardTimer;
680
815
  let connectTimer;
681
- const { socket, input, output } = io ?? {
682
- socket: new Socket(),
683
- input: process.stdin,
684
- output: process.stdout
685
- };
816
+ const { socket, input, output } = streams;
686
817
  const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
687
818
  const cleanup = () => {
688
819
  try {
@@ -714,6 +845,7 @@ function runRelayNow(root, invocation, io) {
714
845
  socket.once("connect", () => {
715
846
  if (connectTimer) clearTimeout(connectTimer);
716
847
  connectTimer = void 0;
848
+ if (connectedResponse) output.write("HTTP/1.1 200 Connection Established\r\n\r\n");
717
849
  });
718
850
  socket.on("error", () => finish(new Error("VM relay connection failed")));
719
851
  socket.on("close", () => finish());
@@ -741,8 +873,30 @@ function runRelayNow(root, invocation, io) {
741
873
  RELAY_CONNECT_MS
742
874
  );
743
875
  socket.connect(invocation.port, invocation.address);
876
+ input.resume();
744
877
  });
745
878
  }
879
+ function handshakeExitCleanup(root, record) {
880
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
881
+ const cleanup = () => {
882
+ try {
883
+ unregisterRelay(root, record);
884
+ } catch {
885
+ }
886
+ };
887
+ const release = () => {
888
+ process.removeListener("exit", cleanup);
889
+ for (const signal of signals) process.removeListener(signal, terminated);
890
+ };
891
+ const terminated = (signal) => {
892
+ cleanup();
893
+ release();
894
+ process.kill(process.pid, signal);
895
+ };
896
+ process.once("exit", cleanup);
897
+ for (const signal of signals) process.once(signal, terminated);
898
+ return release;
899
+ }
746
900
 
747
901
  // src/vm/memory-budget.ts
748
902
  function vmHostMemoryGiB(guestGiB) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rallycry/conveyor-k3",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Managed personal compute for Conveyor. Installs and maintains a k3s cluster that runs your own card builds, kept converged on Conveyor's desired state.",
5
5
  "keywords": [
6
6
  "cloudflared",