@forgezero/agent 0.1.11 → 0.1.13
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 +36 -9
- package/dist/deployment.d.ts +1 -1
- package/dist/fz-agent.js +545 -488
- package/dist/fz.js +34 -12
- package/dist/guest-enrolment.d.ts +2 -1
- package/dist/guest-enrolment.js +81 -23
- package/dist/index.d.ts +5 -29
- package/dist/metal-helper-socket.js +152 -26
- package/dist/metal-provision.d.ts +4 -3
- package/dist/metal-provision.js +152 -26
- package/dist/node-vault.d.ts +9 -0
- package/dist/node-vault.js +53 -17
- package/dist/provision.d.ts +1 -0
- package/dist/provision.js +14 -4
- package/dist/provisioning-pull.d.ts +22 -1
- package/dist/provisioning-pull.js +21 -10
- package/dist/signed-node-http.d.ts +1 -1
- package/dist/socket.d.ts +11 -4
- package/dist/ubuntu.d.ts +16 -0
- package/dist/ubuntu.js +18 -0
- package/dist/version.d.ts +2 -0
- package/package.json +11 -6
- package/dist/attestation-client.test.d.ts +0 -1
- package/dist/cache.test.d.ts +0 -1
- package/dist/cli/agent-install.test.d.ts +0 -1
- package/dist/cli/options.test.d.ts +0 -1
- package/dist/cli/run.test.d.ts +0 -1
- package/dist/compute.test.d.ts +0 -1
- package/dist/control.test.d.ts +0 -1
- package/dist/definition.test.d.ts +0 -1
- package/dist/deployment-pull.test.d.ts +0 -1
- package/dist/deployment-runner.test.d.ts +0 -1
- package/dist/deployment-watch.d.ts +0 -36
- package/dist/deployment-watch.test.d.ts +0 -1
- package/dist/deployment.test.d.ts +0 -1
- package/dist/guest-enrolment.test.d.ts +0 -1
- package/dist/index.test.d.ts +0 -1
- package/dist/metal-helper-socket.test.d.ts +0 -1
- package/dist/metal-isolation.test.d.ts +0 -1
- package/dist/metal-provision.test.d.ts +0 -1
- package/dist/node-vault.test.d.ts +0 -1
- package/dist/pipeline.test.d.ts +0 -1
- package/dist/provisioning-pull.test.d.ts +0 -1
- package/dist/snp-attestation.test.d.ts +0 -1
- package/dist/socket.test.d.ts +0 -1
- package/dist/ssh-listen.test.d.ts +0 -1
- package/dist/ssh-server.test.d.ts +0 -1
- package/dist/subscribe.test.d.ts +0 -1
package/dist/fz.js
CHANGED
|
@@ -101,7 +101,7 @@ async function spawnWith(command, env, report = () => {}) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
// src/cli/index.ts
|
|
104
|
-
import { readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
104
|
+
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
105
105
|
import { fileURLToPath } from "url";
|
|
106
106
|
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
107
107
|
|
|
@@ -148,6 +148,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
|
148
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
149
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
150
150
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
151
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
151
152
|
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
152
153
|
var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
|
|
153
154
|
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
@@ -317,7 +318,7 @@ function agentUnit(options) {
|
|
|
317
318
|
`);
|
|
318
319
|
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
319
320
|
DeviceAllow=/dev/sev-guest rw` : "";
|
|
320
|
-
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${
|
|
321
|
+
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
321
322
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
322
323
|
` : "";
|
|
323
324
|
return `[Unit]
|
|
@@ -328,7 +329,7 @@ ${deploymentDependency}
|
|
|
328
329
|
[Service]
|
|
329
330
|
Type=simple
|
|
330
331
|
User=${user}
|
|
331
|
-
Group=${
|
|
332
|
+
Group=${VAULT_GROUP}
|
|
332
333
|
${deploymentGroup}
|
|
333
334
|
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
334
335
|
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
@@ -347,8 +348,8 @@ LimitCORE=0
|
|
|
347
348
|
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
348
349
|
# than wherever the process happened to have write access.
|
|
349
350
|
RuntimeDirectory=forgezero
|
|
350
|
-
RuntimeDirectoryMode=
|
|
351
|
-
UMask=
|
|
351
|
+
RuntimeDirectoryMode=0750
|
|
352
|
+
UMask=0007
|
|
352
353
|
|
|
353
354
|
# Tenant-controlled commands execute in forgezero-deploy-runner.service. This
|
|
354
355
|
# credential-bearing process never needs to cross a privilege boundary.
|
|
@@ -415,6 +416,10 @@ function planProvision(options) {
|
|
|
415
416
|
socketPath: options.socketPath,
|
|
416
417
|
user,
|
|
417
418
|
steps: [
|
|
419
|
+
{
|
|
420
|
+
label: "vault socket access group",
|
|
421
|
+
command: `groupadd --system ${VAULT_GROUP} || true`
|
|
422
|
+
},
|
|
418
423
|
...sourceBinPath && binPath ? [{
|
|
419
424
|
label: "root-owned agent runtime",
|
|
420
425
|
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
|
|
@@ -427,6 +432,10 @@ function planProvision(options) {
|
|
|
427
432
|
label: "service account",
|
|
428
433
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
429
434
|
},
|
|
435
|
+
{
|
|
436
|
+
label: "bind service account to vault group",
|
|
437
|
+
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
438
|
+
},
|
|
430
439
|
...deploymentEnabled ? [{
|
|
431
440
|
label: "credential-free deployment account",
|
|
432
441
|
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
|
|
@@ -771,10 +780,12 @@ async function resolveIdentity(selector, socketPath) {
|
|
|
771
780
|
return chosen;
|
|
772
781
|
}
|
|
773
782
|
|
|
783
|
+
// src/version.ts
|
|
784
|
+
var VERSION = "0.1.13";
|
|
785
|
+
|
|
774
786
|
// src/cli/index.ts
|
|
775
787
|
var DEFAULT_MODE = THRESHOLD_MODES[0].id;
|
|
776
788
|
var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
|
|
777
|
-
var VERSION = "0.1.10";
|
|
778
789
|
var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
|
|
779
790
|
function parseOptions(argv) {
|
|
780
791
|
const options = {
|
|
@@ -1033,13 +1044,24 @@ async function cmdAgent(options, args) {
|
|
|
1033
1044
|
out.ok(`Wrote ${auxiliary.path}`);
|
|
1034
1045
|
}
|
|
1035
1046
|
if (options.enrol) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1047
|
+
if (existsSync(enrolTokenSourcePath)) {
|
|
1048
|
+
const source = statSync(enrolTokenSourcePath);
|
|
1049
|
+
const token = readFileSync(enrolTokenSourcePath, "utf8").trim();
|
|
1050
|
+
if (!source.isFile() || (source.mode & 511) !== 384 || source.uid !== 0) {
|
|
1051
|
+
throw new Error("The preloaded enrolment token must be a root-owned 0600 file in /run.");
|
|
1052
|
+
}
|
|
1053
|
+
if (!/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
1054
|
+
throw new Error("The preloaded enrolment token is malformed.");
|
|
1055
|
+
}
|
|
1056
|
+
} else {
|
|
1057
|
+
const prompt = Bun.spawn(["systemd-ask-password", "--timeout=0", "--echo=no", "ForgeZero one-time enrolment token:"], { stdin: "inherit", stdout: "pipe", stderr: "inherit" });
|
|
1058
|
+
const token = (await new Response(prompt.stdout).text()).trim();
|
|
1059
|
+
if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
1060
|
+
throw new Error("A valid fze_ enrolment token was not provided.");
|
|
1061
|
+
}
|
|
1062
|
+
writeFileSync(enrolTokenSourcePath, `${token}
|
|
1042
1063
|
`, { mode: 384, flag: "wx" });
|
|
1064
|
+
}
|
|
1043
1065
|
}
|
|
1044
1066
|
const transcript = await applyPlan(plan, localRunner);
|
|
1045
1067
|
for (const step of transcript)
|
package/dist/guest-enrolment.js
CHANGED
|
@@ -10,11 +10,72 @@ import {
|
|
|
10
10
|
writeFileSync
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
// src/signed-node-http.ts
|
|
15
|
+
import {
|
|
16
|
+
encodeSignatureHeader,
|
|
17
|
+
generateResponseRecipient,
|
|
18
|
+
openResponse,
|
|
19
|
+
RESPONSE_KEY_HEADER,
|
|
20
|
+
signRequest
|
|
21
|
+
} from "@forgezero/runtime/identity";
|
|
22
|
+
|
|
23
|
+
class SignedNodeHttpError extends Error {
|
|
24
|
+
status;
|
|
25
|
+
constructor(status, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.name = "SignedNodeHttpError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function postSignedNode(options, path, body, sealedResponse = false) {
|
|
32
|
+
const url = new URL(options.apiUrl);
|
|
33
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
|
|
34
|
+
url.search = "";
|
|
35
|
+
url.hash = "";
|
|
36
|
+
const raw = JSON.stringify(body);
|
|
37
|
+
const recipient = sealedResponse ? generateResponseRecipient() : undefined;
|
|
38
|
+
const envelope = signRequest(options.keys, options.nodeKey, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
path: url.pathname,
|
|
41
|
+
query: "",
|
|
42
|
+
body: raw,
|
|
43
|
+
responseKey: recipient?.publicKey
|
|
44
|
+
});
|
|
45
|
+
const signature = encodeSignatureHeader(envelope);
|
|
46
|
+
const response = await (options.fetch ?? globalThis.fetch)(url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"content-type": "application/json",
|
|
50
|
+
"x-fz-node": options.nodeKey,
|
|
51
|
+
"x-fz-signature": signature,
|
|
52
|
+
...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
|
|
53
|
+
},
|
|
54
|
+
body: raw,
|
|
55
|
+
signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
|
|
56
|
+
});
|
|
57
|
+
const payload = await response.json().catch(() => null);
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const failure = payload;
|
|
60
|
+
const reason = failure ? failure.error?.message ?? failure.message : undefined;
|
|
61
|
+
throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
|
|
62
|
+
}
|
|
63
|
+
if (recipient) {
|
|
64
|
+
try {
|
|
65
|
+
return await openResponse(recipient.secretKey, signature, payload);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/guest-enrolment.ts
|
|
13
74
|
var validBinding = (value, expectedNodeKey) => {
|
|
14
75
|
if (!value || typeof value !== "object")
|
|
15
76
|
return false;
|
|
16
77
|
const row = value;
|
|
17
|
-
return ["nodeKey", "computeReference", "projectKey", "environmentKey"
|
|
78
|
+
return ["nodeKey", "computeReference", "projectKey", "environmentKey"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (row.realm === "platform" || row.realm === "tenant" && typeof row.tenantSlug === "string" && row.tenantSlug.length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
|
|
18
79
|
};
|
|
19
80
|
function loadGuestBinding(path, expectedNodeKey) {
|
|
20
81
|
if (!existsSync(path))
|
|
@@ -46,34 +107,31 @@ async function enrolGuestIdentity(options) {
|
|
|
46
107
|
const token = options.token?.trim() ?? (options.tokenPath ? readFileSync(options.tokenPath, "utf8").trim() : "");
|
|
47
108
|
if (!token.startsWith("fze_"))
|
|
48
109
|
throw new Error("guest enrolment credential is malformed");
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
});
|
|
67
|
-
const payload = await response.json().catch(() => null);
|
|
68
|
-
if (!response.ok || !payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || !payload.tenantSlug) {
|
|
69
|
-
throw new Error(payload?.error?.message || `guest enrolment returned HTTP ${response.status}`);
|
|
110
|
+
const payload = await postSignedNode({
|
|
111
|
+
apiUrl: options.apiUrl,
|
|
112
|
+
nodeKey: options.nodeKey,
|
|
113
|
+
keys: options.keys,
|
|
114
|
+
fetch: options.fetch,
|
|
115
|
+
requestTimeoutMs: options.requestTimeoutMs
|
|
116
|
+
}, "v1/compute/enrol", {
|
|
117
|
+
token,
|
|
118
|
+
label: options.label,
|
|
119
|
+
gitDeployPublicKey: options.gitDeployPublicKey,
|
|
120
|
+
publicKeys: {
|
|
121
|
+
ed25519: options.keys.ed25519.publicKey,
|
|
122
|
+
mlDsa: options.keys.mlDsa.publicKey
|
|
123
|
+
}
|
|
124
|
+
}, true);
|
|
125
|
+
if (!payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || payload.realm !== "platform" && (payload.realm !== "tenant" || !payload.tenantSlug)) {
|
|
126
|
+
throw new Error(payload?.error?.message || "guest enrolment response was not accepted");
|
|
70
127
|
}
|
|
71
128
|
const binding = {
|
|
72
129
|
nodeKey: payload.nodeKey,
|
|
73
130
|
computeReference: payload.computeReference,
|
|
74
131
|
projectKey: payload.projectKey,
|
|
75
132
|
environmentKey: payload.environmentKey,
|
|
76
|
-
|
|
133
|
+
realm: payload.realm,
|
|
134
|
+
...payload.tenantSlug ? { tenantSlug: payload.tenantSlug } : {}
|
|
77
135
|
};
|
|
78
136
|
persistGuestBinding(options.statePath, binding);
|
|
79
137
|
if (options.consume)
|
package/dist/index.d.ts
CHANGED
|
@@ -2,30 +2,7 @@
|
|
|
2
2
|
import { type NodeKeyPair } from '@forgezero/runtime/identity';
|
|
3
3
|
import { startAgent, type AgentOptions, type AttestationSource } from './socket';
|
|
4
4
|
import type { SecretCache } from './cache';
|
|
5
|
-
|
|
6
|
-
* fz-agent — runs inside managed compute, driven locally before the platform exists and
|
|
7
|
-
* over HTTPS after it does. One implementation, two front doors: bootstrap is
|
|
8
|
-
* not a special case, it is the general case run first, because a tenant's own
|
|
9
|
-
* bare metal has no ForgeZero on it either.
|
|
10
|
-
*
|
|
11
|
-
* Everything it does is recorded in a hash-chained journal from its first
|
|
12
|
-
* command — before there is a database to log into. A flat log written then is
|
|
13
|
-
* a log anyone could have edited afterwards; the chain either verifies or
|
|
14
|
-
* visibly does not, and it is replayed into `audit_log` once the platform is
|
|
15
|
-
* operational.
|
|
16
|
-
*
|
|
17
|
-
* ## What it serves
|
|
18
|
-
*
|
|
19
|
-
* A unix socket that SIGNS and never surrenders the key. `@forgezero/vault`
|
|
20
|
-
* discovers `/run/forgezero/vault.sock` and prefers it over `FORGEZERO_API_KEY`, so
|
|
21
|
-
* moving an application onto managed compute means deleting an environment
|
|
22
|
-
* variable rather than changing a line of code — and a machine that used to hold
|
|
23
|
-
* a signing seed now holds nothing an attacker can take.
|
|
24
|
-
*
|
|
25
|
-
* That preference was implemented in the client long before anything listened.
|
|
26
|
-
* See `socket.ts` for why it signs rather than handing back a token.
|
|
27
|
-
*/
|
|
28
|
-
export declare const VERSION = "0.1.11";
|
|
5
|
+
export { VERSION } from './version';
|
|
29
6
|
export { startAgent, handleRequest } from './socket';
|
|
30
7
|
export type { AgentOptions, AttestationSource, Request, Response } from './socket';
|
|
31
8
|
export { createSecretCache, CacheError } from './cache';
|
|
@@ -35,13 +12,11 @@ export type { DeploymentManager, DeploymentOptions, DeploymentRequest, Deploymen
|
|
|
35
12
|
export { DEFAULT_CONTROL_SOCKET, requestControl, startControlServer } from './control';
|
|
36
13
|
export { pullDeploymentOnce, startDeploymentPull } from './deployment-pull';
|
|
37
14
|
export type { DeploymentPullOptions, RemoteDeploymentClaim, PullResult } from './deployment-pull';
|
|
38
|
-
export { readStaticDeploymentState, writeStaticDeploymentState, startStaticDeploymentWatch } from './deployment-watch';
|
|
39
|
-
export type { StaticDeploymentState, StaticDeploymentWatchOptions } from './deployment-watch';
|
|
40
15
|
export { pullProvisioningOnce, startProvisioningPull } from './provisioning-pull';
|
|
41
|
-
export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
|
|
16
|
+
export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, GuestAccess, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
|
|
42
17
|
export { enrolGuestIdentity, loadGuestBinding } from './guest-enrolment';
|
|
43
18
|
export type { GuestBinding, GuestEnrolmentOptions } from './guest-enrolment';
|
|
44
|
-
export { createNodeVaultCache, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
|
|
19
|
+
export { createNodeVaultCache, projectVaultCacheKey, projectVaultCoordinate, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
|
|
45
20
|
export type { NodeVaultOptions, NodeVaultSyncOptions } from './node-vault';
|
|
46
21
|
export { allocateAddress, allocateCpuPool, cloudInit, guestNameFor, provisionMetalGuest, removeMetalGuest } from './metal-provision';
|
|
47
22
|
export { validateMetalProfile } from './metal-provision';
|
|
@@ -53,6 +28,7 @@ export type { SnpAttestationOptions } from './snp-attestation';
|
|
|
53
28
|
export { attestNodeOnce, startNodeAttestation } from './attestation-client';
|
|
54
29
|
export type { NodeAttestationOptions } from './attestation-client';
|
|
55
30
|
export { applyMetalIsolation, metalGuestSliceUnit, metalHousekeepingDropIn } from './metal-isolation';
|
|
31
|
+
export { assertSupportedGuestImage, SUPPORTED_GUEST_IMAGE } from './ubuntu';
|
|
56
32
|
/**
|
|
57
33
|
* The node seed, on disk, owner-only.
|
|
58
34
|
*
|
|
@@ -128,5 +104,5 @@ export declare function runAgent(config?: AgentConfig): {
|
|
|
128
104
|
server: ReturnType<typeof startAgent>;
|
|
129
105
|
keys: NodeKeyPair;
|
|
130
106
|
nodeKey: string;
|
|
131
|
-
|
|
107
|
+
setVault(cache: SecretCache, projectKey: string): void;
|
|
132
108
|
};
|