@forgezero/agent 0.1.22 → 0.1.24
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/README.md +24 -0
- package/dist/cli/agent-install.d.ts +10 -0
- package/dist/fz-agent.js +494 -218
- package/dist/fz.js +221 -18
- package/dist/guest-enrolment.d.ts +9 -0
- package/dist/guest-enrolment.js +22 -0
- package/dist/index.d.ts +5 -0
- package/dist/lifecycle-helper.d.ts +36 -0
- package/dist/lifecycle-helper.js +216 -0
- package/dist/metal-helper-socket.js +8 -532
- package/dist/metal-provision.d.ts +1 -6
- package/dist/metal-provision.js +8 -536
- package/dist/migration-pull.d.ts +55 -0
- package/dist/migration-pull.js +185 -0
- package/dist/provision.d.ts +22 -0
- package/dist/provision.js +215 -16
- package/dist/version.d.ts +1 -1
- package/dist/warp-config.d.ts +20 -0
- package/package.json +10 -2
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// src/lifecycle-helper.ts
|
|
2
|
+
import { chmodSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { connect, createConnection, createServer, isIP } from "node:net";
|
|
4
|
+
var DEFAULT_LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
5
|
+
var MAX_REQUEST_BYTES = 16 * 1024;
|
|
6
|
+
var REQUEST_TIMEOUT_MS = 5000;
|
|
7
|
+
var ACTION_TIMEOUT_MS = 10 * 60000;
|
|
8
|
+
var unitPattern = /^[A-Za-z0-9_.@-]+\.service$/;
|
|
9
|
+
var privateIp = (value) => {
|
|
10
|
+
const address = value.replace(/^\[|\]$/g, "").toLowerCase();
|
|
11
|
+
if (isIP(address) === 4) {
|
|
12
|
+
const [a, b] = address.split(".").map(Number);
|
|
13
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
14
|
+
}
|
|
15
|
+
if (isIP(address) === 6) {
|
|
16
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
17
|
+
return Number.isFinite(first) && (first & 65024) === 64512;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
};
|
|
21
|
+
function validateLifecycleProfile(profile) {
|
|
22
|
+
if (!Array.isArray(profile.apiUnits) || profile.apiUnits.length < 1 || profile.apiUnits.some((unit) => !unitPattern.test(unit))) {
|
|
23
|
+
throw new Error("lifecycle profile needs one or more valid API service units");
|
|
24
|
+
}
|
|
25
|
+
if (!unitPattern.test(profile.databaseUnit))
|
|
26
|
+
throw new Error("lifecycle profile database unit is invalid");
|
|
27
|
+
const apiUrl = new URL(profile.apiHealthUrl);
|
|
28
|
+
if (apiUrl.protocol !== "http:" || !["127.0.0.1", "[::1]", "::1", "localhost"].includes(apiUrl.hostname)) {
|
|
29
|
+
throw new Error("API health URL must be loopback HTTP");
|
|
30
|
+
}
|
|
31
|
+
const databaseUrl = new URL(profile.databaseHealthUrl);
|
|
32
|
+
if (databaseUrl.protocol !== "http:" || !(["127.0.0.1", "[::1]", "::1", "localhost"].includes(databaseUrl.hostname) || privateIp(databaseUrl.hostname))) {
|
|
33
|
+
throw new Error("database health URL must be loopback or private HTTP");
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(profile.databasePorts) || profile.databasePorts.length < 1 || profile.databasePorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
36
|
+
throw new Error("lifecycle profile database ports are invalid");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function loadLifecycleProfile(path) {
|
|
40
|
+
const profile = JSON.parse(readFileSync(path, "utf8"));
|
|
41
|
+
validateLifecycleProfile(profile);
|
|
42
|
+
return profile;
|
|
43
|
+
}
|
|
44
|
+
var spawnLifecycleCommand = async (argv) => {
|
|
45
|
+
const child = Bun.spawn([...argv], {
|
|
46
|
+
stdout: "pipe",
|
|
47
|
+
stderr: "pipe",
|
|
48
|
+
env: { PATH: "/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
49
|
+
});
|
|
50
|
+
let timedOut = false;
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
timedOut = true;
|
|
53
|
+
child.kill("SIGTERM");
|
|
54
|
+
}, ACTION_TIMEOUT_MS);
|
|
55
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
56
|
+
new Response(child.stdout).text(),
|
|
57
|
+
new Response(child.stderr).text(),
|
|
58
|
+
child.exited
|
|
59
|
+
]);
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
return { exitCode: timedOut ? 124 : exitCode, stdout, stderr };
|
|
62
|
+
};
|
|
63
|
+
var requireSuccess = async (exec, argv, label) => {
|
|
64
|
+
const result = await exec(argv);
|
|
65
|
+
if (result.exitCode !== 0)
|
|
66
|
+
throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
|
|
67
|
+
return result.stdout;
|
|
68
|
+
};
|
|
69
|
+
var probeTcp = (host, port, timeoutMs = 5000) => new Promise((resolve, reject) => {
|
|
70
|
+
const socket = createConnection({ host, port });
|
|
71
|
+
socket.setTimeout(timeoutMs);
|
|
72
|
+
socket.once("connect", () => {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
resolve();
|
|
75
|
+
});
|
|
76
|
+
socket.once("timeout", () => {
|
|
77
|
+
socket.destroy();
|
|
78
|
+
reject(new Error(`private peer ${host}:${port} timed out`));
|
|
79
|
+
});
|
|
80
|
+
socket.once("error", reject);
|
|
81
|
+
});
|
|
82
|
+
async function executeLifecycleAction(profile, claim, exec = spawnLifecycleCommand, tcpProbe = probeTcp, fetcher = fetch) {
|
|
83
|
+
validateLifecycleProfile(profile);
|
|
84
|
+
switch (claim.action) {
|
|
85
|
+
case "network-ready": {
|
|
86
|
+
if (!claim.peerPrivateAddresses?.length)
|
|
87
|
+
throw new Error("network claim has no private peers");
|
|
88
|
+
if (claim.network === "cloudflare-warp") {
|
|
89
|
+
const status = await requireSuccess(exec, ["/usr/bin/warp-cli", "--accept-tos", "status"], "WARP status");
|
|
90
|
+
if (!/\bconnected\b/i.test(status) || /\bdisconnected\b/i.test(status))
|
|
91
|
+
throw new Error("WARP is not connected");
|
|
92
|
+
}
|
|
93
|
+
for (const address of claim.peerPrivateAddresses) {
|
|
94
|
+
for (const port of profile.databasePorts)
|
|
95
|
+
await tcpProbe(address, port);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
targetAgentReady: true,
|
|
99
|
+
privateNetworkReady: true,
|
|
100
|
+
...claim.network === "cloudflare-warp" ? { warpConnected: true } : {}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
case "database-member-ready": {
|
|
104
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "is-active", profile.databaseUnit], "database service check");
|
|
105
|
+
const response = await fetcher(profile.databaseHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
106
|
+
if (!response.ok && response.status !== 401)
|
|
107
|
+
throw new Error(`database health returned HTTP ${response.status}`);
|
|
108
|
+
return { databaseMemberHealthy: true };
|
|
109
|
+
}
|
|
110
|
+
case "api-ready": {
|
|
111
|
+
const response = await fetcher(profile.apiHealthUrl, { signal: AbortSignal.timeout(5000) });
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
throw new Error(`API health returned HTTP ${response.status}`);
|
|
114
|
+
return { apiHealthy: true };
|
|
115
|
+
}
|
|
116
|
+
case "source-drained":
|
|
117
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits], "API drain");
|
|
118
|
+
return { sourceDrained: true };
|
|
119
|
+
case "source-stopped":
|
|
120
|
+
await requireSuccess(exec, ["/usr/bin/systemctl", "stop", ...profile.apiUnits, profile.databaseUnit], "source retirement");
|
|
121
|
+
return { sourceStopped: true };
|
|
122
|
+
default:
|
|
123
|
+
throw new Error("unknown lifecycle action");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function startLifecycleHelper(options) {
|
|
127
|
+
validateLifecycleProfile(options.profile);
|
|
128
|
+
const socketPath = options.socketPath ?? DEFAULT_LIFECYCLE_HELPER_SOCKET;
|
|
129
|
+
if (existsSync(socketPath))
|
|
130
|
+
unlinkSync(socketPath);
|
|
131
|
+
let tail = Promise.resolve();
|
|
132
|
+
const server = createServer((socket) => {
|
|
133
|
+
let buffer = "";
|
|
134
|
+
socket.setTimeout(REQUEST_TIMEOUT_MS, () => socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request timed out" } })}
|
|
135
|
+
`));
|
|
136
|
+
socket.on("data", (chunk) => {
|
|
137
|
+
buffer += chunk.toString("utf8");
|
|
138
|
+
if (buffer.length > MAX_REQUEST_BYTES)
|
|
139
|
+
return void socket.end(`${JSON.stringify({ ok: false, error: { code: "REFUSED", message: "request too large" } })}
|
|
140
|
+
`);
|
|
141
|
+
const newline = buffer.indexOf(`
|
|
142
|
+
`);
|
|
143
|
+
if (newline < 0)
|
|
144
|
+
return;
|
|
145
|
+
socket.setTimeout(0);
|
|
146
|
+
const line = buffer.slice(0, newline);
|
|
147
|
+
buffer = "";
|
|
148
|
+
const work = async () => {
|
|
149
|
+
let request;
|
|
150
|
+
try {
|
|
151
|
+
request = JSON.parse(line);
|
|
152
|
+
} catch {
|
|
153
|
+
return { ok: false, error: { code: "REFUSED", message: "invalid request" } };
|
|
154
|
+
}
|
|
155
|
+
if (request?.op !== "apply" || !request.claim)
|
|
156
|
+
return { ok: false, error: { code: "REFUSED", message: "unknown operation" } };
|
|
157
|
+
try {
|
|
158
|
+
return { ok: true, evidence: await executeLifecycleAction(options.profile, request.claim, options.exec, options.tcpProbe, options.fetch) };
|
|
159
|
+
} catch (cause) {
|
|
160
|
+
return { ok: false, error: { code: "FAILED", message: cause instanceof Error ? cause.message : "lifecycle action failed" } };
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const response = tail.then(work, work);
|
|
164
|
+
tail = response;
|
|
165
|
+
response.then((value) => socket.end(`${JSON.stringify(value)}
|
|
166
|
+
`));
|
|
167
|
+
});
|
|
168
|
+
socket.on("error", () => socket.destroy());
|
|
169
|
+
});
|
|
170
|
+
server.listen(socketPath, () => chmodSync(socketPath, 432));
|
|
171
|
+
return {
|
|
172
|
+
server,
|
|
173
|
+
async stop() {
|
|
174
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
175
|
+
await tail;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function requestLifecycleAction(claim, socketPath = DEFAULT_LIFECYCLE_HELPER_SOCKET) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const socket = connect(socketPath, () => socket.write(`${JSON.stringify({ op: "apply", claim })}
|
|
182
|
+
`));
|
|
183
|
+
socket.setTimeout(ACTION_TIMEOUT_MS + 1e4, () => {
|
|
184
|
+
socket.destroy();
|
|
185
|
+
reject(new Error("lifecycle helper response timed out"));
|
|
186
|
+
});
|
|
187
|
+
let buffer = "";
|
|
188
|
+
socket.on("data", (chunk) => {
|
|
189
|
+
buffer += chunk.toString("utf8");
|
|
190
|
+
const newline = buffer.indexOf(`
|
|
191
|
+
`);
|
|
192
|
+
if (newline < 0)
|
|
193
|
+
return;
|
|
194
|
+
socket.end();
|
|
195
|
+
try {
|
|
196
|
+
const response = JSON.parse(buffer.slice(0, newline));
|
|
197
|
+
if (response.ok)
|
|
198
|
+
resolve(response.evidence);
|
|
199
|
+
else
|
|
200
|
+
reject(new Error(response.error.message));
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
reject(cause);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
socket.on("error", reject);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export {
|
|
209
|
+
validateLifecycleProfile,
|
|
210
|
+
startLifecycleHelper,
|
|
211
|
+
spawnLifecycleCommand,
|
|
212
|
+
requestLifecycleAction,
|
|
213
|
+
loadLifecycleProfile,
|
|
214
|
+
executeLifecycleAction,
|
|
215
|
+
DEFAULT_LIFECYCLE_HELPER_SOCKET
|
|
216
|
+
};
|