@rallycry/conveyor-k3 0.1.8 → 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/cli.js +9024 -8433
- package/dist/index.d.ts +13 -4
- package/dist/index.js +89 -11
- package/dist/vm-qemu.js +172 -11
- package/package.json +1 -1
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
|
-
/**
|
|
23
|
-
sendHeartbeat(payload: PersonalClusterHeartbeat):
|
|
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[];
|
|
@@ -774,4 +780,7 @@ interface QemuPolicyContext {
|
|
|
774
780
|
/** Rewrites only a complete, known Lima QEMU profile; every unknown input fails closed. */
|
|
775
781
|
declare function rewriteQemuArgs(args: string[], network: QemuNetworkPolicy, ncPath?: string, context?: QemuPolicyContext): string[];
|
|
776
782
|
|
|
777
|
-
|
|
783
|
+
/** Reserve 25% of physical host RAM after accounting for VM overhead. */
|
|
784
|
+
declare function maximumGuestMemoryGiB(hostBytes: number): number;
|
|
785
|
+
|
|
786
|
+
export { ClusterConnection, type ClusterConnectionConfig, ClusterDaemon, CommandError, type CrictlImage, type DaemonPorts, type Executor, type FileSystemPort$1 as FileSystemPort, type GuestDnsProvision, type GuestSrvRecord, HELP, type ImageInventory, K3S_DISABLED_COMPONENTS, K3S_INSTALL_EXEC, type K3sUpgradeDecision, type MachineState, type NetworkEndpoint, type NetworkPolicy, type QemuNetworkPolicy, type ReconcileContext, type ReconcileResult, type RegistriesFile, type ResolvedNetwork, type ResolvedNetworkHost, type SRVLookup, type UninstallResult, type UnitRenderOptions, VERBS, type Verb, cloudflaredSrvRecords, collectHeartbeat, configuredAuthHosts, countRunningConveyorPods, createMemoryFileSystem, decideK3sUpgrade, isGloballyRoutableIPv4, isVerb, maximumGuestMemoryGiB, meetsK3sMinimum, missingAuthHosts, nodeFileSystem, ownerConfiguredKeys, ownerConfiguredKubeletFlags, parseArgs, parseK3sVersion, parseRegistries, pruneStrandedImages, readImageInventory, readState, reconcile, registryAdd, registryList, renderAllUnits, renderGuestDnsProvision, renderK3sDropIn, renderKubeletDropIn, renderRegistryAuthSummary, resolveCloudflaredSrvRecords, resolveNetwork, rewriteQemuArgs, runOrThrow, runUpdate, selectStrandedImages, serializeRegistries, spawnExecutor, uninstall, upsertRegistryAuth, warmImages, writeState };
|
package/dist/index.js
CHANGED
|
@@ -4109,12 +4109,12 @@ var coerce = {
|
|
|
4109
4109
|
};
|
|
4110
4110
|
var NEVER = INVALID;
|
|
4111
4111
|
|
|
4112
|
-
// ../shared/dist/chunk-
|
|
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-
|
|
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",
|
|
6179
|
-
anthropicEntry(PREVIOUS_SONNET_MODEL, "Sonnet
|
|
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
|
-
/**
|
|
6615
|
-
sendHeartbeat(payload) {
|
|
6616
|
-
this.socket?.
|
|
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
|
|
7693
|
+
async persistWorkloads(data) {
|
|
7658
7694
|
try {
|
|
7659
|
-
this.ports.
|
|
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
|
}
|
|
@@ -7723,6 +7782,7 @@ Documentation=https://conveyor.rallycryapp.com/docs/guides/managed-personal-comp
|
|
|
7723
7782
|
|
|
7724
7783
|
[Service]
|
|
7725
7784
|
Type=oneshot
|
|
7785
|
+
User=root
|
|
7726
7786
|
EnvironmentFile=${ENV_FILE}
|
|
7727
7787
|
ExecStart=${options.cliBin} update
|
|
7728
7788
|
StandardOutput=journal
|
|
@@ -8448,6 +8508,13 @@ while True:
|
|
|
8448
8508
|
udp.sendto(response, peer)
|
|
8449
8509
|
`;
|
|
8450
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
|
+
|
|
8451
8518
|
// src/vm/qemu-policy.ts
|
|
8452
8519
|
var ALLOWED_PROBES = new Set(
|
|
8453
8520
|
[
|
|
@@ -8684,7 +8751,10 @@ function restrictedUsernet(network, forwards, ncPath) {
|
|
|
8684
8751
|
"restrict=on",
|
|
8685
8752
|
"ipv6=off",
|
|
8686
8753
|
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
8687
|
-
...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
|
+
] : []
|
|
8688
8758
|
].join(",");
|
|
8689
8759
|
}
|
|
8690
8760
|
function validateForwards(forwards) {
|
|
@@ -8850,6 +8920,13 @@ function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", context = {}) {
|
|
|
8850
8920
|
assertCompleteProfile(state);
|
|
8851
8921
|
return output;
|
|
8852
8922
|
}
|
|
8923
|
+
|
|
8924
|
+
// src/vm/memory-budget.ts
|
|
8925
|
+
function maximumGuestMemoryGiB(hostBytes) {
|
|
8926
|
+
if (!Number.isFinite(hostBytes) || hostBytes <= 0) return 0;
|
|
8927
|
+
const budget = hostBytes / 1024 ** 3 * 0.75;
|
|
8928
|
+
return Math.max(0, Math.floor(Math.min(budget - 1, budget / 1.25)));
|
|
8929
|
+
}
|
|
8853
8930
|
export {
|
|
8854
8931
|
ClusterConnection,
|
|
8855
8932
|
ClusterDaemon,
|
|
@@ -8866,6 +8943,7 @@ export {
|
|
|
8866
8943
|
decideK3sUpgrade,
|
|
8867
8944
|
isGloballyRoutableIPv4,
|
|
8868
8945
|
isVerb,
|
|
8946
|
+
maximumGuestMemoryGiB,
|
|
8869
8947
|
meetsK3sMinimum,
|
|
8870
8948
|
missingAuthHosts,
|
|
8871
8949
|
nodeFileSystem,
|
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 =
|
|
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
|
-
|
|
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 } =
|
|
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,35 @@ 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
|
+
}
|
|
900
|
+
|
|
901
|
+
// src/vm/memory-budget.ts
|
|
902
|
+
function vmHostMemoryGiB(guestGiB) {
|
|
903
|
+
return guestGiB + Math.max(1, guestGiB / 4);
|
|
904
|
+
}
|
|
746
905
|
|
|
747
906
|
// src/vm/resource-guard.ts
|
|
748
907
|
var GIB = 1024 ** 3;
|
|
@@ -753,7 +912,9 @@ function guardLimits(resources) {
|
|
|
753
912
|
}
|
|
754
913
|
return {
|
|
755
914
|
minFreeBytes: 10 * GIB,
|
|
756
|
-
|
|
915
|
+
// A 24 GiB Mac guest exceeded 27 GiB during image unpacking. Allow 25%
|
|
916
|
+
// overhead (at least 1 GiB); admission reserves host RAM using this same cap.
|
|
917
|
+
maxRssKiB: vmHostMemoryGiB(resources.memoryGiB) * 1024 ** 2,
|
|
757
918
|
maxProcesses: 64,
|
|
758
919
|
maxOutputBytes: 1024 * 1024,
|
|
759
920
|
maxDiskBytes: (resources.diskGiB + 1) * GIB,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rallycry/conveyor-k3",
|
|
3
|
-
"version": "0.1.
|
|
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",
|