@forgezero/agent 0.1.9 → 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/README.md +25 -2
- package/dist/cli/agent-install.d.ts +82 -0
- package/dist/cli/agent-install.test.d.ts +1 -0
- package/dist/cli/custody.d.ts +35 -0
- package/dist/cli/genesis.d.ts +79 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/options.test.d.ts +1 -0
- package/dist/cli/run.d.ts +92 -0
- package/dist/cli/run.test.d.ts +1 -0
- package/dist/deployment-pull.d.ts +2 -1
- package/dist/deployment.d.ts +14 -0
- package/dist/fz-agent.js +49 -7
- package/dist/fz.js +1270 -0
- package/dist/index.d.ts +1 -1
- package/dist/provisioning-pull.d.ts +2 -0
- package/dist/provisioning-pull.js +9 -1
- package/package.json +8 -6
package/dist/fz.js
ADDED
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __require = import.meta.require;
|
|
4
|
+
|
|
5
|
+
// src/cli/index.ts
|
|
6
|
+
import { THRESHOLD_MODES, thresholdMode } from "@forgezero/access/ceremony-modes";
|
|
7
|
+
import { signWithIdentity } from "@forgezero/runtime/ssh-agent";
|
|
8
|
+
|
|
9
|
+
// src/cli/run.ts
|
|
10
|
+
class RunError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "RunError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function splitAtSeparator(argv) {
|
|
19
|
+
const at = argv.indexOf("--");
|
|
20
|
+
if (at === -1) {
|
|
21
|
+
throw new RunError("NO_SEPARATOR", "Put `--` before the command: fz run -- npm start. Without it the flags after it would be read as fz\u2019s own.");
|
|
22
|
+
}
|
|
23
|
+
const command = argv.slice(at + 1);
|
|
24
|
+
if (command.length === 0) {
|
|
25
|
+
throw new RunError("NO_COMMAND", "Nothing to run after `--`.");
|
|
26
|
+
}
|
|
27
|
+
return { own: argv.slice(0, at), command };
|
|
28
|
+
}
|
|
29
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
30
|
+
function mergeEnvironment(base, secrets, options = {}) {
|
|
31
|
+
const env = {};
|
|
32
|
+
for (const [name, value] of Object.entries(base)) {
|
|
33
|
+
if (value !== undefined)
|
|
34
|
+
env[name] = value;
|
|
35
|
+
}
|
|
36
|
+
const collisions = [];
|
|
37
|
+
const refused = [];
|
|
38
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
39
|
+
if (!NAME.test(name)) {
|
|
40
|
+
refused.push(name);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (name in env) {
|
|
44
|
+
collisions.push(name);
|
|
45
|
+
if (options.preserveEnv)
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
env[name] = value;
|
|
49
|
+
}
|
|
50
|
+
return { env, collisions: collisions.sort(), refused: refused.sort() };
|
|
51
|
+
}
|
|
52
|
+
function describeInjection(result, count) {
|
|
53
|
+
const lines = [`${count} secret${count === 1 ? "" : "s"} injected`];
|
|
54
|
+
if (result.collisions.length > 0) {
|
|
55
|
+
lines.push(` overriding: ${result.collisions.join(", ")} (use --preserve-env to keep the existing values)`);
|
|
56
|
+
}
|
|
57
|
+
if (result.refused.length > 0) {
|
|
58
|
+
lines.push(` skipped: ${result.refused.join(", ")} \u2014 not usable as environment variable names`);
|
|
59
|
+
}
|
|
60
|
+
return lines.join(`
|
|
61
|
+
`);
|
|
62
|
+
}
|
|
63
|
+
function exitCodeFor(status) {
|
|
64
|
+
if (status.signal) {
|
|
65
|
+
const numbers = {
|
|
66
|
+
SIGHUP: 1,
|
|
67
|
+
SIGINT: 2,
|
|
68
|
+
SIGQUIT: 3,
|
|
69
|
+
SIGKILL: 9,
|
|
70
|
+
SIGTERM: 15
|
|
71
|
+
};
|
|
72
|
+
return 128 + (numbers[status.signal] ?? 0);
|
|
73
|
+
}
|
|
74
|
+
return status.code ?? 0;
|
|
75
|
+
}
|
|
76
|
+
async function spawnWith(command, env, report = () => {}) {
|
|
77
|
+
const { spawn } = await import("child_process");
|
|
78
|
+
const child = spawn(command[0], command.slice(1), {
|
|
79
|
+
env,
|
|
80
|
+
stdio: "inherit"
|
|
81
|
+
});
|
|
82
|
+
const forward = (signal) => () => {
|
|
83
|
+
child.kill(signal);
|
|
84
|
+
};
|
|
85
|
+
const onInt = forward("SIGINT");
|
|
86
|
+
const onTerm = forward("SIGTERM");
|
|
87
|
+
process.on("SIGINT", onInt);
|
|
88
|
+
process.on("SIGTERM", onTerm);
|
|
89
|
+
try {
|
|
90
|
+
return await new Promise((resolve) => {
|
|
91
|
+
child.on("error", (error) => {
|
|
92
|
+
report(`Could not start ${command[0]}: ${error.message}`);
|
|
93
|
+
resolve(127);
|
|
94
|
+
});
|
|
95
|
+
child.on("exit", (code, signal) => resolve(exitCodeFor({ code, signal })));
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
process.off("SIGINT", onInt);
|
|
99
|
+
process.off("SIGTERM", onTerm);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/cli/index.ts
|
|
104
|
+
import { readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
105
|
+
import { fileURLToPath } from "url";
|
|
106
|
+
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
107
|
+
|
|
108
|
+
// src/provision.ts
|
|
109
|
+
function atLeast(version, floor) {
|
|
110
|
+
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
111
|
+
const got = parse(version);
|
|
112
|
+
const want = parse(floor);
|
|
113
|
+
if (got.length === 0)
|
|
114
|
+
return false;
|
|
115
|
+
for (let index = 0;index < want.length; index += 1) {
|
|
116
|
+
const a = got[index] ?? 0;
|
|
117
|
+
const b = want[index] ?? 0;
|
|
118
|
+
if (a > b)
|
|
119
|
+
return true;
|
|
120
|
+
if (a < b)
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
var CAPABILITY_CHECKS = {
|
|
126
|
+
snpGuest: {
|
|
127
|
+
command: "test -e /dev/sev-guest && echo yes || echo no",
|
|
128
|
+
satisfied: (stdout) => stdout.trim() === "yes",
|
|
129
|
+
remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
|
|
130
|
+
},
|
|
131
|
+
systemd: {
|
|
132
|
+
command: "test -d /run/systemd/system && echo yes || echo no",
|
|
133
|
+
satisfied: (stdout) => stdout.trim() === "yes",
|
|
134
|
+
remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
|
|
135
|
+
},
|
|
136
|
+
bun: {
|
|
137
|
+
command: "bun --version 2>/dev/null || echo missing",
|
|
138
|
+
satisfied: (stdout) => atLeast(stdout, "1.1.0"),
|
|
139
|
+
remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
|
|
140
|
+
},
|
|
141
|
+
python: {
|
|
142
|
+
command: "python3 --version 2>/dev/null || echo missing",
|
|
143
|
+
satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
|
|
144
|
+
remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
148
|
+
var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature \u2014 weaker than attestation, stronger than an API key in the application.";
|
|
149
|
+
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
150
|
+
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
151
|
+
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
152
|
+
var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
|
|
153
|
+
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
154
|
+
var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
|
|
155
|
+
function agentEnrolmentUnit(options) {
|
|
156
|
+
if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
|
|
157
|
+
throw new Error("direct enrolment needs API, credential and state paths");
|
|
158
|
+
}
|
|
159
|
+
const bin = options.binPath ?? "fz-agent";
|
|
160
|
+
const user = options.user ?? "forgezero";
|
|
161
|
+
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
162
|
+
const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
|
|
163
|
+
` : "";
|
|
164
|
+
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
165
|
+
` : "";
|
|
166
|
+
const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
|
|
167
|
+
return `[Unit]
|
|
168
|
+
Description=Bind this machine to its ForgeZero compute
|
|
169
|
+
After=network-online.target
|
|
170
|
+
Wants=network-online.target
|
|
171
|
+
Before=forgezero-agent.service
|
|
172
|
+
ConditionPathExists=!${options.enrolStatePath}
|
|
173
|
+
|
|
174
|
+
[Service]
|
|
175
|
+
Type=oneshot
|
|
176
|
+
User=${user}
|
|
177
|
+
Group=${user}
|
|
178
|
+
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
179
|
+
LoadCredentialEncrypted=enrol-token:${options.enrolTokenCredentialPath}
|
|
180
|
+
Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
181
|
+
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
182
|
+
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
183
|
+
Environment=FZ_API=${options.apiUrl}
|
|
184
|
+
${label}${gitPublicKey}ExecStart=${bin} enrol
|
|
185
|
+
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
186
|
+
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
187
|
+
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
188
|
+
NoNewPrivileges=true
|
|
189
|
+
PrivateTmp=true
|
|
190
|
+
ProtectSystem=strict
|
|
191
|
+
ProtectHome=true
|
|
192
|
+
ReadWritePaths=${stateDir}
|
|
193
|
+
LimitCORE=0
|
|
194
|
+
|
|
195
|
+
[Install]
|
|
196
|
+
WantedBy=multi-user.target
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
function deploymentRunnerSocketUnit(agentUser) {
|
|
200
|
+
return `[Unit]
|
|
201
|
+
Description=ForgeZero private project-command socket
|
|
202
|
+
|
|
203
|
+
[Socket]
|
|
204
|
+
ListenStream=${DEPLOYMENT_RUNNER_SOCKET}
|
|
205
|
+
SocketUser=${agentUser}
|
|
206
|
+
SocketGroup=${agentUser}
|
|
207
|
+
SocketMode=0600
|
|
208
|
+
DirectoryMode=0710
|
|
209
|
+
RemoveOnStop=true
|
|
210
|
+
|
|
211
|
+
[Install]
|
|
212
|
+
WantedBy=sockets.target
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
function deploymentRunnerUnit(options) {
|
|
216
|
+
const bin = options.binPath ?? "fz-agent";
|
|
217
|
+
const root = options.deployRoot ?? "/opt/forgezero";
|
|
218
|
+
return `[Unit]
|
|
219
|
+
Description=ForgeZero credential-free project command runner
|
|
220
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
221
|
+
After=forgezero-deploy-runner.socket
|
|
222
|
+
Requires=forgezero-deploy-runner.socket
|
|
223
|
+
|
|
224
|
+
[Service]
|
|
225
|
+
Type=simple
|
|
226
|
+
User=${DEPLOYMENT_RUNNER_USER}
|
|
227
|
+
Group=${DEPLOYMENT_GROUP}
|
|
228
|
+
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
229
|
+
Sockets=forgezero-deploy-runner.socket
|
|
230
|
+
ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
|
|
231
|
+
Restart=always
|
|
232
|
+
RestartSec=2
|
|
233
|
+
UMask=0007
|
|
234
|
+
LimitCORE=0
|
|
235
|
+
NoNewPrivileges=false
|
|
236
|
+
PrivateTmp=true
|
|
237
|
+
ProtectSystem=strict
|
|
238
|
+
ProtectHome=true
|
|
239
|
+
ProtectKernelTunables=true
|
|
240
|
+
ProtectKernelModules=true
|
|
241
|
+
ProtectControlGroups=true
|
|
242
|
+
RestrictRealtime=true
|
|
243
|
+
MemoryDenyWriteExecute=true
|
|
244
|
+
LockPersonality=true
|
|
245
|
+
ReadWritePaths=${root}/releases ${root}/runner-home
|
|
246
|
+
|
|
247
|
+
[Install]
|
|
248
|
+
WantedBy=multi-user.target
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
251
|
+
function agentUnit(options) {
|
|
252
|
+
const bin = options.binPath ?? "fz-agent";
|
|
253
|
+
const user = options.user ?? "forgezero";
|
|
254
|
+
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
255
|
+
const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
|
|
256
|
+
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
257
|
+
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
258
|
+
const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
259
|
+
const deploymentEnvironment = options.deploymentEnvironment ?? {};
|
|
260
|
+
const deploymentCredentials = options.deploymentCredentials ?? {};
|
|
261
|
+
for (const [name, value] of Object.entries(deploymentEnvironment)) {
|
|
262
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !/^[A-Za-z0-9._:\/@+-]+$/.test(value)) {
|
|
263
|
+
throw new Error(`invalid deployment environment entry: ${name}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const [name, path] of Object.entries(deploymentCredentials)) {
|
|
267
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !path.startsWith("/") || /[\r\n:]/.test(path)) {
|
|
268
|
+
throw new Error(`invalid deployment credential entry: ${name}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const environment = [
|
|
272
|
+
`FZ_SOCKET_PATH=${options.socketPath}`,
|
|
273
|
+
`FZ_CONTROL_SOCKET=${controlSocketPath}`,
|
|
274
|
+
`FZ_SEED_CREDENTIAL=agent-seed`,
|
|
275
|
+
`FZ_AGENT_MODE=${options.mode}`,
|
|
276
|
+
options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
|
|
277
|
+
options.project ? `FZ_PROJECT=${options.project}` : null,
|
|
278
|
+
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
|
|
279
|
+
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
280
|
+
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
281
|
+
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
282
|
+
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
283
|
+
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
|
284
|
+
options.role ? `FZ_DEPLOY_ROLE=${options.role}` : null,
|
|
285
|
+
options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
|
|
286
|
+
deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
|
|
287
|
+
deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
|
|
288
|
+
Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
|
|
289
|
+
Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
|
|
290
|
+
...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
|
|
291
|
+
options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
|
|
292
|
+
options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
|
|
293
|
+
].filter((line) => line !== null);
|
|
294
|
+
if (deploymentEnabled) {
|
|
295
|
+
environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
|
|
296
|
+
}
|
|
297
|
+
const gitCredential = options.gitCredentialPath ? `LoadCredentialEncrypted=git-deploy-key:${options.gitCredentialPath}
|
|
298
|
+
` : "";
|
|
299
|
+
const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
|
|
300
|
+
`);
|
|
301
|
+
const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
|
|
302
|
+
const deploymentGroup = deploymentEnabled ? `SupplementaryGroups=${DEPLOYMENT_GROUP}` : "";
|
|
303
|
+
const after = [
|
|
304
|
+
"network-online.target",
|
|
305
|
+
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
306
|
+
enrolmentEnabled ? "forgezero-agent-enrol.service" : null
|
|
307
|
+
].filter((value) => value !== null);
|
|
308
|
+
const requires = [
|
|
309
|
+
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
310
|
+
enrolmentEnabled ? "forgezero-agent-enrol.service" : null
|
|
311
|
+
].filter((value) => value !== null);
|
|
312
|
+
const deploymentDependency = [
|
|
313
|
+
`After=${after.join(" ")}`,
|
|
314
|
+
"Wants=network-online.target",
|
|
315
|
+
requires.length > 0 ? `Requires=${requires.join(" ")}` : null
|
|
316
|
+
].filter((value) => value !== null).join(`
|
|
317
|
+
`);
|
|
318
|
+
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
319
|
+
DeviceAllow=/dev/sev-guest rw` : "";
|
|
320
|
+
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
|
|
321
|
+
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
322
|
+
` : "";
|
|
323
|
+
return `[Unit]
|
|
324
|
+
Description=ForgeZero node agent (${options.mode})
|
|
325
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
326
|
+
${deploymentDependency}
|
|
327
|
+
|
|
328
|
+
[Service]
|
|
329
|
+
Type=simple
|
|
330
|
+
User=${user}
|
|
331
|
+
Group=${user}
|
|
332
|
+
${deploymentGroup}
|
|
333
|
+
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
334
|
+
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
335
|
+
` : ""}${snpPrepare}ExecStart=${bin}
|
|
336
|
+
Restart=always
|
|
337
|
+
RestartSec=2
|
|
338
|
+
|
|
339
|
+
${environment.map((line) => `Environment=${line}`).join(`
|
|
340
|
+
`)}
|
|
341
|
+
|
|
342
|
+
# The node seed and the vault replica live in this process's memory. A core dump
|
|
343
|
+
# writes both to disk, which is the one artefact this design exists to remove.
|
|
344
|
+
LimitCORE=0
|
|
345
|
+
|
|
346
|
+
# The socket is the entire interface: anything that can read it can read the
|
|
347
|
+
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
348
|
+
# than wherever the process happened to have write access.
|
|
349
|
+
RuntimeDirectory=forgezero
|
|
350
|
+
RuntimeDirectoryMode=0710
|
|
351
|
+
UMask=0077
|
|
352
|
+
|
|
353
|
+
# Tenant-controlled commands execute in forgezero-deploy-runner.service. This
|
|
354
|
+
# credential-bearing process never needs to cross a privilege boundary.
|
|
355
|
+
NoNewPrivileges=true
|
|
356
|
+
PrivateTmp=true
|
|
357
|
+
ProtectSystem=strict
|
|
358
|
+
ProtectHome=true
|
|
359
|
+
ProtectKernelTunables=true
|
|
360
|
+
ProtectKernelModules=true
|
|
361
|
+
ProtectControlGroups=true
|
|
362
|
+
RestrictSUIDSGID=true
|
|
363
|
+
RestrictRealtime=true
|
|
364
|
+
MemoryDenyWriteExecute=true
|
|
365
|
+
LockPersonality=true
|
|
366
|
+
${snpDevice}
|
|
367
|
+
${deploymentWrites}
|
|
368
|
+
|
|
369
|
+
[Install]
|
|
370
|
+
WantedBy=multi-user.target
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
|
|
374
|
+
function planProvision(options) {
|
|
375
|
+
const mode = options.mode;
|
|
376
|
+
const user = options.user ?? "forgezero";
|
|
377
|
+
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
378
|
+
const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
|
|
379
|
+
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
380
|
+
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
381
|
+
const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
382
|
+
if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
|
|
383
|
+
throw new Error("direct enrolment paths must be supplied together");
|
|
384
|
+
const safePath = (value, label) => {
|
|
385
|
+
if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
|
|
386
|
+
throw new Error(`invalid ${label} path`);
|
|
387
|
+
return value;
|
|
388
|
+
};
|
|
389
|
+
const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
|
|
390
|
+
const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
|
|
391
|
+
const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
|
|
392
|
+
const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
|
|
393
|
+
const sourceBinPath = options.sourceBinPath ? safePath(options.sourceBinPath, "agent source binary") : undefined;
|
|
394
|
+
const binPath = options.binPath ? safePath(options.binPath, "agent binary") : undefined;
|
|
395
|
+
const gitCredentialPath = options.gitCredentialPath ? safePath(options.gitCredentialPath, "Git credential") : undefined;
|
|
396
|
+
const gitPublicKeyPath = options.gitPublicKeyPath ? safePath(options.gitPublicKeyPath, "Git public key") : undefined;
|
|
397
|
+
if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
|
|
398
|
+
throw new Error("generated Git identity needs credential and public-key paths");
|
|
399
|
+
}
|
|
400
|
+
const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
|
|
401
|
+
return {
|
|
402
|
+
mode,
|
|
403
|
+
reason: reasonFor(mode),
|
|
404
|
+
unitPath: UNIT_PATH,
|
|
405
|
+
unit: agentUnit({ ...options, mode }),
|
|
406
|
+
auxiliaryUnits: [
|
|
407
|
+
...deploymentEnabled ? [
|
|
408
|
+
{ path: DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH, unit: deploymentRunnerSocketUnit(user) },
|
|
409
|
+
{ path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
|
|
410
|
+
] : [],
|
|
411
|
+
...enrolmentEnabled ? [
|
|
412
|
+
{ path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
|
|
413
|
+
] : []
|
|
414
|
+
],
|
|
415
|
+
socketPath: options.socketPath,
|
|
416
|
+
user,
|
|
417
|
+
steps: [
|
|
418
|
+
...sourceBinPath && binPath ? [{
|
|
419
|
+
label: "root-owned agent runtime",
|
|
420
|
+
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
|
|
421
|
+
}] : [],
|
|
422
|
+
...deploymentEnabled ? [{
|
|
423
|
+
label: "deployment isolation group",
|
|
424
|
+
command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
|
|
425
|
+
}] : [],
|
|
426
|
+
{
|
|
427
|
+
label: "service account",
|
|
428
|
+
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
429
|
+
},
|
|
430
|
+
...deploymentEnabled ? [{
|
|
431
|
+
label: "credential-free deployment account",
|
|
432
|
+
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
|
|
433
|
+
}] : [],
|
|
434
|
+
{
|
|
435
|
+
label: "credential directory",
|
|
436
|
+
command: `install -d -o root -g root -m 0700 ${credentialDir}`
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
label: "encrypted node identity",
|
|
440
|
+
command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
|
|
441
|
+
},
|
|
442
|
+
...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
|
|
443
|
+
{
|
|
444
|
+
label: "Git deploy identity directory",
|
|
445
|
+
command: `install -d -o root -g root -m 0755 ${gitPublicKeyDir}`
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
label: "unique encrypted Git deploy identity",
|
|
449
|
+
command: `test -s ${gitCredentialPath} || { ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/forgezero-git-deploy-key; ` + `systemd-creds encrypt --name=git-deploy-key /run/forgezero-git-deploy-key ${gitCredentialPath}; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `chmod 0400 ${gitCredentialPath}; }; ` + `test -s ${gitPublicKeyPath} || { ` + `systemd-creds decrypt --name=git-deploy-key ${gitCredentialPath} /run/forgezero-git-deploy-key; ` + `ssh-keygen -y -f /run/forgezero-git-deploy-key | ` + `sed 's/$/ forgezero-compute/' > /run/forgezero-git-deploy-key.pub; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; }; ` + `test -s ${gitCredentialPath} && test -s ${gitPublicKeyPath}`
|
|
450
|
+
}
|
|
451
|
+
] : [],
|
|
452
|
+
...enrolmentEnabled ? [
|
|
453
|
+
{
|
|
454
|
+
label: "enrolment state directory",
|
|
455
|
+
command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
label: "encrypted one-time enrolment capability",
|
|
459
|
+
command: `test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
|
|
460
|
+
}
|
|
461
|
+
] : [],
|
|
462
|
+
...deploymentEnabled ? [{
|
|
463
|
+
label: "deployment directories",
|
|
464
|
+
command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
|
|
465
|
+
}] : [],
|
|
466
|
+
{ label: "reload units", command: "systemctl daemon-reload" },
|
|
467
|
+
{
|
|
468
|
+
label: "enable and start",
|
|
469
|
+
command: `systemctl enable --now ${[
|
|
470
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.socket", "forgezero-deploy-runner.service"] : [],
|
|
471
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
|
|
472
|
+
"forgezero-agent.service"
|
|
473
|
+
].join(" ")}`
|
|
474
|
+
},
|
|
475
|
+
...enrolmentEnabled ? [{
|
|
476
|
+
label: "prove the compute binding is durable",
|
|
477
|
+
command: `test -s ${enrolStatePath}`
|
|
478
|
+
}] : [],
|
|
479
|
+
{ label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
|
|
480
|
+
{ label: "prove the vault socket exists", command: `test -S ${options.socketPath}` },
|
|
481
|
+
...deploymentEnabled ? [{
|
|
482
|
+
label: "prove the deployment runner socket exists",
|
|
483
|
+
command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
|
|
484
|
+
}] : [],
|
|
485
|
+
...options.repository ? [{
|
|
486
|
+
label: "prove the deployment control socket exists",
|
|
487
|
+
command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
|
|
488
|
+
}] : []
|
|
489
|
+
]
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/cli/agent-install.ts
|
|
494
|
+
function parseAssignments(value) {
|
|
495
|
+
if (!value?.trim())
|
|
496
|
+
return;
|
|
497
|
+
const parsed = {};
|
|
498
|
+
for (const entry of value.split(",")) {
|
|
499
|
+
const separator = entry.indexOf("=");
|
|
500
|
+
if (separator < 1)
|
|
501
|
+
throw new Error(`invalid deployment assignment: ${entry}`);
|
|
502
|
+
const name = entry.slice(0, separator);
|
|
503
|
+
const item = entry.slice(separator + 1);
|
|
504
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !item || /[\r\n]/.test(item)) {
|
|
505
|
+
throw new Error(`invalid deployment assignment: ${name}`);
|
|
506
|
+
}
|
|
507
|
+
if (Object.hasOwn(parsed, name))
|
|
508
|
+
throw new Error(`duplicate deployment assignment: ${name}`);
|
|
509
|
+
parsed[name] = item;
|
|
510
|
+
}
|
|
511
|
+
return parsed;
|
|
512
|
+
}
|
|
513
|
+
async function readCapabilities(run) {
|
|
514
|
+
const answers = {};
|
|
515
|
+
const checks = Object.entries(CAPABILITY_CHECKS);
|
|
516
|
+
for (const [id, check] of checks) {
|
|
517
|
+
try {
|
|
518
|
+
const result = await run(check.command);
|
|
519
|
+
answers[id] = check.satisfied(result.stdout, result.exitCode);
|
|
520
|
+
} catch {
|
|
521
|
+
answers[id] = false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return answers;
|
|
525
|
+
}
|
|
526
|
+
async function localRunner(command) {
|
|
527
|
+
const proc = Bun.spawn(["sh", "-c", command], { stdout: "pipe", stderr: "pipe" });
|
|
528
|
+
const stdout = await new Response(proc.stdout).text();
|
|
529
|
+
return { stdout, exitCode: await proc.exited };
|
|
530
|
+
}
|
|
531
|
+
function planInstall(options) {
|
|
532
|
+
const { capabilities, ...unit } = options;
|
|
533
|
+
return planProvision({ ...unit, mode: modeFor(capabilities) });
|
|
534
|
+
}
|
|
535
|
+
async function applyPlan(plan, run) {
|
|
536
|
+
const transcript = [];
|
|
537
|
+
for (const step of plan.steps) {
|
|
538
|
+
const result = await run(step.command);
|
|
539
|
+
transcript.push({ label: step.label, command: step.command, exitCode: result.exitCode });
|
|
540
|
+
if (result.exitCode !== 0 && !step.optional) {
|
|
541
|
+
throw new Error(`${step.label} failed (exit ${result.exitCode}): ${step.command}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return transcript;
|
|
545
|
+
}
|
|
546
|
+
function renderPlan(plan) {
|
|
547
|
+
return [
|
|
548
|
+
"",
|
|
549
|
+
` Mode ${plan.mode.toUpperCase()}`,
|
|
550
|
+
` Reason ${plan.reason}`,
|
|
551
|
+
"",
|
|
552
|
+
` User ${plan.user}`,
|
|
553
|
+
` Socket ${plan.socketPath}`,
|
|
554
|
+
` Unit ${plan.unitPath}`,
|
|
555
|
+
...plan.auxiliaryUnits.map((unit) => ` Aux unit ${unit.path}`),
|
|
556
|
+
"",
|
|
557
|
+
" Applications on this machine will then read secrets through the socket.",
|
|
558
|
+
" @forgezero/vault prefers it over FORGEZERO_API_KEY automatically, so no",
|
|
559
|
+
" application code changes.",
|
|
560
|
+
"",
|
|
561
|
+
" Run with --apply to write the unit and start the service.",
|
|
562
|
+
""
|
|
563
|
+
].join(`
|
|
564
|
+
`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/cli/custody.ts
|
|
568
|
+
import {
|
|
569
|
+
listCustodyIdentities,
|
|
570
|
+
deriveCustodyKey,
|
|
571
|
+
assertDeterministic,
|
|
572
|
+
SshAgentError
|
|
573
|
+
} from "@forgezero/runtime/ssh-agent";
|
|
574
|
+
var PROBE_TIMEOUT_MS = 2000;
|
|
575
|
+
function withTimeout(work, ms, label) {
|
|
576
|
+
return Promise.race([
|
|
577
|
+
work,
|
|
578
|
+
new Promise((_, reject) => setTimeout(() => reject(new SshAgentError(`SSH_AGENT_TIMEOUT:${label}`)), ms))
|
|
579
|
+
]);
|
|
580
|
+
}
|
|
581
|
+
async function usableIdentities(socketPath) {
|
|
582
|
+
const candidates = await listCustodyIdentities(socketPath);
|
|
583
|
+
const usable = [];
|
|
584
|
+
for (const identity of candidates) {
|
|
585
|
+
const started = Date.now();
|
|
586
|
+
try {
|
|
587
|
+
await withTimeout(deriveCustodyKey(identity, socketPath), PROBE_TIMEOUT_MS, identity.fingerprint);
|
|
588
|
+
usable.push({ ...identity, responseMs: Date.now() - started });
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
return usable;
|
|
592
|
+
}
|
|
593
|
+
async function custodyKeyFor(identity, socketPath) {
|
|
594
|
+
return withTimeout(assertDeterministic(identity, socketPath), PROBE_TIMEOUT_MS * 2, identity.fingerprint);
|
|
595
|
+
}
|
|
596
|
+
function selectIdentity(identities, selector) {
|
|
597
|
+
const index = Number.parseInt(selector, 10);
|
|
598
|
+
if (Number.isInteger(index) && index >= 1 && index <= identities.length) {
|
|
599
|
+
return identities[index - 1];
|
|
600
|
+
}
|
|
601
|
+
return identities.find((id) => id.fingerprint === selector) ?? identities.find((id) => id.fingerprint.startsWith(selector)) ?? identities.find((id) => id.comment === selector) ?? null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/cli/genesis.ts
|
|
605
|
+
import { generatePhrase } from "@forgezero/runtime/phrase";
|
|
606
|
+
import {
|
|
607
|
+
wrappingKeysFor,
|
|
608
|
+
openFactorEnvelope
|
|
609
|
+
} from "@forgezero/runtime/custody-share";
|
|
610
|
+
import { listCustodyIdentities as listCustodyIdentities2 } from "@forgezero/runtime/ssh-agent";
|
|
611
|
+
var b64 = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
612
|
+
function fail(response, step) {
|
|
613
|
+
const body = response.body;
|
|
614
|
+
const raw = body?.error;
|
|
615
|
+
const detail = typeof raw === "string" ? raw : raw?.code ? `${raw.code}${raw.message ? ` \u2014 ${raw.message}` : ""}` : body?.message ?? `HTTP ${response.status}`;
|
|
616
|
+
throw new Error(`${step}: ${detail}`);
|
|
617
|
+
}
|
|
618
|
+
async function runGenesis(args) {
|
|
619
|
+
const step = args.onStep ?? (() => {});
|
|
620
|
+
step("Claiming the platform with the setup token");
|
|
621
|
+
const claimed = await args.api("/onboarding/platform/claim", {
|
|
622
|
+
method: "POST",
|
|
623
|
+
body: { token: args.token, email: args.email, displayName: args.displayName }
|
|
624
|
+
});
|
|
625
|
+
if (claimed.status !== 200) {
|
|
626
|
+
const code = claimed.body?.error;
|
|
627
|
+
if (code !== "ALREADY_CLAIMED")
|
|
628
|
+
fail(claimed, "claim");
|
|
629
|
+
step("Already claimed \u2014 continuing to the ceremony");
|
|
630
|
+
}
|
|
631
|
+
step(`Registering ${args.identity.fingerprint} as a custody key`);
|
|
632
|
+
const registered = await args.api("/security/ssh-identities", {
|
|
633
|
+
method: "POST",
|
|
634
|
+
body: {
|
|
635
|
+
authorizedKey: `${args.identity.type} ${Buffer.from(args.identity.blob).toString("base64")}`
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
if (registered.status !== 200)
|
|
639
|
+
fail(registered, "register-ssh-key");
|
|
640
|
+
step(`Deriving custody key from ${args.identity.fingerprint}`);
|
|
641
|
+
const sshKey = await custodyKeyFor(args.identity, args.socketPath);
|
|
642
|
+
step("Generating recovery phrase");
|
|
643
|
+
const phrase = generatePhrase();
|
|
644
|
+
step(`Proposing ${args.modeId} ceremony`);
|
|
645
|
+
const proposed = await args.api("/custody/ceremony/propose", {
|
|
646
|
+
method: "POST",
|
|
647
|
+
body: {
|
|
648
|
+
modeId: args.modeId,
|
|
649
|
+
custodians: [{ userKey: args.userKey, email: args.email }]
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
if (proposed.status !== 200)
|
|
653
|
+
fail(proposed, "propose");
|
|
654
|
+
const ceremonyKey = proposed.body?.ceremony?._key;
|
|
655
|
+
if (!ceremonyKey)
|
|
656
|
+
throw new Error("propose: no ceremony key returned");
|
|
657
|
+
step("Enrolling \u2014 the share is sealed to this machine, never sent in the clear");
|
|
658
|
+
const keys = wrappingKeysFor({
|
|
659
|
+
custodianKey: args.userKey,
|
|
660
|
+
passkeyPrfOutput: sshKey,
|
|
661
|
+
phraseWords: phrase
|
|
662
|
+
});
|
|
663
|
+
const enrolled = await args.api("/custody/ceremony/share", {
|
|
664
|
+
method: "POST",
|
|
665
|
+
body: { ceremonyKey, keys }
|
|
666
|
+
});
|
|
667
|
+
if (enrolled.status !== 200)
|
|
668
|
+
fail(enrolled, "enroll");
|
|
669
|
+
const sealed = enrolled.body?.ceremony?.custodians?.find((c) => c.userKey === args.userKey)?.sealed;
|
|
670
|
+
if (!sealed)
|
|
671
|
+
throw new Error("enroll: the server returned no sealed share");
|
|
672
|
+
for (const factor of ["passkey", "phrase"]) {
|
|
673
|
+
step(`Proving the ${factor === "passkey" ? "ssh" : "phrase"} route opens the share`);
|
|
674
|
+
const { probe } = openFactorEnvelope({
|
|
675
|
+
sealed,
|
|
676
|
+
custodianKey: args.userKey,
|
|
677
|
+
factor,
|
|
678
|
+
...factor === "passkey" ? { passkeyPrfOutput: sshKey } : { phraseWords: phrase }
|
|
679
|
+
});
|
|
680
|
+
const tested = await args.api("/custody/ceremony/test", {
|
|
681
|
+
method: "POST",
|
|
682
|
+
body: { ceremonyKey, factor, openedProbe: b64(probe) }
|
|
683
|
+
});
|
|
684
|
+
if (tested.status !== 200)
|
|
685
|
+
fail(tested, `test:${factor}`);
|
|
686
|
+
}
|
|
687
|
+
step("Activating \u2014 the seed is split and the vault opens");
|
|
688
|
+
const activated = await args.api("/custody/ceremony/activate", {
|
|
689
|
+
method: "POST",
|
|
690
|
+
body: { ceremonyKey }
|
|
691
|
+
});
|
|
692
|
+
if (activated.status !== 200)
|
|
693
|
+
fail(activated, "activate");
|
|
694
|
+
sshKey.fill(0);
|
|
695
|
+
return {
|
|
696
|
+
ceremonyKey,
|
|
697
|
+
phrase,
|
|
698
|
+
fingerprint: activated.body?.ceremony?.masterSeedFingerprint ?? "",
|
|
699
|
+
activated: true
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
async function runUnlock(args) {
|
|
703
|
+
const step = args.onStep ?? (() => {});
|
|
704
|
+
const contributions = [];
|
|
705
|
+
const sealed = args.sealed;
|
|
706
|
+
if (args.identity && sealed) {
|
|
707
|
+
step(`Deriving custody key from ${args.identity.fingerprint}`);
|
|
708
|
+
const sshKey = await custodyKeyFor(args.identity, args.socketPath);
|
|
709
|
+
const { share } = openFactorEnvelope({
|
|
710
|
+
sealed,
|
|
711
|
+
custodianKey: args.userKey,
|
|
712
|
+
factor: "passkey",
|
|
713
|
+
passkeyPrfOutput: sshKey
|
|
714
|
+
});
|
|
715
|
+
contributions.push({
|
|
716
|
+
userKey: args.userKey,
|
|
717
|
+
factor: "passkey",
|
|
718
|
+
share: b64(share)
|
|
719
|
+
});
|
|
720
|
+
sshKey.fill(0);
|
|
721
|
+
}
|
|
722
|
+
if (args.phrase?.length && sealed) {
|
|
723
|
+
const { share } = openFactorEnvelope({
|
|
724
|
+
sealed,
|
|
725
|
+
custodianKey: args.userKey,
|
|
726
|
+
factor: "phrase",
|
|
727
|
+
phraseWords: args.phrase
|
|
728
|
+
});
|
|
729
|
+
contributions.push({
|
|
730
|
+
userKey: args.userKey,
|
|
731
|
+
factor: "phrase",
|
|
732
|
+
share: b64(share)
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (contributions.length === 0) {
|
|
736
|
+
throw new Error("unlock: supply an ssh key or a recovery phrase");
|
|
737
|
+
}
|
|
738
|
+
step(`Reconstructing the seed from ${contributions.length} contribution(s)`);
|
|
739
|
+
const unlocked = await args.api("/custody/ceremony/unlock", {
|
|
740
|
+
method: "POST",
|
|
741
|
+
body: { contributions }
|
|
742
|
+
});
|
|
743
|
+
if (unlocked.status !== 200)
|
|
744
|
+
fail(unlocked, "unlock");
|
|
745
|
+
return {
|
|
746
|
+
fingerprint: unlocked.body?.fingerprint ?? ""
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
async function resolveIdentity(selector, socketPath) {
|
|
750
|
+
if (selector && !/^\d+$/.test(selector)) {
|
|
751
|
+
const listed = await listCustodyIdentities2(socketPath);
|
|
752
|
+
const match = listed.find((id) => id.fingerprint === selector) ?? listed.find((id) => id.fingerprint.startsWith(selector)) ?? listed.find((id) => id.comment === selector);
|
|
753
|
+
if (match) {
|
|
754
|
+
await custodyKeyFor(match, socketPath);
|
|
755
|
+
return { ...match, responseMs: 0 };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const identities = await usableIdentities(socketPath);
|
|
759
|
+
if (identities.length === 0) {
|
|
760
|
+
throw new Error("No usable Ed25519 key in the agent. Add one with `ssh-add ~/.ssh/id_ed25519`, " + "then confirm with `fz keys`.");
|
|
761
|
+
}
|
|
762
|
+
if (!selector) {
|
|
763
|
+
if (identities.length > 1) {
|
|
764
|
+
throw new Error(`${identities.length} usable keys \u2014 choose one with --key <fingerprint|index>. ` + "Run `fz keys` to list them.");
|
|
765
|
+
}
|
|
766
|
+
return identities[0];
|
|
767
|
+
}
|
|
768
|
+
const chosen = selectIdentity(identities, selector);
|
|
769
|
+
if (!chosen)
|
|
770
|
+
throw new Error(`No usable key matches "${selector}". Run \`fz keys\`.`);
|
|
771
|
+
return chosen;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/cli/index.ts
|
|
775
|
+
var DEFAULT_MODE = THRESHOLD_MODES[0].id;
|
|
776
|
+
var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
|
|
777
|
+
var VERSION = "0.1.10";
|
|
778
|
+
var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
|
|
779
|
+
function parseOptions(argv) {
|
|
780
|
+
const options = {
|
|
781
|
+
api: process.env.FZ_API ?? "http://localhost:8787",
|
|
782
|
+
realm: process.env.FZ_REALM ?? "platform",
|
|
783
|
+
json: false,
|
|
784
|
+
apply: false,
|
|
785
|
+
enrol: false,
|
|
786
|
+
socket: process.env.SSH_AUTH_SOCK,
|
|
787
|
+
mode: DEFAULT_MODE,
|
|
788
|
+
user: process.env.FZ_USER ?? "operator",
|
|
789
|
+
email: process.env.FZ_EMAIL ?? "operator@localhost",
|
|
790
|
+
preserveEnv: false
|
|
791
|
+
};
|
|
792
|
+
const positional = [];
|
|
793
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
794
|
+
const token = argv[index];
|
|
795
|
+
if (token === "--api")
|
|
796
|
+
options.api = argv[++index] ?? options.api;
|
|
797
|
+
else if (token === "--realm")
|
|
798
|
+
options.realm = argv[++index] ?? options.realm;
|
|
799
|
+
else if (token === "--socket")
|
|
800
|
+
options.socket = argv[++index];
|
|
801
|
+
else if (token === "--json")
|
|
802
|
+
options.json = true;
|
|
803
|
+
else if (token === "--apply")
|
|
804
|
+
options.apply = true;
|
|
805
|
+
else if (token === "--enrol")
|
|
806
|
+
options.enrol = true;
|
|
807
|
+
else if (token === "--preserve-env")
|
|
808
|
+
options.preserveEnv = true;
|
|
809
|
+
else if (token === "--key")
|
|
810
|
+
options.key = argv[++index];
|
|
811
|
+
else if (token === "--mode")
|
|
812
|
+
options.mode = argv[++index] ?? options.mode;
|
|
813
|
+
else if (token === "--user")
|
|
814
|
+
options.user = argv[++index] ?? options.user;
|
|
815
|
+
else if (token === "--email")
|
|
816
|
+
options.email = argv[++index] ?? options.email;
|
|
817
|
+
else if (token === "--token")
|
|
818
|
+
options.token = argv[++index];
|
|
819
|
+
else if (token === "--phrase")
|
|
820
|
+
options.phrase = (argv[++index] ?? "").trim().split(/\s+/);
|
|
821
|
+
else
|
|
822
|
+
positional.push(token);
|
|
823
|
+
}
|
|
824
|
+
return { command: positional[0] ?? "help", args: positional.slice(1), options };
|
|
825
|
+
}
|
|
826
|
+
var out = {
|
|
827
|
+
line: (text = "") => process.stdout.write(`${text}
|
|
828
|
+
`),
|
|
829
|
+
step: (text) => process.stdout.write(` ${text}
|
|
830
|
+
`),
|
|
831
|
+
warn: (text) => process.stderr.write(` ! ${text}
|
|
832
|
+
`),
|
|
833
|
+
fail: (text) => process.stderr.write(` \u2717 ${text}
|
|
834
|
+
`),
|
|
835
|
+
ok: (text) => process.stdout.write(` \u2713 ${text}
|
|
836
|
+
`)
|
|
837
|
+
};
|
|
838
|
+
var sessionCookie = null;
|
|
839
|
+
function requestHeaders(apiBase, cookie) {
|
|
840
|
+
return {
|
|
841
|
+
"content-type": "application/json",
|
|
842
|
+
origin: new URL(apiBase).origin,
|
|
843
|
+
...cookie ? { cookie } : {}
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
async function api(options, path, init) {
|
|
847
|
+
const base = options.realm === "platform" ? "/api" : `/api/t/${options.realm}`;
|
|
848
|
+
const response = await fetch(`${options.api}${base}${path}`, {
|
|
849
|
+
method: init?.method ?? "GET",
|
|
850
|
+
headers: requestHeaders(options.api, sessionCookie),
|
|
851
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
852
|
+
});
|
|
853
|
+
const setCookie = response.headers.get("set-cookie");
|
|
854
|
+
if (setCookie)
|
|
855
|
+
sessionCookie = setCookie.split(";")[0];
|
|
856
|
+
let body = null;
|
|
857
|
+
try {
|
|
858
|
+
body = await response.json();
|
|
859
|
+
} catch {
|
|
860
|
+
body = null;
|
|
861
|
+
}
|
|
862
|
+
const challenge = body?.security;
|
|
863
|
+
if (response.status === 428 && challenge?.scope === "action" && challenge.requestKey && custodyIdentity && !path.startsWith("/security/step-up/")) {
|
|
864
|
+
const proved = await proveWithAgent(options, challenge.requestKey);
|
|
865
|
+
if (proved) {
|
|
866
|
+
const replay = await fetch(`${options.api}${base}${path}`, {
|
|
867
|
+
method: init?.method ?? "GET",
|
|
868
|
+
headers: {
|
|
869
|
+
...requestHeaders(options.api, sessionCookie),
|
|
870
|
+
"x-security-request-key": challenge.requestKey
|
|
871
|
+
},
|
|
872
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
873
|
+
});
|
|
874
|
+
let replayed = null;
|
|
875
|
+
try {
|
|
876
|
+
replayed = await replay.json();
|
|
877
|
+
} catch {
|
|
878
|
+
replayed = null;
|
|
879
|
+
}
|
|
880
|
+
return { status: replay.status, body: replayed };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return { status: response.status, body };
|
|
884
|
+
}
|
|
885
|
+
var custodyIdentity = null;
|
|
886
|
+
function useCustodyIdentity(identity, socketPath) {
|
|
887
|
+
custodyIdentity = { identity, socketPath };
|
|
888
|
+
}
|
|
889
|
+
async function proveWithAgent(options, requestKey) {
|
|
890
|
+
if (!custodyIdentity)
|
|
891
|
+
return false;
|
|
892
|
+
const begun = await api(options, "/security/step-up/ssh/begin", {
|
|
893
|
+
method: "POST",
|
|
894
|
+
body: { requestKey }
|
|
895
|
+
});
|
|
896
|
+
const nonce = begun.body?.nonce;
|
|
897
|
+
if (begun.status !== 200 || !nonce)
|
|
898
|
+
return false;
|
|
899
|
+
const signature = await signWithIdentity(custodyIdentity.identity, new Uint8Array(Buffer.from(nonce, "base64")), custodyIdentity.socketPath);
|
|
900
|
+
const proved = await api(options, "/security/step-up/ssh/prove", {
|
|
901
|
+
method: "POST",
|
|
902
|
+
body: { requestKey, signature: Buffer.from(signature).toString("base64") }
|
|
903
|
+
});
|
|
904
|
+
return proved.status === 200;
|
|
905
|
+
}
|
|
906
|
+
async function cmdKeys(options) {
|
|
907
|
+
const identities = await usableIdentities(options.socket);
|
|
908
|
+
if (options.json) {
|
|
909
|
+
out.line(JSON.stringify(identities.map(({ fingerprint, comment, responseMs }) => ({
|
|
910
|
+
fingerprint,
|
|
911
|
+
comment,
|
|
912
|
+
responseMs
|
|
913
|
+
})), null, 2));
|
|
914
|
+
return 0;
|
|
915
|
+
}
|
|
916
|
+
if (identities.length === 0) {
|
|
917
|
+
out.fail("No usable Ed25519 keys in the agent.");
|
|
918
|
+
out.line();
|
|
919
|
+
out.step("Custody needs a key that signs DETERMINISTICALLY, so Ed25519 only.");
|
|
920
|
+
out.step("Add one with: ssh-add ~/.ssh/id_ed25519");
|
|
921
|
+
out.line();
|
|
922
|
+
out.step("Note that keys which hang \u2014 forwarded agents whose upstream is");
|
|
923
|
+
out.step("gone, or confirm-on-use keys with nobody at the terminal \u2014 are");
|
|
924
|
+
out.step("skipped here rather than listed, so this can be shorter than");
|
|
925
|
+
out.step("`ssh-add -l`.");
|
|
926
|
+
return 1;
|
|
927
|
+
}
|
|
928
|
+
out.line("Usable custody keys:");
|
|
929
|
+
out.line();
|
|
930
|
+
identities.forEach((identity, index) => {
|
|
931
|
+
out.line(` ${index + 1}. ${identity.fingerprint}`);
|
|
932
|
+
out.line(` ${identity.comment || "(no comment)"} ${identity.responseMs}ms`);
|
|
933
|
+
});
|
|
934
|
+
out.line();
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
async function cmdStatus(options) {
|
|
938
|
+
try {
|
|
939
|
+
const health = await api(options, "/health");
|
|
940
|
+
const ceremony = await api(options, "/custody/ceremony");
|
|
941
|
+
if (options.json) {
|
|
942
|
+
out.line(JSON.stringify({ health: health.body, ceremony: ceremony.body }, null, 2));
|
|
943
|
+
return 0;
|
|
944
|
+
}
|
|
945
|
+
out.line();
|
|
946
|
+
out.line(` API ${options.api}`);
|
|
947
|
+
out.line(` Realm ${options.realm}`);
|
|
948
|
+
if (health.status !== 200) {
|
|
949
|
+
out.fail(`API unreachable or unhealthy (HTTP ${health.status}).`);
|
|
950
|
+
return 1;
|
|
951
|
+
}
|
|
952
|
+
out.ok("API reachable");
|
|
953
|
+
const state = ceremony.body;
|
|
954
|
+
if (!state?.active) {
|
|
955
|
+
out.warn("No active ceremony \u2014 the platform has not been launched.");
|
|
956
|
+
out.step(`Run: fz genesis --mode ${RECOMMENDED_MODE}`);
|
|
957
|
+
return 0;
|
|
958
|
+
}
|
|
959
|
+
out.line(` Vault ${state.vaultUnlocked ? "UNLOCKED" : "LOCKED"}`);
|
|
960
|
+
if (!state.vaultUnlocked)
|
|
961
|
+
out.step("Run: fz unlock");
|
|
962
|
+
out.line();
|
|
963
|
+
return 0;
|
|
964
|
+
} catch (cause) {
|
|
965
|
+
out.fail(`Cannot reach ${options.api}: ${cause.message}`);
|
|
966
|
+
return 1;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
async function cmdAgent(options, args) {
|
|
970
|
+
if (args[0] !== "install") {
|
|
971
|
+
out.fail("Usage: fz agent install [--apply] [--enrol]");
|
|
972
|
+
return 1;
|
|
973
|
+
}
|
|
974
|
+
const enrolTokenSourcePath = "/run/forgezero-enrol-token";
|
|
975
|
+
const enrolTokenCredentialPath = "/etc/forgezero/creds/enrol-token.cred";
|
|
976
|
+
const enrolStatePath = "/var/lib/forgezero/enrolment.json";
|
|
977
|
+
const gitCredentialPath = process.env.FZ_GIT_CREDENTIAL_PATH ?? "/etc/forgezero/creds/git-deploy-key.cred";
|
|
978
|
+
const gitPublicKeyPath = process.env.FZ_GIT_PUBLIC_KEY_PATH ?? "/etc/forgezero/git/deploy.pub";
|
|
979
|
+
const plan = planInstall({
|
|
980
|
+
capabilities: await readCapabilities(localRunner),
|
|
981
|
+
socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET,
|
|
982
|
+
seedPath: process.env.FZ_SEED_PATH ?? "/var/lib/forgezero/node.seed",
|
|
983
|
+
seedCredentialPath: process.env.FZ_SEED_CREDENTIAL_PATH,
|
|
984
|
+
gitCredentialPath,
|
|
985
|
+
gitPublicKeyPath,
|
|
986
|
+
generateGitIdentity: true,
|
|
987
|
+
controlSocketPath: process.env.FZ_CONTROL_SOCKET,
|
|
988
|
+
repository: process.env.FZ_DEPLOY_REPO,
|
|
989
|
+
branch: process.env.FZ_DEPLOY_BRANCH,
|
|
990
|
+
role: process.env.FZ_DEPLOY_ROLE,
|
|
991
|
+
deployRoot: process.env.FZ_DEPLOY_ROOT,
|
|
992
|
+
publicApiUrl: process.env.FZ_PUBLIC_API_URL,
|
|
993
|
+
deploymentEnvironment: parseAssignments(process.env.FZ_DEPLOY_ENV),
|
|
994
|
+
deploymentCredentials: parseAssignments(process.env.FZ_DEPLOY_CREDENTIALS),
|
|
995
|
+
pullDeployments: process.env.FZ_DEPLOY_PULL === "true",
|
|
996
|
+
...options.enrol ? {
|
|
997
|
+
pullDeployments: true,
|
|
998
|
+
enrolTokenSourcePath,
|
|
999
|
+
enrolTokenCredentialPath,
|
|
1000
|
+
enrolStatePath,
|
|
1001
|
+
nodeLabel: process.env.FZ_NODE_LABEL,
|
|
1002
|
+
deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
|
|
1003
|
+
} : {},
|
|
1004
|
+
binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
|
|
1005
|
+
sourceBinPath: process.env.FZ_AGENT_SOURCE_BIN ?? PACKAGED_AGENT_BIN,
|
|
1006
|
+
user: process.env.FZ_AGENT_USER,
|
|
1007
|
+
apiUrl: options.api,
|
|
1008
|
+
project: process.env.FZ_PROJECT,
|
|
1009
|
+
environment: process.env.FZ_ENVIRONMENT
|
|
1010
|
+
});
|
|
1011
|
+
if (options.json) {
|
|
1012
|
+
out.line(JSON.stringify(plan, null, 2));
|
|
1013
|
+
return 0;
|
|
1014
|
+
}
|
|
1015
|
+
out.line(renderPlan(plan));
|
|
1016
|
+
if (!options.apply) {
|
|
1017
|
+
out.line(" --- unit ---");
|
|
1018
|
+
out.line(plan.unit);
|
|
1019
|
+
for (const auxiliary of plan.auxiliaryUnits) {
|
|
1020
|
+
out.line(` --- unit ${auxiliary.path} ---`);
|
|
1021
|
+
out.line(auxiliary.unit);
|
|
1022
|
+
}
|
|
1023
|
+
out.line(" --- then ---");
|
|
1024
|
+
for (const step of plan.steps)
|
|
1025
|
+
out.step(`${step.command}`);
|
|
1026
|
+
return 0;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
writeFileSync(plan.unitPath, plan.unit, { mode: 420 });
|
|
1030
|
+
out.ok(`Wrote ${plan.unitPath}`);
|
|
1031
|
+
for (const auxiliary of plan.auxiliaryUnits) {
|
|
1032
|
+
writeFileSync(auxiliary.path, auxiliary.unit, { mode: 420 });
|
|
1033
|
+
out.ok(`Wrote ${auxiliary.path}`);
|
|
1034
|
+
}
|
|
1035
|
+
if (options.enrol) {
|
|
1036
|
+
const prompt = Bun.spawn(["systemd-ask-password", "--timeout=0", "--echo=no", "ForgeZero one-time enrolment token:"], { stdin: "inherit", stdout: "pipe", stderr: "inherit" });
|
|
1037
|
+
const token = (await new Response(prompt.stdout).text()).trim();
|
|
1038
|
+
if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
1039
|
+
throw new Error("A valid fze_ enrolment token was not provided.");
|
|
1040
|
+
}
|
|
1041
|
+
writeFileSync(enrolTokenSourcePath, `${token}
|
|
1042
|
+
`, { mode: 384, flag: "wx" });
|
|
1043
|
+
}
|
|
1044
|
+
const transcript = await applyPlan(plan, localRunner);
|
|
1045
|
+
for (const step of transcript)
|
|
1046
|
+
out.ok(step.label);
|
|
1047
|
+
out.ok("Agent service and socket verified");
|
|
1048
|
+
out.line();
|
|
1049
|
+
out.line(" Add this machine-specific PUBLIC key as a read-only deploy key:");
|
|
1050
|
+
out.line();
|
|
1051
|
+
out.line(` ${readFileSync(gitPublicKeyPath, "utf8").trim()}`);
|
|
1052
|
+
out.line();
|
|
1053
|
+
return 0;
|
|
1054
|
+
} catch (cause) {
|
|
1055
|
+
if (options.enrol) {
|
|
1056
|
+
try {
|
|
1057
|
+
unlinkSync(enrolTokenSourcePath);
|
|
1058
|
+
} catch {}
|
|
1059
|
+
}
|
|
1060
|
+
out.fail(`Could not write ${plan.unitPath}: ${cause.message}`);
|
|
1061
|
+
out.step("Run this as root, or use --json and hand the unit to your provisioner.");
|
|
1062
|
+
return 1;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
async function cmdGenesis(options) {
|
|
1066
|
+
try {
|
|
1067
|
+
if (!thresholdMode(options.mode)) {
|
|
1068
|
+
throw new Error(`"${options.mode}" is not a threshold mode. ` + `Use one of: ${THRESHOLD_MODES.map((mode) => mode.id).join(", ")}.`);
|
|
1069
|
+
}
|
|
1070
|
+
const identity = await resolveIdentity(options.key, options.socket);
|
|
1071
|
+
let token = options.token;
|
|
1072
|
+
if (!token) {
|
|
1073
|
+
const path = `${process.env.FZ_SHARED_DIR ?? "/etc/forgezero"}/platform-invite.token`;
|
|
1074
|
+
try {
|
|
1075
|
+
token = (await Bun.file(path).text()).trim();
|
|
1076
|
+
} catch {
|
|
1077
|
+
throw new Error(`No --token given and ${path} is unreadable.
|
|
1078
|
+
|
|
1079
|
+
` + ` On the server: the token is read automatically, or set FZ_SHARED_DIR
|
|
1080
|
+
` + ` From a laptop: fz genesis --api https://<your-host> --token plt_...
|
|
1081
|
+
|
|
1082
|
+
` + " The token is what setup.sh printed. It is single use.");
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
useCustodyIdentity(identity, options.socket);
|
|
1086
|
+
const result = await runGenesis({
|
|
1087
|
+
token,
|
|
1088
|
+
api: (path, init) => api(options, path, init),
|
|
1089
|
+
modeId: options.mode,
|
|
1090
|
+
userKey: options.user,
|
|
1091
|
+
email: options.email,
|
|
1092
|
+
identity,
|
|
1093
|
+
socketPath: options.socket,
|
|
1094
|
+
onStep: (message) => out.step(message)
|
|
1095
|
+
});
|
|
1096
|
+
if (options.json) {
|
|
1097
|
+
out.line(JSON.stringify(result, null, 2));
|
|
1098
|
+
return 0;
|
|
1099
|
+
}
|
|
1100
|
+
out.line();
|
|
1101
|
+
out.ok("Platform launched. The vault is unlocked.");
|
|
1102
|
+
out.line();
|
|
1103
|
+
out.line(" RECOVERY PHRASE \u2014 written down now, or not at all.");
|
|
1104
|
+
out.line(" It is not stored, not recoverable, and opens your share");
|
|
1105
|
+
out.line(" on its own if the ssh key is ever lost.");
|
|
1106
|
+
out.line();
|
|
1107
|
+
result.phrase.forEach((word, index) => {
|
|
1108
|
+
const column = index % 4;
|
|
1109
|
+
process.stdout.write(` ${String(index + 1).padStart(2)}. ${word.padEnd(12)}`);
|
|
1110
|
+
if (column === 3)
|
|
1111
|
+
out.line();
|
|
1112
|
+
});
|
|
1113
|
+
out.line();
|
|
1114
|
+
out.line();
|
|
1115
|
+
if (result.fingerprint)
|
|
1116
|
+
out.line(` Seed fingerprint ${result.fingerprint}`);
|
|
1117
|
+
out.line();
|
|
1118
|
+
return 0;
|
|
1119
|
+
} catch (cause) {
|
|
1120
|
+
out.fail(cause.message);
|
|
1121
|
+
return 1;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async function cmdUnlock(options) {
|
|
1125
|
+
try {
|
|
1126
|
+
const identity = options.phrase?.length ? undefined : await resolveIdentity(options.key, options.socket);
|
|
1127
|
+
const result = await runUnlock({
|
|
1128
|
+
api: (path, init) => api(options, path, init),
|
|
1129
|
+
userKey: options.user,
|
|
1130
|
+
identity,
|
|
1131
|
+
phrase: options.phrase,
|
|
1132
|
+
socketPath: options.socket,
|
|
1133
|
+
onStep: (message) => out.step(message)
|
|
1134
|
+
});
|
|
1135
|
+
if (options.json) {
|
|
1136
|
+
out.line(JSON.stringify(result, null, 2));
|
|
1137
|
+
return 0;
|
|
1138
|
+
}
|
|
1139
|
+
out.ok(`Vault unlocked. fingerprint ${result.fingerprint}`);
|
|
1140
|
+
return 0;
|
|
1141
|
+
} catch (cause) {
|
|
1142
|
+
out.fail(cause.message);
|
|
1143
|
+
return 1;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
async function runCommand(argv, options) {
|
|
1147
|
+
let command;
|
|
1148
|
+
try {
|
|
1149
|
+
({ command } = splitAtSeparator(argv));
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
out.line(error instanceof RunError ? error.message : String(error));
|
|
1152
|
+
return 2;
|
|
1153
|
+
}
|
|
1154
|
+
const { createVault } = await import("@forgezero/vault");
|
|
1155
|
+
let secrets;
|
|
1156
|
+
try {
|
|
1157
|
+
secrets = await createVault().getAll();
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
out.line(`Could not read secrets: ${error instanceof Error ? error.message : String(error)}
|
|
1160
|
+
Is fz-agent running on this machine? \`fz agent install --apply\`.`);
|
|
1161
|
+
return 1;
|
|
1162
|
+
}
|
|
1163
|
+
const merged = mergeEnvironment(process.env, secrets, { preserveEnv: options.preserveEnv });
|
|
1164
|
+
if (!options.json)
|
|
1165
|
+
out.line(describeInjection(merged, Object.keys(secrets).length));
|
|
1166
|
+
return spawnWith(command, merged.env, (line) => out.line(line));
|
|
1167
|
+
}
|
|
1168
|
+
function usage() {
|
|
1169
|
+
out.line(`
|
|
1170
|
+
fz ${VERSION} \u2014 ForgeZero control surface
|
|
1171
|
+
|
|
1172
|
+
The platform is launched from here, not from the UI: a session needs a
|
|
1173
|
+
passkey, and a passkey needs a platform to register against. This breaks
|
|
1174
|
+
that circle using an SSH agent and a written recovery phrase.
|
|
1175
|
+
|
|
1176
|
+
COMMANDS
|
|
1177
|
+
fz keys List agent keys usable for custody
|
|
1178
|
+
fz status Platform, ceremony and vault state
|
|
1179
|
+
fz genesis Run the first custodian ceremony and launch
|
|
1180
|
+
fz unlock Reconstruct the seed and unlock the vault
|
|
1181
|
+
fz run -- <command> Start a process with vault values in its
|
|
1182
|
+
environment. A FALLBACK: anything using
|
|
1183
|
+
@forgezero/vault reads the socket and sees a
|
|
1184
|
+
rotation immediately, while an injected environment
|
|
1185
|
+
is frozen at spawn
|
|
1186
|
+
fz agent install Install the node agent as a systemd service, so
|
|
1187
|
+
applications on this box read secrets through a
|
|
1188
|
+
local socket instead of holding an API key
|
|
1189
|
+
|
|
1190
|
+
CEREMONY OPTIONS
|
|
1191
|
+
--key <fp|index> Which agent key to use for custody
|
|
1192
|
+
--mode <m-of-n> Threshold mode (default ${DEFAULT_MODE})
|
|
1193
|
+
one of: ${THRESHOLD_MODES.map((m) => m.id).join(", ")}
|
|
1194
|
+
--user <key> Custodian user key (env FZ_USER)
|
|
1195
|
+
--email <address> Custodian email (env FZ_EMAIL)
|
|
1196
|
+
--phrase "<24 words>" Unlock by phrase instead of the agent
|
|
1197
|
+
--token <plt_...> Platform invite from setup.sh; defaults to
|
|
1198
|
+
$FZ_SHARED_DIR/platform-invite.token
|
|
1199
|
+
|
|
1200
|
+
OPTIONS
|
|
1201
|
+
--api <url> API base URL (env FZ_API)
|
|
1202
|
+
defaults to http://localhost:8787
|
|
1203
|
+
FROM A LAPTOP pass your public host:
|
|
1204
|
+
fz genesis --api https://fz.example --token plt_...
|
|
1205
|
+
--realm <id> Realm to act in (env FZ_REALM, default platform)
|
|
1206
|
+
--socket <path> SSH agent socket (env SSH_AUTH_SOCK)
|
|
1207
|
+
--json Machine-readable output
|
|
1208
|
+
--apply Write the unit rather than printing it (root)
|
|
1209
|
+
--enrol Bind this machine with a one-time token prompted
|
|
1210
|
+
securely by systemd (tenant-owned compute)
|
|
1211
|
+
|
|
1212
|
+
CUSTODY FACTORS
|
|
1213
|
+
Every custodian share is sealed TWICE and either envelope alone opens it:
|
|
1214
|
+
|
|
1215
|
+
ssh a key in your agent, Ed25519 only, signed deterministically
|
|
1216
|
+
phrase a BIP-39 recovery phrase, printed once and never stored
|
|
1217
|
+
|
|
1218
|
+
Ed25519 is not a preference. The derived key must reproduce on every
|
|
1219
|
+
unlock, and RSA-PSS signatures are randomised \u2014 a share sealed under one
|
|
1220
|
+
could never be opened again.
|
|
1221
|
+
`);
|
|
1222
|
+
}
|
|
1223
|
+
if (import.meta.main) {
|
|
1224
|
+
await runCli();
|
|
1225
|
+
}
|
|
1226
|
+
async function runCli() {
|
|
1227
|
+
const { command, args, options } = parseOptions(process.argv.slice(2));
|
|
1228
|
+
let code = 0;
|
|
1229
|
+
switch (command) {
|
|
1230
|
+
case "keys":
|
|
1231
|
+
code = await cmdKeys(options);
|
|
1232
|
+
break;
|
|
1233
|
+
case "status":
|
|
1234
|
+
code = await cmdStatus(options);
|
|
1235
|
+
break;
|
|
1236
|
+
case "agent":
|
|
1237
|
+
code = await cmdAgent(options, args);
|
|
1238
|
+
break;
|
|
1239
|
+
case "genesis":
|
|
1240
|
+
code = await cmdGenesis(options);
|
|
1241
|
+
break;
|
|
1242
|
+
case "run":
|
|
1243
|
+
process.exit(await runCommand(process.argv.slice(3), options));
|
|
1244
|
+
break;
|
|
1245
|
+
case "unlock":
|
|
1246
|
+
code = await cmdUnlock(options);
|
|
1247
|
+
break;
|
|
1248
|
+
case "help":
|
|
1249
|
+
case "--help":
|
|
1250
|
+
case "-h":
|
|
1251
|
+
usage();
|
|
1252
|
+
break;
|
|
1253
|
+
case "version":
|
|
1254
|
+
case "--version":
|
|
1255
|
+
out.line(VERSION);
|
|
1256
|
+
break;
|
|
1257
|
+
default:
|
|
1258
|
+
out.fail(`Unknown command: ${command}`);
|
|
1259
|
+
usage();
|
|
1260
|
+
code = 1;
|
|
1261
|
+
}
|
|
1262
|
+
if (args.length > 0 && code === 0 && command !== "agent") {
|
|
1263
|
+
out.warn(`Ignored: ${args.join(" ")}`);
|
|
1264
|
+
}
|
|
1265
|
+
process.exit(code);
|
|
1266
|
+
}
|
|
1267
|
+
export {
|
|
1268
|
+
useCustodyIdentity,
|
|
1269
|
+
requestHeaders
|
|
1270
|
+
};
|